claude-telegram-bot 0.3.40 → 0.4.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.mjs CHANGED
@@ -84,6 +84,7 @@ const BOT_DIR = join(DATA_DIR, ".claude-bot");
84
84
  const STATE_PATH = join(BOT_DIR, stateFile);
85
85
  const ATTACH_DIR = join(BOT_DIR, "attachments");
86
86
  const MEMORY_PATH = join(BOT_DIR, "memory.md"); // /new 로 초기화해도 유지되는 퍼시스턴트 메모리
87
+ const CODEX_HANDOFF_PATH = join(BOT_DIR, "codex-handoff.md"); // Codex fallback 작업을 Claude에 넘길 요약
87
88
  const LEGACY_STATE_PATH = join(DATA_DIR, stateFile); // 구버전(루트 직하) 호환
88
89
  const LEGACY_ATTACH_DIR = join(DATA_DIR, "attachments");
89
90
 
@@ -112,6 +113,11 @@ if (!existsSync(CONFIG_PATH)) {
112
113
  }
113
114
 
114
115
  const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
116
+ const DEFAULT_PROVIDER = cfg.provider || "claude";
117
+ if (!["claude", "codex"].includes(DEFAULT_PROVIDER)) {
118
+ console.error(`Invalid provider: ${DEFAULT_PROVIDER} (expected claude or codex)`);
119
+ process.exit(1);
120
+ }
115
121
  console.log({ ...cfg, token: cfg.token ? "<redacted>" : "(none)" });
116
122
  const TG = `https://api.telegram.org/bot${cfg.token}`;
117
123
  // allowedChatId 는 문자열 또는 배열 모두 허용 (하위 호환)
@@ -136,6 +142,7 @@ const STR = {
136
142
  "• /new — reset conversation context (new session)\n" +
137
143
  "• /compact — compress context to free up space (keeps the session)\n" +
138
144
  "• /plan <request> — plan only (no edits), then approve/cancel to run for real\n" +
145
+ "• Codex fallback can run automatically when Claude hits a limit (if enabled)\n" +
139
146
  "• /ollama — toggle Ollama chat mode (bypass Claude, use local LLM)\n" +
140
147
  "• /stop — stop the current task · /stop --reset to also roll back the session\n" +
141
148
  "• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
@@ -144,6 +151,7 @@ const STR = {
144
151
  "• /reserve — show retry queue status at usage-limit reset · /reserve rm to cancel\n" +
145
152
  "• /restart — restart the bot (after a syntax check)\n" +
146
153
  "• /status — bot status & version\n" +
154
+ "• /provider — view / switch the default provider\n" +
147
155
  "• /model — view / switch the model\n" +
148
156
  "• /autocompact — view / set the auto-compact token threshold\n" +
149
157
  "• /id — show this chat ID\n" +
@@ -152,14 +160,17 @@ const STR = {
152
160
  compactOk: "🗜️ Context compacted. The conversation continues with a summary.",
153
161
  compactFail: (m) => `⚠️ Compact failed: ${m}`,
154
162
  compactNoSession: "No active session to compact. Just send a message to start one.",
163
+ compactProviderUnsupported: "/compact is currently available only with provider=claude.",
155
164
  autoCompact: "🗜️ Auto-compacted context (conversation was getting long).",
156
165
  planUsage: "Usage: `/plan <request>` — e.g. `/plan add input validation to the signup form`",
157
166
  planApprove: "✅ Proceed",
158
167
  planCancel: "❌ Cancel",
159
168
  planCancelled: "❌ Plan cancelled. No changes were made.",
160
169
  planNoPending: "No pending plan to approve (it may have expired after /new). Send /plan again.",
161
- testFallbackDisabled: "⚠️ Ollama fallback is not enabled. Set `\"ollamaFallback\": true` in config.json.",
162
- testFallbackFail: (m) => `⚠️ Ollama test failed: ${m}`,
170
+ planProviderUnsupported: "/plan approval flow currently requires provider=claude.",
171
+ testFallbackDisabled: "⚠️ No fallback is enabled. Set `\"codexFallback\": true` (recommended) or `\"ollamaFallback\": true` in config.json.",
172
+ testFallbackFail: (m) => `⚠️ Fallback test failed: ${m}`,
173
+ ollamaDisabled: "⚠️ Ollama mode is not enabled. Set `\"ollamaFallback\": true` in config.json.",
163
174
  ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
164
175
  ollamaOff: "✅ Ollama mode off. Back to Claude.",
165
176
  busy: "⏳ A previous task is still running. Please try again when it finishes.",
@@ -167,7 +178,7 @@ const STR = {
167
178
  stopOk: "🛑 Task stopped.",
168
179
  stopReset: "🛑 Task stopped and session rolled back to before the task.",
169
180
  stopNoop: "No task is running.",
170
- localBusy: "💻 A local `ctb claude` session is active. Send a message when it's done.",
181
+ localBusy: "💻 A local `ctb` session is active. Send a message when it's done.",
171
182
  needChatId: (id) => `Add this chat ID to "allowedChatId" in config.json:\n${id}`,
172
183
  cronEmpty:
173
184
  "No scheduled tasks yet.\nAdd one in plain language, e.g. `/cron add summarize open issues every weekday at 9am`.",
@@ -198,7 +209,10 @@ const STR = {
198
209
  status: (i) =>
199
210
  `🤖 ${i.name}\n` +
200
211
  `• Version: ${i.version}\n` +
212
+ `• Provider: ${i.provider}\n` +
213
+ `• CLIs: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
201
214
  `• Model: ${i.model}\n` +
215
+ `• Fallback: ${i.fallback}\n` +
202
216
  `• Session: ${i.hasSession ? "active" : "none (fresh)"}\n` +
203
217
  `• Scheduled jobs: ${i.jobs}\n` +
204
218
  `• Project: ${i.projectDir}\n` +
@@ -209,6 +223,10 @@ const STR = {
209
223
  `/model default — clear the override`,
210
224
  modelSet: (m) => `🧠 Model set to: ${m}`,
211
225
  modelReset: (def) => `🧠 Model reset to default (${def}).`,
226
+ providerStatus: (cur, def) => `🤖 Provider: ${cur}${cur === def ? " (config default)" : ` (config default: ${def})`}\nSwitch: /provider claude · /provider codex · /provider default`,
227
+ providerSet: (provider) => `🤖 Default provider set to ${provider}. Existing Claude and Codex sessions are preserved separately.`,
228
+ providerReset: (provider) => `🤖 Provider reset to the config default (${provider}).`,
229
+ providerUsage: "Usage: /provider claude · /provider codex · /provider default",
212
230
  autoCompactStatus: (cur, def) =>
213
231
  `🗜️ Auto-compact threshold: ${cur} tokens${cur === def ? " (default)" : ""}\n` +
214
232
  "Set: `/autocompact <number>` · `/autocompact off` to disable · `/autocompact default` to reset",
@@ -236,6 +254,7 @@ const STR = {
236
254
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
237
255
  "• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
238
256
  "• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
257
+ "• Codex 폴백 활성화 시 Claude 한도 도달 때 자동으로 대신 실행\n" +
239
258
  "• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
240
259
  "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
241
260
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
@@ -244,6 +263,7 @@ const STR = {
244
263
  "• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
245
264
  "• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
246
265
  "• /status — 봇 상태·버전 보기\n" +
266
+ "• /provider — 기본 provider 보기·전환\n" +
247
267
  "• /model — 모델 보기·전환\n" +
248
268
  "• /autocompact — 자동 압축 임계값 보기·설정\n" +
249
269
  "• /id — 이 채팅 ID 확인\n" +
@@ -254,7 +274,7 @@ const STR = {
254
274
  stopOk: "🛑 작업을 중단했습니다.",
255
275
  stopReset: "🛑 작업을 중단하고 세션을 작업 이전으로 되돌렸습니다.",
256
276
  stopNoop: "실행 중인 작업이 없습니다.",
257
- localBusy: "💻 로컬 `ctb claude` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
277
+ localBusy: "💻 로컬 `ctb` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
258
278
  needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
259
279
  cronEmpty:
260
280
  "등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
@@ -284,7 +304,10 @@ const STR = {
284
304
  status: (i) =>
285
305
  `🤖 ${i.name}\n` +
286
306
  `• 버전: ${i.version}\n` +
307
+ `• 메인 provider: ${i.provider}\n` +
308
+ `• CLI: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
287
309
  `• 모델: ${i.model}\n` +
310
+ `• 폴백: ${i.fallback}\n` +
288
311
  `• 세션: ${i.hasSession ? "이어가는 중" : "없음 (새 세션)"}\n` +
289
312
  `• 예약 작업: ${i.jobs}개\n` +
290
313
  `• 작업 폴더: ${i.projectDir}\n` +
@@ -295,6 +318,10 @@ const STR = {
295
318
  `/model default — 오버라이드 해제`,
296
319
  modelSet: (m) => `🧠 모델을 ${m} (으)로 설정했습니다.`,
297
320
  modelReset: (def) => `🧠 모델을 기본값(${def})으로 되돌렸습니다.`,
321
+ providerStatus: (cur, def) => `🤖 현재 provider: ${cur}${cur === def ? " (config 기본값)" : ` (config 기본값: ${def})`}\n전환: /provider claude · /provider codex · /provider default`,
322
+ providerSet: (provider) => `🤖 기본 provider를 ${provider}(으)로 변경했습니다. Claude와 Codex의 기존 세션은 각각 유지됩니다.`,
323
+ providerReset: (provider) => `🤖 provider를 config 기본값(${provider})으로 되돌렸습니다.`,
324
+ providerUsage: "사용법: /provider claude · /provider codex · /provider default",
298
325
  autoCompactStatus: (cur, def) =>
299
326
  `🗜️ 자동 압축 임계값: ${cur} 토큰${cur === def ? " (기본값)" : ""}\n` +
300
327
  "설정: `/autocompact <숫자>` · `/autocompact off` 로 끄기 · `/autocompact default` 로 초기화",
@@ -316,15 +343,18 @@ const STR = {
316
343
  compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
317
344
  compactFail: (m) => `⚠️ compact 실패: ${m}`,
318
345
  compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
346
+ compactProviderUnsupported: "/compact는 현재 provider=claude에서만 사용할 수 있습니다.",
319
347
  autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
320
348
  planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
321
349
  planApprove: "✅ 진행",
322
350
  planCancel: "❌ 취소",
323
351
  planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
324
352
  planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
353
+ planProviderUnsupported: "/plan 승인 흐름은 현재 provider=claude에서만 사용할 수 있습니다.",
325
354
  contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
326
- testFallbackDisabled: "⚠️ Ollama 폴백이 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
327
- testFallbackFail: (m) => `⚠️ Ollama 테스트 실패: ${m}`,
355
+ testFallbackDisabled: "⚠️ 폴백이 비활성화 상태입니다. config.json에 `\"codexFallback\": true`(권장) 또는 `\"ollamaFallback\": true` 를 추가하세요.",
356
+ testFallbackFail: (m) => `⚠️ 폴백 테스트 실패: ${m}`,
357
+ ollamaDisabled: "⚠️ Ollama 모드가 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
328
358
  ollamaOn: "🌙 Ollama 모드 켜짐. 이제 메시지는 Ollama로 처리됩니다. Claude 세션은 유지됩니다.",
329
359
  ollamaOff: "✅ Ollama 모드 꺼짐. 다시 Claude로 처리합니다.",
330
360
  },
@@ -350,6 +380,7 @@ const COMMANDS = {
350
380
  { command: "cron", description: "List / add / remove scheduled tasks" },
351
381
  { command: "restart", description: "Restart the bot (after syntax check)" },
352
382
  { command: "status", description: "Bot status / version" },
383
+ { command: "provider", description: "View / switch the default provider" },
353
384
  { command: "model", description: "View / switch the model" },
354
385
  { command: "autocompact", description: "View / set the auto-compact token threshold" },
355
386
  { command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
@@ -367,6 +398,7 @@ const COMMANDS = {
367
398
  { command: "cron", description: "예약 작업 보기·추가·삭제" },
368
399
  { command: "restart", description: "봇 재시작 (문법 검사 후)" },
369
400
  { command: "status", description: "봇 상태·버전 보기" },
401
+ { command: "provider", description: "기본 provider 보기·전환" },
370
402
  { command: "model", description: "모델 보기·전환" },
371
403
  { command: "autocompact", description: "자동 압축 임계값 보기·설정" },
372
404
  { command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
@@ -442,6 +474,28 @@ function saveMemory(content) {
442
474
  writeFileSync(MEMORY_PATH, content);
443
475
  }
444
476
 
477
+ function loadCodexHandoff(maxChars = 12000) {
478
+ try {
479
+ const text = readFileSync(CODEX_HANDOFF_PATH, "utf8").trim();
480
+ return text.length > maxChars ? text.slice(-maxChars) : text;
481
+ } catch {
482
+ return "";
483
+ }
484
+ }
485
+
486
+ function appendCodexHandoff({ prompt, response, sessionId }) {
487
+ const entry =
488
+ `\n\n## ${new Date().toISOString()}\n` +
489
+ `Codex session: ${sessionId || "(unknown)"}\n\n` +
490
+ `### Telegram prompt\n${String(prompt || "").trim() || "(empty)"}\n\n` +
491
+ `### Codex response\n${String(response || "").trim() || "(empty)"}\n`;
492
+ try {
493
+ writeFileSync(CODEX_HANDOFF_PATH, loadCodexHandoff(80000) + entry);
494
+ } catch (e) {
495
+ console.error("Failed to append Codex handoff", e.message);
496
+ }
497
+ }
498
+
445
499
  // ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
446
500
  function loadState() {
447
501
  // 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
@@ -460,7 +514,12 @@ function saveState(s) {
460
514
  }
461
515
  }
462
516
  migrateData(); // 루트 직하 → .claude-bot/ 1회 이주(있으면) 후 state 로드
463
- let state = loadState(); // { sessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
517
+ let state = loadState(); // { sessionId?, codexSessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
518
+ if (state.provider && !["claude", "codex"].includes(state.provider)) {
519
+ console.warn(`Ignoring invalid provider override in state: ${state.provider}`);
520
+ state.provider = undefined;
521
+ }
522
+ const currentProvider = () => state.provider || DEFAULT_PROVIDER;
464
523
 
465
524
  // ── 텔레그램 헬퍼 ─────────────────────────────────────────────────────────
466
525
  async function tg(method, body) {
@@ -655,7 +714,11 @@ function runClaude(prompt, sessionId, opts = {}) {
655
714
  const mem = opts.injectMemory ? loadMemory() : "";
656
715
  // 메모리는 persona보다 앞에 배치하고 헤더를 강화 → persona가 덮어쓰는 것 방지
657
716
  const memoryBlock = mem ? `## RULES (must follow before anything else)\n${mem}` : null;
658
- const appendSys = [memoryBlock, cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
717
+ const handoff = opts.injectHandoff !== false ? loadCodexHandoff() : "";
718
+ const handoffBlock = handoff
719
+ ? `## CODEX FALLBACK HANDOFF\nClaude and Codex sessions are separate. The notes below summarize work Codex handled while Claude was unavailable; use them as context, not as your own prior messages.\n${handoff}`
720
+ : null;
721
+ const appendSys = [memoryBlock, handoffBlock, cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
659
722
  if (appendSys) args.push("--append-system-prompt", appendSys);
660
723
  if (model) args.push("--model", model);
661
724
  if (sessionId) args.push("--resume", sessionId);
@@ -699,27 +762,224 @@ function runClaude(prompt, sessionId, opts = {}) {
699
762
  });
700
763
  }
701
764
 
765
+ function extractCodexTextFromJsonl(out) {
766
+ let sessionId;
767
+ let text = "";
768
+ for (const line of String(out || "").split("\n")) {
769
+ if (!line.trim()) continue;
770
+ try {
771
+ const j = JSON.parse(line);
772
+ // Current Codex CLI JSONL format.
773
+ if (j.type === "thread.started" && j.thread_id) sessionId = j.thread_id;
774
+ if (j.type === "item.completed" && j.item?.type === "agent_message" && j.item.text) {
775
+ text = j.item.text;
776
+ }
777
+ // Older app-server event format, kept for compatibility.
778
+ if (j.type === "session_meta" && j.payload?.id) sessionId = j.payload.id;
779
+ const p = j.payload;
780
+ if (p?.type === "message" && p.role === "assistant" && Array.isArray(p.content)) {
781
+ const parts = p.content
782
+ .filter((c) => c.type === "output_text" && c.text)
783
+ .map((c) => c.text);
784
+ if (parts.length) text = parts.join("\n");
785
+ }
786
+ } catch {}
787
+ }
788
+ return { sessionId, text: text.trim() };
789
+ }
790
+
791
+ function resolveCodexBin() {
792
+ if (cfg.codexBin) return cfg.codexBin;
793
+ const candidates = [
794
+ join(dirname(process.execPath), "codex"),
795
+ join(process.env.HOME || "", ".local", "bin", "codex"),
796
+ "/opt/homebrew/bin/codex",
797
+ "/usr/local/bin/codex",
798
+ ];
799
+ return candidates.find((p) => p && existsSync(p)) || "codex";
800
+ }
801
+
802
+ // ── Codex 폴백 실행 ──────────────────────────────────────────────────────
803
+ // Claude와 Codex 세션은 호환되지 않는다. Codex는 별도 session id를 state.codexSessionId에
804
+ // 저장하고, Claude 복귀 시 codex-handoff.md 요약을 시스템 프롬프트로 넘겨 맥락을 연결한다.
805
+ function runCodex(prompt, lang = "en", opts = {}) {
806
+ return new Promise((resolve) => {
807
+ const header = opts.noHeader ? "" : (lang === "ko" ? "🤖 Codex 폴백\n\n" : "🤖 Codex fallback\n\n");
808
+ const lastPath = join(BOT_DIR, `codex-last-message-${process.pid}.txt`);
809
+ const model = state.codexModel || cfg.codexModel;
810
+ const timeoutMs = cfg.codexTimeout || 600_000;
811
+ const args = ["exec"];
812
+ const resumeSessionId = Object.prototype.hasOwnProperty.call(opts, "sessionId")
813
+ ? opts.sessionId
814
+ : state.codexSessionId;
815
+ let codexPrompt = prompt;
816
+ if (!resumeSessionId && opts.injectMemory) {
817
+ const context = [loadMemory(), cfg.persona, cfg.appendSystemPrompt].filter(Boolean).join("\n\n");
818
+ if (context) codexPrompt = `Project instructions and persistent context:\n${context}\n\nUser request:\n${prompt}`;
819
+ }
820
+
821
+ if (resumeSessionId) {
822
+ args.push("resume", "--json", "-o", lastPath);
823
+ if (model) args.push("--model", model);
824
+ args.push(resumeSessionId, "-");
825
+ } else {
826
+ args.push("--json", "-o", lastPath, "-C", cfg.projectDir, "--sandbox", cfg.codexSandbox || "workspace-write");
827
+ if (model) args.push("--model", model);
828
+ args.push("-");
829
+ }
830
+
831
+ const child = spawn(resolveCodexBin(), args, {
832
+ cwd: cfg.projectDir,
833
+ env: { ...process.env, ...(cfg.env || {}) },
834
+ });
835
+ if (opts.trackChild) currentChild = child;
836
+
837
+ let settled = false;
838
+ const finish = (value) => {
839
+ if (settled) return;
840
+ settled = true;
841
+ clearTimeout(timer);
842
+ currentChild = null;
843
+ resolve(value);
844
+ };
845
+ const timer = setTimeout(() => {
846
+ try { child.kill("SIGKILL"); } catch {}
847
+ finish({ ok: false, text: `Codex timed out after ${Math.round(timeoutMs / 1000)}s` });
848
+ }, timeoutMs);
849
+
850
+ let out = "", err = "";
851
+ child.stdout.on("data", (d) => { out += d; });
852
+ child.stderr.on("data", (d) => { err += d; });
853
+ child.stdin.on("error", () => {});
854
+ child.on("error", (e) => finish({ ok: false, text: `Failed to start codex: ${e.message}` }));
855
+ child.on("close", (code) => {
856
+ const parsed = extractCodexTextFromJsonl(out);
857
+ let finalText = parsed.text;
858
+ if (!finalText) {
859
+ try { finalText = readFileSync(lastPath, "utf8").trim(); } catch {}
860
+ }
861
+ const sessionId = parsed.sessionId || resumeSessionId;
862
+ if (code === 0 && finalText) {
863
+ if (opts.recordHandoff !== false) appendCodexHandoff({ prompt, response: finalText, sessionId });
864
+ return finish({ ok: true, text: header + finalText, sessionId });
865
+ }
866
+ const raw = (err || out || finalText || "no output").slice(0, 1000);
867
+ finish({ ok: false, text: `Codex failed (exit ${code}):\n${raw}`, canFallback: isFallbackError(raw, code) });
868
+ });
869
+ child.stdin.end(codexPrompt);
870
+ });
871
+ }
872
+
873
+ function runPrimary(prompt, opts = {}) {
874
+ if (currentProvider() === "codex") {
875
+ return runCodex(prompt, opts.lang || BOT_LANG, {
876
+ noHeader: true,
877
+ trackChild: opts.trackChild,
878
+ injectMemory: opts.injectMemory,
879
+ recordHandoff: opts.recordHandoff,
880
+ ...(Object.prototype.hasOwnProperty.call(opts, "sessionId") ? { sessionId: opts.sessionId } : {}),
881
+ });
882
+ }
883
+ return runClaude(prompt, opts.sessionId, opts);
884
+ }
885
+
886
+ // launchd 데몬은 로그인 셸(zsh)을 거치지 않아 .zshrc/brew shellenv의 PATH를 상속받지 못한다
887
+ // (claudeBin과 동일한 이유). ollamaBin 미지정 시 흔한 설치 경로를 순서대로 탐색한다.
888
+ function resolveOllamaBin() {
889
+ if (cfg.ollamaBin) return cfg.ollamaBin;
890
+ const candidates = ["/opt/homebrew/bin/ollama", "/usr/local/bin/ollama", "/usr/bin/ollama"];
891
+ return candidates.find(existsSync) || "ollama";
892
+ }
893
+
894
+ function getCliVersion(bin, kind) {
895
+ return new Promise((resolve) => {
896
+ const child = spawn(bin, ["--version"], {
897
+ cwd: cfg.projectDir,
898
+ env: { ...process.env, ...(cfg.env || {}) },
899
+ });
900
+ let output = "", settled = false;
901
+ const finish = (value) => {
902
+ if (settled) return;
903
+ settled = true;
904
+ clearTimeout(timer);
905
+ resolve(value);
906
+ };
907
+ child.stdout.on("data", (d) => { output += d; });
908
+ child.stderr.on("data", (d) => { output += d; });
909
+ child.on("error", () => finish("unavailable"));
910
+ child.on("close", () => {
911
+ const patterns = {
912
+ claude: /([0-9]+\.[0-9]+\.[0-9]+)/,
913
+ codex: /codex(?:-cli)?\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
914
+ ollama: /(?:client\s+version\s+is|ollama\s+version\s+is)\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
915
+ };
916
+ finish(output.match(patterns[kind])?.[1] || "unknown");
917
+ });
918
+ const timer = setTimeout(() => {
919
+ try { child.kill("SIGKILL"); } catch {}
920
+ finish("unavailable");
921
+ }, 3000);
922
+ });
923
+ }
924
+
925
+ async function getCliVersions() {
926
+ const [claude, codex, ollama] = await Promise.all([
927
+ getCliVersion(cfg.claudeBin || "claude", "claude"),
928
+ getCliVersion(resolveCodexBin(), "codex"),
929
+ getCliVersion(resolveOllamaBin(), "ollama"),
930
+ ]);
931
+ return { claude, codex, ollama };
932
+ }
933
+
702
934
  // ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
703
- async function runOllama(prompt, lang = "en", opts = {}) {
704
- const header = opts.noHeader ? "" : (lang === "ko"
705
- ? "🌙 Claude가 잠시 쉬고 있어요. 제가 대신 도와드릴게요. (세션은 이어지지 않아요)\n\n"
706
- : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n");
707
- const model = cfg.ollamaModel || "phi3:mini";
708
- const brevity = "This reply is delivered over Telegram. Be concise short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
709
- const systemParts = [cfg.persona, brevity].filter(Boolean);
710
- const messages = [];
711
- if (systemParts.length) messages.push({ role: "system", content: systemParts.join("\n\n") });
712
- messages.push({ role: "user", content: prompt });
713
- const r = await fetch("http://localhost:11434/api/chat", {
714
- method: "POST",
715
- headers: { "Content-Type": "application/json" },
716
- body: JSON.stringify({ model, messages, stream: false }),
717
- signal: AbortSignal.timeout(60_000),
935
+ // `ollama launch claude` Claude Code CLI를 로컬 모델로 구동한다. opts.sessionId가
936
+ // 있으면 `--resume`으로 기존 대화 맥락을 그대로 이어받는다(HTTP api/chat 방식과 달리 세션 유지).
937
+ // 터미널 검증: ollama launch claude --model <m> --yes -- -p -- <prompt> --resume <id>
938
+ function runOllama(prompt, lang = "en", opts = {}) {
939
+ return new Promise((resolve) => {
940
+ const header = opts.noHeader ? "" : (lang === "ko"
941
+ ? "🌙 Claude가 잠시 쉬고 있어요. 그동안 저(로컬 모델)는 복귀하면 Claude에게 넘길 내용을 정리하는 걸 도와드릴게요 — 코딩·파일 작업은 Claude가 돌아온 뒤에요.\n\n"
942
+ : "🌙 Claude is resting right now. Meanwhile I (a local model) can help you jot down and organize what to hand off once it's back — coding and file work waits for Claude.\n\n");
943
+ const model = cfg.ollamaModel || "qwen3.5:4b";
944
+ const brevity = "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
945
+ // 폴백 경로에서만: Claude가 막혀 소형 로컬 모델로 대응 중임을 알리고, 코딩·도구 작업을 시도하는
946
+ // 대신 사용자가 Claude 복귀 후 이어갈 수 있도록 요청 정리·요약을 돕는 조수 역할을 지시한다.
947
+ const fallbackRole = opts.fallback
948
+ ? "You are a small local model standing in because Claude is temporarily unavailable (rate-limited or out of credits). Do NOT attempt coding, file edits, or tool use — you can't do those reliably. Instead, act as a note-taker: help the user capture, organize, and draft what they'll ask Claude to do once it's back. Summaries, request drafts, and tidy notes are your job."
949
+ : "";
950
+ const appendSys = [cfg.persona, brevity, fallbackRole].filter(Boolean).join("\n\n");
951
+ // `--` 앞은 ollama launch 플래그, 뒤는 claude 플래그로 전달된다.
952
+ const claudeArgs = ["--output-format", "json"];
953
+ if (appendSys) claudeArgs.push("--append-system-prompt", appendSys);
954
+ if (opts.sessionId) claudeArgs.push("--resume", opts.sessionId);
955
+ claudeArgs.push("-p", "--", prompt);
956
+ const args = ["launch", "claude", "--model", model, "--yes", "--", ...claudeArgs];
957
+
958
+ const child = spawn(resolveOllamaBin(), args, {
959
+ cwd: cfg.projectDir,
960
+ env: { ...process.env, ...(cfg.env || {}) },
961
+ });
962
+ // 로컬 4B 모델 콜드스타트는 첫 응답까지 수 분이 걸릴 수 있어 기본 타임아웃을 넉넉히 잡는다.
963
+ const timer = setTimeout(() => child.kill("SIGKILL"), cfg.ollamaTimeout || 360_000);
964
+ let out = "", err = "";
965
+ child.stdout.on("data", (d) => { out += d; });
966
+ child.stderr.on("data", (d) => { err += d; });
967
+ child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, text: `Failed to start ollama: ${e.message}` }); });
968
+ child.on("close", () => {
969
+ clearTimeout(timer);
970
+ try {
971
+ const j = JSON.parse(out);
972
+ const text = (j.result ?? "").trim();
973
+ if (j.is_error || !text) return resolve({ ok: false, text: text || "no response" });
974
+ resolve({ ok: true, text: header + text, sessionId: j.session_id });
975
+ } catch {
976
+ // JSON 파싱 실패 시 원시 출력으로 폴백 (구버전 ollama·비-JSON 출력 대비)
977
+ const text = (out || "").trim();
978
+ if (text) return resolve({ ok: true, text: header + text });
979
+ resolve({ ok: false, text: (err || "no output").slice(0, 500) });
980
+ }
981
+ });
718
982
  });
719
- if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
720
- const j = await r.json();
721
- const text = (j.message?.content || "").trim();
722
- return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
723
983
  }
724
984
 
725
985
  // ── 크론 스케줄러 ─────────────────────────────────────────────────────────
@@ -793,7 +1053,7 @@ async function runScheduled(job) {
793
1053
  busy = true;
794
1054
  const started = Date.now();
795
1055
  try {
796
- const res = await runClaude(job.prompt, undefined); // 새 세션 (state 미저장)
1056
+ const res = await runPrimary(job.prompt, { sessionId: null, lang: BOT_LANG, recordHandoff: false }); // 새 세션 (state 미저장)
797
1057
  // 조용한 예약 작업: 출력이 비었거나 정확히 "SKIP"이면 전송하지 않는다.
798
1058
  // (예: "조건 충족 시에만 알리고, 아니면 SKIP만 출력해" 식의 조건부 알림용)
799
1059
  if (res.ok) {
@@ -850,7 +1110,7 @@ async function extractCron(input, l) {
850
1110
  "Reply with ONLY one line of JSON, no prose or code block: " +
851
1111
  '{"cron":"0 9 * * *","prompt":"the task","label":"short name","human":"every day at 09:00"}\n\n' +
852
1112
  `request: ${input}`;
853
- const res = await runClaude(ask, undefined); // 새 세션 (대화 맥락과 분리)
1113
+ const res = await runPrimary(ask, { sessionId: null, lang: l, recordHandoff: false }); // 새 세션 (대화 맥락과 분리)
854
1114
  const m = res.text && res.text.match(/\{[\s\S]*\}/);
855
1115
  if (!res.ok || !m) return { error: res.text || t(l, "extractFail") };
856
1116
  let obj;
@@ -1011,11 +1271,30 @@ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
1011
1271
  async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
1012
1272
  const secs = Math.round((Date.now() - started) / 1000);
1013
1273
  if (!res.ok) {
1014
- // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1274
+ // Codex 폴백: 레이트리밋·크레딧 에러이고 codexFallback 켜져 있으면 reserve 전에 Codex로 재시도
1275
+ if (currentProvider() === "claude" && cfg.codexFallback && res.canFallback && !stopping) {
1276
+ try {
1277
+ const cRes = await runCodex(prompt, l, { trackChild: true });
1278
+ if (cRes.ok) {
1279
+ if (cRes.sessionId) { state.codexSessionId = cRes.sessionId; saveState(state); }
1280
+ await send(chatId, cRes.text); return;
1281
+ }
1282
+ console.error(cRes.text);
1283
+ } catch (e) {
1284
+ console.error("Codex fallback failed:", e.message);
1285
+ }
1286
+ }
1287
+ // Ollama 폴백: Codex 미사용/실패 시, 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1015
1288
  if (cfg.ollamaFallback && res.canFallback && !stopping) {
1016
1289
  try {
1017
- const oRes = await runOllama(prompt, l);
1018
- if (oRes.ok) { await send(chatId, oRes.text); return; }
1290
+ const oRes = await runOllama(prompt, l, {
1291
+ fallback: true,
1292
+ sessionId: currentProvider() === "claude" ? state.sessionId : undefined,
1293
+ });
1294
+ if (oRes.ok) {
1295
+ if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
1296
+ await send(chatId, oRes.text); return;
1297
+ }
1019
1298
  } catch {}
1020
1299
  }
1021
1300
  // 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
@@ -1039,7 +1318,7 @@ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
1039
1318
  if (!stopping) await send(chatId, res.text + footer);
1040
1319
  // 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
1041
1320
  const compactThreshold = state.autoCompactThreshold ?? cfg.autoCompactThreshold ?? 100000;
1042
- if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1321
+ if (currentProvider() === "claude" && compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1043
1322
  try {
1044
1323
  const cr = await runClaude("/compact", state.sessionId);
1045
1324
  if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
@@ -1153,7 +1432,7 @@ async function handle(msg) {
1153
1432
  return;
1154
1433
  }
1155
1434
  if (text === "/status") {
1156
- const latest = await fetchLatestVersion();
1435
+ const [latest, cliVersions] = await Promise.all([fetchLatestVersion(), getCliVersions()]);
1157
1436
  const versionStr = !latest || !isNewer(latest, VERSION)
1158
1437
  ? VERSION
1159
1438
  : `${VERSION} → ${latest} ✨`;
@@ -1161,9 +1440,19 @@ async function handle(msg) {
1161
1440
  chatId,
1162
1441
  t(l, "status", {
1163
1442
  version: versionStr,
1443
+ provider: currentProvider(),
1444
+ cliVersions,
1164
1445
  name: cfg.name || "claude-telegram-bot",
1165
- model: state.model || cfg.model || (l === "ko" ? "(기본값)" : "(default)"),
1166
- hasSession: Boolean(state.sessionId),
1446
+ model: (currentProvider() === "codex" ? state.codexModel || cfg.codexModel : state.model || cfg.model)
1447
+ || (l === "ko" ? "(기본값)" : "(default)"),
1448
+ fallback: currentProvider() === "codex"
1449
+ ? (cfg.ollamaFallback ? "Ollama" : (l === "ko" ? "꺼짐" : "off"))
1450
+ : cfg.codexFallback
1451
+ ? `Codex${state.codexSessionId ? (l === "ko" ? " (세션 있음)" : " (session active)") : ""}`
1452
+ : cfg.ollamaFallback
1453
+ ? "Ollama"
1454
+ : (l === "ko" ? "꺼짐" : "off"),
1455
+ hasSession: Boolean(currentProvider() === "codex" ? state.codexSessionId : state.sessionId),
1167
1456
  jobs: schedule.length,
1168
1457
  projectDir: cfg.projectDir,
1169
1458
  permissionMode: cfg.permissionMode || "acceptEdits",
@@ -1171,20 +1460,48 @@ async function handle(msg) {
1171
1460
  );
1172
1461
  return;
1173
1462
  }
1463
+ if (text === "/provider" || text.startsWith("/provider ")) {
1464
+ if (busy) {
1465
+ await send(chatId, t(l, "busy"));
1466
+ return;
1467
+ }
1468
+ const arg = text.slice(9).trim().toLowerCase();
1469
+ if (!arg) {
1470
+ await send(chatId, t(l, "providerStatus", currentProvider(), DEFAULT_PROVIDER));
1471
+ return;
1472
+ }
1473
+ if (arg === "default" || arg === "reset") {
1474
+ state.provider = undefined;
1475
+ saveState(state);
1476
+ await send(chatId, t(l, "providerReset", DEFAULT_PROVIDER));
1477
+ return;
1478
+ }
1479
+ if (!["claude", "codex"].includes(arg)) {
1480
+ await send(chatId, t(l, "providerUsage"));
1481
+ return;
1482
+ }
1483
+ state.provider = arg;
1484
+ state.ollamaMode = false;
1485
+ saveState(state);
1486
+ await send(chatId, t(l, "providerSet", arg));
1487
+ return;
1488
+ }
1174
1489
  if (text === "/model" || text.startsWith("/model ")) {
1175
1490
  const arg = text.slice(6).trim();
1491
+ const modelStateKey = currentProvider() === "codex" ? "codexModel" : "model";
1492
+ const configuredModel = currentProvider() === "codex" ? cfg.codexModel : cfg.model;
1176
1493
  if (!arg) {
1177
- const cur = state.model || cfg.model || (l === "ko" ? "(기본값)" : "(default)");
1494
+ const cur = state[modelStateKey] || configuredModel || (l === "ko" ? "(기본값)" : "(default)");
1178
1495
  await send(chatId, t(l, "modelStatus", cur, MODEL_SUGGESTIONS));
1179
1496
  return;
1180
1497
  }
1181
1498
  if (arg === "default" || arg === "reset") {
1182
- state.model = undefined;
1499
+ state[modelStateKey] = undefined;
1183
1500
  saveState(state);
1184
- await send(chatId, t(l, "modelReset", cfg.model || (l === "ko" ? "기본값" : "default")));
1501
+ await send(chatId, t(l, "modelReset", configuredModel || (l === "ko" ? "기본값" : "default")));
1185
1502
  return;
1186
1503
  }
1187
- state.model = arg;
1504
+ state[modelStateKey] = arg;
1188
1505
  saveState(state);
1189
1506
  await send(chatId, t(l, "modelSet", arg));
1190
1507
  return;
@@ -1243,6 +1560,7 @@ async function handle(msg) {
1243
1560
  return;
1244
1561
  }
1245
1562
  if (text === "/compact") {
1563
+ if (currentProvider() !== "claude") { await send(chatId, t(l, "compactProviderUnsupported")); return; }
1246
1564
  if (!state.sessionId) { await send(chatId, t(l, "compactNoSession")); return; }
1247
1565
  try {
1248
1566
  const res = await runClaude("/compact", state.sessionId);
@@ -1257,18 +1575,26 @@ async function handle(msg) {
1257
1575
  return;
1258
1576
  }
1259
1577
  if (text === "/ollama") {
1260
- if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1578
+ if (!cfg.ollamaFallback) { await send(chatId, t(l, "ollamaDisabled")); return; }
1261
1579
  state.ollamaMode = !state.ollamaMode;
1262
1580
  saveState(state);
1263
1581
  await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
1264
1582
  return;
1265
1583
  }
1266
1584
  if (text === "/testfallback") {
1267
- if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1268
- await send(chatId, "🧪 Ollama 연결 테스트 중…");
1585
+ if (!cfg.codexFallback && !cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1586
+ await send(chatId, cfg.codexFallback ? "🧪 Codex fallback 연결 테스트 중…" : "🧪 Ollama fallback 연결 테스트 중…");
1269
1587
  try {
1270
- const res = await runOllama("Reply with exactly one sentence: Ollama fallback is working.", l);
1271
- if (res.ok) await send(chatId, res.text);
1588
+ const prompt = cfg.codexFallback
1589
+ ? "Reply with exactly one sentence: Codex fallback is working."
1590
+ : "Reply with exactly one sentence: Ollama fallback is working.";
1591
+ const res = cfg.codexFallback
1592
+ ? await runCodex(prompt, l, { trackChild: true, recordHandoff: false })
1593
+ : await runOllama(prompt, l);
1594
+ if (res.ok) {
1595
+ if (cfg.codexFallback && res.sessionId) { state.codexSessionId = res.sessionId; saveState(state); }
1596
+ await send(chatId, res.text);
1597
+ }
1272
1598
  else await send(chatId, t(l, "testFallbackFail", res.text));
1273
1599
  } catch (e) {
1274
1600
  await send(chatId, t(l, "testFallbackFail", e.message));
@@ -1276,7 +1602,8 @@ async function handle(msg) {
1276
1602
  return;
1277
1603
  }
1278
1604
  if (text === "/new") {
1279
- state.sessionId = undefined;
1605
+ if (currentProvider() === "codex") state.codexSessionId = undefined;
1606
+ else state.sessionId = undefined;
1280
1607
  saveState(state);
1281
1608
  await send(chatId, t(l, "newSession"));
1282
1609
  return;
@@ -1291,7 +1618,8 @@ async function handle(msg) {
1291
1618
  msgQueue.length = 0; // 대기 메시지도 취소
1292
1619
  currentChild.kill();
1293
1620
  if (reset) {
1294
- state.sessionId = prevSessionId;
1621
+ const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
1622
+ state[primarySessionKey] = prevSessionId;
1295
1623
  saveState(state);
1296
1624
  }
1297
1625
  await send(chatId, t(l, reset ? "stopReset" : "stopOk"));
@@ -1373,6 +1701,7 @@ async function handle(msg) {
1373
1701
  // /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
1374
1702
  // 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
1375
1703
  if (text === "/plan" || text.startsWith("/plan ")) {
1704
+ if (currentProvider() !== "claude") { await send(chatId, t(l, "planProviderUnsupported")); return; }
1376
1705
  const planReq = text.slice(5).trim();
1377
1706
  if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
1378
1707
  prevSessionId = state.sessionId;
@@ -1421,18 +1750,28 @@ async function handle(msg) {
1421
1750
  if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
1422
1751
  if (state.ollamaMode) {
1423
1752
  try {
1424
- const oRes = await runOllama(prompt, l, { noHeader: true });
1425
- if (oRes.ok) await send(chatId, oRes.text);
1753
+ const oRes = await runOllama(prompt, l, { noHeader: true, sessionId: state.sessionId });
1754
+ if (oRes.ok) {
1755
+ if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
1756
+ await send(chatId, oRes.text);
1757
+ }
1426
1758
  else await send(chatId, t(l, "testFallbackFail", oRes.text));
1427
1759
  } catch (e) {
1428
1760
  await send(chatId, t(l, "testFallbackFail", e.message));
1429
1761
  }
1430
1762
  return;
1431
1763
  }
1432
- prevSessionId = state.sessionId; // /stop --reset 복원 대상 저장
1433
- const res = await runClaude(prompt, state.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1764
+ const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
1765
+ prevSessionId = state[primarySessionKey]; // /stop --reset 복원 대상 저장
1766
+ const res = await runPrimary(prompt, {
1767
+ sessionId: state[primarySessionKey],
1768
+ lang: l,
1769
+ modelHint: true,
1770
+ trackChild: true,
1771
+ injectMemory: true,
1772
+ });
1434
1773
  if (res.sessionId) {
1435
- state.sessionId = res.sessionId;
1774
+ state[primarySessionKey] = res.sessionId;
1436
1775
  saveState(state);
1437
1776
  }
1438
1777
  await replyWithClaudeResult(chatId, l, prompt, msg, res, started);