claude-telegram-bot 0.3.36 → 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,21 +151,26 @@ 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" +
156
+ "• /autocompact — view / set the auto-compact token threshold\n" +
148
157
  "• /id — show this chat ID\n" +
149
158
  `\nWorking dir: ${cfg.projectDir}\nPermission mode: ${cfg.permissionMode}`,
150
159
  newSession: "🆕 Started a new conversation (previous context cleared).",
151
160
  compactOk: "🗜️ Context compacted. The conversation continues with a summary.",
152
161
  compactFail: (m) => `⚠️ Compact failed: ${m}`,
153
162
  compactNoSession: "No active session to compact. Just send a message to start one.",
163
+ compactProviderUnsupported: "/compact is currently available only with provider=claude.",
154
164
  autoCompact: "🗜️ Auto-compacted context (conversation was getting long).",
155
165
  planUsage: "Usage: `/plan <request>` — e.g. `/plan add input validation to the signup form`",
156
166
  planApprove: "✅ Proceed",
157
167
  planCancel: "❌ Cancel",
158
168
  planCancelled: "❌ Plan cancelled. No changes were made.",
159
169
  planNoPending: "No pending plan to approve (it may have expired after /new). Send /plan again.",
160
- testFallbackDisabled: "⚠️ Ollama fallback is not enabled. Set `\"ollamaFallback\": true` in config.json.",
161
- 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.",
162
174
  ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
163
175
  ollamaOff: "✅ Ollama mode off. Back to Claude.",
164
176
  busy: "⏳ A previous task is still running. Please try again when it finishes.",
@@ -166,7 +178,7 @@ const STR = {
166
178
  stopOk: "🛑 Task stopped.",
167
179
  stopReset: "🛑 Task stopped and session rolled back to before the task.",
168
180
  stopNoop: "No task is running.",
169
- 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.",
170
182
  needChatId: (id) => `Add this chat ID to "allowedChatId" in config.json:\n${id}`,
171
183
  cronEmpty:
172
184
  "No scheduled tasks yet.\nAdd one in plain language, e.g. `/cron add summarize open issues every weekday at 9am`.",
@@ -197,7 +209,10 @@ const STR = {
197
209
  status: (i) =>
198
210
  `🤖 ${i.name}\n` +
199
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` +
200
214
  `• Model: ${i.model}\n` +
215
+ `• Fallback: ${i.fallback}\n` +
201
216
  `• Session: ${i.hasSession ? "active" : "none (fresh)"}\n` +
202
217
  `• Scheduled jobs: ${i.jobs}\n` +
203
218
  `• Project: ${i.projectDir}\n` +
@@ -208,6 +223,17 @@ const STR = {
208
223
  `/model default — clear the override`,
209
224
  modelSet: (m) => `🧠 Model set to: ${m}`,
210
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",
230
+ autoCompactStatus: (cur, def) =>
231
+ `🗜️ Auto-compact threshold: ${cur} tokens${cur === def ? " (default)" : ""}\n` +
232
+ "Set: `/autocompact <number>` · `/autocompact off` to disable · `/autocompact default` to reset",
233
+ autoCompactSet: (n) => `🗜️ Auto-compact threshold set to ${n} tokens.`,
234
+ autoCompactOff: "🗜️ Auto-compact disabled.",
235
+ autoCompactReset: (def) => `🗜️ Auto-compact threshold reset to default (${def}).`,
236
+ autoCompactUsage: "Usage: `/autocompact <number>` · `/autocompact off` · `/autocompact default`",
211
237
  memoryEmpty: "No memory yet. Use `/remember <text>` to add.",
212
238
  memoryShow: (m) => `💾 Memory:\n\`\`\`\n${m}\n\`\`\``,
213
239
  memoryCleared: "🧹 Memory cleared.",
@@ -228,6 +254,7 @@ const STR = {
228
254
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
229
255
  "• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
230
256
  "• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
257
+ "• Codex 폴백 활성화 시 Claude 한도 도달 때 자동으로 대신 실행\n" +
231
258
  "• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
232
259
  "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
233
260
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
@@ -236,7 +263,9 @@ const STR = {
236
263
  "• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
237
264
  "• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
238
265
  "• /status — 봇 상태·버전 보기\n" +
266
+ "• /provider — 기본 provider 보기·전환\n" +
239
267
  "• /model — 모델 보기·전환\n" +
268
+ "• /autocompact — 자동 압축 임계값 보기·설정\n" +
240
269
  "• /id — 이 채팅 ID 확인\n" +
241
270
  `\n작업 폴더: ${cfg.projectDir}\n권한 모드: ${cfg.permissionMode}`,
242
271
  newSession: "🆕 새 대화를 시작합니다 (이전 맥락 초기화).",
@@ -245,7 +274,7 @@ const STR = {
245
274
  stopOk: "🛑 작업을 중단했습니다.",
246
275
  stopReset: "🛑 작업을 중단하고 세션을 작업 이전으로 되돌렸습니다.",
247
276
  stopNoop: "실행 중인 작업이 없습니다.",
248
- localBusy: "💻 로컬 `ctb claude` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
277
+ localBusy: "💻 로컬 `ctb` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
249
278
  needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
250
279
  cronEmpty:
251
280
  "등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
@@ -275,7 +304,10 @@ const STR = {
275
304
  status: (i) =>
276
305
  `🤖 ${i.name}\n` +
277
306
  `• 버전: ${i.version}\n` +
307
+ `• 메인 provider: ${i.provider}\n` +
308
+ `• CLI: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
278
309
  `• 모델: ${i.model}\n` +
310
+ `• 폴백: ${i.fallback}\n` +
279
311
  `• 세션: ${i.hasSession ? "이어가는 중" : "없음 (새 세션)"}\n` +
280
312
  `• 예약 작업: ${i.jobs}개\n` +
281
313
  `• 작업 폴더: ${i.projectDir}\n` +
@@ -286,6 +318,17 @@ const STR = {
286
318
  `/model default — 오버라이드 해제`,
287
319
  modelSet: (m) => `🧠 모델을 ${m} (으)로 설정했습니다.`,
288
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",
325
+ autoCompactStatus: (cur, def) =>
326
+ `🗜️ 자동 압축 임계값: ${cur} 토큰${cur === def ? " (기본값)" : ""}\n` +
327
+ "설정: `/autocompact <숫자>` · `/autocompact off` 로 끄기 · `/autocompact default` 로 초기화",
328
+ autoCompactSet: (n) => `🗜️ 자동 압축 임계값을 ${n} 토큰으로 설정했습니다.`,
329
+ autoCompactOff: "🗜️ 자동 압축을 껐습니다.",
330
+ autoCompactReset: (def) => `🗜️ 자동 압축 임계값을 기본값(${def})으로 되돌렸습니다.`,
331
+ autoCompactUsage: "사용법: `/autocompact <숫자>` · `/autocompact off` · `/autocompact default`",
289
332
  memoryEmpty: "저장된 메모리가 없습니다. `/remember <내용>`으로 추가하세요.",
290
333
  memoryShow: (m) => `💾 메모리:\n\`\`\`\n${m}\n\`\`\``,
291
334
  memoryCleared: "🧹 메모리를 삭제했습니다.",
@@ -300,15 +343,18 @@ const STR = {
300
343
  compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
301
344
  compactFail: (m) => `⚠️ compact 실패: ${m}`,
302
345
  compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
346
+ compactProviderUnsupported: "/compact는 현재 provider=claude에서만 사용할 수 있습니다.",
303
347
  autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
304
348
  planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
305
349
  planApprove: "✅ 진행",
306
350
  planCancel: "❌ 취소",
307
351
  planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
308
352
  planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
353
+ planProviderUnsupported: "/plan 승인 흐름은 현재 provider=claude에서만 사용할 수 있습니다.",
309
354
  contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
310
- testFallbackDisabled: "⚠️ Ollama 폴백이 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
311
- testFallbackFail: (m) => `⚠️ Ollama 테스트 실패: ${m}`,
355
+ testFallbackDisabled: "⚠️ 폴백이 비활성화 상태입니다. config.json에 `\"codexFallback\": true`(권장) 또는 `\"ollamaFallback\": true` 를 추가하세요.",
356
+ testFallbackFail: (m) => `⚠️ 폴백 테스트 실패: ${m}`,
357
+ ollamaDisabled: "⚠️ Ollama 모드가 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
312
358
  ollamaOn: "🌙 Ollama 모드 켜짐. 이제 메시지는 Ollama로 처리됩니다. Claude 세션은 유지됩니다.",
313
359
  ollamaOff: "✅ Ollama 모드 꺼짐. 다시 Claude로 처리합니다.",
314
360
  },
@@ -334,7 +380,9 @@ const COMMANDS = {
334
380
  { command: "cron", description: "List / add / remove scheduled tasks" },
335
381
  { command: "restart", description: "Restart the bot (after syntax check)" },
336
382
  { command: "status", description: "Bot status / version" },
383
+ { command: "provider", description: "View / switch the default provider" },
337
384
  { command: "model", description: "View / switch the model" },
385
+ { command: "autocompact", description: "View / set the auto-compact token threshold" },
338
386
  { command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
339
387
  { command: "id", description: "Show this chat ID" },
340
388
  { command: "help", description: "Help" },
@@ -350,7 +398,9 @@ const COMMANDS = {
350
398
  { command: "cron", description: "예약 작업 보기·추가·삭제" },
351
399
  { command: "restart", description: "봇 재시작 (문법 검사 후)" },
352
400
  { command: "status", description: "봇 상태·버전 보기" },
401
+ { command: "provider", description: "기본 provider 보기·전환" },
353
402
  { command: "model", description: "모델 보기·전환" },
403
+ { command: "autocompact", description: "자동 압축 임계값 보기·설정" },
354
404
  { command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
355
405
  { command: "id", description: "이 채팅 ID 확인" },
356
406
  { command: "help", description: "도움말" },
@@ -424,6 +474,28 @@ function saveMemory(content) {
424
474
  writeFileSync(MEMORY_PATH, content);
425
475
  }
426
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
+
427
499
  // ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
428
500
  function loadState() {
429
501
  // 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
@@ -442,7 +514,12 @@ function saveState(s) {
442
514
  }
443
515
  }
444
516
  migrateData(); // 루트 직하 → .claude-bot/ 1회 이주(있으면) 후 state 로드
445
- 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;
446
523
 
447
524
  // ── 텔레그램 헬퍼 ─────────────────────────────────────────────────────────
448
525
  async function tg(method, body) {
@@ -637,7 +714,11 @@ function runClaude(prompt, sessionId, opts = {}) {
637
714
  const mem = opts.injectMemory ? loadMemory() : "";
638
715
  // 메모리는 persona보다 앞에 배치하고 헤더를 강화 → persona가 덮어쓰는 것 방지
639
716
  const memoryBlock = mem ? `## RULES (must follow before anything else)\n${mem}` : null;
640
- 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");
641
722
  if (appendSys) args.push("--append-system-prompt", appendSys);
642
723
  if (model) args.push("--model", model);
643
724
  if (sessionId) args.push("--resume", sessionId);
@@ -681,27 +762,224 @@ function runClaude(prompt, sessionId, opts = {}) {
681
762
  });
682
763
  }
683
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
+
684
934
  // ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
685
- async function runOllama(prompt, lang = "en", opts = {}) {
686
- const header = opts.noHeader ? "" : (lang === "ko"
687
- ? "🌙 Claude가 잠시 쉬고 있어요. 제가 대신 도와드릴게요. (세션은 이어지지 않아요)\n\n"
688
- : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n");
689
- const model = cfg.ollamaModel || "phi3:mini";
690
- const brevity = "This reply is delivered over Telegram. Be concise short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
691
- const systemParts = [cfg.persona, brevity].filter(Boolean);
692
- const messages = [];
693
- if (systemParts.length) messages.push({ role: "system", content: systemParts.join("\n\n") });
694
- messages.push({ role: "user", content: prompt });
695
- const r = await fetch("http://localhost:11434/api/chat", {
696
- method: "POST",
697
- headers: { "Content-Type": "application/json" },
698
- body: JSON.stringify({ model, messages, stream: false }),
699
- 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
+ });
700
982
  });
701
- if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
702
- const j = await r.json();
703
- const text = (j.message?.content || "").trim();
704
- return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
705
983
  }
706
984
 
707
985
  // ── 크론 스케줄러 ─────────────────────────────────────────────────────────
@@ -775,7 +1053,7 @@ async function runScheduled(job) {
775
1053
  busy = true;
776
1054
  const started = Date.now();
777
1055
  try {
778
- const res = await runClaude(job.prompt, undefined); // 새 세션 (state 미저장)
1056
+ const res = await runPrimary(job.prompt, { sessionId: null, lang: BOT_LANG, recordHandoff: false }); // 새 세션 (state 미저장)
779
1057
  // 조용한 예약 작업: 출력이 비었거나 정확히 "SKIP"이면 전송하지 않는다.
780
1058
  // (예: "조건 충족 시에만 알리고, 아니면 SKIP만 출력해" 식의 조건부 알림용)
781
1059
  if (res.ok) {
@@ -832,7 +1110,7 @@ async function extractCron(input, l) {
832
1110
  "Reply with ONLY one line of JSON, no prose or code block: " +
833
1111
  '{"cron":"0 9 * * *","prompt":"the task","label":"short name","human":"every day at 09:00"}\n\n' +
834
1112
  `request: ${input}`;
835
- const res = await runClaude(ask, undefined); // 새 세션 (대화 맥락과 분리)
1113
+ const res = await runPrimary(ask, { sessionId: null, lang: l, recordHandoff: false }); // 새 세션 (대화 맥락과 분리)
836
1114
  const m = res.text && res.text.match(/\{[\s\S]*\}/);
837
1115
  if (!res.ok || !m) return { error: res.text || t(l, "extractFail") };
838
1116
  let obj;
@@ -873,8 +1151,8 @@ async function handleCron(chatId, rest, l) {
873
1151
  return;
874
1152
  }
875
1153
  busy = true;
876
- await tg("sendChatAction", { chat_id: chatId, action: "typing" });
877
1154
  try {
1155
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
878
1156
  const r = await extractCron(input, l);
879
1157
  if (r.error) {
880
1158
  await send(chatId, `⚠️ ${r.error}`);
@@ -993,11 +1271,30 @@ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
993
1271
  async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
994
1272
  const secs = Math.round((Date.now() - started) / 1000);
995
1273
  if (!res.ok) {
996
- // 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로 재시도
997
1288
  if (cfg.ollamaFallback && res.canFallback && !stopping) {
998
1289
  try {
999
- const oRes = await runOllama(prompt, l);
1000
- 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
+ }
1001
1298
  } catch {}
1002
1299
  }
1003
1300
  // 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
@@ -1020,8 +1317,8 @@ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
1020
1317
  const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1021
1318
  if (!stopping) await send(chatId, res.text + footer);
1022
1319
  // 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
1023
- const compactThreshold = cfg.autoCompactThreshold ?? 100000;
1024
- if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1320
+ const compactThreshold = state.autoCompactThreshold ?? cfg.autoCompactThreshold ?? 100000;
1321
+ if (currentProvider() === "claude" && compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1025
1322
  try {
1026
1323
  const cr = await runClaude("/compact", state.sessionId);
1027
1324
  if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
@@ -1050,7 +1347,6 @@ async function runApprovedPlan(chatId, l) {
1050
1347
  return;
1051
1348
  }
1052
1349
  busy = true;
1053
- await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1054
1350
  const started = Date.now();
1055
1351
  currentTyping = setInterval(
1056
1352
  () => tg("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}),
@@ -1058,6 +1354,7 @@ async function runApprovedPlan(chatId, l) {
1058
1354
  );
1059
1355
  const syntheticMsg = { chat: { id: chatId }, text: PLAN_PROCEED_PROMPT };
1060
1356
  try {
1357
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1061
1358
  prevSessionId = state.sessionId;
1062
1359
  const res = await runClaude(PLAN_PROCEED_PROMPT, pending.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1063
1360
  if (res.sessionId) {
@@ -1135,17 +1432,27 @@ async function handle(msg) {
1135
1432
  return;
1136
1433
  }
1137
1434
  if (text === "/status") {
1138
- const latest = await fetchLatestVersion();
1139
- const versionStr = !latest || latest === VERSION
1435
+ const [latest, cliVersions] = await Promise.all([fetchLatestVersion(), getCliVersions()]);
1436
+ const versionStr = !latest || !isNewer(latest, VERSION)
1140
1437
  ? VERSION
1141
1438
  : `${VERSION} → ${latest} ✨`;
1142
1439
  await send(
1143
1440
  chatId,
1144
1441
  t(l, "status", {
1145
1442
  version: versionStr,
1443
+ provider: currentProvider(),
1444
+ cliVersions,
1146
1445
  name: cfg.name || "claude-telegram-bot",
1147
- model: state.model || cfg.model || (l === "ko" ? "(기본값)" : "(default)"),
1148
- 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),
1149
1456
  jobs: schedule.length,
1150
1457
  projectDir: cfg.projectDir,
1151
1458
  permissionMode: cfg.permissionMode || "acceptEdits",
@@ -1153,24 +1460,82 @@ async function handle(msg) {
1153
1460
  );
1154
1461
  return;
1155
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
+ }
1156
1489
  if (text === "/model" || text.startsWith("/model ")) {
1157
1490
  const arg = text.slice(6).trim();
1491
+ const modelStateKey = currentProvider() === "codex" ? "codexModel" : "model";
1492
+ const configuredModel = currentProvider() === "codex" ? cfg.codexModel : cfg.model;
1158
1493
  if (!arg) {
1159
- const cur = state.model || cfg.model || (l === "ko" ? "(기본값)" : "(default)");
1494
+ const cur = state[modelStateKey] || configuredModel || (l === "ko" ? "(기본값)" : "(default)");
1160
1495
  await send(chatId, t(l, "modelStatus", cur, MODEL_SUGGESTIONS));
1161
1496
  return;
1162
1497
  }
1163
1498
  if (arg === "default" || arg === "reset") {
1164
- state.model = undefined;
1499
+ state[modelStateKey] = undefined;
1165
1500
  saveState(state);
1166
- await send(chatId, t(l, "modelReset", cfg.model || (l === "ko" ? "기본값" : "default")));
1501
+ await send(chatId, t(l, "modelReset", configuredModel || (l === "ko" ? "기본값" : "default")));
1167
1502
  return;
1168
1503
  }
1169
- state.model = arg;
1504
+ state[modelStateKey] = arg;
1170
1505
  saveState(state);
1171
1506
  await send(chatId, t(l, "modelSet", arg));
1172
1507
  return;
1173
1508
  }
1509
+ if (text === "/autocompact" || text.startsWith("/autocompact ")) {
1510
+ const arg = text.slice(13).trim();
1511
+ const def = cfg.autoCompactThreshold ?? 100000;
1512
+ if (!arg) {
1513
+ const cur = state.autoCompactThreshold ?? def;
1514
+ await send(chatId, t(l, "autoCompactStatus", cur, def));
1515
+ return;
1516
+ }
1517
+ if (arg === "default" || arg === "reset") {
1518
+ state.autoCompactThreshold = undefined;
1519
+ saveState(state);
1520
+ await send(chatId, t(l, "autoCompactReset", def));
1521
+ return;
1522
+ }
1523
+ if (arg === "off") {
1524
+ state.autoCompactThreshold = 0;
1525
+ saveState(state);
1526
+ await send(chatId, t(l, "autoCompactOff"));
1527
+ return;
1528
+ }
1529
+ const n = Number(arg);
1530
+ if (!Number.isFinite(n) || n < 0) {
1531
+ await send(chatId, t(l, "autoCompactUsage"));
1532
+ return;
1533
+ }
1534
+ state.autoCompactThreshold = n;
1535
+ saveState(state);
1536
+ await send(chatId, t(l, "autoCompactSet", n));
1537
+ return;
1538
+ }
1174
1539
  if (text === "/cron" || text.startsWith("/cron ")) {
1175
1540
  await handleCron(chatId, text.slice(5).trim(), l);
1176
1541
  return;
@@ -1195,6 +1560,7 @@ async function handle(msg) {
1195
1560
  return;
1196
1561
  }
1197
1562
  if (text === "/compact") {
1563
+ if (currentProvider() !== "claude") { await send(chatId, t(l, "compactProviderUnsupported")); return; }
1198
1564
  if (!state.sessionId) { await send(chatId, t(l, "compactNoSession")); return; }
1199
1565
  try {
1200
1566
  const res = await runClaude("/compact", state.sessionId);
@@ -1209,18 +1575,26 @@ async function handle(msg) {
1209
1575
  return;
1210
1576
  }
1211
1577
  if (text === "/ollama") {
1212
- if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1578
+ if (!cfg.ollamaFallback) { await send(chatId, t(l, "ollamaDisabled")); return; }
1213
1579
  state.ollamaMode = !state.ollamaMode;
1214
1580
  saveState(state);
1215
1581
  await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
1216
1582
  return;
1217
1583
  }
1218
1584
  if (text === "/testfallback") {
1219
- if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1220
- 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 연결 테스트 중…");
1221
1587
  try {
1222
- const res = await runOllama("Reply with exactly one sentence: Ollama fallback is working.", l);
1223
- 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
+ }
1224
1598
  else await send(chatId, t(l, "testFallbackFail", res.text));
1225
1599
  } catch (e) {
1226
1600
  await send(chatId, t(l, "testFallbackFail", e.message));
@@ -1228,7 +1602,8 @@ async function handle(msg) {
1228
1602
  return;
1229
1603
  }
1230
1604
  if (text === "/new") {
1231
- state.sessionId = undefined;
1605
+ if (currentProvider() === "codex") state.codexSessionId = undefined;
1606
+ else state.sessionId = undefined;
1232
1607
  saveState(state);
1233
1608
  await send(chatId, t(l, "newSession"));
1234
1609
  return;
@@ -1243,7 +1618,8 @@ async function handle(msg) {
1243
1618
  msgQueue.length = 0; // 대기 메시지도 취소
1244
1619
  currentChild.kill();
1245
1620
  if (reset) {
1246
- state.sessionId = prevSessionId;
1621
+ const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
1622
+ state[primarySessionKey] = prevSessionId;
1247
1623
  saveState(state);
1248
1624
  }
1249
1625
  await send(chatId, t(l, reset ? "stopReset" : "stopOk"));
@@ -1310,7 +1686,6 @@ async function handle(msg) {
1310
1686
  return;
1311
1687
  }
1312
1688
  busy = true;
1313
- await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1314
1689
  const started = Date.now();
1315
1690
  // 긴 작업 동안 타이핑 표시 유지
1316
1691
  currentTyping = setInterval(
@@ -1322,9 +1697,11 @@ async function handle(msg) {
1322
1697
  );
1323
1698
 
1324
1699
  try {
1700
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1325
1701
  // /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
1326
1702
  // 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
1327
1703
  if (text === "/plan" || text.startsWith("/plan ")) {
1704
+ if (currentProvider() !== "claude") { await send(chatId, t(l, "planProviderUnsupported")); return; }
1328
1705
  const planReq = text.slice(5).trim();
1329
1706
  if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
1330
1707
  prevSessionId = state.sessionId;
@@ -1373,18 +1750,28 @@ async function handle(msg) {
1373
1750
  if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
1374
1751
  if (state.ollamaMode) {
1375
1752
  try {
1376
- const oRes = await runOllama(prompt, l, { noHeader: true });
1377
- 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
+ }
1378
1758
  else await send(chatId, t(l, "testFallbackFail", oRes.text));
1379
1759
  } catch (e) {
1380
1760
  await send(chatId, t(l, "testFallbackFail", e.message));
1381
1761
  }
1382
1762
  return;
1383
1763
  }
1384
- prevSessionId = state.sessionId; // /stop --reset 복원 대상 저장
1385
- 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
+ });
1386
1773
  if (res.sessionId) {
1387
- state.sessionId = res.sessionId;
1774
+ state[primarySessionKey] = res.sessionId;
1388
1775
  saveState(state);
1389
1776
  }
1390
1777
  await replyWithClaudeResult(chatId, l, prompt, msg, res, started);
@@ -1410,7 +1797,18 @@ function drainQueue() {
1410
1797
  return i === 0 ? `[1] ${text}` : `[${i + 1}, +${dt}s] ${text}`;
1411
1798
  })
1412
1799
  .join("\n");
1413
- return { ...group[group.length - 1].msg, text: merged, caption: undefined };
1800
+ // 마지막 메시지 필드만 남기면 앞서 메시지의 사진/첨부가 유실되므로, 전체 첨부를 순서대로 모아 둠
1801
+ const fileIds = group.flatMap((item) => {
1802
+ if (item.msg._mediaGroup?.length) return item.msg._mediaGroup;
1803
+ const att = pickAttachment(item.msg);
1804
+ return att ? [att.fileId] : [];
1805
+ });
1806
+ return {
1807
+ ...group[group.length - 1].msg,
1808
+ text: merged,
1809
+ caption: undefined,
1810
+ _mediaGroup: fileIds.length ? fileIds : undefined,
1811
+ };
1414
1812
  }
1415
1813
 
1416
1814
  // 미디어 그룹(여러 장 동시 전송) — 1초 대기 후 일괄 처리