@inetafrica/open-claudia 1.20.0 → 2.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/bot.js CHANGED
@@ -1,79 +1,57 @@
1
- const TelegramBot = require("node-telegram-bot-api");
2
- const { spawn, execSync } = require("child_process");
3
- const { AsyncLocalStorage } = require("node:async_hooks");
4
- const fs = require("fs");
1
+ // Open Claudia entrypoint. Responsibilities:
2
+ // - Boot config, vault, transcripts, crons.
3
+ // - Bring up every adapter listed in CHANNELS (telegram, kazee, …).
4
+ // - Wire each adapter's "message"/"action" events to the router.
5
+ // - Register slash commands with each adapter.
6
+ // - Handle process signals + crash notifications.
7
+ // Heavy lifting lives in core/* and channels/*; this file is just the wire.
8
+
5
9
  const path = require("path");
6
- const https = require("https");
7
- const cron = require("node-cron");
8
- const Vault = require("./vault");
9
- const CONFIG_DIR = require("./config-dir");
10
- const { ProjectTranscripts, truthy: configTruthy } = require("./project-transcripts");
10
+ const fs = require("fs");
11
+ const { execSync } = require("child_process");
11
12
 
12
- // ── Process tree helpers ───────────────────────────────────────────
13
- // Background tools (e.g. Bash run_in_background, /stop on a dev server)
14
- // often spawn into their own session/group, so a single PGID kill on the
15
- // Claude CLI doesn't reach them. We walk the descendant tree explicitly
16
- // and signal each PID. Without this, runaway poll-loops survive
17
- // indefinitely after the parent CLI is SIGTERMed.
18
- function descendantPids(rootPid) {
19
- const seen = new Set();
20
- const queue = [Number(rootPid)];
21
- while (queue.length) {
22
- const pid = queue.shift();
23
- if (!pid || seen.has(pid)) continue;
24
- seen.add(pid);
25
- try {
26
- const out = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
27
- for (const line of out.split("\n")) {
28
- const child = Number(line.trim());
29
- if (child) queue.push(child);
30
- }
31
- } catch (e) {
32
- // pgrep exits 1 when no children — ignore.
33
- }
34
- }
35
- seen.delete(Number(rootPid));
36
- return [...seen];
37
- }
13
+ const { config, BOT_DIR, CONFIG_DIR, SOUL_FILE, VAULT_FILE, CHAT_ID } = require("./core/config");
14
+ const { vault } = require("./core/vault-store");
15
+ const { isOnboarded } = require("./core/onboarding");
16
+ const { initCrons } = require("./core/cron");
17
+ const { onMessage, onAction } = require("./core/router");
18
+ const { publicCommands } = require("./core/commands");
19
+ const registry = require("./core/adapter-registry");
20
+ require("./core/handlers"); // side-effect: register slash commands
38
21
 
39
- function killProcessTree(rootPid, signal = "SIGTERM") {
40
- if (!rootPid) return;
41
- const descendants = descendantPids(rootPid);
42
- // Kill the process group first (covers the common case quickly).
43
- try { process.kill(-rootPid, signal); } catch (e) {}
44
- try { process.kill(rootPid, signal); } catch (e) {}
45
- // Then mop up any descendants that escaped the group (detached
46
- // children with their own session — e.g. bash run_in_background).
47
- for (const pid of descendants) {
48
- try { process.kill(pid, signal); } catch (e) {}
49
- }
22
+ const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
23
+
24
+ registry.setHandlers({ onMessage, onAction });
25
+ const adapters = registry.bootstrap();
26
+
27
+ if (adapters.length === 0) {
28
+ console.error("No channels configured. Set CHANNELS=telegram and TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, or CHANNELS=kazee with KAZEE_URL/KAZEE_BOT_TOKEN.");
29
+ process.exit(1);
50
30
  }
51
31
 
52
32
  // ── Graceful shutdown & error handling ─────────────────────────────
33
+
34
+ const { userStates } = require("./core/state");
35
+ const { killProcessTree } = require("./core/process-tree");
36
+
53
37
  async function gracefulShutdown(signal) {
54
38
  console.log(`Received ${signal}, shutting down gracefully...`);
55
- // Kill every running Claude subprocess across all user threads
56
- if (typeof userStates !== "undefined") {
57
- for (const state of userStates.values()) {
58
- if (state.runningProcess) {
59
- try { killProcessTree(state.runningProcess.pid, "SIGTERM"); } catch (e) {}
60
- }
39
+ for (const state of userStates.values()) {
40
+ if (state.runningProcess) {
41
+ try { killProcessTree(state.runningProcess.pid, "SIGTERM"); } catch (e) {}
61
42
  }
62
43
  }
63
- // Clean up temp media files older than 1 hour
44
+ for (const a of adapters) { try { await a.stop(); } catch (e) {} }
64
45
  try {
65
- const CONFIG_DIR_TEMP = require("./config-dir");
66
- const mediaDir = require("path").join(CONFIG_DIR_TEMP, "media");
67
- if (require("fs").existsSync(mediaDir)) {
68
- const files = require("fs").readdirSync(mediaDir);
46
+ const mediaDir = path.join(CONFIG_DIR, "media");
47
+ if (fs.existsSync(mediaDir)) {
48
+ const files = fs.readdirSync(mediaDir);
69
49
  const oneHourAgo = Date.now() - 60 * 60 * 1000;
70
50
  for (const file of files) {
71
- const filePath = require("path").join(mediaDir, file);
51
+ const filePath = path.join(mediaDir, file);
72
52
  try {
73
- const stat = require("fs").statSync(filePath);
74
- if (stat.mtimeMs < oneHourAgo) {
75
- require("fs").unlinkSync(filePath);
76
- }
53
+ const stat = fs.statSync(filePath);
54
+ if (stat.mtimeMs < oneHourAgo) fs.unlinkSync(filePath);
77
55
  } catch (e) {}
78
56
  }
79
57
  }
@@ -84,12 +62,12 @@ async function gracefulShutdown(signal) {
84
62
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
85
63
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
86
64
 
87
- // Notify user of crashes via Telegram before exiting
65
+ // Last-ditch crash notification. Prefers Telegram (cheapest, no setup);
66
+ // falls back silent. Anything richer needs an adapter that's still alive.
88
67
  function notifyError(label, err) {
89
68
  const msg = `${label}: ${err?.message || err}`.slice(0, 1000);
90
69
  console.error(msg, err?.stack || "");
91
70
  try {
92
- // Synchronous-style notification using the Telegram API directly
93
71
  const token = process.env.TELEGRAM_BOT_TOKEN;
94
72
  const chatId = process.env.TELEGRAM_CHAT_ID?.split(",")[0];
95
73
  if (token && chatId) {
@@ -101,172 +79,20 @@ function notifyError(label, err) {
101
79
  req.write(data);
102
80
  req.end();
103
81
  }
104
- } catch (e) { /* last resort — ignore */ }
82
+ } catch (e) {}
105
83
  }
106
84
 
107
85
  process.on("uncaughtException", (err) => {
108
86
  notifyError("Uncaught exception", err);
109
- // Give the notification a moment to send before exiting
110
87
  setTimeout(() => process.exit(1), 2000);
111
88
  });
89
+ process.on("unhandledRejection", (reason) => notifyError("Unhandled rejection", reason));
112
90
 
113
- process.on("unhandledRejection", (reason) => {
114
- notifyError("Unhandled rejection", reason);
115
- });
116
-
117
- // ── Load Config from .env ───────────────────────────────────────────
118
- function loadEnv() {
119
- const envPath = path.join(CONFIG_DIR, ".env");
120
- if (!fs.existsSync(envPath)) {
121
- console.error("No .env file found. Run: node setup.js");
122
- process.exit(1);
123
- }
124
- const lines = fs.readFileSync(envPath, "utf-8").split("\n");
125
- const env = {};
126
- for (const line of lines) {
127
- const idx = line.indexOf("=");
128
- if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
129
- }
130
- return env;
131
- }
132
-
133
- function saveEnvKey(key, value) {
134
- const envPath = path.join(CONFIG_DIR, ".env");
135
- const content = fs.readFileSync(envPath, "utf-8");
136
- const lines = content.split("\n");
137
- let found = false;
138
- const updated = lines.map((line) => {
139
- if (line.startsWith(key + "=")) { found = true; return `${key}=${value}`; }
140
- return line;
141
- });
142
- if (!found) updated.push(`${key}=${value}`);
143
- fs.writeFileSync(envPath, updated.join("\n"));
144
- }
145
-
146
- const config = loadEnv();
147
- const TOKEN = config.TELEGRAM_BOT_TOKEN;
148
- const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
149
- const CHAT_ID = CHAT_IDS[0]; // Primary owner chat for crons/notifications
150
- const WORKSPACE = config.WORKSPACE;
151
- const CLAUDE_PATH = config.CLAUDE_PATH;
152
- const CURSOR_PATH = config.CURSOR_PATH || null;
153
- const CODEX_PATH = config.CODEX_PATH || null;
154
- const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "140000", 10);
155
- const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
156
- const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
157
- const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
158
-
159
- // Validate critical config at startup
160
- if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN not set"); process.exit(1); }
161
- if (!CHAT_ID) { console.error("TELEGRAM_CHAT_ID not set"); process.exit(1); }
162
- if (!WORKSPACE) { console.error("WORKSPACE not set"); process.exit(1); }
163
- if (!CLAUDE_PATH) { console.error("CLAUDE_PATH not set"); process.exit(1); }
164
-
165
- // Ensure workspace exists
166
- if (!fs.existsSync(WORKSPACE)) {
167
- try {
168
- fs.mkdirSync(WORKSPACE, { recursive: true });
169
- console.log(`Created workspace: ${WORKSPACE}`);
170
- } catch (e) {
171
- console.error(`Failed to create workspace: ${e.message}`);
172
- process.exit(1);
173
- }
174
- }
175
-
176
- // Verify Claude CLI exists
177
- if (!fs.existsSync(CLAUDE_PATH)) {
178
- const { execSync } = require("child_process");
179
- try {
180
- execSync(`which "${CLAUDE_PATH}" 2>/dev/null || where "${CLAUDE_PATH}" 2>nul`, { encoding: "utf-8" });
181
- } catch (e) {
182
- console.error(`Claude CLI not found at: ${CLAUDE_PATH}`);
183
- process.exit(1);
184
- }
185
- }
186
-
187
- // Resolve Cursor Agent CLI (optional — discovered at startup)
188
- let resolvedCursorPath = CURSOR_PATH;
189
- if (!resolvedCursorPath) {
190
- try {
191
- resolvedCursorPath = execSync("which agent 2>/dev/null", { encoding: "utf-8" }).trim() || null;
192
- } catch (e) { resolvedCursorPath = null; }
193
- }
194
- if (resolvedCursorPath) console.log(`Cursor Agent CLI: ${resolvedCursorPath}`);
195
-
196
- // Resolve Codex CLI (optional — discovered at startup)
197
- let resolvedCodexPath = CODEX_PATH;
198
- if (!resolvedCodexPath) {
199
- try {
200
- resolvedCodexPath = execSync("which codex 2>/dev/null", { encoding: "utf-8" }).trim() || null;
201
- } catch (e) { resolvedCodexPath = null; }
202
- }
203
- if (resolvedCodexPath) console.log(`Codex CLI: ${resolvedCodexPath}`);
204
- const WHISPER_CLI = config.WHISPER_CLI || "";
205
- const WHISPER_MODEL = config.WHISPER_MODEL || "";
206
- const FFMPEG = config.FFMPEG || "";
207
- const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
208
- const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
209
- const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
210
- const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
211
- const IDENTITIES_FILE = config.IDENTITIES_FILE || path.join(CONFIG_DIR, "identities.json");
212
- const BOT_DIR = __dirname;
213
-
214
- // Detect PATH for subprocess
215
- const FULL_PATH = [
216
- path.dirname(CLAUDE_PATH),
217
- resolvedCursorPath ? path.dirname(resolvedCursorPath) : null,
218
- resolvedCodexPath ? path.dirname(resolvedCodexPath) : null,
219
- path.dirname(process.execPath),
220
- FFMPEG ? path.dirname(FFMPEG) : null,
221
- WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
222
- ...(process.platform === "win32"
223
- ? [process.env.APPDATA, process.env.LOCALAPPDATA].filter(Boolean).map((p) => path.join(p, "npm"))
224
- : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
225
- ),
226
- ].filter(Boolean).join(path.delimiter);
227
-
228
- const bot = new TelegramBot(TOKEN, {
229
- polling: {
230
- autoStart: true,
231
- params: { timeout: 30 },
232
- },
233
- });
234
- const vault = new Vault(VAULT_FILE);
235
-
236
- // ── Auto-reconnect on polling errors ───────────────────────────────
237
- let reconnectTimer = null;
238
- bot.on("polling_error", (err) => {
239
- const msg = err.message || "";
240
- console.error("Polling error:", msg);
241
- // Another instance is already running — exit immediately
242
- if (msg.includes("409 Conflict")) {
243
- console.error("Another instance is polling. Exiting.");
244
- process.exit(1);
245
- }
246
- if (msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET") || msg.includes("ENOTFOUND") || msg.includes("EFATAL")) {
247
- if (reconnectTimer) return; // Already scheduled
248
- console.log("Network lost. Reconnecting in 10s...");
249
- reconnectTimer = setTimeout(async () => {
250
- reconnectTimer = null;
251
- try {
252
- await bot.stopPolling();
253
- await new Promise((r) => setTimeout(r, 2000));
254
- await bot.startPolling();
255
- console.log("Reconnected.");
256
- } catch (e) {
257
- console.error("Reconnect failed:", e.message);
258
- // launchd will restart us if we exit
259
- process.exit(1);
260
- }
261
- }, 10000);
262
- }
263
- });
91
+ // ── Update checker ─────────────────────────────────────────────────
264
92
 
265
- // ── Update checker (every 5 mins) ──────────────────────────────────
266
- const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
267
93
  let lastNotifiedVersion = null;
268
-
269
94
  function checkForUpdates() {
95
+ const https = require("https");
270
96
  https.get("https://registry.npmjs.org/@inetafrica/open-claudia/latest", (res) => {
271
97
  let data = "";
272
98
  res.on("data", (d) => { data += d; });
@@ -275,3249 +101,92 @@ function checkForUpdates() {
275
101
  const latest = JSON.parse(data).version;
276
102
  if (latest && latest !== CURRENT_VERSION && latest !== lastNotifiedVersion) {
277
103
  lastNotifiedVersion = latest;
278
- bot.sendMessage(CHAT_ID, `Hey! A new version is available (v${latest}). You're on v${CURRENT_VERSION}.\n\nSend /upgrade to update — I'll be back in a few seconds.`);
104
+ // Notify owner via the first telegram adapter (the historical home).
105
+ const tg = adapters.find((a) => a.type === "telegram");
106
+ if (tg && CHAT_ID) {
107
+ tg.send(CHAT_ID, `Hey! A new version is available (v${latest}). You're on v${CURRENT_VERSION}.\n\nSend /upgrade to update — I'll be back in a few seconds.`).catch(() => {});
108
+ }
279
109
  }
280
110
  } catch (e) {}
281
111
  });
282
112
  }).on("error", () => {});
283
113
  }
284
114
 
285
- // Check on startup (after 30s) and every 5 minutes
286
115
  setTimeout(checkForUpdates, 30000);
287
116
  setInterval(checkForUpdates, 5 * 60 * 1000);
288
117
 
289
- // ── Commands Menu ───────────────────────────────────────────────────
290
- bot.setMyCommands([
291
- { command: "session", description: "Pick a project to work on" },
292
- { command: "projects", description: "Browse all workspace projects" },
293
- { command: "model", description: "Switch model (opus/sonnet/haiku)" },
294
- { command: "effort", description: "Set effort level" },
295
- { command: "budget", description: "Set max spend for next task" },
296
- { command: "plan", description: "Toggle plan mode" },
297
- { command: "ask", description: "Toggle ask mode (Cursor only)" },
298
- { command: "sessions", description: "List conversations for this project" },
299
- { command: "compact", description: "Summarize conversation context" },
300
- { command: "usage", description: "Show token usage & cost for this session" },
301
- { command: "continue", description: "Resume last conversation" },
302
- { command: "worktree", description: "Toggle isolated git branch" },
303
- { command: "cron", description: "Manage scheduled tasks" },
304
- { command: "vault", description: "Manage credentials (password required)" },
305
- { command: "soul", description: "View/edit assistant identity" },
306
- { command: "status", description: "Session & settings info" },
307
- { command: "cursor", description: "Switch to Cursor Agent backend" },
308
- { command: "claude", description: "Switch to Claude Code backend" },
309
- { command: "codex", description: "Switch to OpenAI Codex backend" },
310
- { command: "backend", description: "Show/switch active backend" },
311
- { command: "doctor", description: "Check CLI requirements" },
312
- { command: "requirements", description: "Check CLI requirements" },
313
- { command: "auth", description: "Request access to this bot" },
314
- { command: "link", description: "Link this chat to a canonical user id" },
315
- { command: "whoami", description: "Show your canonical user id" },
316
- { command: "auth_status", description: "Check Claude Code auth" },
317
- { command: "codex_auth_status", description: "Check Codex auth" },
318
- { command: "codex_login", description: "Start Codex device login" },
319
- { command: "login", description: "Start Claude Code login" },
320
- { command: "setup_token", description: "Create Claude OAuth token" },
321
- { command: "stop", description: "Cancel running task" },
322
- { command: "end", description: "End current session" },
323
- { command: "version", description: "Show current version" },
324
- { command: "restart", description: "Restart the bot" },
325
- { command: "upgrade", description: "Upgrade and restart" },
326
- { command: "help", description: "Show all commands" },
327
- ]);
328
-
329
- // Temp dir for media
330
- const TEMP_DIR = path.join(CONFIG_DIR, "media");
331
- const FILES_DIR = path.join(CONFIG_DIR, "files");
332
- if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
333
- if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
334
-
335
- // File size limits (in bytes)
336
- const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB max for documents
337
- const MAX_VOICE_SIZE = 10 * 1024 * 1024; // 10MB max for voice/audio
338
-
339
- // Process timeout (6 hours max)
340
- const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
341
-
342
- // ── Persistent state ───────────────────────────────────────────────
343
- const STATE_FILE = path.join(CONFIG_DIR, "state.json");
344
- const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
345
-
346
- function normalizeCanonicalUserId(value) {
347
- return String(value || "").trim().toLowerCase();
348
- }
349
-
350
- function channelKey(transport, channelId) {
351
- return `${String(transport || "").trim().toLowerCase()}:${String(channelId || "").trim()}`;
352
- }
353
-
354
- function defaultCanonicalForChannel(transport, channelId) {
355
- return channelKey(transport, channelId);
356
- }
118
+ // ── Boot ────────────────────────────────────────────────────────────
357
119
 
358
- function loadIdentities() {
120
+ (async () => {
121
+ // Idempotent setup: ensure tools & config are in place (safe on every boot)
359
122
  try {
360
- const raw = JSON.parse(fs.readFileSync(IDENTITIES_FILE, "utf-8"));
361
- return {
362
- channels: raw && typeof raw.channels === "object" ? raw.channels : {},
363
- preferred: raw && typeof raw.preferred === "object" ? raw.preferred : {},
364
- };
123
+ const { ensureSetup, formatSetupResults } = require("./health");
124
+ const setupResult = ensureSetup();
125
+ console.log("Setup check:");
126
+ console.log(formatSetupResults(setupResult));
127
+ if (!setupResult.ok) console.warn("Some setup steps failed — browser tools may be unavailable.");
365
128
  } catch (e) {
366
- return { channels: {}, preferred: {} };
367
- }
368
- }
369
-
370
- const identities = loadIdentities();
371
-
372
- function saveIdentities() {
373
- try { fs.writeFileSync(IDENTITIES_FILE, JSON.stringify(identities, null, 2)); } catch (e) {}
374
- }
375
-
376
- function canonicalForChannel(transport, channelId) {
377
- const key = channelKey(transport, channelId);
378
- return normalizeCanonicalUserId(identities.channels[key]) || defaultCanonicalForChannel(transport, channelId);
379
- }
380
-
381
- function canonicalForTelegram(chatId) {
382
- return canonicalForChannel("telegram", chatId);
383
- }
384
-
385
- function canonicalForStoredUserKey(key) {
386
- const id = String(key);
387
- if (id.includes(":") || id.includes("@")) return normalizeCanonicalUserId(id);
388
- return canonicalForTelegram(id);
389
- }
390
-
391
- function currentCanonicalUserId() {
392
- return canonicalForTelegram(currentChatId());
393
- }
394
-
395
- function loadStateFile() {
396
- try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); } catch (e) { return {}; }
397
- }
398
-
399
- function mergeSavedState(existing, next) {
400
- return {
401
- ...existing,
402
- ...next,
403
- settings: { ...(existing.settings || {}), ...(next.settings || {}) },
404
- sessionUsage: { ...(existing.sessionUsage || {}), ...(next.sessionUsage || {}) },
405
- };
406
- }
407
-
408
- // Multi-user state. v1.16 and earlier stored a single user's state at the
409
- // top level of state.json; v1.17/v1.18 stored `{ users: { "<chatId>": {...} } }`.
410
- // v1.19+ keys state by canonical user id (`telegram:<chatId>` by default,
411
- // or an explicit mapping like `sumeet@inet.africa`) so future transports can
412
- // share the same session state.
413
- const savedState = (() => {
414
- const raw = loadStateFile();
415
- const users = {};
416
- if (raw && raw.users && typeof raw.users === "object") {
417
- for (const [key, value] of Object.entries(raw.users)) {
418
- const userId = canonicalForStoredUserKey(key);
419
- users[userId] = mergeSavedState(users[userId] || {}, value || {});
420
- }
421
- return { users };
422
- }
423
- // Legacy single-user shape: hoist it under the owner's canonical id.
424
- return { users: { [canonicalForTelegram(CHAT_ID)]: raw || {} } };
425
- })();
426
-
427
- let activeCrons = new Map();
428
-
429
- // Per-user state buckets. Each authorized chat gets its own thread,
430
- // running process, settings, session etc. Owner-only shared resources
431
- // (vault, soul, crons, OAuth token) stay global above.
432
- const userStates = new Map();
433
-
434
- function freshSettings() {
435
- return { model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude" };
436
- }
437
-
438
- function freshUsage() {
439
- return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
440
- }
441
-
442
- function createUserState(userId) {
443
- const saved = (savedState.users && savedState.users[userId]) || {};
444
- const settings = saved.settings || freshSettings();
445
- if (!settings.backend) settings.backend = "claude";
446
- return {
447
- userId: String(userId),
448
- chatId: currentChatId(),
449
- currentSession: saved.currentSession || null,
450
- runningProcess: null,
451
- statusMessageId: null,
452
- streamBuffer: "",
453
- streamInterval: null,
454
- lastSessionId: saved.lastSessionId || null,
455
- cursorSessionId: saved.cursorSessionId || null,
456
- codexSessionId: saved.codexSessionId || null,
457
- messageQueue: [],
458
- isFirstMessage: !saved.lastSessionId,
459
- lastInputWasVoice: false,
460
- sessionUsage: saved.sessionUsage || freshUsage(),
461
- settings,
462
- onboardingStep: null,
463
- onboardingData: {},
464
- pendingVaultUnlock: false,
465
- pendingVaultAction: null,
466
- pendingClaudeAuthProcess: null,
467
- pendingClaudeAuthLabel: null,
468
- pendingCodexAuthProcess: null,
469
- pendingCodexAuthLabel: null,
470
- isCompacting: false,
471
- lastCompactedAt: saved.lastCompactedAt || 0,
472
- };
473
- }
474
-
475
- function getUserState(userId) {
476
- const id = normalizeCanonicalUserId(userId);
477
- if (!userStates.has(id)) userStates.set(id, createUserState(id));
478
- const state = userStates.get(id);
479
- state.chatId = currentChatId();
480
- return state;
481
- }
482
-
483
- // AsyncLocalStorage carries the active chat id through the async call
484
- // chain, so send()/editMessage()/etc. route replies back to whoever
485
- // triggered the work without having to thread chatId through every
486
- // function signature.
487
- const chatContext = new AsyncLocalStorage();
488
-
489
- function currentChatId() {
490
- return chatContext.getStore() || CHAT_ID;
491
- }
492
-
493
- function currentState() {
494
- return getUserState(currentCanonicalUserId());
495
- }
496
-
497
- function resetSessionUsage(state = currentState()) {
498
- state.sessionUsage = freshUsage();
499
- }
500
-
501
- function resetSettings(state = currentState()) {
502
- state.settings = freshSettings();
503
- }
504
-
505
- function saveState() {
506
- const data = { users: { ...(savedState.users || {}) } };
507
- for (const [id, s] of userStates) {
508
- data.users[id] = {
509
- currentSession: s.currentSession,
510
- lastSessionId: s.lastSessionId,
511
- cursorSessionId: s.cursorSessionId,
512
- codexSessionId: s.codexSessionId,
513
- settings: s.settings,
514
- sessionUsage: s.sessionUsage,
515
- lastCompactedAt: s.lastCompactedAt || 0,
516
- };
517
- }
518
- savedState.users = data.users;
519
- try { fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch (e) {}
520
- }
521
-
522
- function migrateUserData(fromUserId, toUserId) {
523
- const from = normalizeCanonicalUserId(fromUserId);
524
- const to = normalizeCanonicalUserId(toUserId);
525
- if (!from || !to || from === to) return;
526
-
527
- if (savedState.users && savedState.users[from]) {
528
- savedState.users[to] = mergeSavedState(savedState.users[to] || {}, savedState.users[from]);
529
- delete savedState.users[from];
530
- }
531
-
532
- if (userStates.has(from)) {
533
- const fromState = userStates.get(from);
534
- if (userStates.has(to)) {
535
- const toState = userStates.get(to);
536
- Object.assign(toState, mergeSavedState(toState, fromState));
537
- toState.userId = to;
538
- } else {
539
- fromState.userId = to;
540
- userStates.set(to, fromState);
541
- }
542
- userStates.delete(from);
543
- }
544
-
545
- const sessions = loadSessions();
546
- if (sessions[from]) {
547
- sessions[to] = { ...(sessions[to] || {}) };
548
- for (const [project, list] of Object.entries(sessions[from])) {
549
- sessions[to][project] = [
550
- ...(sessions[to][project] || []),
551
- ...(Array.isArray(list) ? list : []),
552
- ].slice(-20);
553
- }
554
- delete sessions[from];
555
- saveSessions(sessions);
556
- }
557
-
558
- saveState();
559
- }
560
-
561
- function setIdentityMapping(transport, channelId, canonicalUserId) {
562
- const key = channelKey(transport, channelId);
563
- const userId = normalizeCanonicalUserId(canonicalUserId);
564
- if (!userId) throw new Error("Canonical user id is required.");
565
- const hadExplicitMapping = Object.prototype.hasOwnProperty.call(identities.channels, key);
566
- const previousUserId = canonicalForChannel(transport, channelId);
567
- const defaultUserId = defaultCanonicalForChannel(transport, channelId);
568
- identities.channels[key] = userId;
569
- identities.preferred[userId] = { transport: String(transport).toLowerCase(), channelId: String(channelId) };
570
- saveIdentities();
571
- const shouldMigrate = !hadExplicitMapping || previousUserId === defaultUserId;
572
- if (shouldMigrate) migrateUserData(previousUserId, userId);
573
- return { key, previousUserId, userId, migrated: shouldMigrate && previousUserId !== userId };
574
- }
575
-
576
- // ── Message deduplication ──────────────────────────────────────────
577
- // Telegram message_ids are unique per chat, not globally — namespace by
578
- // chat id so two users' messages can't collide.
579
- const processedMessages = new Set();
580
- function isDuplicate(msg) {
581
- const key = `${msg.chat.id}:${msg.message_id}`;
582
- if (processedMessages.has(key)) return true;
583
- processedMessages.add(key);
584
- if (processedMessages.size > 400) {
585
- const arr = [...processedMessages];
586
- processedMessages.clear();
587
- arr.slice(-200).forEach((k) => processedMessages.add(k));
588
- }
589
- return false;
590
- }
591
-
592
- // ── Per-project session history ────────────────────────────────────
593
- // Sessions are stored per canonical user so linked channels share the same
594
- // conversation history per project. Legacy files keyed by chat id are migrated
595
- // through the same identity resolver on first read.
596
-
597
- function loadSessions() {
598
- let raw;
599
- try { raw = JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
600
- if (!raw || typeof raw !== "object") return {};
601
- // Legacy detection: top-level keys map directly to arrays of session objects.
602
- const looksLegacy = Object.values(raw).some((v) => Array.isArray(v));
603
- if (looksLegacy) return { [canonicalForTelegram(CHAT_ID)]: raw };
604
- const migrated = {};
605
- for (const [key, value] of Object.entries(raw)) {
606
- const userId = canonicalForStoredUserKey(key);
607
- migrated[userId] = { ...(migrated[userId] || {}) };
608
- for (const [project, list] of Object.entries(value || {})) {
609
- migrated[userId][project] = [
610
- ...(migrated[userId][project] || []),
611
- ...(Array.isArray(list) ? list : []),
612
- ].slice(-20);
613
- }
614
- }
615
- return migrated;
616
- }
617
-
618
- function saveSessions(sessions) {
619
- try { fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); } catch (e) {}
620
- }
621
-
622
- function recordSession(userId, projectName, sessionId, title) {
623
- const all = loadSessions();
624
- const id = normalizeCanonicalUserId(userId);
625
- if (!all[id]) all[id] = {};
626
- if (!all[id][projectName]) all[id][projectName] = [];
627
- const arr = all[id][projectName];
628
- const existing = arr.find((s) => s.id === sessionId);
629
- if (existing) {
630
- if (title) existing.title = title;
631
- existing.lastUsed = new Date().toISOString();
632
- } else {
633
- arr.push({
634
- id: sessionId,
635
- title: title || "Untitled",
636
- created: new Date().toISOString(),
637
- lastUsed: new Date().toISOString(),
638
- });
129
+ console.warn("Setup check skipped:", e.message);
639
130
  }
640
- all[id][projectName] = arr.slice(-20);
641
- saveSessions(all);
642
- }
643
-
644
- function getProjectSessions(userId, projectName) {
645
- const all = loadSessions();
646
- return ((all[normalizeCanonicalUserId(userId)] || {})[projectName] || []).slice().reverse();
647
- }
648
-
649
- function getLastProjectSession(userId, projectName) {
650
- const sessions = getProjectSessions(userId, projectName);
651
- return sessions.length > 0 ? sessions[0] : null;
652
- }
653
-
654
- function isAuthorized(msg) {
655
- const chatId = String(msg.chat.id);
656
- if (CHAT_IDS.includes(chatId)) return true;
657
- // Also check auth.json for dynamically added chats
658
- try {
659
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
660
- return Array.isArray(auth.authorized) && auth.authorized.some((a) => String(a.chatId) === chatId);
661
- } catch (e) {}
662
- return false;
663
- }
664
131
 
665
- function isOwner(msg) {
666
- const chatId = String(msg.chat.id);
667
- if (chatId === CHAT_ID) return true;
132
+ // Sweep stale `claude login` / `claude setup-token` processes that
133
+ // survived a previous bot crash. They're blocked on stdin forever and
134
+ // can hold keychain locks / resources. Anything older than 30 minutes
135
+ // is assumed to be abandoned.
668
136
  try {
669
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
670
- const authorized = Array.isArray(auth.authorized) ? auth.authorized : [];
671
- if (authorized.some((a) => String(a.chatId) === chatId && a.isOwner === true)) return true;
672
- if (!authorized.some((a) => a.isOwner === true) && CHAT_IDS.includes(chatId)) return true;
137
+ const out = execSync(
138
+ `ps -axo pid,etime,command | awk '/claude (login|setup-token)/ && !/awk/'`,
139
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
140
+ );
141
+ for (const line of out.split("\n")) {
142
+ const m = line.trim().match(/^(\d+)\s+(\S+)\s/);
143
+ if (!m) continue;
144
+ const pid = Number(m[1]);
145
+ const elapsed = m[2];
146
+ const minutes = elapsed.includes("-")
147
+ ? Number(elapsed.split("-")[0]) * 24 * 60 + 60
148
+ : elapsed.split(":").length === 3
149
+ ? Number(elapsed.split(":")[0]) * 60 + Number(elapsed.split(":")[1])
150
+ : Number(elapsed.split(":")[0]);
151
+ if (minutes >= 30) {
152
+ try {
153
+ process.kill(pid, "SIGTERM");
154
+ console.log(`Swept stale auth process pid=${pid} elapsed=${elapsed}`);
155
+ } catch (e) {}
156
+ }
157
+ }
673
158
  } catch (e) {}
674
- return false;
675
- }
676
-
677
- function loadAuth() {
678
- try {
679
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
680
- return {
681
- authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
682
- pending: Array.isArray(auth.pending) ? auth.pending : [],
683
- };
684
- } catch (e) {
685
- return { authorized: [], pending: [] };
686
- }
687
- }
688
-
689
- function saveAuth(auth) {
690
- fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
691
- }
692
-
693
- function authRequestLabel(request) {
694
- return request.username ? `@${request.username}` : request.name || request.chatId;
695
- }
696
-
697
- function updateAuthorizedChatEnv(auth) {
698
- const ids = new Set(CHAT_IDS);
699
- for (const user of auth.authorized) {
700
- if (user.chatId) ids.add(String(user.chatId));
701
- }
702
- saveEnvKey("TELEGRAM_CHAT_ID", [...ids].join(","));
703
- }
704
-
705
- async function approveAuthRequest(chatId) {
706
- const auth = loadAuth();
707
- if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
708
- auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
709
- saveAuth(auth);
710
- updateAuthorizedChatEnv(auth);
711
- return { status: "already_authorized" };
712
- }
713
-
714
- const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
715
- if (idx < 0) return { status: "not_found" };
716
-
717
- const approved = auth.pending.splice(idx, 1)[0];
718
- auth.authorized.push({
719
- chatId: approved.chatId,
720
- name: approved.name,
721
- username: approved.username,
722
- isOwner: false,
723
- authorizedAt: new Date().toISOString(),
724
- });
725
- saveAuth(auth);
726
- updateAuthorizedChatEnv(auth);
727
- return { status: "approved", request: approved };
728
- }
729
-
730
- async function denyAuthRequest(chatId) {
731
- const auth = loadAuth();
732
- const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
733
- if (idx < 0) return { status: "not_found" };
734
-
735
- const denied = auth.pending.splice(idx, 1)[0];
736
- saveAuth(auth);
737
- return { status: "denied", request: denied };
738
- }
739
-
740
- // ── Auth request handler (for unauthorized users) ──────────────────
741
- bot.onText(/\/auth$/, async (msg) => {
742
- if (isAuthorized(msg)) {
743
- bot.sendMessage(msg.chat.id, "You're already authorized.");
744
- return;
745
- }
746
- const chatId = String(msg.chat.id);
747
- const name = [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ");
748
- const username = msg.from?.username || "";
749
-
750
- // Add to pending in auth.json
751
- const auth = loadAuth();
752
-
753
- // Check if already pending
754
- if (auth.pending.some((p) => String(p.chatId) === chatId)) {
755
- bot.sendMessage(msg.chat.id, "Your request is already pending. The bot owner will review it.");
756
- return;
757
- }
758
-
759
- auth.pending.push({
760
- chatId,
761
- name,
762
- username,
763
- requestedAt: new Date().toISOString(),
764
- });
765
- saveAuth(auth);
766
-
767
- bot.sendMessage(msg.chat.id, "Access requested! The bot owner will review your request.");
768
-
769
- // Notify owner
770
- const label = authRequestLabel({ chatId, name, username });
771
- bot.sendMessage(CHAT_ID, `New auth request from ${label} (${chatId}).`, {
772
- reply_markup: {
773
- inline_keyboard: [[
774
- { text: "Approve", callback_data: `auth:approve:${chatId}` },
775
- { text: "Deny", callback_data: `auth:deny:${chatId}` },
776
- ]],
777
- },
778
- });
779
- });
780
-
781
- // ── Onboarding ──────────────────────────────────────────────────────
782
- // Onboarding state lives per-user, but `isOnboarded()` is a global gate
783
- // off .env — so in practice only the first install user (the owner) ever
784
- // runs the flow. Authorized teammates skip onboarding entirely.
785
-
786
- function isOnboarded() {
787
- return config.ONBOARDED === "true";
788
- }
789
-
790
- async function startOnboarding() {
791
- const state = currentState();
792
- state.onboardingStep = "name";
793
- state.onboardingData = {};
794
- await send(
795
- "Welcome! Let's set me up.\n\nWhat should I call you?"
796
- );
797
- }
798
-
799
- async function handleOnboarding(msg) {
800
- const state = currentState();
801
- const text = msg.text;
802
-
803
- if (state.onboardingStep === "name") {
804
- state.onboardingData.name = text;
805
- state.onboardingStep = "role";
806
- await send(`Nice to meet you, ${text}!\n\nWhat's your role? (e.g., "full-stack developer", "startup founder", "student")`);
807
- return true;
808
- }
809
-
810
- if (state.onboardingStep === "role") {
811
- state.onboardingData.role = text;
812
- state.onboardingStep = "style";
813
- await send("How should I communicate?\n\nPick a style:", {
814
- keyboard: {
815
- inline_keyboard: [
816
- [
817
- { text: "Concise & technical", callback_data: "ob:concise" },
818
- { text: "Detailed & educational", callback_data: "ob:detailed" },
819
- ],
820
- [
821
- { text: "Casual & friendly", callback_data: "ob:casual" },
822
- { text: "Minimal (just code)", callback_data: "ob:minimal" },
823
- ],
824
- ],
825
- },
826
- });
827
- return true;
828
- }
829
-
830
- return false;
831
- }
832
-
833
- function finishOnboarding(style) {
834
- const state = currentState();
835
- state.onboardingData.style = style;
836
- state.onboardingStep = null;
837
- const onboardingData = state.onboardingData;
838
-
839
- // Generate soul.md
840
- const styleDescriptions = {
841
- concise: "Direct, concise, technical. No fluff. Lead with the answer.",
842
- detailed: "Thorough and educational. Explain the why, not just the what.",
843
- casual: "Casual and friendly. Like chatting with a smart friend who codes.",
844
- minimal: "Minimal output. Just code and brief explanations. No chatter.",
845
- };
846
-
847
- const soul = `# Soul
848
159
 
849
- You are ${onboardingData.name}'s personal AI coding assistant, running 24/7 via Telegram.
160
+ initCrons();
850
161
 
851
- ## Identity
852
- - Tone: ${styleDescriptions[style]}
853
- - Platform: Telegram (mobile-first, keep messages short)
854
-
855
- ## About ${onboardingData.name}
856
- - Role: ${onboardingData.role}
857
-
858
- ## Working Style
859
- - Prefer action over discussion. Do the thing, then explain.
860
- - Send files for long output instead of text walls.
861
- - Proactively flag issues and suggest improvements.
862
-
863
- ## Capabilities
864
- - Write, edit, and refactor code across all projects
865
- - Run commands, tests, builds
866
- - Send files, images, code snippets directly via Telegram
867
- - Run scheduled checks (cron jobs) and report results
868
- - Store and use credentials from the encrypted vault
869
- `;
870
-
871
- fs.writeFileSync(SOUL_FILE, soul);
872
- saveEnvKey("ONBOARDED", "true");
873
- config.ONBOARDED = "true";
874
-
875
- send(`All set, ${onboardingData.name}! I'm configured and ready.\n\nTap /session to pick a project and start working.`, {
876
- keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] },
877
- });
878
- }
879
-
880
- // ── Soul / System Prompt ────────────────────────────────────────────
881
-
882
- function loadSoul() {
883
- try { return fs.readFileSync(SOUL_FILE, "utf-8"); } catch (e) { return "You are a helpful AI coding assistant."; }
884
- }
885
-
886
- function loadCrons() {
887
- try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
888
- }
889
-
890
- function saveCrons(list) {
891
- fs.writeFileSync(CRONS_FILE, JSON.stringify(list, null, 2));
892
- }
893
-
894
- function buildSystemPrompt() {
895
- const state = currentState();
896
- const soul = loadSoul();
897
- const hasVoice = WHISPER_CLI && FFMPEG;
898
-
899
- return `
900
- ${soul}
901
-
902
- ## Runtime Context
903
- - Interface: Telegram mobile chat through Open Claudia.
904
- - Active project path: ${state.currentSession ? state.currentSession.dir : "none"}
905
- - Voice notes: ${hasVoice ? "enabled" : "disabled"}
906
- - Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}
907
- - Session: ${state.lastSessionId ? "resuming existing conversation" : "new conversation"}
908
-
909
- ## Stable Local Paths
910
- - Bot code: ${path.join(BOT_DIR, "bot.js")}
911
- - Personality file: ${SOUL_FILE}
912
- - Cron config: ${CRONS_FILE}
913
- - Vault file: ${VAULT_FILE}
914
- - Bot environment: ${path.join(BOT_DIR, ".env")} (sensitive; never expose values)
915
- - Received user files directory: ${FILES_DIR}
916
-
917
- ${transcriptPointerNote(state)}
918
-
919
- ## Telegram Delivery
920
- Reply normally in your final answer. If you must send a large file, image, or artifact directly, use the Telegram API with the configured bot token from the environment/config; never print or embed the token in prompts, commands, logs, or messages.
921
-
922
- ## Guidelines
923
- - Keep responses concise — this is a mobile screen.
924
- - Use Telegram-compatible markdown: *bold*, _italic_, \`code\`, \`\`\`code blocks\`\`\`. No headers (#), no links [text](url).
925
- - For long output (logs, diffs, large code), save to a file and send it as an artifact instead of pasting walls of text.
926
- - Act on screenshots (fix bugs, implement designs) — don't just describe what you see.
927
- - When the user sends a file, it is saved in the received files directory above. Read it with the Read tool.
928
- - When the user sends a credential, token, or API key, store it in the vault immediately using the vault CLI or bot commands. Tell them it's stored and that you've deleted their message for security.
929
- - When asked to change your personality, edit the personality file above.
930
- - When asked about yourself, you are Open Claudia — an AI coding assistant running Claude Code via Telegram.
931
- - If a task will take a while, let the user know upfront.
932
- - Don't ask for confirmation on simple tasks — just do them.
933
- - NEVER start long-running processes (dev servers, watchers, tails) in the foreground. They block all further messages. Instead, run them in the background: \`nohup command &\` or \`command &disown\`. Then report the PID so the user can stop it later.
934
- - If asked to "start" or "run" a dev server, ALWAYS background it.
935
- `.trim();
936
- }
937
-
938
- // ── Helpers ─────────────────────────────────────────────────────────
939
-
940
- function listProjects() {
941
- try {
942
- return fs.readdirSync(WORKSPACE, { withFileTypes: true })
943
- .filter((d) => d.isDirectory())
944
- .map((d) => d.name)
945
- .filter((n) => !n.startsWith("."));
946
- } catch (e) { return []; }
947
- }
948
-
949
- function findProject(query) {
950
- const projects = listProjects();
951
- const q = query.toLowerCase();
952
- const exact = projects.find((p) => p.toLowerCase() === q);
953
- if (exact) return exact;
954
- const startsWith = projects.filter((p) => p.toLowerCase().startsWith(q));
955
- if (startsWith.length === 1) return startsWith[0];
956
- const contains = projects.filter((p) => p.toLowerCase().includes(q));
957
- if (contains.length === 1) return contains[0];
958
- return contains.length > 0 ? contains : null;
959
- }
960
-
961
- function projectKeyboard() {
962
- const projects = listProjects();
963
- const rows = [[{ text: "\u{1F4C1} Workspace (root)", callback_data: "s:__workspace__" }]];
964
- for (let i = 0; i < projects.length; i += 2) {
965
- const row = [{ text: projects[i], callback_data: `s:${projects[i]}` }];
966
- if (projects[i + 1]) row.push({ text: projects[i + 1], callback_data: `s:${projects[i + 1]}` });
967
- rows.push(row);
968
- }
969
- return { inline_keyboard: rows };
970
- }
971
-
972
- async function send(text, opts = {}) {
973
- const chatId = opts.chatId || currentChatId();
974
- const o = {};
975
- if (opts.parseMode) o.parse_mode = opts.parseMode;
976
- if (opts.keyboard) o.reply_markup = opts.keyboard;
977
- if (opts.replyTo) o.reply_to_message_id = opts.replyTo;
978
-
979
- for (let attempt = 0; attempt < 3; attempt++) {
162
+ for (const adapter of adapters) {
980
163
  try {
981
- const msg = await bot.sendMessage(chatId, text, o);
982
- return msg.message_id;
164
+ await adapter.start();
165
+ await adapter.registerCommands(publicCommands());
166
+ console.log(`Adapter ready: ${adapter.id} (${adapter.type})`);
983
167
  } catch (e) {
984
- const errMsg = e.message || "";
985
-
986
- // replyTo message was deleted or not found — retry without it
987
- if (o.reply_to_message_id && errMsg.includes("message to be replied not found")) {
988
- delete o.reply_to_message_id;
989
- continue;
990
- }
991
-
992
- // Rate limited — wait and retry
993
- const retryMatch = errMsg.match(/retry after (\d+)/i);
994
- if (retryMatch) {
995
- const waitSec = Math.min(parseInt(retryMatch[1], 10), 30);
996
- console.error(`Send: rate limited, waiting ${waitSec}s`);
997
- await new Promise((r) => setTimeout(r, waitSec * 1000));
998
- continue;
999
- }
1000
-
1001
- // Parse mode failed — retry without it
1002
- if (opts.parseMode && o.parse_mode) {
1003
- delete o.parse_mode;
1004
- continue;
1005
- }
1006
-
1007
- console.error("Send error:", errMsg);
1008
- return null;
1009
- }
1010
- }
1011
- console.error("Send: exhausted retries");
1012
- return null;
1013
- }
1014
-
1015
- async function editMessage(messageId, text, opts = {}) {
1016
- try {
1017
- const o = { chat_id: opts.chatId || currentChatId(), message_id: messageId };
1018
- if (opts.keyboard) o.reply_markup = opts.keyboard;
1019
- await bot.editMessageText(text, o);
1020
- } catch (e) {
1021
- const errMsg = e.message || "";
1022
- // Rate limited — skip this update (next interval will catch up)
1023
- if (errMsg.includes("retry after")) return;
1024
- // Message unchanged — ignore
1025
- if (errMsg.includes("message is not modified")) return;
1026
- // Log anything unexpected
1027
- if (!errMsg.includes("message to edit not found")) {
1028
- console.error("Edit error:", errMsg);
168
+ console.error(`Adapter ${adapter.id} failed to start:`, e.message);
1029
169
  }
1030
170
  }
1031
- }
1032
-
1033
- function splitMessage(text, maxLen = 4000) {
1034
- if (text.length <= maxLen) return [text];
1035
- const chunks = [];
1036
- while (text.length > 0) { chunks.push(text.slice(0, maxLen)); text = text.slice(maxLen); }
1037
- return chunks;
1038
- }
1039
171
 
1040
- async function downloadFile(fileId, ext) {
1041
- const file = await bot.getFile(fileId);
1042
- const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
1043
- const localPath = path.join(TEMP_DIR, `tg-${Date.now()}${ext}`);
1044
- await new Promise((resolve, reject) => {
1045
- const out = fs.createWriteStream(localPath);
1046
- https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
1047
- });
1048
- return localPath;
1049
- }
1050
-
1051
- function transcribeAudio(oggPath) {
1052
- if (!WHISPER_CLI || !FFMPEG) return null;
1053
- const wavPath = oggPath.replace(/\.[^.]+$/, ".wav");
1054
- execSync(`"${FFMPEG}" -i "${oggPath}" -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`);
1055
- const output = execSync(`"${WHISPER_CLI}" -m "${WHISPER_MODEL}" --no-timestamps -f "${wavPath}" 2>/dev/null`, { encoding: "utf-8" });
1056
- try { fs.unlinkSync(wavPath); } catch (e) { /* ignore */ }
1057
- return output.split("\n")
1058
- .filter((l) => l.trim() && !l.startsWith("whisper_") && !l.startsWith("ggml_") && !l.startsWith("load_"))
1059
- .join(" ").trim();
1060
- }
1061
-
1062
- // ── Text-to-Speech ────────────────────────────────────────────────
1063
-
1064
- const TTS_CMD = process.platform === "darwin" ? "say" : null;
1065
-
1066
- function textToVoice(text) {
1067
- if (!TTS_CMD || !FFMPEG) return null;
1068
- try {
1069
- // Strip markdown formatting for cleaner speech
1070
- const clean = text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
1071
- if (!clean) return null;
1072
- const aiffPath = path.join(TEMP_DIR, `tts-${Date.now()}.aiff`);
1073
- const oggPath = aiffPath.replace(".aiff", ".ogg");
1074
- execSync(`${TTS_CMD} ${JSON.stringify(clean)} -o "${aiffPath}"`, { timeout: 30000 });
1075
- execSync(`"${FFMPEG}" -i "${aiffPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
1076
- try { fs.unlinkSync(aiffPath); } catch (e) {}
1077
- return oggPath;
1078
- } catch (e) {
1079
- console.error("TTS error:", e.message);
1080
- return null;
1081
- }
1082
- }
172
+ console.log(`Open Claudia v${CURRENT_VERSION} running on: ${adapters.map((a) => a.id).join(", ")}`);
173
+ console.log(`Workspace: ${require("./core/config").WORKSPACE}`);
174
+ console.log(`Soul: ${SOUL_FILE}`);
175
+ console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
176
+ console.log(`Onboarded: ${isOnboarded()}`);
1083
177
 
1084
- async function sendVoice(oggPath) {
1085
- try {
1086
- await bot.sendVoice(currentChatId(), oggPath);
1087
- try { fs.unlinkSync(oggPath); } catch (e) {}
1088
- return true;
1089
- } catch (e) {
1090
- console.error("Send voice error:", e.message);
1091
- try { fs.unlinkSync(oggPath); } catch (e2) {}
1092
- return false;
178
+ // Notify owner that bot is back online via every adapter that knows
179
+ // an owner channel. Kazee skipped: ownerUserId is a user id, not a
180
+ // chat_id, and chat-central has no DM-by-userId send. Owner DM still
181
+ // wakes the bot fine this is just the proactive "I'm back" ping.
182
+ for (const a of adapters) {
183
+ try {
184
+ if (a.type === "telegram" && CHAT_ID) {
185
+ await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`);
186
+ }
187
+ } catch (e) {}
1093
188
  }
1094
- }
1095
-
1096
- // Delete a message (used for vault password cleanup)
1097
- async function deleteMessage(msgId) {
1098
- try { await bot.deleteMessage(currentChatId(), msgId); } catch (e) { /* ignore */ }
1099
- }
1100
-
1101
-
1102
- // ── Requirements / Doctor Helpers ──────────────────────────────────
1103
-
1104
- function shellQuote(value) {
1105
- return `"${String(value).replace(/"/g, '\\"')}"`;
1106
- }
1107
-
1108
- function runCommandForDoctor(command, args = [], opts = {}) {
1109
- try {
1110
- const out = execSync([command, ...args].map(shellQuote).join(" "), {
1111
- cwd: opts.cwd || process.env.HOME || require("os").homedir(),
1112
- env: opts.env || botSubprocessEnv(),
1113
- encoding: "utf-8",
1114
- timeout: opts.timeout || 10000,
1115
- stdio: ["ignore", "pipe", "pipe"],
1116
- });
1117
- return { ok: true, output: out.trim(), code: 0 };
1118
- } catch (e) {
1119
- return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
1120
- }
1121
- }
1122
-
1123
- function binaryCheck(binPath, name) {
1124
- if (!binPath) return { ok: false, label: name, detail: "not configured/found" };
1125
- try {
1126
- if (fs.existsSync(binPath)) fs.accessSync(binPath, fs.constants.X_OK);
1127
- else execSync(process.platform === "win32" ? `where ${shellQuote(binPath)}` : `which ${shellQuote(binPath)}`, { stdio: "ignore", env: botSubprocessEnv() });
1128
- return { ok: true, label: name, detail: binPath };
1129
- } catch (e) {
1130
- return { ok: false, label: name, detail: `not executable: ${binPath}` };
1131
- }
1132
- }
1133
-
1134
- function checkWritableDir(dirPath, label) {
1135
- if (!dirPath) return { ok: false, label, detail: "not configured" };
1136
- try {
1137
- if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
1138
- const test = path.join(dirPath, `.open-claudia-write-test-${Date.now()}`);
1139
- fs.writeFileSync(test, "ok");
1140
- fs.unlinkSync(test);
1141
- return { ok: true, label, detail: dirPath };
1142
- } catch (e) {
1143
- return { ok: false, label, detail: redactSensitive(e.message) };
1144
- }
1145
- }
1146
-
1147
- function summarizeAuthOutput(output, ok) {
1148
- const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
1149
- if (!clean) return ok ? "ok" : "no output";
1150
- return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
1151
- }
1152
-
1153
- function isCodexAuthErrorText(text) {
1154
- return /not (?:logged in|authenticated)|unauthenticated|login required|please (?:log|sign) in|401 unauthorized|invalid api key|no credentials/i.test(String(text || ""));
1155
- }
1156
-
1157
- function runDoctorChecks() {
1158
- const checks = [];
1159
- const nodeMajor = parseInt(process.version.slice(1).split(".")[0], 10);
1160
- checks.push({ ok: nodeMajor >= 18, label: "Node.js", detail: process.version, action: nodeMajor >= 18 ? "" : "Install Node.js 18+." });
1161
-
1162
- const claudeBin = binaryCheck(CLAUDE_PATH, "Claude CLI");
1163
- if (claudeBin.ok) {
1164
- const ver = runCommandForDoctor(CLAUDE_PATH, ["--version"]);
1165
- const auth = runCommandForDoctor(CLAUDE_PATH, ["auth", "status"], { env: claudeSubprocessEnv(), timeout: 12000 });
1166
- checks.push({ ok: ver.ok, label: "Claude version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CLAUDE_PATH." });
1167
- checks.push({ ok: auth.ok && !isClaudeAuthErrorText(auth.output), label: "Claude auth", detail: summarizeAuthOutput(auth.output, auth.ok), action: auth.ok && !isClaudeAuthErrorText(auth.output) ? "" : "Run /auth_status then /setup_token or /login." });
1168
- } else checks.push({ ...claudeBin, action: "Install @anthropic-ai/claude-code or fix CLAUDE_PATH." });
1169
-
1170
- if (CURSOR_PATH || resolvedCursorPath) {
1171
- const cursorPath = resolvedCursorPath || CURSOR_PATH;
1172
- const cursorBin = binaryCheck(cursorPath, "Cursor Agent");
1173
- if (cursorBin.ok) {
1174
- const ver = runCommandForDoctor(cursorPath, ["--version"]);
1175
- const status = runCommandForDoctor(cursorPath, ["status"], { timeout: 12000 });
1176
- checks.push({ ok: ver.ok, label: "Cursor version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CURSOR_PATH." });
1177
- checks.push({ ok: status.ok, label: "Cursor auth/status", detail: summarizeAuthOutput(status.output, status.ok), action: status.ok ? "" : "Run `agent login` on this host." });
1178
- } else checks.push({ ...cursorBin, action: "Install Cursor Agent or fix CURSOR_PATH." });
1179
- } else checks.push({ ok: true, warn: true, label: "Cursor Agent", detail: "not configured/found (optional)", action: "Install only if you want /cursor." });
1180
-
1181
- if (CODEX_PATH || resolvedCodexPath) {
1182
- const codexPath = resolvedCodexPath || CODEX_PATH;
1183
- const codexBin = binaryCheck(codexPath, "Codex CLI");
1184
- if (codexBin.ok) {
1185
- const ver = runCommandForDoctor(codexPath, ["--version"]);
1186
- const status = runCommandForDoctor(codexPath, ["login", "status"], { timeout: 12000 });
1187
- checks.push({ ok: ver.ok, label: "Codex version", detail: summarizeAuthOutput(ver.output, ver.ok), action: ver.ok ? "" : "Check CODEX_PATH." });
1188
- checks.push({ ok: status.ok && !isCodexAuthErrorText(status.output), label: "Codex auth", detail: summarizeAuthOutput(status.output, status.ok), action: status.ok && !isCodexAuthErrorText(status.output) ? "" : "Run /codex_login or /codex_setup_token." });
1189
- } else checks.push({ ...codexBin, action: "Install @openai/codex or fix CODEX_PATH." });
1190
- } else checks.push({ ok: true, warn: true, label: "Codex CLI", detail: "not configured/found (optional)", action: "Install only if you want /codex." });
1191
-
1192
- if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
1193
- const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");
1194
- const wh = binaryCheck(WHISPER_CLI, "Whisper CLI");
1195
- checks.push({ ...ff, action: ff.ok ? "" : "Install ffmpeg or set FFMPEG." });
1196
- checks.push({ ...wh, action: wh.ok ? "" : "Install whisper.cpp or set WHISPER_CLI." });
1197
- checks.push({ ok: !!WHISPER_MODEL && fs.existsSync(WHISPER_MODEL), label: "Whisper model", detail: WHISPER_MODEL || "not configured", action: WHISPER_MODEL ? "Check model path." : "Set WHISPER_MODEL for voice notes." });
1198
- } else checks.push({ ok: true, warn: true, label: "Voice stack", detail: "not configured (optional)", action: "Set FFMPEG/WHISPER_CLI/WHISPER_MODEL for voice notes." });
1199
-
1200
- checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
1201
- checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
1202
- return checks;
1203
- }
1204
-
1205
- function formatDoctorReport(checks) {
1206
- const hardProblems = checks.filter((c) => !c.ok && !c.warn);
1207
- const warnings = checks.filter((c) => c.warn || (!c.ok && c.warn));
1208
- const lines = [hardProblems.length ? "⚠️ Open Claudia doctor found issues" : "✅ Open Claudia doctor looks good", ""];
1209
- for (const c of checks) {
1210
- const icon = c.ok ? (c.warn ? "⚠️" : "✅") : "⚠️";
1211
- lines.push(`${icon} ${c.label}: ${c.detail || (c.ok ? "ok" : "issue")}`);
1212
- }
1213
- const actions = checks.filter((c) => (!c.ok || c.warn) && c.action).map((c) => `• ${c.label}: ${c.action}`);
1214
- if (actions.length) lines.push("", "Next actions:", ...actions.slice(0, 8));
1215
- if (warnings.length && !hardProblems.length) lines[0] = "✅ Core requirements pass (optional items noted)";
1216
- return lines.join("\n");
1217
- }
1218
-
1219
- function looksLikeOpenAIKey(value) {
1220
- return /^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(String(value || "").trim());
1221
- }
1222
-
1223
- function clearPendingCodexAuth(state = currentState()) {
1224
- if (state.pendingCodexAuthProcess && state.pendingCodexAuthProcess.kill) {
1225
- try { state.pendingCodexAuthProcess.kill("SIGTERM"); } catch (e) {}
1226
- }
1227
- state.pendingCodexAuthProcess = null;
1228
- state.pendingCodexAuthLabel = null;
1229
- }
1230
-
1231
- function runCodexLoginStatus() {
1232
- if (!resolvedCodexPath) return { ok: false, code: 1, output: "Codex CLI not found" };
1233
- const result = runCommandForDoctor(resolvedCodexPath, ["login", "status"], { timeout: 12000 });
1234
- return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
1235
- }
1236
-
1237
- async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
1238
- const status = runCodexLoginStatus();
1239
- const version = resolvedCodexPath ? runCommandForDoctor(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
1240
- await send([
1241
- prefix,
1242
- "",
1243
- `CLI: ${resolvedCodexPath ? "found" : "not found"}`,
1244
- `Version: ${summarizeAuthOutput(version.output, version.ok)}`,
1245
- `Logged in: ${status.ok ? "yes" : "no/unknown"}`,
1246
- `Status: ${summarizeAuthOutput(status.output, status.ok)}`,
1247
- status.ok ? "" : "Next: /codex_login for device auth, or /codex_setup_token to paste an OpenAI API key securely.",
1248
- ].filter(Boolean).join("\n"));
1249
- }
1250
-
1251
- async function saveCodexApiKeyWithCli(apiKey) {
1252
- return new Promise((resolve) => {
1253
- const proc = spawn(resolvedCodexPath, ["login", "--with-api-key"], {
1254
- cwd: process.env.HOME || require("os").homedir(),
1255
- env: botSubprocessEnv(),
1256
- stdio: ["pipe", "pipe", "pipe"],
1257
- });
1258
- let output = "";
1259
- proc.stdout.on("data", (d) => { output += d.toString(); });
1260
- proc.stderr.on("data", (d) => { output += d.toString(); });
1261
- proc.on("close", (code) => resolve({ ok: code === 0, code, output }));
1262
- proc.on("error", (err) => resolve({ ok: false, code: 1, output: err.message }));
1263
- proc.stdin.write(apiKey.trim() + "\n");
1264
- proc.stdin.end();
1265
- });
1266
- }
1267
-
1268
- async function runCodexDeviceLogin() {
1269
- const state = currentState();
1270
- if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
1271
- if (state.pendingCodexAuthProcess) return send(`Another Codex auth flow is already running (${state.pendingCodexAuthLabel}). Send /cancel_codex_auth to cancel.`);
1272
- await send("Codex device login started. I’ll send the URL/code if the CLI prints one.");
1273
- const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
1274
- cwd: process.env.HOME || require("os").homedir(),
1275
- env: botSubprocessEnv(),
1276
- stdio: ["pipe", "pipe", "pipe"],
1277
- });
1278
- state.pendingCodexAuthProcess = proc;
1279
- state.pendingCodexAuthLabel = "Codex device login";
1280
- let output = "";
1281
- let sent = new Set();
1282
- let lastSnippetAt = 0;
1283
- const handleChunk = async (chunk) => {
1284
- output += chunk;
1285
- const cleanChunk = redactSensitive(stripTerminalControls(chunk));
1286
- for (const url of extractUrls(cleanChunk)) {
1287
- if (!sent.has(url)) {
1288
- sent.add(url);
1289
- await send(`Codex login URL:\n${redactSensitive(url)}\n\nOpen it and enter the device code if shown.`);
1290
- }
1291
- }
1292
- const codeMatch = cleanChunk.match(/(?:code|device code)[:\s]+([A-Z0-9-]{6,})/i);
1293
- if (codeMatch && !sent.has(codeMatch[1])) {
1294
- sent.add(codeMatch[1]);
1295
- await send(`Codex device code: ${codeMatch[1]}`);
1296
- }
1297
- const now = Date.now();
1298
- if (cleanChunk.trim() && /device|code|login|browser|open|auth|token|error|failed|api key/i.test(cleanChunk) && now - lastSnippetAt > 3000) {
1299
- lastSnippetAt = now;
1300
- await send(cleanChunk.trim().slice(-1200));
1301
- }
1302
- };
1303
- proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth output error:", e.message)));
1304
- proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Codex auth stderr error:", e.message)));
1305
- proc.on("close", async (code) => {
1306
- state.pendingCodexAuthProcess = null;
1307
- state.pendingCodexAuthLabel = null;
1308
- const clean = redactSensitive(stripTerminalControls(output)).trim();
1309
- if (/raw mode is not supported|not a tty|inappropriate ioctl/i.test(clean)) {
1310
- await send("Codex device login could not complete inside Telegram on this host. Use /codex_setup_token to paste an OpenAI API key securely, or run `codex login --device-auth` in an SSH/terminal session.");
1311
- } else if (clean) await send(`Codex login finished (exit ${code}).\n\n${clean.slice(-2000)}`);
1312
- else await send(`Codex login finished (exit ${code}).`);
1313
- await sendCodexAuthStatusSummary("Post-Codex-auth check:");
1314
- });
1315
- proc.on("error", async (err) => {
1316
- state.pendingCodexAuthProcess = null;
1317
- state.pendingCodexAuthLabel = null;
1318
- await send(`Codex login failed: ${redactSensitive(err.message)}`);
1319
- });
1320
- }
1321
-
1322
- // ── Claude Auth Helpers ─────────────────────────────────────────────
1323
-
1324
- const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
1325
- const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
1326
-
1327
- function redactSensitive(value) {
1328
- return String(value || "")
1329
- .replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
1330
- .replace(/sk-proj-[A-Za-z0-9._-]+/g, "[REDACTED_OPENAI_KEY]")
1331
- .replace(/sk-[A-Za-z0-9._-]{20,}/g, "[REDACTED_OPENAI_KEY]")
1332
- .replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
1333
- .replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
1334
- .replace(/(OPENAI_API_KEY\s*=\s*)\S+/gi, "$1[REDACTED_OPENAI_KEY]")
1335
- .replace(/([?&](?:token|access_token|refresh_token|api_key)=)[^\s&]+/gi, "$1[REDACTED]");
1336
- }
1337
-
1338
- const projectTranscripts = new ProjectTranscripts({
1339
- configDir: CONFIG_DIR,
1340
- enabled: PROJECT_TRANSCRIPTS,
1341
- transcriptsDir: TRANSCRIPTS_DIR,
1342
- maxEntryChars: TRANSCRIPT_MAX_ENTRY_CHARS,
1343
- redact: redactSensitive,
189
+ })().catch((e) => {
190
+ console.error("Boot failure:", e);
191
+ process.exit(1);
1344
192
  });
1345
- if (PROJECT_TRANSCRIPTS) {
1346
- try { fs.mkdirSync(projectTranscripts.transcriptsDir, { recursive: true, mode: 0o700 }); } catch (e) {}
1347
- }
1348
-
1349
- function transcriptProjectInfo(state = currentState()) {
1350
- if (!state.currentSession) return null;
1351
- return projectTranscripts.pointer(state.currentSession.dir, state.currentSession.name);
1352
- }
1353
-
1354
- function transcriptPointerNote(state = currentState()) {
1355
- if (!state.currentSession) return "";
1356
- return projectTranscripts.buildPointerNote(state.currentSession.dir, state.currentSession.name);
1357
- }
1358
-
1359
- function appendProjectTranscript(role, text, metadata = {}, state = currentState()) {
1360
- if (!state.currentSession) return null;
1361
- try {
1362
- return projectTranscripts.append({
1363
- role,
1364
- text,
1365
- userId: state.userId,
1366
- chat: { transport: "telegram", id: String(currentChatId()) },
1367
- projectName: state.currentSession.name,
1368
- projectPath: state.currentSession.dir,
1369
- backend: state.settings.backend,
1370
- sessionId: getActiveSessionId(),
1371
- metadata,
1372
- });
1373
- } catch (e) {
1374
- console.error("Transcript write failed:", redactSensitive(e.message));
1375
- return null;
1376
- }
1377
- }
1378
-
1379
- function promptWithTranscriptPointer(prompt, state = currentState()) {
1380
- if (!state.currentSession) return prompt;
1381
- return projectTranscripts.withPointer(prompt, state.currentSession.dir, state.currentSession.name);
1382
- }
1383
-
1384
- function stripTranscriptPointerForStorage(prompt) {
1385
- return String(prompt || "").replace(/^## Project Transcript Memory\n[\s\S]*?\n\nCurrent user request:\n/, "");
1386
- }
1387
-
1388
-
1389
- function stripTerminalControls(value) {
1390
- return String(value || "")
1391
- // OSC 8 hyperlinks and other OSC sequences
1392
- .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
1393
- // CSI ANSI sequences
1394
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
1395
- // remaining non-printing controls except newline/tab
1396
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
1397
- }
1398
-
1399
- function isClaudeAuthUrl(url) {
1400
- try {
1401
- const u = new URL(url);
1402
- const host = u.hostname.toLowerCase();
1403
- return host === "claude.com" || host.endsWith(".claude.com") ||
1404
- host === "anthropic.com" || host.endsWith(".anthropic.com") ||
1405
- host === "localhost" || host === "127.0.0.1";
1406
- } catch (e) {
1407
- return false;
1408
- }
1409
- }
1410
-
1411
- function looksLikeClaudeToken(value) {
1412
- const text = String(value || "").trim();
1413
- return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text);
1414
- }
1415
-
1416
-
1417
- function looksLikeClaudeAuthReply(value) {
1418
- const text = String(value || "").trim();
1419
- if (!text) return false;
1420
- if (looksLikeClaudeToken(text)) return true;
1421
- if (/^https?:\/\//i.test(text) && /claude|anthropic/i.test(text)) return true;
1422
- // OAuth callback/login codes are usually long, dense strings. Do not consume normal chat.
1423
- if (text.length >= 24 && !/\s/.test(text) && /^[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+$/.test(text)) return true;
1424
- return false;
1425
- }
1426
-
1427
- function clearPendingClaudeAuth(state = currentState()) {
1428
- if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthProcess.kill) {
1429
- try { state.pendingClaudeAuthProcess.kill("SIGTERM"); } catch (e) {}
1430
- }
1431
- state.pendingClaudeAuthProcess = null;
1432
- state.pendingClaudeAuthLabel = null;
1433
- }
1434
-
1435
- function getClaudeOAuthToken() {
1436
- if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
1437
- if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
1438
- if (vault.isUnlocked()) {
1439
- const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
1440
- if (value) return { value, source: "vault" };
1441
- }
1442
- return { value: null, source: null };
1443
- }
1444
-
1445
- function botSubprocessEnv() {
1446
- return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
1447
- }
1448
-
1449
- function claudeSubprocessEnv() {
1450
- const env = botSubprocessEnv();
1451
- const token = getClaudeOAuthToken().value;
1452
- if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
1453
- return env;
1454
- }
1455
-
1456
- function saveClaudeOAuthToken(token) {
1457
- const clean = String(token || "").trim();
1458
- if (!clean) return false;
1459
- saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
1460
- config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
1461
- process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
1462
- if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
1463
- return true;
1464
- }
1465
-
1466
- function clearClaudeOAuthToken() {
1467
- saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
1468
- config[CLAUDE_OAUTH_TOKEN_KEY] = "";
1469
- delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
1470
- if (vault.isUnlocked()) {
1471
- vault.remove(CLAUDE_OAUTH_VAULT_KEY);
1472
- vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
1473
- }
1474
- }
1475
-
1476
- function extractClaudeToken(text) {
1477
- const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
1478
- return match ? match[0] : null;
1479
- }
1480
-
1481
- function extractUrls(text) {
1482
- return [...stripTerminalControls(text).matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
1483
- }
1484
-
1485
-
1486
- function isClaudeAuthErrorText(text) {
1487
- const lower = String(text || "").toLowerCase();
1488
- return lower.includes("unauthorized") ||
1489
- lower.includes("not logged in") ||
1490
- lower.includes("login required") ||
1491
- lower.includes("reauthenticate") ||
1492
- lower.includes("re-authenticate") ||
1493
- lower.includes("authentication failed") ||
1494
- lower.includes("auth failed") ||
1495
- lower.includes("invalid api key") ||
1496
- lower.includes("api key") && lower.includes("invalid") ||
1497
- lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed")) ||
1498
- lower.includes("security unlock-keychain");
1499
- }
1500
-
1501
- function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
1502
- const tokenInfo = getClaudeOAuthToken();
1503
- const tokenLine = tokenInfo.value
1504
- ? `Stored OAuth token: yes (${tokenInfo.source})`
1505
- : "Stored OAuth token: no";
1506
- return [
1507
- `Claude auth needs attention: ${redactSensitive(reason)}`,
1508
- "",
1509
- tokenLine,
1510
- "",
1511
- "Try one of these from Telegram:",
1512
- "1. /auth_status — check what Claude sees",
1513
- "2. /setup_token — create a long-lived Claude Code token, then store it",
1514
- "3. /use_oauth_token — paste the token securely if you already generated one",
1515
- "4. /login — interactive Claude.ai browser login fallback",
1516
- "",
1517
- "If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
1518
- "security unlock-keychain",
1519
- "",
1520
- "Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain."
1521
- ].join("\n");
1522
- }
1523
-
1524
- function preflightClaudeAuthMessage() {
1525
- const state = currentState();
1526
- if (state.settings.backend !== "claude") return null;
1527
- if (getClaudeOAuthToken().value) return null;
1528
- try {
1529
- const output = execSync(`"${CLAUDE_PATH}" auth status`, {
1530
- cwd: process.env.HOME || require("os").homedir(),
1531
- env: claudeSubprocessEnv(),
1532
- encoding: "utf8",
1533
- timeout: 10000,
1534
- stdio: ["ignore", "pipe", "pipe"],
1535
- });
1536
- if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
1537
- return null;
1538
- } catch (e) {
1539
- const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
1540
- if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
1541
- return null;
1542
- }
1543
- }
1544
-
1545
-
1546
- function isClaudeUsageLimitText(text) {
1547
- const lower = String(text || "").toLowerCase();
1548
- return lower.includes("usage limit") ||
1549
- lower.includes("you've hit your usage limit") ||
1550
- lower.includes("you have hit your usage limit") ||
1551
- lower.includes("spend limit") ||
1552
- lower.includes("monthly cycle") ||
1553
- lower.includes("rate limit") && lower.includes("model");
1554
- }
1555
-
1556
- function claudeUsageLimitMessage(details = "") {
1557
- return [
1558
- "Claude ran, but the selected model is unavailable/limited right now.",
1559
- details ? `\nDetails:\n${redactSensitive(details)}` : "",
1560
- "",
1561
- "Try from Telegram:",
1562
- "1. /model sonnet",
1563
- "2. Then send your message again",
1564
- "",
1565
- "If you specifically need Opus, wait for the usage window to reset or increase the spend/usage limit."
1566
- ].filter(Boolean).join("\n");
1567
- }
1568
-
1569
- function runClaudeAuthStatusDiagnostic() {
1570
- try {
1571
- const output = execSync(`"${CLAUDE_PATH}" auth status`, {
1572
- cwd: process.env.HOME || require("os").homedir(),
1573
- env: claudeSubprocessEnv(),
1574
- encoding: "utf8",
1575
- timeout: 10000,
1576
- stdio: ["ignore", "pipe", "pipe"],
1577
- });
1578
- return output.trim();
1579
- } catch (e) {
1580
- return `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim();
1581
- }
1582
- }
1583
-
1584
-
1585
- async function sendClaudeAuthStatusSummary(prefix = "Claude auth status") {
1586
- const output = runClaudeAuthStatusDiagnostic();
1587
- const tokenInfo = getClaudeOAuthToken();
1588
- const lines = summarizeClaudeAuthStatus(output, isClaudeAuthErrorText(output) ? 1 : 0, tokenInfo);
1589
- await send([
1590
- prefix,
1591
- "",
1592
- ...lines,
1593
- `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1594
- ].join("\n"));
1595
- }
1596
-
1597
- function claudeEmptyFailureMessage(code, stderrText = "") {
1598
- const stderr = redactSensitive(String(stderrText || "").trim());
1599
- if (isClaudeUsageLimitText(stderr)) return claudeUsageLimitMessage(stderr.slice(-1200));
1600
- if (isClaudeAuthErrorText(stderr)) return claudeAuthRecoveryMessage(stderr.slice(-1200));
1601
-
1602
- const authStatus = runClaudeAuthStatusDiagnostic();
1603
- if (isClaudeAuthErrorText(authStatus)) return claudeAuthRecoveryMessage(authStatus.slice(-1200));
1604
- if (isClaudeUsageLimitText(authStatus)) return claudeUsageLimitMessage(authStatus.slice(-1200));
1605
-
1606
- return [
1607
- `Claude exited with code ${code} but produced no assistant output.`,
1608
- stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
1609
- authStatus ? `\nAuth status:\n${redactSensitive(authStatus).slice(-1200)}` : "",
1610
- "",
1611
- "Useful next steps:",
1612
- "• /auth_status — verify Claude auth",
1613
- "• /model sonnet — switch away from Opus if usage-limited",
1614
- "• /setup_token — create a launchd-safe OAuth token if Keychain is the issue"
1615
- ].filter(Boolean).join("\n");
1616
- }
1617
-
1618
-
1619
- function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
1620
- const text = String(output || "");
1621
- const lower = text.toLowerCase();
1622
- const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
1623
- const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
1624
- const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
1625
- const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
1626
- let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
1627
- if (/api key|apikey/.test(lower)) method = "API key";
1628
- else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
1629
- return [
1630
- `Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
1631
- `Auth method: ${method}`,
1632
- `Email: ${email || "unknown"}`,
1633
- `Provider: ${provider}`,
1634
- ];
1635
- }
1636
-
1637
- async function runClaudeAuthCommand(args, label, opts = {}) {
1638
- const state = currentState();
1639
- if (state.pendingClaudeAuthProcess) {
1640
- await send(`Another Claude auth flow is already running (${state.pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
1641
- return;
1642
- }
1643
- await send(`${label} started...`);
1644
- const proc = spawn(CLAUDE_PATH, args, {
1645
- cwd: process.env.HOME || require("os").homedir(),
1646
- env: claudeSubprocessEnv(),
1647
- stdio: ["pipe", "pipe", "pipe"],
1648
- });
1649
- state.pendingClaudeAuthProcess = proc;
1650
- state.pendingClaudeAuthLabel = label;
1651
- let output = "";
1652
- let sentUrls = new Set();
1653
- let tokenStored = false;
1654
- let lastSnippetAt = 0;
1655
-
1656
- const handleChunk = async (chunk) => {
1657
- output += chunk;
1658
- const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
1659
- if (token && !tokenStored) {
1660
- tokenStored = saveClaudeOAuthToken(token);
1661
- await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
1662
- }
1663
- for (const url of extractUrls(chunk)) {
1664
- if (!isClaudeAuthUrl(url)) continue;
1665
- if (!sentUrls.has(url)) {
1666
- sentUrls.add(url);
1667
- await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
1668
- }
1669
- }
1670
- const redacted = redactSensitive(stripTerminalControls(chunk)).trim();
1671
- const now = Date.now();
1672
- const usefulAuthLine = /opening browser|paste code|enter code|login|auth|token|error|failed/i.test(redacted);
1673
- if (redacted && usefulAuthLine && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted) && !/github\.com\/vadimdemedes\/ink/i.test(redacted)) {
1674
- lastSnippetAt = now;
1675
- await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
1676
- }
1677
- };
1678
-
1679
- proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
1680
- proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
1681
- proc.on("close", async (code) => {
1682
- state.pendingClaudeAuthProcess = null;
1683
- state.pendingClaudeAuthLabel = null;
1684
- const token = opts.captureToken ? extractClaudeToken(output) : null;
1685
- if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
1686
- const cleaned = redactSensitive(stripTerminalControls(output))
1687
- .replace(/https?:\/\/github\.com\/vadimdemedes\/ink\S*/gi, "")
1688
- .trim();
1689
- const isTtyError = /raw mode is not supported|process\.stdin/i.test(output);
1690
- if (tokenStored) {
1691
- await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
1692
- await sendClaudeAuthStatusSummary("Post-auth check:");
1693
- } else if (isTtyError && opts.captureToken) {
1694
- await send(`${label} cannot complete from this bot — the Claude CLI needs an interactive terminal for setup-token.\n\nRun this in your Mac terminal:\n claude setup-token\n\nThen paste the token here with:\n /use_oauth_token <token>`);
1695
- } else if (cleaned) {
1696
- await send(`${label} finished (exit ${code}).\n\n${cleaned.slice(-2500)}`);
1697
- await sendClaudeAuthStatusSummary("Post-auth check:");
1698
- } else {
1699
- await send(`${label} finished (exit ${code}).`);
1700
- await sendClaudeAuthStatusSummary("Post-auth check:");
1701
- }
1702
- });
1703
- proc.on("error", async (err) => {
1704
- state.pendingClaudeAuthProcess = null;
1705
- state.pendingClaudeAuthLabel = null;
1706
- await send(`${label} failed: ${redactSensitive(err.message)}`);
1707
- });
1708
- }
1709
-
1710
- // ── Claude Runner ───────────────────────────────────────────────────
1711
-
1712
- function parseStreamEvents(data) {
1713
- const events = [];
1714
- for (const line of data.split("\n").filter((l) => l.trim())) {
1715
- try { events.push(JSON.parse(line)); } catch (e) { /* partial */ }
1716
- }
1717
- return events;
1718
- }
1719
-
1720
- function buildClaudeArgs(prompt, opts = {}) {
1721
- const state = currentState();
1722
- const { settings } = state;
1723
- if (settings.backend === "cursor") return buildCursorArgs(prompt, opts);
1724
- if (settings.backend === "codex") return buildCodexArgs(prompt, opts);
1725
- const args = ["-p", "--verbose", "--output-format", "stream-json",
1726
- "--append-system-prompt", buildSystemPrompt()];
1727
- const transcriptInfo = transcriptProjectInfo(state);
1728
- if (transcriptInfo) args.push("--add-dir", transcriptInfo.transcriptsDir);
1729
- if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
1730
- else if (opts.continueSession) args.push("--continue");
1731
- else if (state.lastSessionId && !opts.fresh) args.push("--resume", state.lastSessionId);
1732
- if (settings.model) args.push("--model", settings.model);
1733
- if (settings.effort) args.push("--effort", settings.effort);
1734
- if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
1735
- if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
1736
- else args.push("--dangerously-skip-permissions");
1737
- if (settings.worktree) args.push("--worktree");
1738
- args.push(prompt);
1739
- return args;
1740
- }
1741
-
1742
- function buildCursorArgs(prompt, opts = {}) {
1743
- const state = currentState();
1744
- const { settings } = state;
1745
- const args = ["--print", "--trust", "--output-format", "stream-json"];
1746
- if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
1747
- else if (opts.continueSession) args.push("--continue");
1748
- else if (state.cursorSessionId && !opts.fresh) args.push("--resume", state.cursorSessionId);
1749
- if (settings.model) args.push("--model", settings.model);
1750
- if (settings.permissionMode === "plan") args.push("--mode", "plan");
1751
- else if (settings.permissionMode === "ask") args.push("--mode", "ask");
1752
- if (settings.worktree) args.push("--worktree");
1753
- args.push(prompt);
1754
- return args;
1755
- }
1756
-
1757
- function buildCodexArgs(prompt, opts = {}) {
1758
- // Codex uses `exec [SESSION] [PROMPT]` or `exec resume <SESSION> [PROMPT]`.
1759
- // Sandbox: bot runs headless, so bypass approvals (mirrors --dangerously-skip-permissions).
1760
- // Plan mode: drop to read-only sandbox so the model can't write.
1761
- const state = currentState();
1762
- const { settings, codexSessionId } = state;
1763
- const args = [];
1764
- const resumeId = opts.resumeSessionId || ((!opts.fresh && !opts.continueSession) ? codexSessionId : null);
1765
- if (opts.continueSession && codexSessionId) {
1766
- args.push("exec", "resume", codexSessionId);
1767
- } else if (resumeId) {
1768
- args.push("exec", "resume", resumeId);
1769
- } else {
1770
- args.push("exec");
1771
- }
1772
- args.push("--json", "--skip-git-repo-check");
1773
- const transcriptInfo = transcriptProjectInfo(state);
1774
- if (transcriptInfo) args.push("--add-dir", transcriptInfo.transcriptsDir);
1775
- if (settings.permissionMode === "plan") {
1776
- args.push("--sandbox", "read-only");
1777
- } else {
1778
- args.push("--dangerously-bypass-approvals-and-sandbox");
1779
- }
1780
- if (settings.model) args.push("--model", settings.model);
1781
- args.push(promptWithTranscriptPointer(prompt, state));
1782
- return args;
1783
- }
1784
-
1785
- function getActiveBinary() {
1786
- const { settings } = currentState();
1787
- if (settings.backend === "cursor") return resolvedCursorPath;
1788
- if (settings.backend === "codex") return resolvedCodexPath;
1789
- return CLAUDE_PATH;
1790
- }
1791
-
1792
- function getActiveSessionId() {
1793
- const state = currentState();
1794
- if (state.settings.backend === "cursor") return state.cursorSessionId;
1795
- if (state.settings.backend === "codex") return state.codexSessionId;
1796
- return state.lastSessionId;
1797
- }
1798
-
1799
- function getActiveSessionKey(state = currentState()) {
1800
- if (state.settings.backend === "cursor") return "cursorSessionId";
1801
- if (state.settings.backend === "codex") return "codexSessionId";
1802
- return "lastSessionId";
1803
- }
1804
-
1805
- function shouldAutoCompact(state = currentState(), opts = {}) {
1806
- if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
1807
- if (!state[getActiveSessionKey(state)]) return false;
1808
- const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 140000;
1809
- return (state.sessionUsage?.lastInputTokens || 0) >= threshold;
1810
- }
1811
-
1812
- function compactSummaryPrompt() {
1813
- return [
1814
- "Summarize this conversation for a fresh compacted continuation.",
1815
- "Include only durable context needed to continue effectively:",
1816
- "- current user goal and constraints",
1817
- "- important decisions and preferences",
1818
- "- files/repos touched and current code state",
1819
- "- commands/tests already run and results",
1820
- "- open TODOs, blockers, and exact next step",
1821
- "Do not include secrets, raw tokens, or irrelevant chat transcript."
1822
- ].join("\n");
1823
- }
1824
-
1825
- function compactSeedPrompt(summary) {
1826
- return [
1827
- "This is a compacted continuation of a previous Open Claudia session.",
1828
- "Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
1829
- "Continue from this state in future turns.",
1830
- "",
1831
- "Compacted summary:",
1832
- summary
1833
- ].join("\n");
1834
- }
1835
-
1836
- async function runClaudeCapture(prompt, cwd, opts = {}) {
1837
- const state = currentState();
1838
- const chatId = currentChatId();
1839
- if (state.runningProcess) throw new Error("Another task is already running.");
1840
- const authPreflight = preflightClaudeAuthMessage();
1841
- if (authPreflight) throw new Error(authPreflight);
1842
-
1843
- return new Promise((resolve, reject) => {
1844
- let assistantText = "";
1845
- let stderrBuffer = "";
1846
- let streamBuffer = "";
1847
- let sessionId = null;
1848
- appendProjectTranscript(opts.transcriptRole || "system-note", stripTranscriptPointerForStorage(prompt), {
1849
- capture: true,
1850
- label: opts.label || null,
1851
- fresh: !!opts.fresh,
1852
- continueSession: !!opts.continueSession,
1853
- resumeSessionId: opts.resumeSessionId || null,
1854
- }, state);
1855
- const args = buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
1856
- const proc = spawn(getActiveBinary(), args, {
1857
- cwd,
1858
- env: claudeSubprocessEnv(),
1859
- stdio: ["ignore", "pipe", "pipe"],
1860
- detached: process.platform !== "win32",
1861
- });
1862
- state.runningProcess = proc;
1863
- const timeout = setTimeout(() => {
1864
- if (state.runningProcess === proc) {
1865
- killProcessTree(proc.pid, "SIGTERM");
1866
- setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
1867
- }
1868
- }, MAX_PROCESS_TIMEOUT);
1869
-
1870
- proc.stdout.on("data", (data) => {
1871
- streamBuffer += data.toString();
1872
- const events = parseStreamEvents(streamBuffer);
1873
- const lastNewline = streamBuffer.lastIndexOf("\n");
1874
- streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
1875
- for (const evt of events) {
1876
- if (evt.type === "assistant" && evt.message?.content) {
1877
- for (const block of evt.message.content) {
1878
- if (block.type === "text") assistantText += block.text;
1879
- }
1880
- }
1881
- if (evt.type === "item.completed" && evt.item?.type === "agent_message" && typeof evt.item.text === "string") {
1882
- assistantText += (assistantText ? "\n" : "") + evt.item.text;
1883
- }
1884
- if (evt.type === "thread.started" && evt.thread_id) {
1885
- state.codexSessionId = evt.thread_id;
1886
- sessionId = evt.thread_id;
1887
- saveState();
1888
- }
1889
- if (evt.type === "result" && evt.session_id) {
1890
- if (state.settings.backend === "cursor") state.cursorSessionId = evt.session_id;
1891
- else state.lastSessionId = evt.session_id;
1892
- sessionId = evt.session_id;
1893
- if (evt.usage) {
1894
- const u = state.sessionUsage;
1895
- u.turns += 1;
1896
- u.inputTokens += evt.usage.input_tokens || 0;
1897
- u.outputTokens += evt.usage.output_tokens || 0;
1898
- u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
1899
- u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
1900
- u.lastInputTokens = (evt.usage.input_tokens || 0) +
1901
- (evt.usage.cache_read_input_tokens || 0) +
1902
- (evt.usage.cache_creation_input_tokens || 0);
1903
- }
1904
- if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
1905
- saveState();
1906
- }
1907
- if (evt.type === "result" && evt.result) assistantText = evt.result;
1908
- }
1909
- });
1910
- proc.stderr.on("data", (d) => { stderrBuffer += d.toString(); });
1911
- proc.on("close", (code) => chatContext.run(chatId, () => {
1912
- if (state.runningProcess === proc) state.runningProcess = null;
1913
- clearTimeout(timeout);
1914
- if (code !== 0 && code !== null) {
1915
- const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
1916
- appendProjectTranscript("system-note", failureText, { capture: true, label: opts.label || null, exitCode: code, kind: "backend-error" }, state);
1917
- reject(new Error(failureText));
1918
- return;
1919
- }
1920
- const cleanText = redactSensitive(assistantText.trim());
1921
- appendProjectTranscript("assistant", cleanText, { capture: true, label: opts.label || null, exitCode: code }, state);
1922
- resolve({ text: cleanText, sessionId });
1923
- }));
1924
- proc.on("error", (err) => chatContext.run(chatId, () => {
1925
- if (state.runningProcess === proc) state.runningProcess = null;
1926
- clearTimeout(timeout);
1927
- reject(err);
1928
- }));
1929
- });
1930
- }
1931
-
1932
- async function compactActiveSession(cwd, opts = {}) {
1933
- const state = currentState();
1934
- const sessionKey = getActiveSessionKey(state);
1935
- const oldSessionId = state[sessionKey];
1936
- if (!oldSessionId) return { compacted: false, reason: "No conversation." };
1937
- if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
1938
-
1939
- state.isCompacting = true;
1940
- try {
1941
- if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
1942
- const summaryRun = await runClaudeCapture(compactSummaryPrompt(), cwd, { resumeSessionId: oldSessionId, skipAutoCompact: true, label: "compact-summary" });
1943
- const summary = summaryRun.text || "No prior context was returned by the summarizer.";
1944
-
1945
- state[sessionKey] = null;
1946
- resetSessionUsage(state);
1947
- state.isFirstMessage = true;
1948
- saveState();
1949
-
1950
- const seedRun = await runClaudeCapture(compactSeedPrompt(summary), cwd, { fresh: true, skipAutoCompact: true, label: "compact-seed" });
1951
- const newSessionId = seedRun.sessionId || state[sessionKey];
1952
- if (newSessionId) state[sessionKey] = newSessionId;
1953
- state.isFirstMessage = false;
1954
- state.lastCompactedAt = Date.now();
1955
- resetSessionUsage(state);
1956
- saveState();
1957
-
1958
- if (newSessionId && state.currentSession) {
1959
- const title = `Compacted ${new Date().toLocaleDateString()}`;
1960
- recordSession(state.userId, state.currentSession.name, newSessionId, title);
1961
- }
1962
- return { compacted: true, oldSessionId, newSessionId, summary };
1963
- } finally {
1964
- state.isCompacting = false;
1965
- saveState();
1966
- }
1967
- }
1968
-
1969
- async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1970
- // Capture per-user state at entry so event callbacks (stdout/stderr/close)
1971
- // operate on the right user's thread even though AsyncLocalStorage
1972
- // propagation through child_process events isn't guaranteed across all
1973
- // Node versions.
1974
- const state = currentState();
1975
- const chatId = currentChatId();
1976
- const { settings } = state;
1977
-
1978
- if (state.runningProcess) {
1979
- state.messageQueue.push({ prompt, replyToMsgId, opts });
1980
- await send("Queued.", { replyTo: replyToMsgId });
1981
- return;
1982
- }
1983
-
1984
- const authPreflight = preflightClaudeAuthMessage();
1985
- if (authPreflight) {
1986
- await send(authPreflight, { replyTo: replyToMsgId });
1987
- return;
1988
- }
1989
-
1990
- if (shouldAutoCompact(state, opts)) {
1991
- try {
1992
- await compactActiveSession(cwd, {
1993
- notify: true,
1994
- message: "Context is getting large, compacting first so this stays fast…",
1995
- });
1996
- } catch (e) {
1997
- await send(`Compaction failed: ${redactSensitive(e.message)}\nContinuing in the existing session.`, { replyTo: replyToMsgId });
1998
- }
1999
- }
2000
-
2001
- appendProjectTranscript("user", prompt, {
2002
- telegramMessageId: replyToMsgId || null,
2003
- fresh: !!opts.fresh,
2004
- continueSession: !!opts.continueSession,
2005
- resumeSessionId: opts.resumeSessionId || null,
2006
- }, state);
2007
-
2008
- bot.sendChatAction(chatId, "typing");
2009
- state.statusMessageId = null;
2010
- state.streamBuffer = "";
2011
- let assistantText = "";
2012
- let toolUses = [];
2013
- let currentTool = null;
2014
- let currentToolDetail = "";
2015
-
2016
- const args = buildClaudeArgs(prompt, opts);
2017
- const binaryPath = getActiveBinary();
2018
- const proc = spawn(binaryPath, args, {
2019
- cwd,
2020
- env: claudeSubprocessEnv(),
2021
- stdio: ["ignore", "pipe", "pipe"],
2022
- detached: process.platform !== "win32",
2023
- });
2024
-
2025
- state.runningProcess = proc;
2026
- const startTime = Date.now();
2027
- let longRunningNotified = false;
2028
-
2029
- // Hard timeout to prevent runaway processes. We walk the descendant
2030
- // tree (not just the PGID) so that detached background bashes — like
2031
- // `run_in_background: true` poll loops — get cleaned up too.
2032
- const processTimeout = setTimeout(() => {
2033
- if (state.runningProcess === proc) {
2034
- killProcessTree(proc.pid, "SIGTERM");
2035
- setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
2036
- chatContext.run(chatId, () => send(`Task timed out after ${MAX_PROCESS_TIMEOUT / 60000} minutes. Stopped.`));
2037
- }
2038
- }, MAX_PROCESS_TIMEOUT);
2039
-
2040
- let lastUpdate = "";
2041
- // Adaptive update interval: 2s for first 2min, then 5s to avoid rate limits
2042
- const scheduleUpdate = () => {
2043
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
2044
- const interval = elapsed > 120 ? 5000 : 2000;
2045
- state.streamInterval = setTimeout(() => chatContext.run(chatId, updateProgress), interval);
2046
- };
2047
- const updateProgress = async () => {
2048
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
2049
- try {
2050
- bot.sendChatAction(chatId, "typing").catch(() => {});
2051
- const display = formatProgress(assistantText, toolUses, currentTool, elapsed, currentToolDetail);
2052
- if (display && display !== lastUpdate) {
2053
- if (!state.statusMessageId && assistantText) {
2054
- state.statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, { replyTo: replyToMsgId });
2055
- } else if (state.statusMessageId) {
2056
- await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display);
2057
- }
2058
- lastUpdate = display;
2059
- }
2060
- // Notify after 5 minutes that it's still running
2061
- if (elapsed > 300 && !longRunningNotified) {
2062
- longRunningNotified = true;
2063
- await send(`Still working (${Math.floor(elapsed / 60)}min)... Send /stop to cancel.`);
2064
- }
2065
- } catch (e) {
2066
- console.error("Progress update error:", e.message);
2067
- }
2068
- if (state.runningProcess) scheduleUpdate();
2069
- };
2070
- scheduleUpdate();
2071
-
2072
- proc.stdout.on("data", (data) => {
2073
- state.streamBuffer += data.toString();
2074
- const events = parseStreamEvents(state.streamBuffer);
2075
- const lastNewline = state.streamBuffer.lastIndexOf("\n");
2076
- state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
2077
- for (const evt of events) {
2078
- if (evt.type === "assistant" && evt.message?.content) {
2079
- for (const block of evt.message.content) {
2080
- if (block.type === "text") assistantText += block.text;
2081
- else if (block.type === "tool_use") {
2082
- currentTool = block.name;
2083
- toolUses.push(block.name);
2084
- const input = block.input || {};
2085
- if (block.name === "Bash" && input.command) currentToolDetail = input.command.slice(0, 80);
2086
- else if (block.name === "Read" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
2087
- else if (block.name === "Edit" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
2088
- else if (block.name === "Write" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
2089
- else if (block.name === "Grep" && input.pattern) currentToolDetail = input.pattern.slice(0, 40);
2090
- else if (block.name === "Glob" && input.pattern) currentToolDetail = input.pattern;
2091
- else currentToolDetail = "";
2092
- }
2093
- }
2094
- }
2095
- // Cursor Agent tool_call events (different format from Claude's tool_use blocks)
2096
- if (evt.type === "tool_call" && evt.subtype === "started" && evt.tool_call) {
2097
- const tc = evt.tool_call;
2098
- if (tc.shellToolCall) {
2099
- const a = tc.shellToolCall.args || {};
2100
- currentTool = "Shell"; toolUses.push("Shell");
2101
- currentToolDetail = (a.description || a.command || "").slice(0, 80);
2102
- } else if (tc.readToolCall) {
2103
- currentTool = "Read"; toolUses.push("Read");
2104
- currentToolDetail = (tc.readToolCall.args?.path || "").split("/").slice(-2).join("/");
2105
- } else if (tc.editToolCall) {
2106
- currentTool = "Edit"; toolUses.push("Edit");
2107
- currentToolDetail = (tc.editToolCall.args?.filePath || "").split("/").slice(-2).join("/");
2108
- } else if (tc.writeToolCall) {
2109
- currentTool = "Write"; toolUses.push("Write");
2110
- currentToolDetail = (tc.writeToolCall.args?.filePath || "").split("/").slice(-2).join("/");
2111
- } else if (tc.grepToolCall) {
2112
- currentTool = "Grep"; toolUses.push("Grep");
2113
- currentToolDetail = (tc.grepToolCall.args?.pattern || "").slice(0, 40);
2114
- } else if (tc.globToolCall) {
2115
- currentTool = "Glob"; toolUses.push("Glob");
2116
- currentToolDetail = (tc.globToolCall.args?.globPattern || "").slice(0, 40);
2117
- } else if (tc.createPlanToolCall) {
2118
- currentTool = "Plan"; toolUses.push("Plan");
2119
- const plan = tc.createPlanToolCall.args || {};
2120
- let planText = "";
2121
- if (plan.name) planText += `**${plan.name}**\n\n`;
2122
- if (plan.plan) planText += plan.plan + "\n";
2123
- if (plan.todos && plan.todos.length) {
2124
- planText += "\n**Tasks:**\n";
2125
- for (const todo of plan.todos) {
2126
- planText += `• ${todo.content || todo.id}\n`;
2127
- }
2128
- }
2129
- if (planText) assistantText = planText;
2130
- currentToolDetail = plan.name || "creating plan";
2131
- } else {
2132
- const toolKey = Object.keys(tc)[0] || "unknown";
2133
- currentTool = toolKey.replace("ToolCall", ""); toolUses.push(currentTool);
2134
- currentToolDetail = "";
2135
- }
2136
- }
2137
- // Codex stream events
2138
- if (evt.type === "thread.started" && evt.thread_id) {
2139
- state.codexSessionId = evt.thread_id;
2140
- saveState();
2141
- }
2142
- if (evt.type === "item.started" && evt.item) {
2143
- const it = evt.item;
2144
- if (it.type === "command_execution" && it.command) {
2145
- currentTool = "Shell"; toolUses.push("Shell");
2146
- currentToolDetail = String(it.command).slice(0, 80);
2147
- } else if (it.type === "file_change" && (it.path || it.file_path)) {
2148
- currentTool = "Edit"; toolUses.push("Edit");
2149
- const p = it.path || it.file_path;
2150
- currentToolDetail = String(p).split("/").slice(-2).join("/");
2151
- } else if (it.type === "web_search" && it.query) {
2152
- currentTool = "Web"; toolUses.push("Web");
2153
- currentToolDetail = String(it.query).slice(0, 60);
2154
- } else if (it.type) {
2155
- currentTool = it.type; toolUses.push(it.type);
2156
- currentToolDetail = "";
2157
- }
2158
- }
2159
- if (evt.type === "item.completed" && evt.item) {
2160
- const it = evt.item;
2161
- if (it.type === "agent_message" && typeof it.text === "string") {
2162
- assistantText += (assistantText ? "\n" : "") + it.text;
2163
- }
2164
- }
2165
- if (evt.type === "turn.completed" && evt.usage && settings.backend === "codex") {
2166
- const u = state.sessionUsage;
2167
- u.turns += 1;
2168
- const input = evt.usage.input_tokens || 0;
2169
- const cached = evt.usage.cached_input_tokens || 0;
2170
- const output = evt.usage.output_tokens || 0;
2171
- u.inputTokens += input;
2172
- u.outputTokens += output;
2173
- u.cacheReadTokens += cached;
2174
- u.lastInputTokens = input + cached;
2175
- saveState();
2176
- }
2177
- if (evt.type === "result" && evt.session_id) {
2178
- if (settings.backend === "cursor") { state.cursorSessionId = evt.session_id; }
2179
- else { state.lastSessionId = evt.session_id; }
2180
- if (evt.usage) {
2181
- const u = state.sessionUsage;
2182
- u.turns += 1;
2183
- u.inputTokens += evt.usage.input_tokens || 0;
2184
- u.outputTokens += evt.usage.output_tokens || 0;
2185
- u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
2186
- u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
2187
- u.lastInputTokens = (evt.usage.input_tokens || 0) +
2188
- (evt.usage.cache_read_input_tokens || 0) +
2189
- (evt.usage.cache_creation_input_tokens || 0);
2190
- }
2191
- if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
2192
- saveState();
2193
- }
2194
- if (evt.type === "result" && evt.result) assistantText = evt.result;
2195
- }
2196
- });
2197
-
2198
- let stderrBuffer = "";
2199
- proc.stderr.on("data", (d) => {
2200
- const chunk = d.toString();
2201
- stderrBuffer += chunk;
2202
- console.error("STDERR:", redactSensitive(chunk));
2203
- });
2204
-
2205
- proc.on("close", (code) => chatContext.run(chatId, async () => {
2206
- state.runningProcess = null;
2207
- clearTimeout(state.streamInterval); state.streamInterval = null;
2208
- clearTimeout(processTimeout);
2209
-
2210
- // Check for auth errors in stderr and give actionable Telegram recovery steps.
2211
- if (settings.backend === "claude" && isClaudeAuthErrorText(stderrBuffer)) {
2212
- const authText = claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800));
2213
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state);
2214
- await send(authText, { replyTo: replyToMsgId });
2215
- return;
2216
- }
2217
- if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
2218
- const authText = "Cursor authentication error. Run `agent login` on this machine, then retry.";
2219
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state);
2220
- await send(authText, { replyTo: replyToMsgId });
2221
- return;
2222
- }
2223
- if (settings.backend === "codex" && /not (?:logged in|authenticated)|please (?:log|sign) in|401 unauthorized|invalid api key/i.test(stderrBuffer)) {
2224
- const authText = "Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.";
2225
- appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state);
2226
- await send(authText, { replyTo: replyToMsgId });
2227
- return;
2228
- }
2229
-
2230
- try {
2231
- if (code !== 0 && code !== null && !assistantText.trim()) {
2232
- const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
2233
- appendProjectTranscript("system-note", failureText, { exitCode: code, kind: "backend-error" }, state);
2234
- await send(failureText, { replyTo: replyToMsgId });
2235
- return;
2236
- }
2237
-
2238
- const finalText = redactSensitive(assistantText || "(no output)");
2239
- appendProjectTranscript("assistant", finalText, {
2240
- exitCode: code,
2241
- telegramMessageId: state.statusMessageId || null,
2242
- }, state);
2243
- const chunks = splitMessage(finalText);
2244
- const firstChunk = chunks[0];
2245
-
2246
- // If there's already a progress message showing, edit it to the final text
2247
- // instead of sending a duplicate. Only send a NEW message if there was no
2248
- // progress message or the response is multi-chunk (too long for one edit).
2249
- if (state.statusMessageId && chunks.length === 1) {
2250
- // Edit progress message to clean final text
2251
- await editMessage(state.statusMessageId, firstChunk);
2252
- } else {
2253
- // Send final result as a new message (triggers notification)
2254
- const sent = await send(firstChunk, { replyTo: replyToMsgId });
2255
- if (!sent) await send(firstChunk);
2256
- for (let i = 1; i < chunks.length; i++) {
2257
- await send(chunks[i]);
2258
- }
2259
- }
2260
- if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
2261
-
2262
- // Send voice reply if input was a voice note
2263
- if (state.lastInputWasVoice && TTS_CMD) {
2264
- state.lastInputWasVoice = false;
2265
- const voicePath = textToVoice(finalText);
2266
- if (voicePath) await sendVoice(voicePath);
2267
- }
2268
-
2269
- // High-context sessions are compacted automatically before the next turn.
2270
- } catch (e) {
2271
- console.error("Final message delivery failed:", e.message);
2272
- await send("Task completed but failed to deliver the response. Send /continue to see the result.");
2273
- }
2274
- if (settings.budget) settings.budget = null;
2275
- state.statusMessageId = null;
2276
-
2277
- // Record session with auto-title from first message
2278
- if (state.lastSessionId && state.currentSession) {
2279
- const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
2280
- recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
2281
- state.isFirstMessage = false;
2282
- }
2283
- if (state.messageQueue.length > 0 && state.currentSession) {
2284
- const next = state.messageQueue.shift();
2285
- await runClaude(next.prompt, state.currentSession.dir, next.replyToMsgId, next.opts);
2286
- }
2287
- }));
2288
-
2289
- proc.on("error", (err) => chatContext.run(chatId, async () => {
2290
- state.runningProcess = null;
2291
- clearTimeout(state.streamInterval);
2292
- clearTimeout(processTimeout);
2293
- await send(`Error: ${err.message}`);
2294
- state.statusMessageId = null;
2295
- }));
2296
- }
2297
-
2298
- async function runClaudeSilent(prompt, cwd, label) {
2299
- return new Promise((resolve) => {
2300
- const args = ["-p", "--output-format", "text", "--verbose",
2301
- "--append-system-prompt", buildSystemPrompt(),
2302
- "--dangerously-skip-permissions", prompt];
2303
- const proc = spawn(CLAUDE_PATH, args, {
2304
- cwd, env: claudeSubprocessEnv(),
2305
- stdio: ["ignore", "pipe", "pipe"],
2306
- });
2307
- let output = "";
2308
- proc.stdout.on("data", (d) => { output += d.toString(); });
2309
- proc.on("close", async () => {
2310
- const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
2311
- for (const c of chunks) await send(c);
2312
- resolve();
2313
- });
2314
- proc.on("error", async (err) => { await send(`Cron "${label}" failed: ${err.message}`); resolve(); });
2315
- });
2316
- }
2317
-
2318
- function formatProgress(text, tools, currentTool, elapsed, toolDetail) {
2319
- const mins = Math.floor(elapsed / 60);
2320
- const secs = elapsed % 60;
2321
- const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
2322
-
2323
- const parts = [];
2324
- let status = currentTool ? `Working: ${currentTool}` : (tools.length > 0 ? "Processing..." : "Thinking...");
2325
- if (currentTool && toolDetail) status += ` — ${toolDetail}`;
2326
- parts.push(`${status} (${time})`);
2327
- if (tools.length > 1) parts.push(`Steps: ${[...new Set(tools)].join(" > ")}`);
2328
- if (text) parts.push(text.length > 800 ? "..." + text.slice(-800) : text);
2329
- return parts.join("\n\n");
2330
- }
2331
-
2332
- // ── Cron System ─────────────────────────────────────────────────────
2333
-
2334
- function scheduleCron(c) {
2335
- const cwd = path.join(WORKSPACE, c.project);
2336
- if (activeCrons.has(c.id)) activeCrons.get(c.id).task.stop();
2337
- // Crons fire from a timer, not a Telegram message — bind explicitly to
2338
- // the owner's chat so any send() goes to them.
2339
- const task = cron.schedule(c.schedule, () => chatContext.run(CHAT_ID, () => runClaudeSilent(c.prompt, cwd, c.label)));
2340
- activeCrons.set(c.id, { task, config: c });
2341
- }
2342
-
2343
- function initCrons() {
2344
- for (const c of loadCrons()) { try { scheduleCron(c); } catch (e) { console.error("Cron error:", e.message); } }
2345
- console.log(`Loaded ${loadCrons().length} cron(s)`);
2346
- }
2347
-
2348
- // ── Session ─────────────────────────────────────────────────────────
2349
-
2350
- function startSession(name, resumeSessionId) {
2351
- const state = currentState();
2352
- let projectName, projectDir;
2353
- if (name === "__workspace__") {
2354
- projectName = "Workspace";
2355
- projectDir = WORKSPACE;
2356
- } else {
2357
- const result = findProject(name);
2358
- if (!result) return send(`No match for "${name}".`, { keyboard: projectKeyboard() });
2359
- if (Array.isArray(result)) return send("Multiple matches:", { keyboard: { inline_keyboard: result.map((p) => [{ text: p, callback_data: `s:${p}` }]) } });
2360
- projectName = result;
2361
- projectDir = path.join(WORKSPACE, result);
2362
- }
2363
-
2364
- state.currentSession = { name: projectName, dir: projectDir };
2365
- state.messageQueue = []; resetSettings(state);
2366
-
2367
- // Resume a specific session or the last one for this project
2368
- if (resumeSessionId) {
2369
- state.lastSessionId = resumeSessionId;
2370
- const sessions = getProjectSessions(state.userId, projectName);
2371
- const s = sessions.find((x) => x.id === resumeSessionId);
2372
- const title = s ? s.title : "";
2373
- state.isFirstMessage = false;
2374
- saveState();
2375
- send(`Session: ${projectName}\nResumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
2376
- } else {
2377
- const last = getLastProjectSession(state.userId, projectName);
2378
- if (last) {
2379
- state.lastSessionId = last.id;
2380
- state.isFirstMessage = false;
2381
- saveState();
2382
- send(`Session: ${projectName}\nResumed: ${last.title}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`, {
2383
- keyboard: { inline_keyboard: [[{ text: "New conversation", callback_data: `new:${projectName}` }]] },
2384
- });
2385
- } else {
2386
- state.lastSessionId = null;
2387
- state.isFirstMessage = true;
2388
- saveState();
2389
- send(`Session: ${projectName}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
2390
- }
2391
- }
2392
- }
2393
-
2394
- function requireSession(msg) {
2395
- if (!currentState().currentSession) { send("Pick a project first:", { keyboard: projectKeyboard() }); return false; }
2396
- return true;
2397
- }
2398
-
2399
- // Wrap every bot handler so it enters the ALS chat context bound to
2400
- // whoever sent the message. Inside the wrapped fn, `currentChatId()`
2401
- // and `currentState()` resolve to that user's per-thread state. Used
2402
- // for bot.onText, bot.on("message"|"voice"|"audio"|"photo"|"document"),
2403
- // and bot.on("callback_query") — callback_query carries the chat id
2404
- // under q.message.chat.id rather than q.chat.id.
2405
- function wrapHandler(fn) {
2406
- return function (...args) {
2407
- const event = args[0];
2408
- const chatId = String(
2409
- event?.chat?.id ||
2410
- event?.message?.chat?.id ||
2411
- event?.from?.id ||
2412
- CHAT_ID
2413
- );
2414
- return chatContext.run(chatId, () => fn(...args));
2415
- };
2416
- }
2417
-
2418
- // ── Commands ────────────────────────────────────────────────────────
2419
-
2420
- bot.onText(/\/start/, wrapHandler((msg) => {
2421
- if (!isAuthorized(msg)) return;
2422
- if (!isOnboarded()) return startOnboarding();
2423
- send("Pick a project to start:", { keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] } });
2424
- }));
2425
-
2426
- bot.onText(/\/help/, wrapHandler((msg) => {
2427
- if (!isAuthorized(msg)) return;
2428
- send([
2429
- "Session: /session /sessions /projects /continue /status /stop /end",
2430
- "Settings: /model /effort /budget /plan /compact /worktree /mode",
2431
- "Identity: /whoami /link",
2432
- "Automation: /cron /vault /soul",
2433
- "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
2434
- "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
2435
- "System: /doctor /requirements /restart /upgrade",
2436
- "",
2437
- "Send text, voice, photos, or files.",
2438
- "Reply to any message for context.",
2439
- ].join("\n"));
2440
- }));
2441
-
2442
- bot.onText(/\/whoami$/, wrapHandler((msg) => {
2443
- if (!isAuthorized(msg)) return;
2444
- const chatId = String(msg.chat.id);
2445
- const key = channelKey("telegram", chatId);
2446
- const userId = canonicalForTelegram(chatId);
2447
- const preferred = identities.preferred[userId];
2448
- send([
2449
- `Channel: ${key}`,
2450
- `User: ${userId}`,
2451
- preferred ? `Preferred: ${preferred.transport}:${preferred.channelId}` : "Preferred: this channel",
2452
- ].join("\n"));
2453
- }));
2454
-
2455
- bot.onText(/\/links$/, wrapHandler((msg) => {
2456
- if (!isOwner(msg)) return;
2457
- const rows = Object.entries(identities.channels)
2458
- .sort(([a], [b]) => a.localeCompare(b))
2459
- .map(([channel, userId]) => `${channel} -> ${userId}`);
2460
- send(rows.length ? rows.join("\n") : "No explicit identity links. Unlinked Telegram chats use telegram:<chatId>.");
2461
- }));
2462
-
2463
- bot.onText(/\/link$/, wrapHandler((msg) => {
2464
- if (!isAuthorized(msg)) return;
2465
- send(isOwner(msg)
2466
- ? "Usage:\n/link <email-or-user-id>\n/link <telegram-chat-id> <email-or-user-id>\n/link telegram:<chat-id> <email-or-user-id>\n\nOwner can link any Telegram chat; other users can link only their current chat."
2467
- : "Usage:\n/link <email-or-user-id>\n\nThis links your Telegram chat to a canonical user id.");
2468
- }));
2469
-
2470
- bot.onText(/\/link\s+(.+)$/, wrapHandler((msg, match) => {
2471
- if (!isAuthorized(msg)) return;
2472
- const parts = String(match[1] || "").trim().split(/\s+/).filter(Boolean);
2473
- if (parts.length === 0 || parts.length > 2) return send("Usage: /link <email-or-user-id>");
2474
-
2475
- let targetChatId = String(msg.chat.id);
2476
- let userId = parts[0];
2477
- if (parts.length === 2) {
2478
- if (!isOwner(msg)) return send("Only the owner can link another chat.");
2479
- const channel = parts[0];
2480
- if (channel.startsWith("telegram:")) targetChatId = channel.slice("telegram:".length);
2481
- else targetChatId = channel;
2482
- userId = parts[1];
2483
- }
2484
-
2485
- if (!/^-?\d+$/.test(targetChatId)) return send("Telegram chat id must be numeric.");
2486
- const normalizedUserId = normalizeCanonicalUserId(userId);
2487
- if (!normalizedUserId || /\s/.test(normalizedUserId)) return send("Canonical user id cannot be empty or contain spaces.");
2488
-
2489
- const result = setIdentityMapping("telegram", targetChatId, normalizedUserId);
2490
- send([
2491
- `Linked ${result.key} -> ${result.userId}`,
2492
- result.migrated ? `Migrated state from ${result.previousUserId}.` : "Existing canonical state was left in place.",
2493
- ].join("\n"));
2494
- }));
2495
-
2496
- bot.onText(/\/version$/, wrapHandler((msg) => {
2497
- if (!isAuthorized(msg)) return;
2498
- send(`Open Claudia v${CURRENT_VERSION}`);
2499
- }));
2500
-
2501
- bot.onText(/\/restart$/, wrapHandler(async (msg) => {
2502
- if (!isOwner(msg)) return;
2503
- await send("Going offline for a quick restart — back in a moment.");
2504
- setTimeout(() => process.exit(0), 1000);
2505
- }));
2506
-
2507
- bot.onText(/\/upgrade$/, wrapHandler(async (msg) => {
2508
- if (!isOwner(msg)) return;
2509
- // Change to HOME first — npm install -g replaces the package directory
2510
- // which can delete the cwd out from under the running process
2511
- try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) { /* already gone */ }
2512
- // Check if there's actually a newer version. `latest` is hoisted so
2513
- // the install step below can reference it; if `npm view` fails we
2514
- // fall back to npm's `latest` dist-tag.
2515
- let latest = null;
2516
- try {
2517
- latest = execSync("npm view @inetafrica/open-claudia version", {
2518
- encoding: "utf-8", timeout: 15000,
2519
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
2520
- }).trim();
2521
- if (latest === CURRENT_VERSION) {
2522
- await send(`Already on the latest version (v${CURRENT_VERSION}).`);
2523
- return;
2524
- }
2525
- await send(`Upgrading v${CURRENT_VERSION} → v${latest}...`);
2526
- } catch (e) {
2527
- await send("Upgrading...");
2528
- }
2529
- const installTarget = latest || "latest";
2530
- try {
2531
- execSync(`npm install -g @inetafrica/open-claudia@${installTarget} 2>&1`, {
2532
- encoding: "utf-8", timeout: 120000,
2533
- cwd: process.env.HOME || require("os").homedir(),
2534
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
2535
- });
2536
- // Run idempotent setup (install/configure tools like Playwright MCP)
2537
- try {
2538
- const { ensureSetup: postUpgradeSetup } = require("./health");
2539
- postUpgradeSetup();
2540
- } catch (e) { /* setup will run on next boot anyway */ }
2541
- // Read version and changelog from newly installed package
2542
- const root = execSync("npm root -g", { encoding: "utf-8", cwd: process.env.HOME || require("os").homedir(), env: { ...process.env, PATH: FULL_PATH } }).trim();
2543
- const newPkg = JSON.parse(fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "package.json"), "utf-8"));
2544
- // Extract current version's changelog entry
2545
- let whatsNew = "";
2546
- try {
2547
- const changelog = fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "CHANGELOG.md"), "utf-8");
2548
- let versionHeader = `## v${newPkg.version}`;
2549
- let start = changelog.indexOf(versionHeader);
2550
- if (start < 0) { versionHeader = `## ${newPkg.version}`; start = changelog.indexOf(versionHeader); }
2551
- if (start >= 0) {
2552
- const afterHeader = changelog.slice(start + versionHeader.length);
2553
- const nextVersion = afterHeader.indexOf("\n## ");
2554
- const section = nextVersion >= 0 ? afterHeader.slice(0, nextVersion) : afterHeader;
2555
- whatsNew = section.trim();
2556
- }
2557
- } catch (e) { /* no changelog */ }
2558
- const doctorReport = formatDoctorReport(runDoctorChecks());
2559
- const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
2560
- await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
2561
- } catch (e) {
2562
- const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
2563
- await send(`Upgrade failed:\n${errOutput}`);
2564
- return;
2565
- }
2566
- // Give Telegram time to deliver the message, then exit for launchd to restart
2567
- setTimeout(() => process.exit(0), 2000);
2568
- }));
2569
-
2570
- bot.onText(/\/projects$/, wrapHandler((msg) => { if (isAuthorized(msg)) send("Pick:", { keyboard: projectKeyboard() }); }));
2571
- bot.onText(/\/session$/, wrapHandler((msg) => {
2572
- if (!isAuthorized(msg)) return;
2573
- const cs = currentState().currentSession;
2574
- if (cs) send(`Active: ${cs.name}\n\nSwitch?`, { keyboard: projectKeyboard() });
2575
- else send("Pick:", { keyboard: projectKeyboard() });
2576
- }));
2577
- bot.onText(/\/session (.+)/, wrapHandler((msg, match) => { if (isAuthorized(msg)) startSession(match[1].trim()); }));
2578
-
2579
- bot.onText(/\/sessions$/, wrapHandler((msg) => {
2580
- if (!isAuthorized(msg)) return;
2581
- if (!requireSession(msg)) return;
2582
- const state = currentState();
2583
- const sessions = getProjectSessions(state.userId, state.currentSession.name);
2584
- if (sessions.length === 0) return send("No past conversations for this project.");
2585
- const rows = sessions.slice(0, 10).map((s) => {
2586
- const date = new Date(s.lastUsed).toLocaleDateString();
2587
- const active = state.lastSessionId === s.id ? " (active)" : "";
2588
- return [{ text: `${s.title}${active} — ${date}`, callback_data: `ss:${s.id}` }];
2589
- });
2590
- rows.push([{ text: "New conversation", callback_data: `new:${state.currentSession.name}` }]);
2591
- send(`Conversations in ${state.currentSession.name}:`, { keyboard: { inline_keyboard: rows } });
2592
- }));
2593
-
2594
- bot.onText(/\/model$/, wrapHandler((msg) => {
2595
- if (!isAuthorized(msg)) return;
2596
- // Unified picker: every model from every available backend in one keyboard.
2597
- // Tapping a button switches backend (if needed) and sets the model.
2598
- // Callback format: `mb:<backend>:<model>` (model may itself contain `-`).
2599
- const { settings } = currentState();
2600
- const rows = [];
2601
- rows.push([{ text: "── Claude ──", callback_data: "noop" }]);
2602
- rows.push([
2603
- { text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
2604
- { text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
2605
- { text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
2606
- { text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
2607
- ]);
2608
- if (resolvedCursorPath) {
2609
- rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
2610
- rows.push([
2611
- { text: "Composer 2", callback_data: "mb:cursor:composer-2" },
2612
- { text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
2613
- { text: "Auto", callback_data: "mb:cursor:auto" },
2614
- ]);
2615
- rows.push([
2616
- { text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
2617
- { text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
2618
- ]);
2619
- }
2620
- if (resolvedCodexPath) {
2621
- rows.push([{ text: "── Codex ──", callback_data: "noop" }]);
2622
- rows.push([
2623
- { text: "gpt-5", callback_data: "mb:codex:gpt-5" },
2624
- { text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
2625
- ]);
2626
- rows.push([
2627
- { text: "o3", callback_data: "mb:codex:o3" },
2628
- { text: "o4-mini", callback_data: "mb:codex:o4-mini" },
2629
- ]);
2630
- }
2631
- rows.push([{ text: "Default (current backend)", callback_data: "m:default" }]);
2632
- const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
2633
- send(`Current: ${beLabel} · ${settings.model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
2634
- }));
2635
- bot.onText(/\/model (.+)/, wrapHandler((msg, match) => {
2636
- if (!isAuthorized(msg)) return;
2637
- const { settings } = currentState();
2638
- settings.model = match[1].trim().toLowerCase();
2639
- if (settings.model === "default") settings.model = null;
2640
- send(`Model: ${settings.model || "default"}`);
2641
- }));
2642
-
2643
- bot.onText(/\/effort$/, wrapHandler((msg) => {
2644
- if (!isAuthorized(msg)) return;
2645
- const { settings } = currentState();
2646
- if (settings.backend === "cursor") return send("Effort levels are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
2647
- if (settings.backend === "codex") return send("Effort levels are not exposed as a flag on Codex.\nSet `model_reasoning_effort` in ~/.codex/config.toml, or switch to /claude.");
2648
- send(`Effort: ${settings.effort || "default"}`, { keyboard: { inline_keyboard: [
2649
- [{ text: "Low", callback_data: "e:low" }, { text: "Med", callback_data: "e:medium" }, { text: "High", callback_data: "e:high" }, { text: "Max", callback_data: "e:max" }],
2650
- [{ text: "Default", callback_data: "e:default" }],
2651
- ] } });
2652
- }));
2653
- bot.onText(/\/effort (.+)/, wrapHandler((msg, match) => {
2654
- if (!isAuthorized(msg)) return;
2655
- const { settings } = currentState();
2656
- if (settings.backend === "cursor") return send("Effort levels are not supported on Cursor Agent.");
2657
- if (settings.backend === "codex") return send("Effort levels are not exposed as a flag on Codex.");
2658
- const e = match[1].trim().toLowerCase(); settings.effort = ["low","medium","high","max"].includes(e) ? e : null; send(`Effort: ${settings.effort || "default"}`);
2659
- }));
2660
-
2661
- bot.onText(/\/budget$/, wrapHandler((msg) => {
2662
- if (!isAuthorized(msg)) return;
2663
- const { settings } = currentState();
2664
- if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
2665
- if (settings.backend === "codex") return send("Budget limits are not supported on Codex.\nSwitch to Claude with /claude to use this.");
2666
- send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, { keyboard: { inline_keyboard: [
2667
- [{ text: "$1", callback_data: "b:1" }, { text: "$5", callback_data: "b:5" }, { text: "$10", callback_data: "b:10" }, { text: "$25", callback_data: "b:25" }],
2668
- [{ text: "No limit", callback_data: "b:none" }],
2669
- ] } });
2670
- }));
2671
- bot.onText(/\/budget (.+)/, wrapHandler((msg, match) => {
2672
- if (!isAuthorized(msg)) return;
2673
- const { settings } = currentState();
2674
- if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.");
2675
- if (settings.backend === "codex") return send("Budget limits are not supported on Codex.");
2676
- const v = parseFloat(match[1].replace("$","")); settings.budget = v > 0 ? v : null; send(`Budget: ${settings.budget ? "$"+settings.budget : "none"}`);
2677
- }));
2678
-
2679
- bot.onText(/\/plan$/, wrapHandler((msg) => {
2680
- if (!isAuthorized(msg)) return;
2681
- const state = currentState();
2682
- const { settings } = state;
2683
- const p = settings.permissionMode === "plan";
2684
- settings.permissionMode = p ? null : "plan";
2685
- const label = settings.backend === "cursor" ? "read-only planning, no edits"
2686
- : settings.backend === "codex" ? "read-only sandbox, no edits"
2687
- : "plan permission mode";
2688
- if (p) {
2689
- // Reset session so next message doesn't resume in plan-mode for the active backend.
2690
- if (settings.backend === "cursor") state.cursorSessionId = null;
2691
- else if (settings.backend === "codex") state.codexSessionId = null;
2692
- }
2693
- send(p ? "Plan mode off (session reset)." : `Plan mode on (${label}).`);
2694
- }));
2695
- bot.onText(/\/ask$/, wrapHandler((msg) => {
2696
- if (!isAuthorized(msg)) return;
2697
- const { settings } = currentState();
2698
- if (settings.backend !== "cursor") return send("Ask mode is only available on Cursor Agent.\nUse /cursor to switch.");
2699
- const a = settings.permissionMode === "ask";
2700
- settings.permissionMode = a ? null : "ask";
2701
- send(a ? "Ask mode off." : "Ask mode on (read-only Q&A, no edits).");
2702
- }));
2703
- bot.onText(/\/compact/, wrapHandler(async (msg) => {
2704
- if (!isAuthorized(msg)) return;
2705
- if (!requireSession(msg)) return;
2706
- if (!getActiveSessionId()) return send("No conversation.");
2707
- try {
2708
- const result = await compactActiveSession(currentState().currentSession.dir, {
2709
- notify: true,
2710
- message: "Compacting this conversation into a fresh session…",
2711
- });
2712
- if (result.compacted) await send(`Compacted into a fresh session${result.newSessionId ? ` (${result.newSessionId.slice(0, 8)}…)` : ""}. Continue normally.`, { replyTo: msg.message_id });
2713
- else await send(result.reason || "Could not compact.", { replyTo: msg.message_id });
2714
- } catch (e) {
2715
- await send(`Compaction failed: ${redactSensitive(e.message)}`, { replyTo: msg.message_id });
2716
- }
2717
- }));
2718
- bot.onText(/\/continue$/, wrapHandler(async (msg) => {
2719
- if (!isAuthorized(msg)) return;
2720
- if (!requireSession(msg)) return;
2721
- if (!getActiveSessionId()) return send("No conversation to continue.");
2722
- await runClaude("continue where we left off", currentState().currentSession.dir, msg.message_id);
2723
- }));
2724
- bot.onText(/\/worktree$/, wrapHandler((msg) => { if (!isAuthorized(msg)) return; const { settings } = currentState(); settings.worktree = !settings.worktree; send(settings.worktree ? "Worktree on." : "Worktree off."); }));
2725
-
2726
- bot.onText(/\/cursor$/, wrapHandler(async (msg) => {
2727
- if (!isAuthorized(msg)) return;
2728
- if (!resolvedCursorPath) return send("Cursor Agent CLI not found.\nSet CURSOR_PATH in .env or install: https://docs.cursor.com/agent");
2729
- const state = currentState();
2730
- state.settings.backend = "cursor";
2731
- state.settings.model = null;
2732
- saveState();
2733
- const sid = state.cursorSessionId ? `\nSession: ${state.cursorSessionId.slice(0, 8)}...` : "\nNew session.";
2734
- send(`Switched to Cursor Agent.${sid}\n\n/claude — switch back · /codex — Codex`);
2735
- }));
2736
-
2737
- bot.onText(/\/claude$/, wrapHandler(async (msg) => {
2738
- if (!isAuthorized(msg)) return;
2739
- const state = currentState();
2740
- state.settings.backend = "claude";
2741
- state.settings.model = null;
2742
- saveState();
2743
- const sid = state.lastSessionId ? `\nSession: ${state.lastSessionId.slice(0, 8)}...` : "\nNew session.";
2744
- send(`Switched to Claude Code.${sid}\n\n/cursor — Cursor · /codex — Codex`);
2745
- }));
2746
-
2747
- bot.onText(/\/codex$/, wrapHandler(async (msg) => {
2748
- if (!isAuthorized(msg)) return;
2749
- if (!resolvedCodexPath) return send("Codex CLI not found.\nInstall: npm install -g @openai/codex\nThen run `codex login` to authenticate.");
2750
- const state = currentState();
2751
- state.settings.backend = "codex";
2752
- state.settings.model = null;
2753
- saveState();
2754
- const sid = state.codexSessionId ? `\nSession: ${state.codexSessionId.slice(0, 8)}...` : "\nNew session.";
2755
- send(`Switched to Codex.${sid}\n\n/claude — Claude · /cursor — Cursor`);
2756
- }));
2757
-
2758
- bot.onText(/\/backend$/, wrapHandler(async (msg) => {
2759
- if (!isAuthorized(msg)) return;
2760
- const { settings } = currentState();
2761
- const label = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
2762
- const cursorAvail = resolvedCursorPath ? "available" : "not found";
2763
- const codexAvail = resolvedCodexPath ? "available" : "not found";
2764
- send(`Backend: ${label}\n\nClaude Code: available\nCursor Agent: ${cursorAvail}\nCodex: ${codexAvail}`, { keyboard: { inline_keyboard: [
2765
- [{ text: "Claude Code", callback_data: "be:claude" }, { text: "Cursor Agent", callback_data: "be:cursor" }, { text: "Codex", callback_data: "be:codex" }],
2766
- ] } });
2767
- }));
2768
-
2769
- bot.onText(/\/mode$/, wrapHandler(async (msg) => {
2770
- if (!isAuthorized(msg)) return;
2771
- await send("Bot mode: *direct* (default)\n\nSwitch to agent mode for non-blocking execution.\nIn agent mode, heavy tasks run in the background and you can keep chatting.", {
2772
- parseMode: "Markdown",
2773
- keyboard: { inline_keyboard: [
2774
- [{ text: "Switch to Agent Mode", callback_data: "mode:agent" }],
2775
- ] },
2776
- });
2777
- }));
2778
-
2779
- bot.onText(/\/stop/, wrapHandler(async (msg) => {
2780
- if (!isAuthorized(msg)) return;
2781
- const state = currentState();
2782
- if (state.runningProcess) {
2783
- const pid = state.runningProcess.pid;
2784
- // Walk the descendant tree so detached children (background bashes,
2785
- // dev servers) get cleaned up, not just the PGID. Only stops THIS
2786
- // user's running Claude — other users' threads keep going.
2787
- killProcessTree(pid, "SIGTERM");
2788
- setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
2789
- state.runningProcess = null;
2790
- if (state.streamInterval) clearTimeout(state.streamInterval);
2791
- state.messageQueue = [];
2792
- await send("Cancelled.");
2793
- }
2794
- else await send("Nothing running.");
2795
- }));
2796
-
2797
- bot.onText(/\/(?:doctor|requirements)$/, wrapHandler(async (msg) => {
2798
- if (!isAuthorized(msg)) return;
2799
- await send(formatDoctorReport(runDoctorChecks()));
2800
- }));
2801
-
2802
- bot.onText(/\/status/, wrapHandler((msg) => {
2803
- if (!isAuthorized(msg)) return;
2804
- const state = currentState();
2805
- if (!state.currentSession) return send("No session.", { keyboard: { inline_keyboard: [[{ text: "Pick", callback_data: "show:projects" }]] } });
2806
- const { settings } = state;
2807
- const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
2808
- send([
2809
- `Project: ${state.currentSession.name}`,
2810
- `Backend: ${backendLabel}`,
2811
- `Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
2812
- `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${activeCrons.size}`,
2813
- state.runningProcess ? "Working..." : "Ready.",
2814
- ].join("\n"));
2815
- }));
2816
-
2817
- bot.onText(/\/end/, wrapHandler((msg) => {
2818
- if (!isAuthorized(msg)) return;
2819
- const state = currentState();
2820
- if (state.currentSession) {
2821
- const n = state.currentSession.name;
2822
- state.currentSession = null;
2823
- state.lastSessionId = null;
2824
- state.messageQueue = [];
2825
- resetSettings(state);
2826
- resetSessionUsage(state);
2827
- saveState();
2828
- send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
2829
- } else send("No session.");
2830
- }));
2831
-
2832
- bot.onText(/\/usage$/, wrapHandler((msg) => {
2833
- if (!isAuthorized(msg)) return;
2834
- const u = currentState().sessionUsage;
2835
- if (u.turns === 0) return send("No usage yet in this session.");
2836
- const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
2837
- const lines = [
2838
- `*Session usage* (${u.turns} turn${u.turns === 1 ? "" : "s"})`,
2839
- `Input: ${fmt(u.inputTokens)} · Output: ${fmt(u.outputTokens)}`,
2840
- `Cache read: ${fmt(u.cacheReadTokens)} · Cache write: ${fmt(u.cacheCreationTokens)}`,
2841
- `Cost: $${u.costUsd.toFixed(4)}`,
2842
- `Last turn context: ${fmt(u.lastInputTokens)}`,
2843
- ];
2844
- if (u.lastInputTokens > 100000) lines.push(`\nTip: context is large. The bot auto-compacts before the next turn at ${fmt(AUTO_COMPACT_TOKENS)} tokens; /compact does it now.`);
2845
- send(lines.join("\n"), { parseMode: "Markdown" });
2846
- }));
2847
-
2848
- bot.onText(/\/soul$/, wrapHandler(async (msg) => {
2849
- if (!isAuthorized(msg)) return;
2850
- const soul = loadSoul();
2851
- const preview = soul.length > 3000 ? soul.slice(0, 3000) + "\n..." : soul;
2852
- await send(preview);
2853
- await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
2854
- }));
2855
-
2856
- // ── Claude Auth Commands ────────────────────────────────────────────
2857
- // These commands touch the SHARED Claude OAuth token (single Anthropic
2858
- // account per pod), so they're owner-only. Non-owner authorized users
2859
- // can still call /auth_status to see whether Claude is happy.
2860
-
2861
- bot.onText(/\/(?:auth_status|auth status)$/, wrapHandler(async (msg) => {
2862
- if (!isAuthorized(msg)) return;
2863
- const chatId = String(msg.chat.id);
2864
- const tokenInfo = getClaudeOAuthToken();
2865
- const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
2866
- cwd: process.env.HOME || require("os").homedir(),
2867
- env: claudeSubprocessEnv(),
2868
- stdio: ["ignore", "pipe", "pipe"],
2869
- });
2870
- let output = "";
2871
- proc.stdout.on("data", (d) => { output += d.toString(); });
2872
- proc.stderr.on("data", (d) => { output += d.toString(); });
2873
- proc.on("close", (code) => chatContext.run(chatId, async () => {
2874
- const clean = redactSensitive(output.trim()) || "(no output)";
2875
- await send([
2876
- `Claude auth status: exit ${code}`,
2877
- ...summarizeClaudeAuthStatus(output, code, tokenInfo),
2878
- `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
2879
- `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
2880
- "",
2881
- clean.slice(-2500),
2882
- ].join("\n"));
2883
- }));
2884
- proc.on("error", (err) => chatContext.run(chatId, () => send(`Claude auth status failed: ${redactSensitive(err.message)}`)));
2885
- }));
2886
-
2887
- bot.onText(/\/cancel_auth$/, wrapHandler(async (msg) => {
2888
- if (!isOwner(msg)) return send("Owner only — Claude auth is shared across users.");
2889
- const state = currentState();
2890
- if (!state.pendingClaudeAuthProcess) return send("No Claude auth flow is pending.");
2891
- clearPendingClaudeAuth(state);
2892
- await send("Claude auth flow cancelled. Normal messages will go to the assistant again.");
2893
- }));
2894
-
2895
- bot.onText(/\/auth_code(?:\s+(.+))?$/, wrapHandler(async (msg, match) => {
2896
- if (!isOwner(msg)) return send("Owner only — Claude auth is shared across users.");
2897
- const state = currentState();
2898
- const code = (match[1] || "").trim();
2899
- await deleteMessage(msg.message_id);
2900
- if (!state.pendingClaudeAuthProcess || state.pendingClaudeAuthLabel === "manual OAuth token save") {
2901
- return send("No Claude login flow is waiting for an auth code. Start with /login, or use /use_oauth_token for tokens.");
2902
- }
2903
- if (!code || !looksLikeClaudeAuthReply(code)) {
2904
- return send("That does not look like a Claude auth code/callback. Use /cancel_auth to cancel the login flow.");
2905
- }
2906
- try {
2907
- state.pendingClaudeAuthProcess.stdin.write(code + "\n");
2908
- await send("Auth code sent to Claude. I’ll confirm with auth status when Claude finishes.");
2909
- } catch (e) {
2910
- clearPendingClaudeAuth(state);
2911
- await send(`Could not send auth code to Claude: ${redactSensitive(e.message)}`);
2912
- }
2913
- }));
2914
-
2915
- bot.onText(/\/login$/, wrapHandler(async (msg) => {
2916
- if (!isOwner(msg)) return send("Owner only — Claude auth is shared across users.");
2917
- await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
2918
- }));
2919
-
2920
- bot.onText(/\/setup_token$/, wrapHandler(async (msg) => {
2921
- if (!isOwner(msg)) return send("Owner only — Claude auth is shared across users.");
2922
- await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
2923
- }));
2924
-
2925
- bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, wrapHandler(async (msg, match) => {
2926
- if (!isOwner(msg)) return send("Owner only — Claude auth is shared across users.");
2927
- const state = currentState();
2928
- const token = (match[1] || "").trim();
2929
- await deleteMessage(msg.message_id);
2930
- if (!token) {
2931
- state.pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
2932
- state.pendingClaudeAuthLabel = "manual OAuth token save";
2933
- await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
2934
- return;
2935
- }
2936
- if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
2937
- saveClaudeOAuthToken(token);
2938
- await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
2939
- await sendClaudeAuthStatusSummary("Stored token. Current Claude auth status:");
2940
- }));
2941
-
2942
- bot.onText(/\/clear_oauth_token$/, wrapHandler(async (msg) => {
2943
- if (!isOwner(msg)) return send("Owner only — Claude auth is shared across users.");
2944
- clearClaudeOAuthToken();
2945
- await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
2946
- }));
2947
-
2948
- bot.onText(/\/(?:codex_auth_status|codex auth status)$/, wrapHandler(async (msg) => {
2949
- if (!isAuthorized(msg)) return;
2950
- await sendCodexAuthStatusSummary();
2951
- }));
2952
-
2953
- bot.onText(/\/codex_login$/, wrapHandler(async (msg) => {
2954
- if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
2955
- await runCodexDeviceLogin();
2956
- }));
2957
-
2958
- bot.onText(/\/cancel_codex_auth$/, wrapHandler(async (msg) => {
2959
- if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
2960
- const state = currentState();
2961
- if (!state.pendingCodexAuthProcess) return send("No Codex auth flow is pending.");
2962
- clearPendingCodexAuth(state);
2963
- await send("Codex auth flow cancelled. Normal messages will go to the assistant again.");
2964
- }));
2965
-
2966
- bot.onText(/\/(?:codex_setup_token|codex_use_api_key)(?:\s+(.+))?$/, wrapHandler(async (msg, match) => {
2967
- if (!isOwner(msg)) return send("Owner only — Codex auth is shared across users.");
2968
- if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
2969
- const key = (match[1] || "").trim();
2970
- await deleteMessage(msg.message_id);
2971
- if (!key) {
2972
- const state = currentState();
2973
- state.pendingCodexAuthProcess = { kill: () => {} };
2974
- state.pendingCodexAuthLabel = "manual OpenAI API key save";
2975
- await send("Send the OpenAI API key in your next message. I’ll delete it and pass it to `codex login --with-api-key` without echoing it.");
2976
- return;
2977
- }
2978
- if (!looksLikeOpenAIKey(key)) return send("That does not look like an OpenAI API key. Not saved.");
2979
- const result = await saveCodexApiKeyWithCli(key);
2980
- await send(result.ok ? "Codex API key stored by the Codex CLI. I did not print it." : `Codex CLI could not store the API key: ${redactSensitive(result.output).slice(-800)}`);
2981
- await sendCodexAuthStatusSummary("Current Codex auth status:");
2982
- }));
2983
-
2984
- // ── /vault with password protection ─────────────────────────────────
2985
- // Vault is a single shared store. Lock state is global; the
2986
- // password-unlock flow is per-user (pendingVaultUnlock lives on the
2987
- // current user's state so two people can't crash into each other's
2988
- // password prompt).
2989
-
2990
- bot.onText(/\/vault$/, wrapHandler(async (msg) => {
2991
- if (!isAuthorized(msg)) return;
2992
- const state = currentState();
2993
- if (!vault.exists()) {
2994
- await send("No vault found. Run setup first: node setup.js");
2995
- return;
2996
- }
2997
- if (vault.isUnlocked()) {
2998
- const entries = vault.list();
2999
- const keys = Object.keys(entries);
3000
- if (keys.length === 0) await send("Vault is unlocked but empty.\n\nUse /vault set <name> <value>");
3001
- else await send("Vault (unlocked):\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nLocks automatically in 5 min.");
3002
- } else {
3003
- state.pendingVaultUnlock = true;
3004
- state.pendingVaultAction = { type: "list" };
3005
- await send("Vault is locked. Send your vault password.\n(Message will be deleted after reading)");
3006
- }
3007
- }));
3008
-
3009
- bot.onText(/\/vault set (\S+) (.+)/, wrapHandler(async (msg, match) => {
3010
- if (!isAuthorized(msg)) return;
3011
- const state = currentState();
3012
- // Delete the message containing the value immediately
3013
- await deleteMessage(msg.message_id);
3014
- if (vault.isUnlocked()) {
3015
- vault.set(match[1], match[2].trim());
3016
- await send(`Saved: ${match[1]}`);
3017
- } else {
3018
- state.pendingVaultUnlock = true;
3019
- state.pendingVaultAction = { type: "set", key: match[1], value: match[2].trim() };
3020
- await send("Vault locked. Send password to unlock.");
3021
- }
3022
- }));
3023
-
3024
- bot.onText(/\/vault remove (\S+)/, wrapHandler(async (msg, match) => {
3025
- if (!isAuthorized(msg)) return;
3026
- const state = currentState();
3027
- if (vault.isUnlocked()) {
3028
- vault.remove(match[1]);
3029
- await send(`Removed: ${match[1]}`);
3030
- } else {
3031
- state.pendingVaultUnlock = true;
3032
- state.pendingVaultAction = { type: "remove", key: match[1] };
3033
- await send("Vault locked. Send password to unlock.");
3034
- }
3035
- }));
3036
-
3037
- bot.onText(/\/vault lock/, wrapHandler(async (msg) => {
3038
- if (!isAuthorized(msg)) return;
3039
- vault.lock();
3040
- await send("Vault locked.");
3041
- }));
3042
-
3043
- // ── /cron ───────────────────────────────────────────────────────────
3044
-
3045
- bot.onText(/\/cron$/, wrapHandler((msg) => {
3046
- if (!isAuthorized(msg)) return;
3047
- const list = loadCrons();
3048
- if (list.length === 0) {
3049
- send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
3050
- keyboard: { inline_keyboard: [
3051
- [{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
3052
- [{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
3053
- ] },
3054
- });
3055
- } else {
3056
- send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project}`).join("\n") + "\n\nRemove: /cron remove <#>");
3057
- }
3058
- }));
3059
-
3060
- bot.onText(/\/cron add "(.+)" (\S+) "(.+)"/, wrapHandler((msg, match) => {
3061
- if (!isAuthorized(msg)) return;
3062
- if (!cron.validate(match[1])) return send("Invalid cron schedule.");
3063
- const proj = findProject(match[2]);
3064
- if (!proj || Array.isArray(proj)) return send("Project not found.");
3065
- const c = { id: `cron_${Date.now()}`, schedule: match[1], project: proj, prompt: match[3], label: match[3].slice(0, 50) };
3066
- const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
3067
- send(`Added: ${c.label} (${c.schedule}) for ${proj}`);
3068
- }));
3069
-
3070
- bot.onText(/\/cron remove (\d+)/, wrapHandler((msg, match) => {
3071
- if (!isAuthorized(msg)) return;
3072
- const list = loadCrons(); const idx = parseInt(match[1]) - 1;
3073
- if (idx < 0 || idx >= list.length) return send("Invalid number.");
3074
- const removed = list.splice(idx, 1)[0]; saveCrons(list);
3075
- if (activeCrons.has(removed.id)) { activeCrons.get(removed.id).task.stop(); activeCrons.delete(removed.id); }
3076
- send(`Removed: ${removed.label}`);
3077
- }));
3078
-
3079
- // ── Callback Queries ────────────────────────────────────────────────
3080
-
3081
- bot.on("callback_query", wrapHandler(async (q) => {
3082
- const d = q.data;
3083
- await bot.answerCallbackQuery(q.id);
3084
- const state = currentState();
3085
-
3086
- if (d.startsWith("auth:")) {
3087
- const callbackFromOwner = String(q.from?.id) === CHAT_ID || String(q.message?.chat?.id) === CHAT_ID;
3088
- if (!callbackFromOwner) {
3089
- await send("Owner only — auth approvals are restricted.");
3090
- return;
3091
- }
3092
-
3093
- const [, action, chatId] = d.split(":");
3094
- if (!chatId || !["approve", "deny"].includes(action)) return;
3095
-
3096
- const result = action === "approve"
3097
- ? await approveAuthRequest(chatId)
3098
- : await denyAuthRequest(chatId);
3099
-
3100
- if (result.status === "not_found") {
3101
- await send(`No pending auth request found for ${chatId}.`);
3102
- return;
3103
- }
3104
-
3105
- if (result.status === "already_authorized") {
3106
- await send(`Chat ${chatId} is already authorized.`);
3107
- return;
3108
- }
3109
-
3110
- const label = authRequestLabel(result.request);
3111
- if (result.status === "approved") {
3112
- await bot.sendMessage(chatId, "Your access has been approved! You can now use the bot. Send /start to begin.").catch(() => {});
3113
- let edited = false;
3114
- if (q.message) {
3115
- edited = await bot.editMessageText(`Approved auth request from ${label} (${chatId}).`, {
3116
- chat_id: q.message.chat.id,
3117
- message_id: q.message.message_id,
3118
- reply_markup: { inline_keyboard: [] },
3119
- }).then(() => true).catch(() => false);
3120
- }
3121
- if (!edited) await send(`Approved ${label} (${chatId}).`);
3122
- return;
3123
- }
3124
-
3125
- await bot.sendMessage(chatId, "Your access request was denied.").catch(() => {});
3126
- let edited = false;
3127
- if (q.message) {
3128
- edited = await bot.editMessageText(`Denied auth request from ${label} (${chatId}).`, {
3129
- chat_id: q.message.chat.id,
3130
- message_id: q.message.message_id,
3131
- reply_markup: { inline_keyboard: [] },
3132
- }).then(() => true).catch(() => false);
3133
- }
3134
- if (!edited) await send(`Denied ${label} (${chatId}).`);
3135
- return;
3136
- }
3137
-
3138
- // Onboarding style selection
3139
- if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
3140
-
3141
- if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
3142
- if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
3143
- if (d.startsWith("ss:")) { if (state.currentSession) startSession(state.currentSession.name, d.slice(3)); return; }
3144
- if (d.startsWith("new:")) {
3145
- const proj = d.slice(4);
3146
- // Don't delete history, just start fresh
3147
- state.currentSession = { name: proj === "__workspace__" ? "Workspace" : proj, dir: proj === "__workspace__" ? WORKSPACE : path.join(WORKSPACE, proj) };
3148
- state.lastSessionId = null;
3149
- state.isFirstMessage = true;
3150
- state.messageQueue = [];
3151
- resetSettings(state);
3152
- resetSessionUsage(state);
3153
- saveState();
3154
- await send(`Session: ${state.currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
3155
- return;
3156
- }
3157
- if (d === "a:continue") { if (state.currentSession && getActiveSessionId()) await runClaude("continue", state.currentSession.dir); else send("No session to continue."); return; }
3158
- if (d === "a:end") {
3159
- if (state.currentSession) {
3160
- const n = state.currentSession.name;
3161
- state.currentSession = null;
3162
- state.lastSessionId = null;
3163
- state.messageQueue = [];
3164
- resetSettings(state);
3165
- resetSessionUsage(state);
3166
- saveState();
3167
- await send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
3168
- }
3169
- return;
3170
- }
3171
- if (d === "noop") { return; }
3172
- if (d.startsWith("mb:")) {
3173
- // Unified picker: switch backend + set model in one tap.
3174
- // Format: mb:<backend>:<model>. Model may contain `:` itself (unlikely), so split on first two.
3175
- const rest = d.slice(3);
3176
- const colon = rest.indexOf(":");
3177
- if (colon < 0) return;
3178
- const be = rest.slice(0, colon);
3179
- const model = rest.slice(colon + 1);
3180
- if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
3181
- if (be === "codex" && !resolvedCodexPath) { await send("Codex CLI not found. Install: npm install -g @openai/codex"); return; }
3182
- const switched = state.settings.backend !== be;
3183
- state.settings.backend = be;
3184
- state.settings.model = model;
3185
- saveState();
3186
- const beLabel = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
3187
- await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
3188
- return;
3189
- }
3190
- if (d.startsWith("m:")) { state.settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${state.settings.model || "default"}`); return; }
3191
- if (d.startsWith("e:")) { const e = d.slice(2); state.settings.effort = e === "default" ? null : e; await send(`Effort: ${state.settings.effort || "default"}`); return; }
3192
- if (d.startsWith("b:")) { const b = d.slice(2); state.settings.budget = b === "none" ? null : parseFloat(b); await send(`Budget: ${state.settings.budget ? "$"+state.settings.budget : "none"}`); return; }
3193
- if (d.startsWith("be:")) {
3194
- const be = d.slice(3);
3195
- if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
3196
- if (be === "codex" && !resolvedCodexPath) { await send("Codex CLI not found. Install: npm install -g @openai/codex"); return; }
3197
- state.settings.backend = be;
3198
- state.settings.model = null;
3199
- saveState();
3200
- const label = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
3201
- await send(`Switched to ${label}.`);
3202
- return;
3203
- }
3204
-
3205
- // Mode switching — writes mode file and restarts bot. Owner only —
3206
- // it affects the whole pod, not just this user.
3207
- if (d.startsWith("mode:")) {
3208
- if (String(q.from?.id) !== CHAT_ID) { await send("Owner only — mode switch restarts the bot."); return; }
3209
- const newMode = d.slice(5);
3210
- const modeFile = path.join(CONFIG_DIR, ".bot-mode");
3211
- fs.writeFileSync(modeFile, newMode);
3212
- await send(`Switching to ${newMode} mode... restarting.`);
3213
- setTimeout(() => process.exit(0), 500); // Let launchd/systemd restart with new mode
3214
- return;
3215
- }
3216
-
3217
- // Cron presets
3218
- if (d.startsWith("cp:") && d !== "cp:clear") {
3219
- if (!state.currentSession) return send("Start a session first.");
3220
- const presets = {
3221
- standup: { schedule: "0 9 * * 1-5", prompt: "Morning standup: recent commits, failing tests, open TODOs, what to focus on today. Brief.", label: "Morning standup" },
3222
- git: { schedule: "0 18 * * 1-5", prompt: "Git digest: today's commits, changed files, uncommitted changes. Flag concerns.", label: "Git digest" },
3223
- deps: { schedule: "0 10 * * 1", prompt: "Check outdated/vulnerable dependencies. Brief — just what needs attention.", label: "Dep check" },
3224
- health: { schedule: "*/30 * * * *", prompt: "Quick health: can the project build? Run build/lint, report pass/fail.", label: "Health check" },
3225
- };
3226
- const p = presets[d.slice(3)];
3227
- if (!p) return;
3228
- const c = { id: `cron_${Date.now()}`, ...p, project: state.currentSession.name };
3229
- const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
3230
- await send(`Added: ${c.label} for ${state.currentSession.name}`);
3231
- return;
3232
- }
3233
- if (d === "cp:clear") { for (const [,v] of activeCrons) v.task.stop(); activeCrons.clear(); saveCrons([]); await send("All crons cleared."); return; }
3234
- }));
3235
-
3236
- // ── Media Handlers ──────────────────────────────────────────────────
3237
-
3238
- bot.on("voice", wrapHandler(async (msg) => {
3239
- if (isDuplicate(msg)) return;
3240
- if (!isAuthorized(msg)) return;
3241
- if (!requireSession(msg)) return;
3242
- const state = currentState();
3243
- try {
3244
- // Check file size
3245
- if (msg.voice.file_size && msg.voice.file_size > MAX_VOICE_SIZE) {
3246
- return send(`Voice note too large (${Math.round(msg.voice.file_size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
3247
- }
3248
- bot.sendChatAction(currentChatId(), "typing");
3249
- const oggPath = await downloadFile(msg.voice.file_id, ".ogg");
3250
- const transcript = transcribeAudio(oggPath);
3251
- try { fs.unlinkSync(oggPath); } catch (e) {}
3252
- if (!transcript) return send("Couldn't transcribe. Try typing it.");
3253
- await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
3254
- state.lastInputWasVoice = true;
3255
- await runClaude(transcript, state.currentSession.dir, msg.message_id);
3256
- } catch (err) { await send(`Voice failed: ${err.message}`); }
3257
- }));
3258
-
3259
- bot.on("audio", wrapHandler(async (msg) => {
3260
- if (isDuplicate(msg)) return;
3261
- if (!isAuthorized(msg)) return;
3262
- if (!requireSession(msg)) return;
3263
- const state = currentState();
3264
- try {
3265
- bot.sendChatAction(currentChatId(), "typing");
3266
- const p = await downloadFile(msg.audio.file_id, path.extname(msg.audio.file_name || ".ogg"));
3267
- const t = transcribeAudio(p);
3268
- try { fs.unlinkSync(p); } catch (e) {}
3269
- if (!t) return send("Couldn't transcribe.");
3270
- await send(`Heard: "${t}"`, { replyTo: msg.message_id });
3271
- await runClaude(t, state.currentSession.dir, msg.message_id);
3272
- } catch (err) { await send(`Audio failed: ${err.message}`); }
3273
- }));
3274
-
3275
- bot.on("photo", wrapHandler(async (msg) => {
3276
- if (isDuplicate(msg)) return;
3277
- if (!isAuthorized(msg)) return;
3278
- if (!requireSession(msg)) return;
3279
- const state = currentState();
3280
- try {
3281
- const p = await downloadFile(msg.photo[msg.photo.length - 1].file_id, ".jpg");
3282
- const caption = msg.caption || "Describe this image. If code/UI/error — explain and fix.";
3283
- await runClaude(`Image at ${p}\n\nView it, then: ${caption}`, state.currentSession.dir, msg.message_id);
3284
- } catch (err) { await send(`Image failed: ${err.message}`); }
3285
- }));
3286
-
3287
- bot.on("document", wrapHandler(async (msg) => {
3288
- if (isDuplicate(msg)) return;
3289
- if (!isAuthorized(msg)) return;
3290
- if (!requireSession(msg)) return;
3291
- const state = currentState();
3292
- try {
3293
- // Check file size
3294
- if (msg.document.file_size && msg.document.file_size > MAX_FILE_SIZE) {
3295
- return send(`File too large (${Math.round(msg.document.file_size / 1024 / 1024)}MB). Max: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
3296
- }
3297
- const fileName = msg.document.file_name || `file-${Date.now()}`;
3298
- const mime = msg.document.mime_type || "";
3299
- // Save with original name to files dir
3300
- const savePath = path.join(FILES_DIR, fileName);
3301
- const file = await bot.getFile(msg.document.file_id);
3302
- const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
3303
- await new Promise((resolve, reject) => {
3304
- const out = fs.createWriteStream(savePath);
3305
- https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
3306
- });
3307
- const caption = msg.caption || "";
3308
- const isImage = mime.startsWith("image/");
3309
- let prompt;
3310
- if (isImage) {
3311
- prompt = `Image file saved at ${savePath}\n\nView it, then: ${caption || "Describe this image. If code/UI/error — explain and fix."}`;
3312
- } else {
3313
- prompt = `File received: ${fileName} (${mime})\nSaved at: ${savePath}\n\nRead this file and ${caption || "summarize its contents. If it's code, explain what it does. If it's a document, give key points."}`;
3314
- }
3315
- await send(`File saved: ${fileName}`, { replyTo: msg.message_id });
3316
- await runClaude(prompt, state.currentSession.dir, msg.message_id);
3317
- } catch (err) { await send(`Failed: ${err.message}`); }
3318
- }));
3319
-
3320
- // ── Text Message Handler (handles onboarding, vault password, normal messages) ──
3321
-
3322
- bot.on("message", wrapHandler(async (msg) => {
3323
- if (!isAuthorized(msg)) return;
3324
- if (!msg.text || msg.text.startsWith("/")) return;
3325
- if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
3326
- if (isDuplicate(msg)) return;
3327
- const state = currentState();
3328
-
3329
- // Handle pending manual Codex API key paste mode.
3330
- if (state.pendingCodexAuthProcess && state.pendingCodexAuthLabel === "manual OpenAI API key save") {
3331
- const text = msg.text.trim();
3332
- await deleteMessage(msg.message_id);
3333
- if (!looksLikeOpenAIKey(text)) {
3334
- clearPendingCodexAuth(state);
3335
- await send("That did not look like an OpenAI API key. Not saved.");
3336
- return;
3337
- }
3338
- clearPendingCodexAuth(state);
3339
- const result = await saveCodexApiKeyWithCli(text);
3340
- await send(result.ok ? "Codex API key stored by the Codex CLI. I did not print it." : `Codex CLI could not store the API key: ${redactSensitive(result.output).slice(-800)}`);
3341
- await sendCodexAuthStatusSummary("Current Codex auth status:");
3342
- return;
3343
- }
3344
-
3345
- if (state.pendingCodexAuthProcess) {
3346
- await send("Codex login is still running. Complete the device flow in your browser, or send /cancel_codex_auth.");
3347
- return;
3348
- }
3349
-
3350
- // Handle pending manual OAuth token paste mode. Login codes must be sent explicitly
3351
- // with /auth_code so normal chat can never be deleted/consumed accidentally.
3352
- if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel === "manual OAuth token save") {
3353
- const text = msg.text.trim();
3354
- if (looksLikeClaudeToken(text)) {
3355
- await deleteMessage(msg.message_id);
3356
- clearPendingClaudeAuth(state);
3357
- saveClaudeOAuthToken(text);
3358
- await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
3359
- await sendClaudeAuthStatusSummary("Stored token. Current Claude auth status:");
3360
- return;
3361
- }
3362
- await send("Token paste mode is active, but that does not look like a Claude OAuth token. I left your message visible and will handle it normally. Send /cancel_auth to stop token paste mode.");
3363
- }
3364
-
3365
- if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel !== "manual OAuth token save") {
3366
- const text = msg.text.trim();
3367
- if (looksLikeClaudeAuthReply(text)) {
3368
- await send("That looks like a Claude login code/callback. I did not delete it or send it to Claude as a prompt. Please resend it as `/auth_code YOUR_CODE`, or use /cancel_auth to stop the login flow.");
3369
- return;
3370
- }
3371
- await send("Claude login is still waiting. I will not delete normal messages. If Claude gave you a code, send it as `/auth_code YOUR_CODE`, or use /cancel_auth.");
3372
- }
3373
-
3374
- // Handle onboarding (only the owner gets here in practice — ONBOARDED
3375
- // flips to true after the first run, gating everyone else out).
3376
- if (!isOnboarded() && state.onboardingStep) {
3377
- await handleOnboarding(msg);
3378
- return;
3379
- }
3380
-
3381
- // Handle vault password — per-user flow so two people can't fight over
3382
- // the prompt. Vault itself is shared once unlocked.
3383
- if (state.pendingVaultUnlock) {
3384
- const password = msg.text;
3385
- // Delete the password message immediately
3386
- await deleteMessage(msg.message_id);
3387
-
3388
- const ok = vault.unlock(password);
3389
- if (!ok) {
3390
- state.pendingVaultUnlock = false;
3391
- state.pendingVaultAction = null;
3392
- await send("Wrong password.");
3393
- return;
3394
- }
3395
-
3396
- // Execute pending action
3397
- const action = state.pendingVaultAction;
3398
- state.pendingVaultUnlock = false;
3399
- state.pendingVaultAction = null;
3400
-
3401
- if (action.type === "list") {
3402
- const entries = vault.list();
3403
- const keys = Object.keys(entries);
3404
- if (keys.length === 0) await send("Vault unlocked (empty).\n\nUse /vault set <name> <value>");
3405
- else await send("Vault unlocked:\n\n" + keys.map(k => `${k}: ${entries[k]}`).join("\n") + "\n\nAuto-locks in 5 min.");
3406
- } else if (action.type === "set") {
3407
- vault.set(action.key, action.value);
3408
- await send(`Saved: ${action.key}`);
3409
- } else if (action.type === "remove") {
3410
- vault.remove(action.key);
3411
- await send(`Removed: ${action.key}`);
3412
- }
3413
- return;
3414
- }
3415
-
3416
- // Normal message
3417
- if (!requireSession(msg)) return;
3418
-
3419
- // Detect credential-like messages and delete them from chat
3420
- const text = msg.text;
3421
- const credPatterns = [
3422
- /^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/, // API keys
3423
- /^[A-Za-z0-9_-]{20,}$/, // Long token-like strings with no spaces
3424
- /^(Bearer |token:|key:|secret:|password:)/i, // Prefixed credentials
3425
- ];
3426
- const looksLikeCredential = credPatterns.some((p) => p.test(text.trim()));
3427
- if (looksLikeCredential) {
3428
- await deleteMessage(msg.message_id);
3429
- }
3430
-
3431
- let prompt = msg.text;
3432
- const reply = msg.reply_to_message;
3433
- const skipReplyContext = reply?.from?.is_bot && !reply.document && !reply.photo;
3434
- if (reply && !skipReplyContext) {
3435
- let ctx = "";
3436
- if (reply.text) {
3437
- ctx = reply.text;
3438
- }
3439
- if (reply.caption) {
3440
- ctx += (ctx ? "\n" : "") + reply.caption;
3441
- }
3442
- if (reply.document) {
3443
- const fileName = reply.document.file_name || "unknown";
3444
- const filePath = path.join(FILES_DIR, fileName);
3445
- if (fs.existsSync(filePath)) {
3446
- ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
3447
- } else {
3448
- // Download the file from the replied message
3449
- try {
3450
- const file = await bot.getFile(reply.document.file_id);
3451
- const fileUrl = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}`;
3452
- await new Promise((resolve, reject) => {
3453
- const out = fs.createWriteStream(filePath);
3454
- https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
3455
- });
3456
- ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
3457
- } catch (e) {
3458
- ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} — could not download]`;
3459
- }
3460
- }
3461
- }
3462
- if (reply.photo) {
3463
- ctx += (ctx ? "\n" : "") + "[Attached photo]";
3464
- }
3465
- if (ctx) {
3466
- prompt = `Replying to message:\n---\n${ctx.length > 1000 ? ctx.slice(0, 1000) + "..." : ctx}\n---\n\n${msg.text}`;
3467
- }
3468
- }
3469
-
3470
- await runClaude(prompt, state.currentSession.dir, msg.message_id);
3471
- }));
3472
-
3473
- // ── Startup ─────────────────────────────────────────────────────────
3474
- // Idempotent setup: ensure tools & config are in place (safe on every boot)
3475
- try {
3476
- const { ensureSetup, formatSetupResults } = require("./health");
3477
- const setupResult = ensureSetup();
3478
- console.log("Setup check:");
3479
- console.log(formatSetupResults(setupResult));
3480
- if (!setupResult.ok) console.warn("Some setup steps failed — browser tools may be unavailable.");
3481
- } catch (e) {
3482
- console.warn("Setup check skipped:", e.message);
3483
- }
3484
-
3485
- // Sweep stale `claude login` / `claude setup-token` processes that
3486
- // survived a previous bot crash. They're blocked on stdin forever and
3487
- // can hold keychain locks / resources. Anything older than 30 minutes
3488
- // is assumed to be abandoned.
3489
- try {
3490
- const out = execSync(
3491
- `ps -axo pid,etime,command | awk '/claude (login|setup-token)/ && !/awk/'`,
3492
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
3493
- );
3494
- for (const line of out.split("\n")) {
3495
- const m = line.trim().match(/^(\d+)\s+(\S+)\s/);
3496
- if (!m) continue;
3497
- const pid = Number(m[1]);
3498
- const elapsed = m[2]; // [DD-]HH:MM:SS or MM:SS
3499
- const minutes = elapsed.includes("-")
3500
- ? Number(elapsed.split("-")[0]) * 24 * 60 + 60 // any "DD-" form is ≥1 day
3501
- : elapsed.split(":").length === 3
3502
- ? Number(elapsed.split(":")[0]) * 60 + Number(elapsed.split(":")[1])
3503
- : Number(elapsed.split(":")[0]);
3504
- if (minutes >= 30) {
3505
- try {
3506
- process.kill(pid, "SIGTERM");
3507
- console.log(`Swept stale auth process pid=${pid} elapsed=${elapsed}`);
3508
- } catch (e) {}
3509
- }
3510
- }
3511
- } catch (e) {
3512
- // ps/awk not available or no matches — fine.
3513
- }
3514
-
3515
- initCrons();
3516
- console.log("Claude Code Telegram bot running");
3517
- console.log(`Workspace: ${WORKSPACE}`);
3518
- console.log(`Soul: ${SOUL_FILE}`);
3519
- console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
3520
- console.log(`Onboarded: ${isOnboarded()}`);
3521
-
3522
- // Notify owner that bot is back online
3523
- bot.sendMessage(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`).catch(() => {});