/getUpdates
/switch |
- แสดงเซสชันล่าสุด โดยเรียงจากใหม่ไปเก่า รายการนี้รวมทั้งเซสชันที่ bot ดูแลและ local Codex/Copilot CLI เซสชันของ project ปัจจุบัน |
+ แสดงเซสชันล่าสุด โดยเรียงจากใหม่ไปเก่า รายการนี้รวมทั้งเซสชันที่ bot ดูแลและ local Codex/Copilot/Claude Code CLI เซสชันของ project ปัจจุบัน |
/switch page <number> |
@@ -313,13 +372,21 @@ https://api.telegram.org/bot/getUpdates
APP_LOCALE |
ภาษา UI สำหรับข้อความของบอตและคำอธิบายคำสั่งที่ใช้ร่วมกัน ค่าที่รองรับ: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW |
+
+ DEFAULT_AGENT_PROVIDER |
+ ผู้ให้บริการเริ่มต้นสำหรับเซสชันใหม่: codex, copilot หรือ claude ค่าเริ่มต้น: codex |
+
CODEX_BIN |
- คำสั่งที่ใช้เรียก Codex CLI ค่าเริ่มต้น: codex |
+ คำสั่งที่ใช้เรียก Codex CLI. แอปจะพยายามตรวจหาเส้นทางของ Codex ที่ติดตั้งอยู่ในเครื่องโดยอัตโนมัติระหว่างการเริ่มต้น .env_coding_agent_telegram หรือสามารถใช้ which codex เพื่อดูเส้นทางได้ |
COPILOT_BIN |
- คำสั่งที่ใช้เรียก Copilot CLI ค่าเริ่มต้น: copilot |
+ คำสั่งที่ใช้เรียก Copilot CLI. แอปจะพยายามตรวจหาเส้นทางของ Copilot ที่ติดตั้งอยู่ในเครื่องโดยอัตโนมัติระหว่างการเริ่มต้น .env_coding_agent_telegram หรือสามารถใช้ which copilot เพื่อดูเส้นทางได้ |
+
+
+ CLAUDE_BIN |
+ คำสั่งที่ใช้เรียก Claude Code CLI. แอปจะพยายามตรวจหาเส้นทางของ Claude ที่ติดตั้งอยู่ในเครื่องโดยอัตโนมัติระหว่างการเริ่มต้น .env_coding_agent_telegram หรือสามารถใช้ which claude เพื่อดูเส้นทางได้ |
CODEX_MODEL |
@@ -329,6 +396,10 @@ https://api.telegram.org/bot/getUpdates
COPILOT_MODEL |
กำหนด model ของ Copilot เพิ่มเติมได้แบบ optional หากปล่อยว่างจะใช้ model เริ่มต้นของ Copilot CLI ตัวอย่าง: gpt-5.4, claude-sonnet-4.6 GitHub Copilot supported models |
+
+ CLAUDE_MODEL |
+ กำหนด model ของ Claude Code เพิ่มเติมได้แบบ optional หากปล่อยว่างจะใช้ model เริ่มต้นของ Claude Code CLI ตัวอย่าง: sonnet, opus, haiku Claude Code model configuration |
+
CODEX_APPROVAL_POLICY |
โหมด approval ที่ส่งให้ Codex ค่าเริ่มต้น: never |
@@ -341,6 +412,18 @@ https://api.telegram.org/bot/getUpdates
CODEX_SKIP_GIT_REPO_CHECK |
หากเปิดไว้ จะข้ามการตรวจ trusted-repo ของ Codex เสมอ |
+
+ CLAUDE_PERMISSION_MODE |
+ Permission mode ที่ส่งให้ Claude Code เลือกได้จาก default, acceptEdits, plan, auto, dontAsk, bypassPermissions, manual ค่าเริ่มต้น: bypassPermissions (ทำงานอัตโนมัติเต็มรูปแบบ เนื่องจากไม่มี terminal แบบ interactive ให้ยืนยัน prompt) |
+
+
+ CLAUDE_ALLOWED_TOOLS |
+ allowlist ของ tools สำหรับ Claude Code แบบคั่นด้วย comma โดยใช้ syntax กฎ permission ของ Claude Code ตัวอย่าง: Read,Edit,Bash(git *) |
+
+
+ CLAUDE_DISALLOWED_TOOLS |
+ denylist ของ tools สำหรับ Claude Code แบบคั่นด้วย comma ตัวอย่าง: Bash(rm *) |
+
ENABLE_COMMIT_COMMAND |
เปิดใช้งานคำสั่ง Telegram /commit ค่าเริ่มต้น: false |
@@ -426,8 +509,10 @@ ALLOWED_CHAT_IDS=123456789
DEFAULT_AGENT_PROVIDER=codex
CODEX_BIN=codex
COPILOT_BIN=copilot
+CLAUDE_BIN=claude
CODEX_APPROVAL_POLICY=never
CODEX_SANDBOX_MODE=workspace-write
+CLAUDE_PERMISSION_MODE=bypassPermissions
ENABLE_SENSITIVE_DIFF_FILTER=true
ENABLE_SECRET_SCRUB_FILTER=true
```
@@ -491,7 +576,7 @@ lock นี้อยู่ในหน่วยความจำ ไม่ไ
## ⚠️ Diff (การเปลี่ยนไฟล์)
-_ในแต่ละ การรันของเอเจนต์ บอตจะสร้าง สแนปช็อตก่อนและหลังการรัน แบบเบาของโปรเจกต์ด้วย เพื่อสรุปไฟล์ที่เปลี่ยนและส่ง diff กลับไปยัง Telegram ได้ สแนปช็อต นี้ถูกสร้างโดยตัวบอตเอง ไม่ใช่โดย Codex หรือ Copilot._
+_ในแต่ละ การรันของเอเจนต์ บอตจะสร้าง สแนปช็อตก่อนและหลังการรัน แบบเบาของโปรเจกต์ด้วย เพื่อสรุปไฟล์ที่เปลี่ยนและส่ง diff กลับไปยัง Telegram ได้ สแนปช็อต นี้ถูกสร้างโดยตัวบอตเอง ไม่ใช่โดย Codex, Copilot หรือ Claude Code._
**สิ่งที่ควรรู้เกี่ยวกับ สแนปช็อต:**
diff --git a/README.vi.md b/README.vi.md
index f7cd261..adfbde3 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -35,14 +35,14 @@
## ✨ Vì sao nên dùng
- ✅ Nhẹ: không cần framework nặng, minh bạch hoàn toàn
- ✅ Nhiều bot: nhiều cuộc chat, nhiều phiên
- - ✅ Dùng Telegram để điều khiển Codex / Copilot CLI
+ - ✅ Dùng Telegram để điều khiển Codex / Copilot / Claude Code CLI
- ✅ Dễ xem câu trả lời và các tệp đã thay đổi trong code block
- ✅ Có thể xếp hàng câu hỏi tiếp theo khi agent đang làm việc
- ✅ Chấp nhận tin nhắn ✏️ văn bản, 🌄 hình ảnh và 🎙️ thoại
## 🔁 Chuyển thiết bị/phiên liền mạch
- Bắt đầu một phiên trên Telegram rồi tiếp tục đúng phiên Codex/Copilot CLI đó trên máy tính sau mà không cần thao tác rườm rà. Với `/switch`, bạn cũng có thể chuyển qua lại mượt mà giữa Telegram và command line.
+ Bắt đầu một phiên trên Telegram rồi tiếp tục đúng phiên Codex/Copilot/Claude Code CLI đó trên máy tính sau mà không cần thao tác rườm rà. Với `/switch`, bạn cũng có thể chuyển qua lại mượt mà giữa Telegram và command line.
- Dùng `/switch` để tiếp tục một phiên cục bộ
- Hỗ trợ cả các phiên lịch sử
@@ -86,6 +86,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- Các thư mục có sẵn có thể yêu cầu xác nhận trust trước khi chạy Git operation có thay đổi
- Máy chủ không thực hiện các lời gọi ra ngoài một cách ẩn. Bạn giữ toàn quyền kiểm soát.
- Hoạt động tốt với Codex Sandbox mode, bạn không cần cấp `danger-full-access`
+- Tích hợp Claude Code hỗ trợ các chế độ phân quyền có thể cấu hình và danh sách tool allow/deny
@@ -96,16 +97,74 @@ Trước khi khởi động server, hãy chuẩn bị:
- Python 3.9 trở lên
- Telegram bot token tạo từ _@BotFather_
- Telegram chat ID của bạn
-- Codex CLI và/hoặc Copilot CLI đã được cài cục bộ
-- [Cài Codex CLI](https://developers.openai.com/codex/cli)
-- [Cài Copilot CLI](https://github.com/features/copilot/cli)
+- Codex CLI và/hoặc Copilot CLI và/hoặc Claude Code CLI đã được cài cục bộ
+- [Cài Codex CLI](https://developers.openai.com/codex/cli) / [Cài Copilot CLI](https://github.com/features/copilot/cli) / [Cài Claude Code CLI](https://code.claude.com/docs/en/quickstart)
- [Tùy chọn] Whisper, ffmpeg
|
## 🦞 Vì sao tôi cần cái này nếu đã có Openclaw?
-Openclaw cung cấp bộ tính năng rất đầy đủ và đã có sẵn agent loop tích hợp tên là Pi-Agent. Nó khá toàn diện và phù hợp với nhiều use case đa dạng hơn. Tôi cũng là người thích Openclaw và từng code với Openclaw. Tuy vậy, riêng cho coding thì đây không phải lúc nào cũng là lựa chọn tốt nhất vì system prompt tích hợp khá lớn và context đi kèm nhiều hơn. Claude Code / Codex / Copilot vẫn thường hiệu quả hơn, chính xác hơn, ít bị phân tán hơn và trực tiếp hơn cho việc coding. Dự án này được giữ rất đơn giản, chỉ tích hợp Codex / Copilot CLI. Nghĩa là bạn giao việc trực tiếp cho Codex / Copilot.
+Openclaw cung cấp bộ tính năng rất đầy đủ và đã có sẵn agent loop tích hợp tên là Pi-Agent. Nó khá toàn diện và phù hợp với nhiều use case đa dạng hơn. Tôi cũng là người thích Openclaw và từng code với Openclaw. Tuy vậy, riêng cho coding thì đây không phải lúc nào cũng là lựa chọn tốt nhất vì system prompt tích hợp khá lớn và context đi kèm nhiều hơn. Claude Code / Codex / Copilot vẫn thường hiệu quả hơn, chính xác hơn, ít bị phân tán hơn và trực tiếp hơn cho việc coding. Dự án này được giữ rất đơn giản, chỉ tích hợp Codex / Copilot / Claude Code CLI. Nghĩa là bạn giao việc trực tiếp cho Codex / Copilot / Claude Code.
+
+## 🆚 Tại sao tôi cần coding-agent-telegram nếu đã có Claude Code + Telegram Plugin?
+
+| Tính năng | Claude Code + Telegram Plugin chính thức | coding-agent-telegram (hỗ trợ Claude) |
+|-----------|------------------------------------------|---------------------------------------|
+| Trò chuyện với AI qua Telegram | ✅ | ✅ |
+| Chỉnh sửa mã nguồn cục bộ và chạy lệnh | ✅ | ✅ |
+| Yêu cầu một phiên CLI đang chạy sẵn | **Có** | **Không** (tự động khởi động hoặc tiếp tục phiên làm việc) |
+| Hỗ trợ nhiều nhà cung cấp AI | ❌ Chỉ Claude | ✅ Claude Code, Codex CLI, GitHub Copilot CLI |
+| Quản lý dự án từ Telegram | ❌ | ✅ `/project` |
+| Quản lý nhánh (branch) từ Telegram | Thực hiện bằng lệnh Git thủ công | ✅ Quy trình `/branch` |
+| Tạo và chuyển đổi giữa các phiên | Chỉ giới hạn ở phiên Claude đang hoạt động | ✅ `/new`, `/switch`, `/current`, `/compact` |
+| Tiếp tục các phiên CLI cục bộ đã có | ❌ | ✅ |
+| Duy trì phiên làm việc trên nhiều thiết bị | Hạn chế | ✅ |
+| Ngăn nhiều tác nhân chỉnh sửa cùng một workspace | ❌ | ✅ Ngăn nhiều agent chỉnh sửa cùng một dự án đồng thời |
+| Hàng đợi tác vụ khi agent đang bận | ❌ | ✅ |
+| Snapshot và diff hệ thống tệp độc lập | ❌ | ✅ |
+| Xem diff tệp có cấu trúc ngay trên Telegram | ❌ | ✅ |
+| Lọc secrets và diff chứa dữ liệu nhạy cảm | ❌ | ✅ |
+| Quy trình Git tích hợp (pull / push / commit) | Thủ công | ✅ |
+| Hỗ trợ nhiều bot | Cần chạy nhiều instance | ✅ Được quản lý bởi một máy chủ duy nhất |
+| Quản lý trạng thái theo từng dự án | ❌ | ✅ |
+| Kiến trúc độc lập với nhà cung cấp | ❌ | ✅ |
+
+> **Điểm khác biệt chính**
+>
+> Telegram Plugin chính thức của Claude Code kết nối Telegram với **một phiên Claude Code đang chạy**.
+>
+> **coding-agent-telegram** hoạt động như một **Telegram Control Plane**, cho phép quản lý dự án, nhánh, phiên làm việc, quy trình Git và nhiều coding agent (Claude Code, Codex CLI, GitHub Copilot CLI) thông qua một giao diện duy nhất.
+
+### 🏛️ Kiến trúc
+
+#### Claude Code + Telegram Plugin
+
+```text
+Telegram
+ │
+ ▼
+Claude Code Channel
+ │
+ ▼
+Một phiên Claude Code đang chạy
+```
+
+#### coding-agent-telegram
+
+```text
+Telegram
+ │
+ ▼
+coding-agent-telegram
+ │
+ ├── Claude Code
+ ├── Codex CLI
+ └── GitHub Copilot CLI
+ │
+ ▼
+Dự án • Nhánh • Phiên • Hàng đợi • Git • Diff • Bộ lọc Secrets
+```
## 🚀 Bắt đầu nhanh
@@ -203,7 +262,7 @@ Hiện tại bot chấp nhận:
- tin nhắn văn bản
- ảnh
- tin nhắn thoại và tệp âm thanh khi `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` và các điều kiện cần cục bộ của Whisper đã được cài đặt
-- hiện tại Codex và Copilot chỉ hỗ trợ văn bản và hình ảnh, chưa hỗ trợ video
+- Codex và Claude Code hỗ trợ văn bản và hình ảnh; Copilot hiện chỉ hỗ trợ văn bản. Video chưa được hỗ trợ bởi bất kỳ nhà cung cấp nào.
## 🤖 Lệnh Telegram
@@ -234,7 +293,7 @@ Hiện tại bot chấp nhận:
/switch |
- Hiển thị các phiên mới nhất, mới nhất trước. Danh sách bao gồm cả phiên do bot quản lý và phiên CLI Codex/Copilot cục bộ của project hiện tại. |
+ Hiển thị các phiên mới nhất, mới nhất trước. Danh sách bao gồm cả phiên do bot quản lý và phiên CLI Codex/Copilot/Claude Code cục bộ của project hiện tại. |
/switch page <number> |
@@ -313,13 +372,21 @@ Hiện tại bot chấp nhận:
APP_LOCALE |
Ngôn ngữ UI cho các thông điệp bot dùng chung và mô tả lệnh. Giá trị hỗ trợ: en, de, fr, ja, ko, nl, th, vi, zh-CN, zh-HK, zh-TW. |
+
+ DEFAULT_AGENT_PROVIDER |
+ Nhà cung cấp mặc định cho các phiên mới: codex, copilot, hoặc claude. Mặc định: codex. |
+
CODEX_BIN |
- Lệnh dùng để chạy Codex CLI. Mặc định: codex. |
+ Lệnh dùng để chạy Codex CLI. Ứng dụng sẽ cố gắng tự động phát hiện đường dẫn của Codex được cài đặt trên máy khi khởi tạo .env_coding_agent_telegram. Ngoài ra, bạn có thể sử dụng which codex để xem đường dẫn. |
COPILOT_BIN |
- Lệnh dùng để chạy Copilot CLI. Mặc định: copilot. |
+ Lệnh dùng để chạy Copilot CLI. Ứng dụng sẽ cố gắng tự động phát hiện đường dẫn của Copilot được cài đặt trên máy khi khởi tạo .env_coding_agent_telegram. Ngoài ra, bạn có thể sử dụng which copilot để xem đường dẫn. |
+
+
+ CLAUDE_BIN |
+ Lệnh dùng để chạy Claude Code CLI. Ứng dụng sẽ cố gắng tự động phát hiện đường dẫn của Claude được cài đặt trên máy khi khởi tạo .env_coding_agent_telegram. Ngoài ra, bạn có thể sử dụng which claude để xem đường dẫn. |
CODEX_MODEL |
@@ -329,6 +396,10 @@ Hiện tại bot chấp nhận:
COPILOT_MODEL |
Ghi đè model Copilot nếu cần. Để trống để dùng model mặc định của Copilot CLI. Ví dụ: gpt-5.4, claude-sonnet-4.6 GitHub Copilot supported models |
+
+ CLAUDE_MODEL |
+ Ghi đè model Claude Code nếu cần. Để trống để dùng model mặc định của Claude Code CLI. Ví dụ: sonnet, opus, haiku Claude Code model configuration |
+
CODEX_APPROVAL_POLICY |
Chế độ approval truyền cho Codex. Mặc định: never. |
@@ -341,6 +412,18 @@ Hiện tại bot chấp nhận:
CODEX_SKIP_GIT_REPO_CHECK |
Nếu bật, luôn bỏ qua trusted-repo check của Codex. |
+
+ CLAUDE_PERMISSION_MODE |
+ Chế độ phân quyền truyền cho Claude Code. Một trong các giá trị default, acceptEdits, plan, auto, dontAsk, bypassPermissions, manual. Mặc định: bypassPermissions (hoàn toàn tự động, vì không có terminal tương tác để xác nhận yêu cầu). |
+
+
+ CLAUDE_ALLOWED_TOOLS |
+ Danh sách allowlist các tool của Claude Code, ngăn cách bằng dấu phẩy, dùng cú pháp permission rule của Claude Code. Ví dụ: Read,Edit,Bash(git *) |
+
+
+ CLAUDE_DISALLOWED_TOOLS |
+ Danh sách denylist các tool của Claude Code, ngăn cách bằng dấu phẩy. Ví dụ: Bash(rm *) |
+
ENABLE_COMMIT_COMMAND |
Bật lệnh Telegram /commit. Mặc định: false. |
@@ -420,8 +503,10 @@ ALLOWED_CHAT_IDS=123456789
DEFAULT_AGENT_PROVIDER=codex
CODEX_BIN=codex
COPILOT_BIN=copilot
+CLAUDE_BIN=claude
CODEX_APPROVAL_POLICY=never
CODEX_SANDBOX_MODE=workspace-write
+CLAUDE_PERMISSION_MODE=bypassPermissions
ENABLE_SENSITIVE_DIFF_FILTER=true
ENABLE_SECRET_SCRUB_FILTER=true
```
@@ -485,7 +570,7 @@ Nếu run hiện tại bị abort và vẫn còn các câu hỏi trong hàng đ
## ⚠️ Diff (thay đổi tệp)
-_Trong mỗi lần chạy tác nhân, bot cũng tạo một ảnh chụp nhanh trước/sau nhẹ của project để có thể tóm tắt các tệp thay đổi và gửi diff về Telegram. Bản chụp nhanh này do chính bot app tạo ra, không phải bởi Codex hay Copilot._
+_Trong mỗi lần chạy tác nhân, bot cũng tạo một ảnh chụp nhanh trước/sau nhẹ của project để có thể tóm tắt các tệp thay đổi và gửi diff về Telegram. Bản chụp nhanh này do chính bot app tạo ra, không phải bởi Codex, Copilot, hay Claude Code._
**Ghi chú về ảnh chụp nhanh:**
diff --git a/README.zh-CN.md b/README.zh-CN.md
index bbb35fc..2b2c731 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -35,14 +35,14 @@
## ✨ 为什么使用它
- ✅ 轻量:没有重型框架,行为清晰可见
- ✅ 多 Bot:支持多个聊天、多个会话
- - ✅ 使用 Telegram 控制 Codex / Copilot CLI
+ - ✅ 使用 Telegram 控制 Codex / Copilot / Claude Code CLI
- ✅ 可以在代码块中轻松查看 agent 回复和改动文件
- ✅ agent 工作时也能继续排队后续问题
- ✅ 支持 ✏️ 文本、🌄 图片和 🎙️ 语音消息
## 🔁 设备与会话无缝切换
- 你可以先在 Telegram 中开启一个会话,之后在电脑上继续同一个 Codex/Copilot CLI 会话,不需要折腾。使用 `/switch` 也可以在 Telegram 和命令行之间无缝切换。
+ 你可以先在 Telegram 中开启一个会话,之后在电脑上继续同一个 Codex/Copilot/Claude Code CLI 会话,不需要折腾。使用 `/switch` 也可以在 Telegram 和命令行之间无缝切换。
- 使用 `/switch` 继续本地会话
- 也支持历史会话
@@ -86,6 +86,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- 现有文件夹在执行会修改内容的 Git 操作前,可能需要先确认 trust
- 服务器不会发出隐藏的外部请求,一切都由你掌控。
- 与 Codex Sandbox mode 配合良好,你不需要授予 `danger-full-access`
+- Claude Code 集成支持可配置的权限模式以及工具允许/拒绝列表
@@ -96,16 +97,76 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- Python 3.9 或更高版本
- 通过 _@BotFather_ 创建的 Telegram bot token
- 你的 Telegram chat ID
-- 已在本地安装 Codex CLI 和/或 Copilot CLI
+- 已在本地安装 Codex CLI 和/或 Copilot CLI 和/或 Claude Code CLI
- [安装 Codex CLI](https://developers.openai.com/codex/cli)
- [安装 Copilot CLI](https://github.com/features/copilot/cli)
+- [安装 Claude Code CLI](https://code.claude.com/docs/en/quickstart)
- [可选] Whisper、ffmpeg
|
## 🦞 如果我已经有 Openclaw,为什么还需要它?
-Openclaw 功能非常完整,也内置了名为 Pi-Agent 的 agent loop,适合更丰富、更广泛的使用场景。我自己也很喜欢 Openclaw,以前也用它来写代码。不过对纯 coding 来说,它并不总是最佳选择,因为内置的大型 system prompt 和额外 context 有时会让模型更容易分心。Claude Code / Codex / Copilot 在 coding 场景下通常仍然更高效、更准确、更不容易跑偏,也更直接。这个项目刻意保持简单,只集成 Codex / Copilot CLI,所以你是在直接把任务交给 Codex / Copilot。
+Openclaw 功能非常完整,也内置了名为 Pi-Agent 的 agent loop,适合更丰富、更广泛的使用场景。我自己也很喜欢 Openclaw,以前也用它来写代码。不过对纯 coding 来说,它并不总是最佳选择,因为内置的大型 system prompt 和额外 context 有时会让模型更容易分心。Claude Code / Codex / Copilot 在 coding 场景下通常仍然更高效、更准确、更不容易跑偏,也更直接。这个项目刻意保持简单,只集成 Codex / Copilot / Claude Code CLI,所以你是在直接把任务交给 Codex / Copilot / Claude Code。
+
+## 🆚 如果我已经有 Claude Code + Telegram Plugin,为什么还需要它?
+
+| 功能 | Claude Code + 官方 Telegram Plugin | coding-agent-telegram(支持 Claude) |
+|------|------------------------------------|--------------------------------------|
+| 通过 Telegram 与 AI 对话 | ✅ | ✅ |
+| 编辑本地代码并执行命令 | ✅ | ✅ |
+| 需要预先启动一个 CLI 会话 | **是** | **否**(自动启动或恢复会话) |
+| 支持多个 AI 提供商 | ❌ 仅支持 Claude | ✅ Claude Code、Codex CLI、GitHub Copilot CLI |
+| 通过 Telegram 管理项目 | ❌ | ✅ `/project` |
+| 通过 Telegram 管理分支 | 手动执行 Git 命令 | ✅ `/branch` 工作流 |
+| 创建和切换会话 | 仅限当前活动的 Claude 会话 | ✅ `/new`、`/switch`、`/current`、`/compact` |
+| 恢复已有的本地 CLI 会话 | ❌ | ✅ |
+| 跨设备持续使用同一个会话 | 有限制 | ✅ |
+| 防止多个 Agent 同时修改同一工作区 | ❌ | ✅ 防止多个 Agent 同时修改同一个项目 |
+| Agent 忙碌时的任务队列 | ❌ | ✅ |
+| 独立的文件系统快照与 Diff | ❌ | ✅ |
+| 在 Telegram 中查看结构化文件 Diff | ❌ | ✅ |
+| Secret 和敏感 Diff 过滤 | ❌ | ✅ |
+| 内置 Git 工作流(pull / push / commit) | 手动操作 | ✅ |
+| 支持多个 Bot | 需要运行多个实例 | ✅ 由单个服务器统一管理 |
+| 项目级状态管理 | ❌ | ✅ |
+| 与 AI 提供商无关的架构 | ❌ | ✅ |
+
+> **核心区别**
+>
+> 官方 Claude Code Telegram Plugin 会将 Telegram 连接到**一个已经运行中的 Claude Code 会话**。
+>
+> **coding-agent-telegram** 则充当一个 **Telegram Control Plane(控制平面)**,通过统一的界面管理项目、分支、会话、Git 工作流以及多个 Coding Agent(Claude Code、Codex CLI、GitHub Copilot CLI)。
+
+### 🏛️ 架构
+
+#### Claude Code + Telegram Plugin
+
+```text
+Telegram
+ │
+ ▼
+Claude Code Channel
+ │
+ ▼
+一个正在运行的 Claude Code 会话
+```
+
+#### coding-agent-telegram
+
+```text
+Telegram
+ │
+ ▼
+coding-agent-telegram
+ │
+ ├── Claude Code
+ ├── Codex CLI
+ └── GitHub Copilot CLI
+ │
+ ▼
+项目 • 分支 • 会话 • 队列 • Git • Diff • Secret 过滤
+```
## 🚀 快速开始
@@ -203,7 +264,7 @@ bot 当前接受:
- 文本消息
- 图片
- 当 `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 且已安装本地 Whisper 依赖时的语音消息和音频文件
-- Codex 和 Copilot 当前只支持文本和图片,不支持视频
+- Codex 和 Claude Code 会话支持文本和图片;Copilot 会话目前只支持文本。所有提供方均不支持视频
## 🤖 Telegram 命令
@@ -234,7 +295,7 @@ bot 当前接受:
/switch |
- 显示最新的会话,按从新到旧排序。列表同时包含 bot 管理的会话和当前项目的本地 Codex/Copilot CLI 会话。 |
+ 显示最新的会话,按从新到旧排序。列表同时包含 bot 管理的会话和当前项目的本地 Codex/Copilot/Claude Code CLI 会话。 |
/switch page <number> |
@@ -313,13 +374,21 @@ bot 当前接受:
APP_LOCALE |
共享 bot 消息和命令说明所使用的 UI 语言。支持值:en、de、fr、ja、ko、nl、th、vi、zh-CN、zh-HK、zh-TW。 |
+
+ DEFAULT_AGENT_PROVIDER |
+ 新会话的默认提供方:codex、copilot 或 claude。默认:codex。 |
+
CODEX_BIN |
- 用于启动 Codex CLI 的命令。默认:codex。 |
+ 用于启动 Codex CLI 的命令。应用程序会在初始化 .env_coding_agent_telegram 时尝试自动检测本地安装的 Codex 路径。您也可以使用 which codex 查看该路径。 |
COPILOT_BIN |
- 用于启动 Copilot CLI 的命令。默认:copilot。 |
+ 用于启动 Copilot CLI 的命令。应用程序会在初始化 .env_coding_agent_telegram 时尝试自动检测本地安装的 Copilot 路径。您也可以使用 which copilot 查看该路径。 |
+
+
+ CLAUDE_BIN |
+ 用于启动 Claude Code CLI 的命令。应用程序会在初始化 .env_coding_agent_telegram 时尝试自动检测本地安装的 Claude 路径。您也可以使用 which claude 查看该路径。 |
CODEX_MODEL |
@@ -329,6 +398,14 @@ bot 当前接受:
COPILOT_MODEL |
可选的 Copilot model 覆盖。留空则使用 Copilot CLI 默认 model。示例:gpt-5.4、claude-sonnet-4.6 GitHub Copilot supported models |
+
+ CLAUDE_MODEL |
+ 可选的 Claude Code model 覆盖。
+ 留空则使用 Claude Code CLI 默认 model。
+ 示例:sonnet、opus、haiku
+ Claude Code model configuration
+ |
+
CODEX_APPROVAL_POLICY |
传递给 Codex 的 approval mode。默认:never。 |
@@ -341,6 +418,18 @@ bot 当前接受:
CODEX_SKIP_GIT_REPO_CHECK |
如果启用,将始终跳过 Codex 的 trusted-repo 检查。 |
+
+ CLAUDE_PERMISSION_MODE |
+ 传递给 Claude Code 的 permission mode。可选值:default、acceptEdits、plan、auto、dontAsk、bypassPermissions、manual。默认:bypassPermissions(完全自主运行,因为没有交互式终端来确认提示)。 |
+
+
+ CLAUDE_ALLOWED_TOOLS |
+ 以逗号分隔的 Claude Code 工具允许列表,使用 Claude Code 的 permission rule 语法。示例:Read,Edit,Bash(git *) |
+
+
+ CLAUDE_DISALLOWED_TOOLS |
+ 以逗号分隔的 Claude Code 工具拒绝列表。示例:Bash(rm *) |
+
ENABLE_COMMIT_COMMAND |
启用 Telegram 的 /commit 命令。默认:false。 |
@@ -420,8 +509,10 @@ ALLOWED_CHAT_IDS=123456789
DEFAULT_AGENT_PROVIDER=codex
CODEX_BIN=codex
COPILOT_BIN=copilot
+CLAUDE_BIN=claude
CODEX_APPROVAL_POLICY=never
CODEX_SANDBOX_MODE=workspace-write
+CLAUDE_PERMISSION_MODE=bypassPermissions
ENABLE_SENSITIVE_DIFF_FILTER=true
ENABLE_SECRET_SCRUB_FILTER=true
```
@@ -485,7 +576,7 @@ bot 会强制这个限制,避免两个 agent 同时写入同一个 workspace
## ⚠️ Diff(文件变更)
-_在每次 代理运行 期间,bot 也会为项目生成轻量的 前后快照,用来汇总改动文件并把 diff 发回 Telegram。这个 快照 由 bot 应用自己生成,而不是由 Codex 或 Copilot 生成。_
+_在每次 代理运行 期间,bot 也会为项目生成轻量的 前后快照,用来汇总改动文件并把 diff 发回 Telegram。这个 快照 由 bot 应用自己生成,而不是由 Codex、Copilot 或 Claude Code 生成。_
**快照说明:**
diff --git a/README.zh-HK.md b/README.zh-HK.md
index f787b8a..73c9c2a 100644
--- a/README.zh-HK.md
+++ b/README.zh-HK.md
@@ -35,14 +35,14 @@
## ✨ 為什麼使用它
- ✅ 輕量:沒有重型框架,行為清晰透明
- ✅ 多 Bot:支援多個聊天、多個工作階段
- - ✅ 使用 Telegram 控制 Codex / Copilot CLI
+ - ✅ 使用 Telegram 控制 Codex / Copilot / Claude Code CLI
- ✅ 可在 code block 中輕鬆查看 agent 回覆及改動檔案
- ✅ agent 執行中仍可把後續問題排入佇列
- ✅ 支援 ✏️ 文字、🌄 圖片和 🎙️ 語音訊息
## 🔁 裝置與工作階段無縫切換
- 你可以先在 Telegram 開始一個工作階段,之後再在電腦上繼續同一個 Codex/Copilot CLI 工作階段,過程自然順暢。使用 `/switch` 亦可在 Telegram 與 command line 之間輕鬆切換。
+ 你可以先在 Telegram 開始一個工作階段,之後再在電腦上繼續同一個 Codex/Copilot/Claude Code CLI 工作階段,過程自然順暢。使用 `/switch` 亦可在 Telegram 與 command line 之間輕鬆切換。
- 使用 `/switch` 繼續本機工作階段
- 亦支援歷史工作階段
@@ -86,6 +86,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- 現有資料夾在執行會修改內容的 Git operation 前,可能需要先確認 trust
- 伺服器不會作出隱藏的外部呼叫,一切都由你掌控。
- 與 Codex Sandbox mode 配合良好,你毋須授予 `danger-full-access`
+- Claude Code 整合支援可設定的 permission modes 及工具 allow/deny 清單
@@ -96,16 +97,74 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- Python 3.9 或以上
- 由 _@BotFather_ 建立的 Telegram bot token
- 你的 Telegram chat ID
-- 已在本機安裝 Codex CLI 及/或 Copilot CLI
-- [安裝 Codex CLI](https://developers.openai.com/codex/cli)
-- [安裝 Copilot CLI](https://github.com/features/copilot/cli)
+- 已在本機安裝 Codex CLI 及/或 Copilot CLI 及/或 Claude Code CLI
+- [安裝 Codex CLI](https://developers.openai.com/codex/cli) / [安裝 Copilot CLI](https://github.com/features/copilot/cli) / [安裝 Claude Code CLI](https://code.claude.com/docs/en/quickstart)
- [可選] Whisper、ffmpeg
|
## 🦞 如果我已經有 Openclaw,為甚麼還需要它?
-Openclaw 提供非常完整的能力,也內建了名為 Pi-Agent 的 agent loop,適合更多元的 use case。我自己也很喜歡 Openclaw,過去亦曾用它來寫 code。不過對純 coding 來說,它未必永遠是最佳選擇,因為內建的大型 system prompt 和額外 context 有時會令模型更容易分心。Claude Code / Codex / Copilot 在 coding 場景下通常仍然更有效率、更準確、更少被干擾,也更直接。這個 project 刻意保持簡單,只整合 Codex / Copilot CLI,所以你其實是在直接把工作交給 Codex / Copilot。
+Openclaw 提供非常完整的能力,也內建了名為 Pi-Agent 的 agent loop,適合更多元的 use case。我自己也很喜歡 Openclaw,過去亦曾用它來寫 code。不過對純 coding 來說,它未必永遠是最佳選擇,因為內建的大型 system prompt 和額外 context 有時會令模型更容易分心。Claude Code / Codex / Copilot 在 coding 場景下通常仍然更有效率、更準確、更少被干擾,也更直接。這個 project 刻意保持簡單,只整合 Codex / Copilot / Claude Code CLI,所以你其實是在直接把工作交給 Codex / Copilot / Claude Code。
+
+## 🆚 如果我已經有 Claude Code + Telegram Plugin,為甚麼還需要它?
+
+| 功能 | Claude Code + 官方 Telegram Plugin | coding-agent-telegram(支援 Claude) |
+|------|------------------------------------|--------------------------------------|
+| 透過 Telegram 與 AI 對話 | ✅ | ✅ |
+| 編輯本機程式碼並執行指令 | ✅ | ✅ |
+| 需要預先啟動一個 CLI 工作階段 | **是** | **否**(自動啟動或恢復工作階段) |
+| 支援多個 AI 供應商 | ❌ 僅支援 Claude | ✅ Claude Code、Codex CLI、GitHub Copilot CLI |
+| 透過 Telegram 管理專案 | ❌ | ✅ `/project` |
+| 透過 Telegram 管理分支 | 手動執行 Git 指令 | ✅ `/branch` 工作流程 |
+| 建立及切換工作階段 | 僅限目前啟用的 Claude 工作階段 | ✅ `/new`、`/switch`、`/current`、`/compact` |
+| 恢復現有的本機 CLI 工作階段 | ❌ | ✅ |
+| 跨裝置延續同一個工作階段 | 有限制 | ✅ |
+| 防止多個 Agent 同時修改同一個工作區 | ❌ | ✅ 防止多個 Agent 同時修改同一個專案 |
+| Agent 忙碌時的工作佇列 | ❌ | ✅ |
+| 獨立的檔案系統快照與 Diff | ❌ | ✅ |
+| 在 Telegram 中檢視結構化檔案 Diff | ❌ | ✅ |
+| Secret 及敏感 Diff 過濾 | ❌ | ✅ |
+| 內建 Git 工作流程(pull / push / commit) | 手動操作 | ✅ |
+| 支援多個 Bot | 需要執行多個實例 | ✅ 由單一伺服器統一管理 |
+| 專案層級狀態管理 | ❌ | ✅ |
+| 與 AI 供應商無關的架構 | ❌ | ✅ |
+
+> **主要分別**
+>
+> 官方 Claude Code Telegram Plugin 會將 Telegram 連接到**一個已經執行中的 Claude Code 工作階段**。
+>
+> **coding-agent-telegram** 則充當一個 **Telegram Control Plane(控制平面)**,透過單一介面管理專案、分支、工作階段、Git 工作流程,以及多個 Coding Agent(Claude Code、Codex CLI、GitHub Copilot CLI)。
+
+### 🏛️ 架構
+
+#### Claude Code + Telegram Plugin
+
+```text
+Telegram
+ │
+ ▼
+Claude Code Channel
+ │
+ ▼
+一個正在執行中的 Claude Code 工作階段
+```
+
+#### coding-agent-telegram
+
+```text
+Telegram
+ │
+ ▼
+coding-agent-telegram
+ │
+ ├── Claude Code
+ ├── Codex CLI
+ └── GitHub Copilot CLI
+ │
+ ▼
+專案 • 分支 • 工作階段 • 佇列 • Git • Diff • Secret 過濾
+```
## 🚀 快速開始
@@ -203,7 +262,7 @@ bot 目前接受:
- 文字訊息
- 圖片
- 當 `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 且已安裝本機 Whisper 依賴時的語音訊息與音訊檔案
-- Codex 與 Copilot 目前只支援文字與圖片,不支援影片
+- Codex 與 Claude Code 的工作階段支援文字與圖片;Copilot 的工作階段目前只支援文字。所有提供者均不支援影片。
## 🤖 Telegram 指令
@@ -234,7 +293,7 @@ bot 目前接受:
/switch |
- 顯示最新的工作階段,按由新到舊排序。列表同時包含 bot 管理的工作階段以及目前專案的本機 Codex/Copilot CLI 工作階段。 |
+ 顯示最新的工作階段,按由新到舊排序。列表同時包含 bot 管理的工作階段以及目前專案的本機 Codex/Copilot/Claude Code CLI 工作階段。 |
/switch page <number> |
@@ -313,13 +372,21 @@ bot 目前接受:
APP_LOCALE |
共用 bot 訊息與指令說明所使用的 UI 語言。支援值:en、de、fr、ja、ko、nl、th、vi、zh-CN、zh-HK、zh-TW。 |
+
+ DEFAULT_AGENT_PROVIDER |
+ 新工作階段的預設提供者:codex、copilot 或 claude。預設:codex。 |
+
CODEX_BIN |
- 用來啟動 Codex CLI 的指令。預設:codex。 |
+ 用來啟動 Codex CLI 的指令。應用程式會在初始化 .env_coding_agent_telegram 時嘗試自動偵測本機已安裝的 Codex 路徑。您亦可使用 which codex 查看該路徑。 |
COPILOT_BIN |
- 用來啟動 Copilot CLI 的指令。預設:copilot。 |
+ 用來啟動 Copilot CLI 的指令。應用程式會在初始化 .env_coding_agent_telegram 時嘗試自動偵測本機已安裝的 Copilot 路徑。您亦可使用 which copilot 查看該路徑。 |
+
+
+ CLAUDE_BIN |
+ 用來啟動 Claude Code CLI 的指令。應用程式會在初始化 .env_coding_agent_telegram 時嘗試自動偵測本機已安裝的 Claude 路徑。您亦可使用 which claude 查看該路徑。 |
CODEX_MODEL |
@@ -329,6 +396,12 @@ bot 目前接受:
COPILOT_MODEL |
可選的 Copilot model override。留空則使用 Copilot CLI 預設 model。例子:gpt-5.4、claude-sonnet-4.6 GitHub Copilot supported models |
+
+ CLAUDE_MODEL |
+ 可選的 Claude Code model override。留空則使用 Claude Code CLI 預設 model。例子:sonnet、opus、haiku
+ Claude Code model configuration
+ |
+
CODEX_APPROVAL_POLICY |
傳遞給 Codex 的 approval mode。預設:never。 |
@@ -341,6 +414,18 @@ bot 目前接受:
CODEX_SKIP_GIT_REPO_CHECK |
如果啟用,會一直略過 Codex 的 trusted-repo 檢查。 |
+
+ CLAUDE_PERMISSION_MODE |
+ 傳遞給 Claude Code 的 permission mode。可選值:default、acceptEdits、plan、auto、dontAsk、bypassPermissions、manual。預設:bypassPermissions(完全自動化,因為並沒有互動式 terminal 可以核准提示)。 |
+
+
+ CLAUDE_ALLOWED_TOOLS |
+ 以逗號分隔的 Claude Code 工具 allowlist,使用 Claude Code 的 permission rule 語法。例子:Read,Edit,Bash(git *) |
+
+
+ CLAUDE_DISALLOWED_TOOLS |
+ 以逗號分隔的 Claude Code 工具 denylist。例子:Bash(rm *) |
+
ENABLE_COMMIT_COMMAND |
啟用 Telegram 的 /commit 指令。預設:false。 |
@@ -426,8 +511,10 @@ ALLOWED_CHAT_IDS=123456789
DEFAULT_AGENT_PROVIDER=codex
CODEX_BIN=codex
COPILOT_BIN=copilot
+CLAUDE_BIN=claude
CODEX_APPROVAL_POLICY=never
CODEX_SANDBOX_MODE=workspace-write
+CLAUDE_PERMISSION_MODE=bypassPermissions
ENABLE_SENSITIVE_DIFF_FILTER=true
ENABLE_SECRET_SCRUB_FILTER=true
```
@@ -491,7 +578,7 @@ bot 會強制這個限制,避免兩個 agent 同時寫入同一個 workspace
## ⚠️ Diff(檔案變更)
-_在每次 代理執行 期間,bot 也會為 project 產生輕量的 前後快照,用來總結已變更檔案並把 diff 傳回 Telegram。這個 快照 是由 bot app 自己建立,不是由 Codex 或 Copilot 建立。_
+_在每次 代理執行 期間,bot 也會為 project 產生輕量的 前後快照,用來總結已變更檔案並把 diff 傳回 Telegram。這個 快照 是由 bot app 自己建立,不是由 Codex、Copilot 或 Claude Code 建立。_
**快照說明:**
diff --git a/README.zh-TW.md b/README.zh-TW.md
index e8a5d40..4ca3122 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -35,14 +35,14 @@
## ✨ 為什麼使用它
- ✅ 輕量:沒有重型框架,行為清楚透明
- ✅ 多 Bot:支援多個聊天、多個工作階段
- - ✅ 使用 Telegram 控制 Codex / Copilot CLI
+ - ✅ 使用 Telegram 控制 Codex / Copilot / Claude Code CLI
- ✅ 可以在 code block 中輕鬆檢視 agent 回覆與改動檔案
- ✅ agent 執行期間也能把後續問題排入佇列
- ✅ 支援 ✏️ 文字、🌄 圖片和 🎙️ 語音訊息
## 🔁 裝置與工作階段無縫切換
- 你可以先在 Telegram 開始一個工作階段,之後在電腦上繼續同一個 Codex/Copilot CLI 工作階段,不需要額外折騰。使用 `/switch` 也能在 Telegram 與 command line 之間自然切換。
+ 你可以先在 Telegram 開始一個工作階段,之後在電腦上繼續同一個 Codex/Copilot/Claude Code CLI 工作階段,不需要額外折騰。使用 `/switch` 也能在 Telegram 與 command line 之間自然切換。
- 使用 `/switch` 繼續本機工作階段
- 也支援歷史工作階段
@@ -86,6 +86,7 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- 現有資料夾在執行會修改內容的 Git operation 前,可能需要先確認 trust
- 伺服器不會進行隱藏的外部呼叫,一切都由你掌控。
- 與 Codex Sandbox mode 配合良好,你不需要授予 `danger-full-access`
+- Claude Code 整合支援可設定的 permission modes 以及 tool allow/deny lists
@@ -96,16 +97,76 @@ curl -fsSL https://raw.githubusercontent.com/daocha/coding-agent-telegram/main/i
- Python 3.9 或以上
- 由 _@BotFather_ 建立的 Telegram bot token
- 你的 Telegram chat ID
-- 已在本機安裝 Codex CLI 及/或 Copilot CLI
+- 已在本機安裝 Codex CLI 及/或 Copilot CLI 及/或 Claude Code CLI
- [安裝 Codex CLI](https://developers.openai.com/codex/cli)
- [安裝 Copilot CLI](https://github.com/features/copilot/cli)
+- [安裝 Claude Code CLI](https://code.claude.com/docs/en/quickstart)
- [可選] Whisper、ffmpeg
|
## 🦞 如果我已經有 Openclaw,為什麼還需要它?
-Openclaw 提供非常完整的能力,也內建了名為 Pi-Agent 的 agent loop,適合更多元的 use case。我自己也很喜歡 Openclaw,過去也曾用它來寫 code。不過對純 coding 來說,它未必永遠是最佳選擇,因為內建的大型 system prompt 和額外 context 有時會讓模型更容易分心。Claude Code / Codex / Copilot 在 coding 場景下通常仍然更有效率、更準確、更不容易跑偏,也更直接。這個 project 刻意保持簡單,只整合 Codex / Copilot CLI,所以你其實是在直接把工作交給 Codex / Copilot。
+Openclaw 提供非常完整的能力,也內建了名為 Pi-Agent 的 agent loop,適合更多元的 use case。我自己也很喜歡 Openclaw,過去也曾用它來寫 code。不過對純 coding 來說,它未必永遠是最佳選擇,因為內建的大型 system prompt 和額外 context 有時會讓模型更容易分心。Claude Code / Codex / Copilot 在 coding 場景下通常仍然更有效率、更準確、更不容易跑偏,也更直接。這個 project 刻意保持簡單,只整合 Codex / Copilot / Claude Code CLI,所以你其實是在直接把工作交給 Codex / Copilot / Claude Code。
+
+## 🆚 如果我已經有 Claude Code + Telegram Plugin,為什麼還需要它?
+
+| 功能 | Claude Code + 官方 Telegram Plugin | coding-agent-telegram(支援 Claude) |
+|------|------------------------------------|--------------------------------------|
+| 透過 Telegram 與 AI 對話 | ✅ | ✅ |
+| 編輯本機程式碼並執行指令 | ✅ | ✅ |
+| 需要預先啟動一個 CLI 工作階段 | **是** | **否**(自動啟動或恢復工作階段) |
+| 支援多個 AI 提供商 | ❌ 僅支援 Claude | ✅ Claude Code、Codex CLI、GitHub Copilot CLI |
+| 透過 Telegram 管理專案 | ❌ | ✅ `/project` |
+| 透過 Telegram 管理分支 | 手動執行 Git 指令 | ✅ `/branch` 工作流程 |
+| 建立與切換工作階段 | 僅限目前啟用的 Claude 工作階段 | ✅ `/new`、`/switch`、`/current`、`/compact` |
+| 恢復既有的本機 CLI 工作階段 | ❌ | ✅ |
+| 跨裝置延續同一個工作階段 | 有限制 | ✅ |
+| 防止多個 Agent 同時修改同一個工作區 | ❌ | ✅ 防止多個 Agent 同時修改同一個專案 |
+| Agent 忙碌時的工作佇列 | ❌ | ✅ |
+| 獨立的檔案系統快照與 Diff | ❌ | ✅ |
+| 在 Telegram 中檢視結構化檔案 Diff | ❌ | ✅ |
+| Secret 與敏感 Diff 過濾 | ❌ | ✅ |
+| 內建 Git 工作流程(pull / push / commit) | 手動操作 | ✅ |
+| 支援多個 Bot | 需要執行多個實例 | ✅ 由單一伺服器統一管理 |
+| 專案層級狀態管理 | ❌ | ✅ |
+| 與 AI 提供商無關的架構 | ❌ | ✅ |
+
+> **核心差異**
+>
+> 官方 Claude Code Telegram Plugin 會將 Telegram 連接到**一個已經執行中的 Claude Code 工作階段**。
+>
+> **coding-agent-telegram** 則充當一個 **Telegram Control Plane(控制平面)**,透過單一介面管理專案、分支、工作階段、Git 工作流程,以及多個 Coding Agent(Claude Code、Codex CLI、GitHub Copilot CLI)。
+
+### 🏛️ 架構
+
+#### Claude Code + Telegram Plugin
+
+```text
+Telegram
+ │
+ ▼
+Claude Code Channel
+ │
+ ▼
+一個正在執行中的 Claude Code 工作階段
+```
+
+#### coding-agent-telegram
+
+```text
+Telegram
+ │
+ ▼
+coding-agent-telegram
+ │
+ ├── Claude Code
+ ├── Codex CLI
+ └── GitHub Copilot CLI
+ │
+ ▼
+專案 • 分支 • 工作階段 • 佇列 • Git • Diff • Secret 過濾
+```
## 🚀 快速開始
@@ -203,7 +264,7 @@ bot 目前接受:
- 文字訊息
- 圖片
- 當 `ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true` 且已安裝本機 Whisper 依賴時的語音訊息與音訊檔案
-- Codex 與 Copilot 目前只支援文字與圖片,不支援影片
+- Codex 與 Claude Code 工作階段支援文字與圖片;Copilot 工作階段目前僅支援文字。所有提供者皆不支援影片
## 🤖 Telegram 指令
@@ -234,7 +295,7 @@ bot 目前接受:
/switch |
- 顯示最新的工作階段,依新到舊排序。列表同時包含 bot 管理的工作階段與目前專案的本機 Codex/Copilot CLI 工作階段。 |
+ 顯示最新的工作階段,依新到舊排序。列表同時包含 bot 管理的工作階段與目前專案的本機 Codex/Copilot/Claude Code CLI 工作階段。 |
/switch page <number> |
@@ -313,13 +374,21 @@ bot 目前接受:
APP_LOCALE |
共用 bot 訊息與指令說明所使用的 UI 語言。支援值:en、de、fr、ja、ko、nl、th、vi、zh-CN、zh-HK、zh-TW。 |
+
+ DEFAULT_AGENT_PROVIDER |
+ 新工作階段預設使用的提供者:codex、copilot 或 claude。預設:codex。 |
+
CODEX_BIN |
- 用來啟動 Codex CLI 的指令。預設:codex。 |
+ 用來啟動 Codex CLI 的指令。應用程式會在初始化 .env_coding_agent_telegram 時嘗試自動偵測本機安裝的 Codex 路徑。您也可以使用 which codex 查看該路徑。 |
COPILOT_BIN |
- 用來啟動 Copilot CLI 的指令。預設:copilot。 |
+ 用來啟動 Copilot CLI 的指令。應用程式會在初始化 .env_coding_agent_telegram 時嘗試自動偵測本機安裝的 Copilot 路徑。您也可以使用 which copilot 查看該路徑。 |
+
+
+ CLAUDE_BIN |
+ 用來啟動 Claude Code CLI 的指令。應用程式會在初始化 .env_coding_agent_telegram 時嘗試自動偵測本機安裝的 Claude 路徑。您也可以使用 which claude 查看該路徑。 |
CODEX_MODEL |
@@ -329,6 +398,10 @@ bot 目前接受:
COPILOT_MODEL |
可選的 Copilot model override。留空則使用 Copilot CLI 預設 model。例子:gpt-5.4、claude-sonnet-4.6 GitHub Copilot supported models |
+
+ CLAUDE_MODEL |
+ 可選的 Claude Code model override。留空則使用 Claude Code CLI 預設 model。例子:sonnet、opus、haiku Claude Code model configuration |
+
CODEX_APPROVAL_POLICY |
傳遞給 Codex 的 approval mode。預設:never。 |
@@ -341,6 +414,18 @@ bot 目前接受:
CODEX_SKIP_GIT_REPO_CHECK |
如果啟用,會永遠略過 Codex 的 trusted-repo 檢查。 |
+
+ CLAUDE_PERMISSION_MODE |
+ 傳遞給 Claude Code 的 permission mode。可選值:default、acceptEdits、plan、auto、dontAsk、bypassPermissions、manual。預設:bypassPermissions(完全自動化,因為沒有互動式終端機可以核准提示)。 |
+
+
+ CLAUDE_ALLOWED_TOOLS |
+ 以逗號分隔的 Claude Code tool allowlist,使用 Claude Code 的 permission rule 語法。例子:Read,Edit,Bash(git *) |
+
+
+ CLAUDE_DISALLOWED_TOOLS |
+ 以逗號分隔的 Claude Code tool denylist。例子:Bash(rm *) |
+
ENABLE_COMMIT_COMMAND |
啟用 Telegram 的 /commit 指令。預設:false。 |
@@ -426,8 +511,10 @@ ALLOWED_CHAT_IDS=123456789
DEFAULT_AGENT_PROVIDER=codex
CODEX_BIN=codex
COPILOT_BIN=copilot
+CLAUDE_BIN=claude
CODEX_APPROVAL_POLICY=never
CODEX_SANDBOX_MODE=workspace-write
+CLAUDE_PERMISSION_MODE=bypassPermissions
ENABLE_SENSITIVE_DIFF_FILTER=true
ENABLE_SECRET_SCRUB_FILTER=true
```
@@ -491,7 +578,7 @@ bot 會強制這個限制,避免兩個 agent 同時寫入同一個 workspace
## ⚠️ Diff(檔案變更)
-_在每次 代理執行 期間,bot 也會為 project 產生輕量的 前後快照,用來彙整已變更檔案並把 diff 傳回 Telegram。這個 快照 是由 bot app 自己建立,不是由 Codex 或 Copilot 建立。_
+_在每次 代理執行 期間,bot 也會為 project 產生輕量的 前後快照,用來彙整已變更檔案並把 diff 傳回 Telegram。這個 快照 是由 bot app 自己建立,不是由 Codex、Copilot 或 Claude Code 建立。_
**快照說明:**
diff --git a/src/coding_agent_telegram/agent_runner.py b/src/coding_agent_telegram/agent_runner.py
index 37e8b15..d5d94ac 100644
--- a/src/coding_agent_telegram/agent_runner.py
+++ b/src/coding_agent_telegram/agent_runner.py
@@ -90,6 +90,11 @@ def __init__(
copilot_allow_tools: tuple[str, ...] = (),
copilot_deny_tools: tuple[str, ...] = (),
copilot_available_tools: tuple[str, ...] = (),
+ claude_bin: str = "claude",
+ claude_model: str = "",
+ claude_permission_mode: str = "bypassPermissions",
+ claude_allowed_tools: tuple[str, ...] = (),
+ claude_disallowed_tools: tuple[str, ...] = (),
hard_timeout_seconds: int = 0,
) -> None:
self.codex_bin = codex_bin
@@ -105,6 +110,11 @@ def __init__(
self.copilot_allow_tools = tuple(tool.strip() for tool in copilot_allow_tools if tool.strip())
self.copilot_deny_tools = tuple(tool.strip() for tool in copilot_deny_tools if tool.strip())
self.copilot_available_tools = tuple(tool.strip() for tool in copilot_available_tools if tool.strip())
+ self.claude_bin = claude_bin
+ self.claude_model = claude_model.strip()
+ self.claude_permission_mode = claude_permission_mode.strip()
+ self.claude_allowed_tools = tuple(tool.strip() for tool in claude_allowed_tools if tool.strip())
+ self.claude_disallowed_tools = tuple(tool.strip() for tool in claude_disallowed_tools if tool.strip())
# 0 = disabled. When > 0, the agent subprocess is killed after this many seconds.
self.hard_timeout_seconds = max(0, int(hard_timeout_seconds))
self._running_processes: dict[str, subprocess.Popen] = {}
@@ -210,12 +220,7 @@ def _extract_copilot_assistant_text(self, event: AssistantEvent) -> str:
return ""
return "\n".join(self._unique_text_fragments(self._collect_text_fragments(event)))
- def _parse_jsonl(
- self,
- stdout: str,
- *,
- assistant_text_extractor: Callable[[AssistantEvent], str],
- ) -> Tuple[Optional[str], bool, str, Optional[str], list[dict]]:
+ def _parse_json_lines(self, stdout: str) -> list[dict]:
events: list[dict] = []
for line in stdout.splitlines():
line = line.strip()
@@ -225,6 +230,15 @@ def _parse_jsonl(
events.append(json.loads(line))
except json.JSONDecodeError:
continue
+ return events
+
+ def _parse_jsonl(
+ self,
+ stdout: str,
+ *,
+ assistant_text_extractor: Callable[[AssistantEvent], str],
+ ) -> Tuple[Optional[str], bool, str, Optional[str], list[dict]]:
+ events = self._parse_json_lines(stdout)
session_id = None
success = True
@@ -255,6 +269,51 @@ def _parse_codex_jsonl(self, stdout: str) -> Tuple[Optional[str], bool, str, Opt
def _parse_copilot_jsonl(self, stdout: str) -> Tuple[Optional[str], bool, str, Optional[str], list[dict]]:
return self._parse_jsonl(stdout, assistant_text_extractor=self._extract_copilot_assistant_text)
+ def _extract_claude_assistant_text(self, event: AssistantEvent) -> str:
+ if isinstance(event, str):
+ return event
+ if isinstance(event, list):
+ return "\n".join(filter(None, [self._extract_claude_assistant_text(item) for item in event]))
+ if not isinstance(event, dict):
+ return ""
+ if event.get("type") == "result":
+ result_text = event.get("result")
+ return result_text if isinstance(result_text, str) else ""
+ return "\n".join(self._unique_text_fragments(self._collect_text_fragments(event)))
+
+ def _parse_claude_jsonl(self, stdout: str) -> Tuple[Optional[str], bool, str, Optional[str], list[dict]]:
+ events = self._parse_json_lines(stdout)
+
+ session_id = None
+ success = True
+ assistant_text = ""
+ error_message = None
+
+ for ev in events:
+ if not isinstance(ev, dict):
+ continue
+ if isinstance(ev.get("session_id"), str):
+ validated = _validate_session_id(ev["session_id"])
+ if validated:
+ session_id = validated
+ if ev.get("type") == "result":
+ is_error = bool(ev.get("is_error"))
+ success = not is_error
+ result_text = ev.get("result")
+ if isinstance(result_text, str) and result_text:
+ assistant_text = result_text
+ if is_error:
+ subtype = str(ev.get("subtype") or "").strip()
+ error_message = (
+ result_text if isinstance(result_text, str) and result_text else (subtype or "Claude run failed.")
+ )
+ else:
+ extracted_text = self._extract_claude_assistant_text(ev)
+ if extracted_text:
+ assistant_text = extracted_text
+
+ return session_id, success, assistant_text, error_message, events
+
def _extract_codex_progress_text(self, chunk: str, *, is_stderr: bool) -> str:
stripped = chunk.strip()
if not stripped:
@@ -321,6 +380,67 @@ def _extract_copilot_progress_event_text(self, event: AssistantEvent) -> str:
return json.dumps(event, ensure_ascii=False)
return str(event)
+ def _extract_claude_progress_text(self, chunk: str, *, is_stderr: bool) -> str:
+ stripped = chunk.strip()
+ if not stripped:
+ return ""
+ if is_stderr:
+ return stripped
+ try:
+ event = json.loads(stripped)
+ except json.JSONDecodeError:
+ return stripped
+ return self._extract_claude_progress_event_text(event)
+
+ def _extract_claude_progress_event_text(self, event: AssistantEvent) -> str:
+ if isinstance(event, list):
+ return json.dumps(event, ensure_ascii=False)
+ if not isinstance(event, dict):
+ return str(event)
+
+ event_type = str(event.get("type") or "")
+
+ if event_type == "result":
+ result_text = event.get("result")
+ if isinstance(result_text, str) and result_text:
+ return result_text
+ return self._summarize_structured_event(event)
+
+ if event_type == "system":
+ if str(event.get("subtype") or "") == "api_retry":
+ error_category = str(event.get("error") or "").strip() or "an error"
+ attempt = event.get("attempt")
+ max_retries = event.get("max_retries")
+ if attempt and max_retries:
+ return f"Retrying after {error_category} (attempt {attempt}/{max_retries})"
+ return f"Retrying after {error_category}"
+ return ""
+
+ if event_type in {"assistant", "user"}:
+ message = event.get("message")
+ content = message.get("content") if isinstance(message, dict) else None
+ if isinstance(content, list):
+ text_fragments: list[str] = []
+ tool_summaries: list[str] = []
+ for block in content:
+ if not isinstance(block, dict):
+ continue
+ block_type = block.get("type")
+ if block_type == "text" and isinstance(block.get("text"), str):
+ text_fragments.append(block["text"])
+ elif block_type == "tool_use":
+ tool_summaries.append(f"Using {block.get('name') or 'tool'}")
+ elif block_type == "tool_result":
+ tool_summaries.append("Tool result received")
+ unique_text = self._unique_text_fragments(text_fragments)
+ if unique_text:
+ return "\n".join(unique_text)
+ if tool_summaries:
+ return "; ".join(tool_summaries)
+ return self._summarize_structured_event(event)
+
+ return self._summarize_structured_event(event)
+
def _summarize_progress_item(self, event: dict[str, Any]) -> str:
event_type = str(event.get("type") or "")
phase = event_type.split(".", 1)[1] if "." in event_type else ""
@@ -398,6 +518,8 @@ def record_activity(chunk: str, *, is_stderr: bool) -> None:
last_stderr = chunk.strip()
if provider == "codex":
progress_text = self._extract_codex_progress_text(chunk, is_stderr=is_stderr)
+ elif provider == "claude":
+ progress_text = self._extract_claude_progress_text(chunk, is_stderr=is_stderr)
else:
progress_text = self._extract_copilot_progress_text(chunk, is_stderr=is_stderr)
should_report_progress = bool(
@@ -505,6 +627,8 @@ def _watchdog() -> None:
stderr = "".join(stderr_chunks)
if provider == "codex":
session_id, parsed_success, assistant_text, error_message, events = self._parse_codex_jsonl(stdout)
+ elif provider == "claude":
+ session_id, parsed_success, assistant_text, error_message, events = self._parse_claude_jsonl(stdout)
else:
session_id, parsed_success, assistant_text, error_message, events = self._parse_copilot_jsonl(stdout)
@@ -711,6 +835,27 @@ def _copilot_base(
)
return args
+ def _claude_base(self, user_message: str) -> list[str]:
+ args = []
+ if self.claude_model:
+ args.extend(["--model", self.claude_model])
+ if self.claude_permission_mode:
+ args.extend(["--permission-mode", self.claude_permission_mode])
+ if self.claude_allowed_tools:
+ args.extend(["--allowedTools", ",".join(self.claude_allowed_tools)])
+ if self.claude_disallowed_tools:
+ args.extend(["--disallowedTools", ",".join(self.claude_disallowed_tools)])
+ args.extend(
+ [
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "-p",
+ f"{self.PROMPT_PREFIX}{user_message}",
+ ]
+ )
+ return args
+
def create_session(
self,
provider: str,
@@ -748,6 +893,15 @@ def create_session(
on_stall=on_stall,
on_progress=on_progress,
)
+ elif provider == "claude":
+ args = [self.claude_bin, *self._claude_base(user_message)]
+ return self._run(
+ args,
+ provider="claude",
+ cwd=project_path,
+ on_stall=on_stall,
+ on_progress=on_progress,
+ )
else:
return AgentRunResult(None, False, "", f"Unsupported provider: {provider}", [])
@@ -790,5 +944,14 @@ def resume_session(
on_stall=on_stall,
on_progress=on_progress,
)
+ elif provider == "claude":
+ args = [self.claude_bin, "--resume", session_id, *self._claude_base(user_message)]
+ return self._run(
+ args,
+ provider="claude",
+ cwd=project_path,
+ on_stall=on_stall,
+ on_progress=on_progress,
+ )
else:
return AgentRunResult(None, False, "", f"Unsupported provider: {provider}", [])
diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py
index cb0ba4d..56bc217 100644
--- a/src/coding_agent_telegram/bot.py
+++ b/src/coding_agent_telegram/bot.py
@@ -8,6 +8,7 @@
from coding_agent_telegram.command_router import CommandRouter
from coding_agent_telegram.i18n import DEFAULT_LOCALE, translate
+from coding_agent_telegram.providers import SUPPORTED_PROVIDERS
from coding_agent_telegram.session_store import SessionStoreError
@@ -103,6 +104,11 @@ async def handle_error(update, context) -> None:
async def handle_error(update, context) -> None:
await build_error_handler(DEFAULT_LOCALE)(update, context)
+
+def provider_callback_pattern() -> str:
+ return rf"^provider:set:({'|'.join(SUPPORTED_PROVIDERS)})$"
+
+
def build_application(token: str, router: CommandRouter, *, allowed_chat_ids: set[int]) -> Application:
request = HTTPXRequest(
connect_timeout=TELEGRAM_CONNECT_TIMEOUT_SECONDS,
@@ -155,7 +161,13 @@ async def log_incoming_private_message(update, _context) -> None:
app.add_handler(CommandHandler("pull", router.handle_pull, filters=allowed_private))
app.add_handler(CommandHandler("push", router.handle_push, filters=allowed_private))
app.add_handler(CommandHandler("abort", router.handle_abort, filters=allowed_private))
- app.add_handler(CallbackQueryHandler(router.handle_provider_callback, pattern=r"^provider:set:(codex|copilot)$", block=False))
+ app.add_handler(
+ CallbackQueryHandler(
+ router.handle_provider_callback,
+ pattern=provider_callback_pattern(),
+ block=False,
+ )
+ )
app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single|cancel)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:[0-9a-f]{12}$", block=False))
diff --git a/src/coding_agent_telegram/cli.py b/src/coding_agent_telegram/cli.py
index 3cf2299..232ce31 100644
--- a/src/coding_agent_telegram/cli.py
+++ b/src/coding_agent_telegram/cli.py
@@ -153,6 +153,14 @@ def main() -> None:
raise
store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ cleared_pending_actions = store.clear_all_pending_actions()
+ if cleared_pending_actions:
+ logger.warning(
+ "Cleared %d stale pending_action entr%s left over from a previous run "
+ "(likely an unclean shutdown mid-message); affected chats can send messages again.",
+ cleared_pending_actions,
+ "y" if cleared_pending_actions == 1 else "ies",
+ )
runner = MultiAgentRunner(
codex_bin=cfg.codex_bin,
copilot_bin=cfg.copilot_bin,
@@ -167,6 +175,11 @@ def main() -> None:
copilot_allow_tools=cfg.copilot_allow_tools,
copilot_deny_tools=cfg.copilot_deny_tools,
copilot_available_tools=cfg.copilot_available_tools,
+ claude_bin=cfg.claude_bin,
+ claude_model=cfg.claude_model,
+ claude_permission_mode=cfg.claude_permission_mode,
+ claude_allowed_tools=cfg.claude_allowed_tools,
+ claude_disallowed_tools=cfg.claude_disallowed_tools,
hard_timeout_seconds=cfg.agent_hard_timeout_seconds,
)
try:
diff --git a/src/coding_agent_telegram/config.py b/src/coding_agent_telegram/config.py
index 919d25f..aee9bf8 100644
--- a/src/coding_agent_telegram/config.py
+++ b/src/coding_agent_telegram/config.py
@@ -6,6 +6,7 @@
import os
import pwd
import re
+import shutil
import locale as system_locale
from dataclasses import dataclass
from pathlib import Path
@@ -13,6 +14,7 @@
from dotenv import load_dotenv
from coding_agent_telegram.i18n import DEFAULT_LOCALE, normalize_locale
+from coding_agent_telegram.providers import SUPPORTED_PROVIDERS
DEFAULT_SNAPSHOT_TEXT_FILE_MAX_BYTES = 200_000
DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH = 3_000
@@ -36,8 +38,10 @@ class AppConfig:
allowed_chat_ids: set[int]
codex_bin: str
copilot_bin: str
+ claude_bin: str
codex_model: str
copilot_model: str
+ claude_model: str
copilot_autopilot: bool
copilot_no_ask_user: bool
copilot_allow_all: bool
@@ -45,6 +49,9 @@ class AppConfig:
copilot_allow_tools: tuple[str, ...]
copilot_deny_tools: tuple[str, ...]
copilot_available_tools: tuple[str, ...]
+ claude_permission_mode: str
+ claude_allowed_tools: tuple[str, ...]
+ claude_disallowed_tools: tuple[str, ...]
codex_approval_policy: str
codex_sandbox_mode: str
codex_skip_git_repo_check: bool
@@ -128,6 +135,59 @@ def _apply_initial_app_locale(template_text: str, app_locale: str) -> str:
return f"{replacement}\n{template_text}"
+# Fallback install locations checked when a CLI isn't on PATH, e.g. because the
+# process runs under a service manager or a shell profile that startup.sh
+# doesn't source. Order matters: earlier entries win when a bin exists in
+# more than one of them.
+_BIN_CANDIDATE_DIRS = (
+ "~/.local/bin",
+ "~/bin",
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/usr/bin",
+ "~/.npm-global/bin",
+ "~/.volta/bin",
+)
+
+
+def detect_installed_bin(bin_name: str) -> Optional[str]:
+ """Return the absolute path of a locally installed CLI, or None if not found.
+
+ Deliberately does not resolve symlinks to their final target (e.g. a
+ version manager's ``.local/bin/claude`` -> ``.local/share/claude/versions/x.y.z``):
+ keeping the symlink path lets in-place CLI upgrades keep working without
+ editing the env file again.
+ """
+ found = shutil.which(bin_name)
+ if found:
+ return str(Path(found).absolute())
+ for candidate_dir in _BIN_CANDIDATE_DIRS:
+ candidate = Path(candidate_dir).expanduser() / bin_name
+ if candidate.is_file() and os.access(candidate, os.X_OK):
+ return str(candidate.absolute())
+ return None
+
+
+# Maps each provider's env var name to the CLI executable it looks for.
+_PROVIDER_BIN_ENV_VARS = (
+ ("CODEX_BIN", "codex"),
+ ("COPILOT_BIN", "copilot"),
+ ("CLAUDE_BIN", "claude"),
+)
+
+
+def _apply_detected_provider_bins(template_text: str) -> str:
+ text = template_text
+ for env_var, bin_name in _PROVIDER_BIN_ENV_VARS:
+ detected_path = detect_installed_bin(bin_name)
+ if not detected_path:
+ continue
+ pattern = rf"(?m)^{env_var}=.*$"
+ if re.search(pattern, text):
+ text = re.sub(pattern, f"{env_var}={detected_path}", text, count=1)
+ return text
+
+
def create_initial_env_file(env_path: Path, template_path: Optional[Path] = None) -> str:
if template_path is None:
template_text = importlib.resources.files("coding_agent_telegram").joinpath("resources/.env.example").read_text(
@@ -137,7 +197,9 @@ def create_initial_env_file(env_path: Path, template_path: Optional[Path] = None
template_text = template_path.read_text(encoding="utf-8")
app_locale = detect_system_locale()
env_path.parent.mkdir(parents=True, exist_ok=True)
- env_path.write_text(_apply_initial_app_locale(template_text, app_locale), encoding="utf-8")
+ template_text = _apply_initial_app_locale(template_text, app_locale)
+ template_text = _apply_detected_provider_bins(template_text)
+ env_path.write_text(template_text, encoding="utf-8")
return app_locale
@@ -199,8 +261,8 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig:
raise ValueError("Missing required config: TELEGRAM_BOT_TOKENS")
if not allowed_ids:
raise ValueError("Missing required config: ALLOWED_CHAT_IDS")
- if provider not in {"codex", "copilot"}:
- raise ValueError("DEFAULT_AGENT_PROVIDER must be either codex or copilot")
+ if provider not in SUPPORTED_PROVIDERS:
+ raise ValueError(f"DEFAULT_AGENT_PROVIDER must be one of: {', '.join(SUPPORTED_PROVIDERS)}")
workspace_root = Path(workspace_root_raw).expanduser().resolve()
app_internal_root = resolve_app_internal_root(workspace_root)
@@ -214,8 +276,10 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig:
allowed_chat_ids=allowed_ids,
codex_bin=os.getenv("CODEX_BIN", "codex"),
copilot_bin=os.getenv("COPILOT_BIN", "copilot"),
+ claude_bin=os.getenv("CLAUDE_BIN", "claude"),
codex_model=os.getenv("CODEX_MODEL", "").strip(),
copilot_model=os.getenv("COPILOT_MODEL", "").strip(),
+ claude_model=os.getenv("CLAUDE_MODEL", "").strip(),
copilot_autopilot=_parse_bool(os.getenv("COPILOT_AUTOPILOT", "true"), default=True),
copilot_no_ask_user=_parse_bool(os.getenv("COPILOT_NO_ASK_USER", "true"), default=True),
copilot_allow_all=_parse_bool(os.getenv("COPILOT_ALLOW_ALL", "true"), default=True),
@@ -223,6 +287,9 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig:
copilot_allow_tools=tuple(_parse_csv_env("COPILOT_ALLOW_TOOLS")),
copilot_deny_tools=tuple(_parse_csv_env("COPILOT_DENY_TOOLS")),
copilot_available_tools=tuple(_parse_csv_env("COPILOT_AVAILABLE_TOOLS")),
+ claude_permission_mode=os.getenv("CLAUDE_PERMISSION_MODE", "bypassPermissions").strip(),
+ claude_allowed_tools=tuple(_parse_csv_env("CLAUDE_ALLOWED_TOOLS")),
+ claude_disallowed_tools=tuple(_parse_csv_env("CLAUDE_DISALLOWED_TOOLS")),
codex_approval_policy=os.getenv("CODEX_APPROVAL_POLICY", "never"),
codex_sandbox_mode=os.getenv("CODEX_SANDBOX_MODE", "workspace-write"),
codex_skip_git_repo_check=_parse_bool(os.getenv("CODEX_SKIP_GIT_REPO_CHECK", "false")),
diff --git a/src/coding_agent_telegram/native_claude_sessions.py b/src/coding_agent_telegram/native_claude_sessions.py
new file mode 100644
index 0000000..220c509
--- /dev/null
+++ b/src/coding_agent_telegram/native_claude_sessions.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from coding_agent_telegram.native_session_types import NativeSessionRecord
+from coding_agent_telegram.native_session_utils import normalize_init_text, path_matches_project
+
+
+def claude_projects_root() -> Path:
+ config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
+ base = Path(config_dir).expanduser() if config_dir else Path.home() / ".claude"
+ return base / "projects"
+
+
+def _first_text_from_content(content: object) -> str:
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
+ return block["text"]
+ return ""
+
+
+def _read_claude_session_file(jsonl_path: Path, project_path: Path, project_folder: str) -> NativeSessionRecord | None:
+ session_id = jsonl_path.stem
+ branch_name = ""
+ created_at = ""
+ updated_at = ""
+ first_user_message = ""
+ matched = False
+
+ try:
+ with jsonl_path.open(encoding="utf-8") as handle:
+ for raw_line in handle:
+ line = raw_line.strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(entry, dict):
+ continue
+
+ entry_cwd = entry.get("cwd")
+ if isinstance(entry_cwd, str) and entry_cwd and not matched:
+ if not path_matches_project(entry_cwd, project_path):
+ return None
+ matched = True
+
+ entry_branch = entry.get("gitBranch")
+ if isinstance(entry_branch, str) and entry_branch:
+ branch_name = entry_branch
+
+ timestamp = entry.get("timestamp")
+ if isinstance(timestamp, str) and timestamp:
+ if not created_at:
+ created_at = timestamp
+ updated_at = timestamp
+
+ if not first_user_message and entry.get("type") == "user" and not entry.get("isMeta"):
+ message = entry.get("message")
+ if isinstance(message, dict):
+ text = _first_text_from_content(message.get("content")).strip()
+ if text:
+ first_user_message = text
+ except OSError:
+ return None
+
+ if not matched:
+ return None
+
+ init_text = first_user_message or session_id
+ return NativeSessionRecord(
+ session_id=session_id,
+ name=normalize_init_text(init_text, fallback=f"Claude session in {project_folder}"),
+ project_folder=project_folder,
+ provider="claude",
+ branch_name=branch_name,
+ created_at=created_at,
+ updated_at=updated_at,
+ source_label="native claude",
+ initialized_from=normalize_init_text(init_text, fallback="Native Claude session"),
+ )
+
+
+def discover_claude_sessions(project_path: Path, project_folder: str) -> list[NativeSessionRecord]:
+ projects_root = claude_projects_root()
+ if not projects_root.exists():
+ return []
+
+ try:
+ project_dirs = [entry for entry in projects_root.iterdir() if entry.is_dir()]
+ except OSError:
+ return []
+
+ records: list[NativeSessionRecord] = []
+ for project_dir in project_dirs:
+ try:
+ jsonl_paths = sorted(project_dir.glob("*.jsonl"))
+ except OSError:
+ continue
+ for jsonl_path in jsonl_paths:
+ record = _read_claude_session_file(jsonl_path, project_path, project_folder)
+ if record is not None:
+ records.append(record)
+
+ records.sort(key=lambda item: (item.updated_at or item.created_at, item.session_id), reverse=True)
+ return records
diff --git a/src/coding_agent_telegram/native_sessions.py b/src/coding_agent_telegram/native_sessions.py
index 3413a9c..a4776f4 100644
--- a/src/coding_agent_telegram/native_sessions.py
+++ b/src/coding_agent_telegram/native_sessions.py
@@ -2,6 +2,7 @@
from pathlib import Path
+from coding_agent_telegram.native_claude_sessions import discover_claude_sessions
from coding_agent_telegram.native_codex_sessions import discover_codex_sessions
from coding_agent_telegram.native_copilot_sessions import discover_copilot_sessions
from coding_agent_telegram.native_session_types import NativeSessionRecord
@@ -20,4 +21,7 @@ def discover_native_project_sessions(
if provider in (None, "", "copilot"):
for record in discover_copilot_sessions(project_path, project_folder):
sessions.setdefault(record.session_id, record)
+ if provider in (None, "", "claude"):
+ for record in discover_claude_sessions(project_path, project_folder):
+ sessions.setdefault(record.session_id, record)
return sessions
diff --git a/src/coding_agent_telegram/providers.py b/src/coding_agent_telegram/providers.py
new file mode 100644
index 0000000..6f8a20b
--- /dev/null
+++ b/src/coding_agent_telegram/providers.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+"""Shared provider identifiers and display labels used across config, router, and runtime code."""
+
+SUPPORTED_PROVIDERS: tuple[str, ...] = ("codex", "copilot", "claude")
+
+_PROVIDER_LABELS: dict[str, str] = {
+ "codex": "Codex",
+ "copilot": "Copilot",
+ "claude": "Claude",
+}
+
+
+def provider_label(provider: str) -> str:
+ """Return a human-friendly display label for a provider identifier."""
+ return _PROVIDER_LABELS.get(provider, provider.capitalize() if provider else "")
diff --git a/src/coding_agent_telegram/resources/.env.example b/src/coding_agent_telegram/resources/.env.example
index 582d866..408409c 100644
--- a/src/coding_agent_telegram/resources/.env.example
+++ b/src/coding_agent_telegram/resources/.env.example
@@ -19,18 +19,24 @@ TELEGRAM_BOT_TOKENS=
# Comma-separated allowed private chat IDs.
ALLOWED_CHAT_IDS=
-# Codex CLI executable name or path.
+# Codex CLI executable name or path, Use `which codex` to view the path
CODEX_BIN=codex
-# Copilot CLI executable name or path.
+# Copilot CLI executable name or path. Use `which copilot` to view the path
COPILOT_BIN=copilot
+# Claude Code CLI executable name or path. Use `which claude` to view the path
+CLAUDE_BIN=claude
+
# Optional Codex model override.
CODEX_MODEL=
# Optional Copilot model override.
COPILOT_MODEL=
+# Optional Claude Code model override (e.g. sonnet, opus, haiku).
+CLAUDE_MODEL=
+
# Whether Copilot should run in autopilot mode.
COPILOT_AUTOPILOT=true
@@ -52,6 +58,17 @@ COPILOT_DENY_TOOLS=
# Comma-separated list of all Copilot tools exposed to the agent.
COPILOT_AVAILABLE_TOOLS=
+# Claude Code permission mode. Controls how much Claude can do without prompting.
+# One of: default, acceptEdits, plan, auto, dontAsk, bypassPermissions, manual.
+# Default: bypassPermissions (fully autonomous, no interactive terminal to approve anything).
+CLAUDE_PERMISSION_MODE=bypassPermissions
+
+# Comma-separated Claude Code tool allowlist (permission rule syntax, e.g. Read,Edit,Bash(git *)).
+CLAUDE_ALLOWED_TOOLS=
+
+# Comma-separated Claude Code tool denylist.
+CLAUDE_DISALLOWED_TOOLS=
+
# Codex approval policy.
CODEX_APPROVAL_POLICY=never
@@ -106,7 +123,7 @@ OPENAI_WHISPER_MODEL=base
# Timeout for a single Whisper transcription call, in seconds.
OPENAI_WHISPER_TIMEOUT_SECONDS=120
-# Default agent provider for new sessions: codex or copilot.
+# Default agent provider for new sessions: codex, copilot, or claude.
DEFAULT_AGENT_PROVIDER=codex
# Hard time limit (seconds) for a single agent run.
diff --git a/src/coding_agent_telegram/resources/locales/en.json b/src/coding_agent_telegram/resources/locales/en.json
index 4d0678f..8dfd74e 100644
--- a/src/coding_agent_telegram/resources/locales/en.json
+++ b/src/coding_agent_telegram/resources/locales/en.json
@@ -36,7 +36,7 @@
"git.push_in_progress": "Pushing branch `{branch_name}` to `origin`...",
"git.usage_pull": "Usage: /pull",
"git.usage_push": "Usage: /push",
- "message.photo_only_codex": "Photo attachments are currently supported only for codex sessions.",
+ "message.photo_only_codex": "Photo attachments are currently supported only for Codex and Claude sessions.",
"message.question_queued": "Question queued as Q{question_number}. It will run after the current agent task finishes.",
"message.voice_speech_to_text_disabled": "Voice messages are not enabled.\nSet ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true and install the local Whisper prerequisites first.",
"message.unsupported_message_type": "Unsupported message type.\nThis bot currently accepts text messages, photos, voice messages, and audio files.",
diff --git a/src/coding_agent_telegram/router/git_commands.py b/src/coding_agent_telegram/router/git_commands.py
index 5009102..9fa626b 100644
--- a/src/coding_agent_telegram/router/git_commands.py
+++ b/src/coding_agent_telegram/router/git_commands.py
@@ -21,7 +21,10 @@ class GitCommandMixin:
'Only include files you intentionally modified for this task. '
'Do not include unrelated changed files. '
'Do not include untracked files unless they were created for this task and are clearly required. '
- 'Output only a single executable command in this format with \\ if there is line break: git add && git commit -m "".'
+ 'Write the message as plain text: use one -m "" flag per summary or bullet line (git joins multiple -m values into separate paragraphs automatically) instead of embedding literal newlines inside a single -m value. '
+ 'Do not use $(...), backticks, heredocs (< && git commit -m "" -m "" -m "" ... '
+ 'If the file list is long, you may wrap it across lines with a trailing \\ for readability, but keep every -m value on its own single line.'
)
@staticmethod
diff --git a/src/coding_agent_telegram/router/message_commands.py b/src/coding_agent_telegram/router/message_commands.py
index a4a6769..923dfc8 100644
--- a/src/coding_agent_telegram/router/message_commands.py
+++ b/src/coding_agent_telegram/router/message_commands.py
@@ -95,7 +95,7 @@ async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE)
if session is None or project_path is None:
return
- if session.get("provider", "codex") != "codex":
+ if session.get("provider", "codex") not in ("codex", "claude"):
await send_text(update, context, self._t(update, "message.photo_only_codex"))
return
diff --git a/src/coding_agent_telegram/router/session_common.py b/src/coding_agent_telegram/router/session_common.py
index 2ee0669..be601ca 100644
--- a/src/coding_agent_telegram/router/session_common.py
+++ b/src/coding_agent_telegram/router/session_common.py
@@ -2,6 +2,8 @@
from datetime import datetime
+from coding_agent_telegram.providers import SUPPORTED_PROVIDERS
+
class SessionCommonMixin:
def _next_available_session_name(self, chat_id: int, base_name: str) -> str:
@@ -21,7 +23,7 @@ def _next_available_session_name(self, chat_id: int, base_name: str) -> str:
def _selected_provider(self, chat_state: dict[str, object]) -> str:
provider = str(chat_state.get("current_provider") or "").strip().lower()
- return provider if provider in {"codex", "copilot"} else ""
+ return provider if provider in SUPPORTED_PROVIDERS else ""
def _active_session_matches_current_context(self, chat_state: dict[str, object]) -> bool:
active_session_id = chat_state.get("active_session_id")
diff --git a/src/coding_agent_telegram/router/session_provider_commands.py b/src/coding_agent_telegram/router/session_provider_commands.py
index e7a22e4..fded396 100644
--- a/src/coding_agent_telegram/router/session_provider_commands.py
+++ b/src/coding_agent_telegram/router/session_provider_commands.py
@@ -6,6 +6,7 @@
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes
+from coding_agent_telegram.providers import SUPPORTED_PROVIDERS, provider_label
from coding_agent_telegram.telegram_sender import send_text
from .base import require_allowed_chat
@@ -16,7 +17,11 @@ class SessionProviderCommandMixin:
PROVIDER_BIN_MISSING_CACHE_TTL_SECONDS = 5 * 60
def _provider_bin(self, provider: str) -> str:
- return self.deps.cfg.codex_bin if provider == "codex" else self.deps.cfg.copilot_bin
+ if provider == "codex":
+ return self.deps.cfg.codex_bin
+ if provider == "claude":
+ return self.deps.cfg.claude_bin
+ return self.deps.cfg.copilot_bin
def _provider_available(self, provider: str) -> bool:
bin_name = self._provider_bin(provider)
@@ -46,34 +51,33 @@ async def _ensure_provider_available(
) -> bool:
if self._provider_available(provider):
return True
- provider_label = "Codex" if provider == "codex" else "Copilot"
await send_text(
update,
context,
- self._t(update, "provider.cli_not_found", provider_label=provider_label, bin_name=self._provider_bin(provider)),
+ self._t(
+ update,
+ "provider.cli_not_found",
+ provider_label=provider_label(provider),
+ bin_name=self._provider_bin(provider),
+ ),
)
return False
def _build_provider_keyboard(self, current_provider: str) -> InlineKeyboardMarkup:
def button_label(provider: str) -> str:
- provider_label = "Codex" if provider == "codex" else "Copilot"
status = self._t(None, "provider.status_available") if self._provider_available(provider) else self._t(None, "provider.status_missing")
marker = self._t(None, "provider.status_current") if provider == current_provider else status
- return f"{provider_label} ({marker})"
+ return f"{provider_label(provider)} ({marker})"
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
- button_label("codex"),
- callback_data="provider:set:codex",
+ button_label(provider),
+ callback_data=f"provider:set:{provider}",
api_kwargs={"style": "success"},
- ),
- InlineKeyboardButton(
- button_label("copilot"),
- callback_data="provider:set:copilot",
- api_kwargs={"style": "success"},
- ),
+ )
+ for provider in SUPPORTED_PROVIDERS
]
]
)
@@ -110,15 +114,19 @@ async def handle_provider_callback(self, update: Update, context: ContextTypes.D
if await self._notify_if_current_project_busy(update, context):
return
_, _, provider = query.data.partition("provider:set:")
- if provider not in {"codex", "copilot"}:
+ if provider not in SUPPORTED_PROVIDERS:
return
chat_id = update.effective_chat.id
previous_provider = self._selected_provider(self.deps.store.get_chat_state(self.deps.bot_id, chat_id))
if not self._provider_available(provider):
- provider_label = "Codex" if provider == "codex" else "Copilot"
await query.edit_message_text(
- self._t(update, "provider.cli_not_found_install_first", provider_label=provider_label, bin_name=self._provider_bin(provider))
+ self._t(
+ update,
+ "provider.cli_not_found_install_first",
+ provider_label=provider_label(provider),
+ bin_name=self._provider_bin(provider),
+ )
)
return
diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py
index 4dcf2f7..cd3fe7e 100644
--- a/src/coding_agent_telegram/session_runtime.py
+++ b/src/coding_agent_telegram/session_runtime.py
@@ -29,6 +29,7 @@
from coding_agent_telegram.filters import is_sensitive_path, resolve_project_path
from coding_agent_telegram.git_utils import GitWorkspaceManager
from coding_agent_telegram.i18n import locale_from_update, translate
+from coding_agent_telegram.providers import provider_label as provider_display_label
from coding_agent_telegram.session_store import SessionStore
from coding_agent_telegram.telegram_sender import (
markdownish_to_html,
@@ -699,7 +700,7 @@ async def _send_assistant_chunks(
)
continue
- provider_label = "Copilot" if provider == "copilot" else "Codex"
+ provider_label = provider_display_label(provider) or "Codex"
title_prefix = (
self._t(update, "runtime.provider_output_single", provider=provider_label)
if total == 1
diff --git a/src/coding_agent_telegram/session_store.py b/src/coding_agent_telegram/session_store.py
index 58fd867..e32ba9b 100644
--- a/src/coding_agent_telegram/session_store.py
+++ b/src/coding_agent_telegram/session_store.py
@@ -206,6 +206,28 @@ def mutate(chat_data: dict[str, Any]) -> None:
self._mutate_chat_data(bot_id, chat_id, mutate, create=True)
+ def clear_all_pending_actions(self) -> int:
+ """Drop any persisted `pending_action` left over from a previous process.
+
+ `pending_action` marks a message as "currently being run through an agent
+ subprocess", which only ever makes sense within the process that started
+ it — in-memory locks and running-process handles never survive a restart.
+ If the process is killed or crashes mid-run, the flag stays set in the
+ state file forever, and every future incoming message gets silently
+ queued (never drained) because `_should_queue_incoming_message` treats a
+ stale pending_action the same as a real in-flight run. Call this once at
+ startup so a crash never permanently wedges a chat.
+ """
+
+ def mutate(state: dict[str, Any]) -> int:
+ cleared = 0
+ for chat_data in state.get("chats", {}).values():
+ if isinstance(chat_data, dict) and chat_data.pop("pending_action", None) is not None:
+ cleared += 1
+ return cleared
+
+ return self._mutate_state(mutate)
+
def create_session(
self,
bot_id: str,
diff --git a/startup.sh b/startup.sh
index 9b61be4..9775095 100755
--- a/startup.sh
+++ b/startup.sh
@@ -176,13 +176,14 @@ fi
DEFAULT_AGENT_PROVIDER="${DEFAULT_AGENT_PROVIDER:-codex}"
CODEX_BIN="${CODEX_BIN:-codex}"
COPILOT_BIN="${COPILOT_BIN:-copilot}"
+CLAUDE_BIN="${CLAUDE_BIN:-claude}"
case "$DEFAULT_AGENT_PROVIDER" in
codex)
if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then
echo "Error: Codex CLI not found: $CODEX_BIN" >&2
echo "Check DEFAULT_AGENT_PROVIDER and CODEX_BIN in $ENV_FILE." >&2
- echo "If this machine only has Copilot, set DEFAULT_AGENT_PROVIDER=copilot." >&2
+ echo "If this machine only has Copilot or Claude Code, set DEFAULT_AGENT_PROVIDER=copilot or claude." >&2
exit 1
fi
;;
@@ -190,12 +191,20 @@ case "$DEFAULT_AGENT_PROVIDER" in
if ! command -v "$COPILOT_BIN" >/dev/null 2>&1; then
echo "Error: Copilot CLI not found: $COPILOT_BIN" >&2
echo "Check DEFAULT_AGENT_PROVIDER and COPILOT_BIN in $ENV_FILE." >&2
- echo "If this machine only has Codex, set DEFAULT_AGENT_PROVIDER=codex." >&2
+ echo "If this machine only has Codex or Claude Code, set DEFAULT_AGENT_PROVIDER=codex or claude." >&2
+ exit 1
+ fi
+ ;;
+ claude)
+ if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
+ echo "Error: Claude Code CLI not found: $CLAUDE_BIN" >&2
+ echo "Check DEFAULT_AGENT_PROVIDER and CLAUDE_BIN in $ENV_FILE." >&2
+ echo "If this machine only has Codex or Copilot, set DEFAULT_AGENT_PROVIDER=codex or copilot." >&2
exit 1
fi
;;
*)
- echo "Error: DEFAULT_AGENT_PROVIDER must be codex or copilot." >&2
+ echo "Error: DEFAULT_AGENT_PROVIDER must be codex, copilot, or claude." >&2
exit 1
;;
esac
diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py
index 5a94a1e..7364d39 100644
--- a/tests/test_agent_runner.py
+++ b/tests/test_agent_runner.py
@@ -515,6 +515,253 @@ def test_copilot_runner_passes_tool_permission_flags(monkeypatch):
assert "--available-tools" not in calls[0][0]
+# ---------------------------------------------------------------------------
+# Claude provider
+# ---------------------------------------------------------------------------
+
+
+def test_claude_runner_uses_print_mode_shape(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout='{"type":"result","subtype":"success","is_error":false,"result":"Done.","session_id":"sess_claude"}\n',
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ result = runner.create_session("claude", Path("/tmp/project"), "hello")
+
+ assert calls[0][0] == [
+ "claude",
+ "--permission-mode",
+ "bypassPermissions",
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "-p",
+ runner.PROMPT_PREFIX + "hello",
+ ]
+ assert calls[0][1] == Path("/tmp/project")
+ assert result.success is True
+ assert result.session_id == "sess_claude"
+ assert result.assistant_text == "Done."
+
+
+def test_claude_runner_resume_uses_resume_flag(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout='{"type":"result","subtype":"success","is_error":false,"result":"Done again.","session_id":"sess_claude"}\n',
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ result = runner.resume_session("claude", "sess_1", Path("/tmp/project"), "hello again")
+
+ assert calls[0][0][:3] == ["claude", "--resume", "sess_1"]
+ assert result.session_id == "sess_claude"
+ assert result.assistant_text == "Done again."
+
+
+def test_claude_runner_passes_model_and_tool_flags_when_configured(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ claude_model="opus",
+ claude_permission_mode="dontAsk",
+ claude_allowed_tools=("Read", "Edit"),
+ claude_disallowed_tools=("Bash(rm *)",),
+ )
+
+ runner.create_session("claude", Path("/tmp/project"), "hello")
+
+ assert calls[0][0] == [
+ "claude",
+ "--model",
+ "opus",
+ "--permission-mode",
+ "dontAsk",
+ "--allowedTools",
+ "Read,Edit",
+ "--disallowedTools",
+ "Bash(rm *)",
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "-p",
+ runner.PROMPT_PREFIX + "hello",
+ ]
+
+
+def test_claude_runner_reports_failure_from_result_event(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout='{"type":"result","subtype":"error_max_turns","is_error":true,"result":"","session_id":"sess_claude"}\n',
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ result = runner.create_session("claude", Path("/tmp/project"), "hello")
+
+ assert result.success is False
+ assert result.error_message == "error_max_turns"
+ assert result.session_id == "sess_claude"
+
+
+def test_claude_runner_extracts_assistant_message_text_as_progress(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout=(
+ '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking at the code now."}]},'
+ '"session_id":"sess_claude"}\n'
+ ),
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+ runner.PROGRESS_UPDATE_INTERVAL_SECONDS = 0
+ captured = []
+
+ runner.create_session("claude", Path("/tmp/project"), "hello", on_progress=captured.append)
+
+ assert captured
+ assert captured[0].text == "Looking at the code now."
+
+
+def test_claude_runner_summarizes_tool_use_as_progress(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout=(
+ '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]},'
+ '"session_id":"sess_claude"}\n'
+ ),
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+ runner.PROGRESS_UPDATE_INTERVAL_SECONDS = 0
+ captured = []
+
+ runner.create_session("claude", Path("/tmp/project"), "hello", on_progress=captured.append)
+
+ assert captured
+ assert captured[0].text == "Using Bash"
+
+
+def test_claude_runner_progress_surfaces_api_retry(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout=(
+ '{"type":"system","subtype":"api_retry","attempt":1,"max_retries":3,"retry_delay_ms":500,'
+ '"error_status":429,"error":"rate_limit","uuid":"u1","session_id":"sess_claude"}\n'
+ ),
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+ runner.PROGRESS_UPDATE_INTERVAL_SECONDS = 0
+ captured = []
+
+ runner.create_session("claude", Path("/tmp/project"), "hello", on_progress=captured.append)
+
+ assert captured
+ assert captured[0].text == "Retrying after rate_limit (attempt 1/3)"
+
+
+def test_claude_runner_rejects_flag_like_session_id(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout='{"type":"result","is_error":false,"result":"ok","session_id":"--malicious"}\n',
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ result = runner.create_session("claude", Path("/tmp/project"), "hello")
+
+ assert result.session_id is None
+
+
+def test_claude_runner_ignores_image_paths_without_error(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ image_path = Path("/tmp/project/.coding-agent-telegram/telegram_attachments/img.jpg")
+ result = runner.create_session("claude", Path("/tmp/project"), "hello", image_paths=(image_path,))
+
+ assert "--image" not in calls[0][0]
+ assert result.success is True
+
+
# ---------------------------------------------------------------------------
# _validate_session_id
# ---------------------------------------------------------------------------
diff --git a/tests/test_bot.py b/tests/test_bot.py
index 6348af3..24e9439 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -184,3 +184,20 @@ class FakeApp:
)
assert len(set_calls) >= 1
+
+
+# ---------------------------------------------------------------------------
+# provider_callback_pattern
+# ---------------------------------------------------------------------------
+
+
+def test_provider_callback_pattern_matches_all_supported_providers():
+ import re
+
+ from coding_agent_telegram.bot import provider_callback_pattern
+
+ pattern = re.compile(provider_callback_pattern())
+ assert pattern.match("provider:set:codex")
+ assert pattern.match("provider:set:copilot")
+ assert pattern.match("provider:set:claude")
+ assert pattern.match("provider:set:gemini") is None
diff --git a/tests/test_command_router.py b/tests/test_command_router.py
index 927d36c..e5576df 100644
--- a/tests/test_command_router.py
+++ b/tests/test_command_router.py
@@ -518,8 +518,10 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig:
allowed_chat_ids={123},
codex_bin=sys.executable,
copilot_bin=sys.executable,
+ claude_bin=sys.executable,
codex_model="",
copilot_model="",
+ claude_model="",
copilot_autopilot=True,
copilot_no_ask_user=True,
copilot_allow_all=True,
@@ -527,6 +529,9 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig:
copilot_allow_tools=(),
copilot_deny_tools=(),
copilot_available_tools=(),
+ claude_permission_mode="bypassPermissions",
+ claude_allowed_tools=(),
+ claude_disallowed_tools=(),
codex_approval_policy="never",
codex_sandbox_mode="workspace-write",
codex_skip_git_repo_check=False,
@@ -1621,10 +1626,13 @@ def test_provider_command_sends_inline_buttons(tmp_path: Path):
buttons = keyboard.inline_keyboard[0]
assert buttons[0].callback_data == "provider:set:codex"
assert buttons[1].callback_data == "provider:set:copilot"
+ assert buttons[2].callback_data == "provider:set:claude"
assert "missing" in buttons[0].text
assert "current" in buttons[1].text
+ assert "missing" in buttons[2].text
assert buttons[0].api_kwargs == {"style": "success"}
assert buttons[1].api_kwargs == {"style": "success"}
+ assert buttons[2].api_kwargs == {"style": "success"}
def test_provider_callback_updates_current_provider(tmp_path: Path):
@@ -1663,6 +1671,71 @@ async def fake_edit(text):
assert store.get_chat_state("bot-a", 123)["current_provider"] == "copilot"
+def test_provider_callback_updates_current_provider_to_claude(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router._provider_available = lambda provider: True
+
+ answers = []
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data="provider:set:claude",
+ answer=None,
+ edit_message_text=None,
+ ),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ answers.append("answered")
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_provider_callback(update, context))
+
+ assert answers == ["answered"]
+ assert edited == ["Current provider set to: claude"]
+ assert store.get_chat_state("bot-a", 123)["current_provider"] == "claude"
+
+
+def test_provider_callback_rejects_unknown_provider(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router._provider_available = lambda provider: True
+
+ answers = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data="provider:set:gemini",
+ answer=None,
+ edit_message_text=None,
+ ),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ answers.append("answered")
+
+ update.callback_query.answer = fake_answer
+
+ asyncio.run(router.handle_provider_callback(update, context))
+
+ assert store.get_chat_state("bot-a", 123).get("current_provider") is None
+
+
def test_provider_callback_continues_pending_new_session(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
@@ -2325,6 +2398,32 @@ def test_photo_message_is_saved_and_forwarded_to_codex(tmp_path: Path):
assert "what is shown here?" in runner.resume_calls[-1]["user_message"]
+def test_photo_message_is_saved_and_forwarded_to_claude(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png"))
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=SimpleNamespace(text=None, photo=[photo], caption="what is shown here?"),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_photo(update, context))
+
+ assert runner.resume_calls
+ image_paths = runner.resume_calls[-1]["image_paths"]
+ assert len(image_paths) == 1
+ assert image_paths[0].is_file()
+
+
def test_photo_message_rejected_for_copilot_session(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
@@ -2345,7 +2444,7 @@ def test_photo_message_rejected_for_copilot_session(tmp_path: Path):
asyncio.run(router.handle_photo(update, context))
assert runner.resume_calls == []
- assert "Photo attachments are currently supported only for codex sessions." in bot.messages[-1][1]
+ assert "Photo attachments are currently supported only for Codex and Claude sessions." in bot.messages[-1][1]
def test_voice_message_sends_transcript_preview_before_running_agent(tmp_path: Path):
diff --git a/tests/test_config.py b/tests/test_config.py
index e513618..ce2e822 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -31,8 +31,10 @@ def _isolate_env(monkeypatch, tmp_path):
"LOG_DIR",
"CODEX_BIN",
"COPILOT_BIN",
+ "CLAUDE_BIN",
"CODEX_MODEL",
"COPILOT_MODEL",
+ "CLAUDE_MODEL",
"COPILOT_AUTOPILOT",
"COPILOT_NO_ASK_USER",
"COPILOT_ALLOW_ALL",
@@ -40,6 +42,9 @@ def _isolate_env(monkeypatch, tmp_path):
"COPILOT_ALLOW_TOOLS",
"COPILOT_DENY_TOOLS",
"COPILOT_AVAILABLE_TOOLS",
+ "CLAUDE_PERMISSION_MODE",
+ "CLAUDE_ALLOWED_TOOLS",
+ "CLAUDE_DISALLOWED_TOOLS",
"CODEX_APPROVAL_POLICY",
"CODEX_SANDBOX_MODE",
"CODEX_SKIP_GIT_REPO_CHECK",
@@ -102,6 +107,7 @@ def test_load_config_required(monkeypatch, tmp_path):
assert cfg.log_dir.name == "logs"
assert cfg.codex_model == ""
assert cfg.copilot_model == ""
+ assert cfg.claude_model == ""
assert cfg.copilot_autopilot is True
assert cfg.copilot_no_ask_user is True
assert cfg.copilot_allow_all is True
@@ -109,6 +115,10 @@ def test_load_config_required(monkeypatch, tmp_path):
assert cfg.copilot_allow_tools == ()
assert cfg.copilot_deny_tools == ()
assert cfg.copilot_available_tools == ()
+ assert cfg.claude_bin == "claude"
+ assert cfg.claude_permission_mode == "bypassPermissions"
+ assert cfg.claude_allowed_tools == ()
+ assert cfg.claude_disallowed_tools == ()
assert cfg.state_file == home / ".coding-agent-telegram" / "state.json"
assert cfg.state_backup_file == home / ".coding-agent-telegram" / "state.json.bak"
@@ -120,6 +130,49 @@ def test_load_config_missing(monkeypatch, tmp_path):
load_config()
+def test_load_config_accepts_claude_as_default_provider(monkeypatch, tmp_path):
+ _isolate_env(monkeypatch, tmp_path)
+ monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+ monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+ monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+ monkeypatch.setenv("DEFAULT_AGENT_PROVIDER", "claude")
+
+ cfg = load_config()
+
+ assert cfg.default_agent_provider == "claude"
+
+
+def test_load_config_rejects_unknown_default_provider(monkeypatch, tmp_path):
+ _isolate_env(monkeypatch, tmp_path)
+ monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+ monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+ monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+ monkeypatch.setenv("DEFAULT_AGENT_PROVIDER", "gemini")
+
+ with pytest.raises(ValueError, match="DEFAULT_AGENT_PROVIDER"):
+ load_config()
+
+
+def test_load_config_claude_overrides(monkeypatch, tmp_path):
+ _isolate_env(monkeypatch, tmp_path)
+ monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+ monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+ monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+ monkeypatch.setenv("CLAUDE_BIN", "/opt/bin/claude")
+ monkeypatch.setenv("CLAUDE_MODEL", "opus")
+ monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "acceptEdits")
+ monkeypatch.setenv("CLAUDE_ALLOWED_TOOLS", "Read,Edit,Bash(git *)")
+ monkeypatch.setenv("CLAUDE_DISALLOWED_TOOLS", "Bash(rm *)")
+
+ cfg = load_config()
+
+ assert cfg.claude_bin == "/opt/bin/claude"
+ assert cfg.claude_model == "opus"
+ assert cfg.claude_permission_mode == "acceptEdits"
+ assert cfg.claude_allowed_tools == ("Read", "Edit", "Bash(git *)")
+ assert cfg.claude_disallowed_tools == ("Bash(rm *)",)
+
+
def test_load_config_commit_command_enabled(monkeypatch, tmp_path):
_isolate_env(monkeypatch, tmp_path)
monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
@@ -424,6 +477,69 @@ def test_create_initial_env_file_initializes_app_locale_from_system_language(tmp
assert "APP_LOCALE=ja" in env_path.read_text(encoding="utf-8")
+def test_create_initial_env_file_fills_in_detected_provider_bin_paths(tmp_path, monkeypatch):
+ env_path = tmp_path / ".env_coding_agent_telegram"
+ template_path = tmp_path / ".env.example"
+ template_path.write_text(
+ "APP_LOCALE=en\nCODEX_BIN=codex\nCOPILOT_BIN=copilot\nCLAUDE_BIN=~/.local/bin/claude\n",
+ encoding="utf-8",
+ )
+
+ detected = {
+ "codex": "/opt/homebrew/bin/codex",
+ "claude": "/usr/local/bin/claude",
+ }
+ monkeypatch.setattr(config_module, "detect_installed_bin", lambda bin_name: detected.get(bin_name))
+
+ create_initial_env_file(env_path, template_path)
+
+ written = env_path.read_text(encoding="utf-8")
+ assert "CODEX_BIN=/opt/homebrew/bin/codex" in written
+ assert "CLAUDE_BIN=/usr/local/bin/claude" in written
+ # copilot wasn't detected, so the template value is left untouched.
+ assert "COPILOT_BIN=copilot" in written
+
+
+def test_detect_installed_bin_prefers_path_then_falls_back_to_candidate_dirs(tmp_path, monkeypatch):
+ from coding_agent_telegram.config import detect_installed_bin
+
+ monkeypatch.setattr(config_module.shutil, "which", lambda name: None)
+ fake_home = tmp_path / "home"
+ local_bin = fake_home / ".local" / "bin"
+ local_bin.mkdir(parents=True)
+ claude_bin = local_bin / "claude"
+ claude_bin.write_text("#!/bin/sh\n", encoding="utf-8")
+ claude_bin.chmod(0o755)
+ monkeypatch.setenv("HOME", str(fake_home))
+
+ assert detect_installed_bin("claude") == str(claude_bin.absolute())
+ assert detect_installed_bin("nonexistent-bin-xyz") is None
+
+
+def test_detect_installed_bin_preserves_symlink_instead_of_resolving_target(tmp_path, monkeypatch):
+ from coding_agent_telegram.config import detect_installed_bin
+
+ # Mirrors real installs where a version manager symlinks a stable path
+ # (e.g. ~/.local/bin/claude) to a versioned target that changes on upgrade.
+ versions_dir = tmp_path / "versions"
+ versions_dir.mkdir()
+ real_bin = versions_dir / "2.1.214"
+ real_bin.write_text("#!/bin/sh\n", encoding="utf-8")
+ real_bin.chmod(0o755)
+
+ path_dir = tmp_path / "path_bin"
+ path_dir.mkdir()
+ symlink_bin = path_dir / "claude"
+ symlink_bin.symlink_to(real_bin)
+
+ monkeypatch.setattr(config_module.shutil, "which", lambda name: str(symlink_bin))
+
+ detected = detect_installed_bin("claude")
+
+ assert detected == str(symlink_bin.absolute())
+ assert detected != str(real_bin)
+
+
# ---------------------------------------------------------------------------
# _parse_allowed_chat_ids: malformed value raises clear ValueError
# ---------------------------------------------------------------------------
diff --git a/tests/test_native_claude_sessions.py b/tests/test_native_claude_sessions.py
new file mode 100644
index 0000000..7fcf157
--- /dev/null
+++ b/tests/test_native_claude_sessions.py
@@ -0,0 +1,250 @@
+"""Tests for native_claude_sessions.py."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+from coding_agent_telegram.native_claude_sessions import claude_projects_root, discover_claude_sessions
+from coding_agent_telegram.native_sessions import discover_native_project_sessions
+
+
+def _write_jsonl(path: Path, entries: list[dict]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("\n".join(json.dumps(entry) for entry in entries) + "\n", encoding="utf-8")
+
+
+def test_claude_projects_root_uses_config_dir_env(tmp_path: Path):
+ custom = tmp_path / "custom-claude-home"
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(custom)}):
+ assert claude_projects_root() == custom / "projects"
+
+
+def test_claude_projects_root_defaults_to_home(tmp_path: Path):
+ env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"}
+ with patch.dict(os.environ, env, clear=True):
+ with patch("coding_agent_telegram.native_claude_sessions.Path.home", return_value=tmp_path):
+ assert claude_projects_root() == tmp_path / ".claude" / "projects"
+
+
+def test_discover_claude_sessions_returns_empty_when_projects_root_missing(tmp_path: Path):
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(tmp_path / "does-not-exist")}):
+ result = discover_claude_sessions(tmp_path / "proj", "proj")
+ assert result == []
+
+
+def test_discover_claude_sessions_skips_non_matching_cwd(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+ other = tmp_path / "other"
+ other.mkdir()
+
+ session_file = claude_home / "projects" / "-tmp-other" / "sess-1.jsonl"
+ _write_jsonl(
+ session_file,
+ [
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "hello"},
+ "cwd": str(other),
+ "gitBranch": "main",
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "sessionId": "sess-1",
+ }
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ result = discover_claude_sessions(proj, "myproj")
+
+ assert result == []
+
+
+def test_discover_claude_sessions_returns_matching_session(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+
+ session_file = claude_home / "projects" / "-tmp-myproj" / "sess-abc.jsonl"
+ _write_jsonl(
+ session_file,
+ [
+ {"type": "mode", "mode": "normal", "sessionId": "sess-abc"},
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "Fix the failing test"},
+ "cwd": str(proj),
+ "gitBranch": "feature-x",
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "sessionId": "sess-abc",
+ },
+ {
+ "type": "assistant",
+ "message": {"role": "assistant", "content": [{"type": "text", "text": "Sure, looking now."}]},
+ "cwd": str(proj),
+ "gitBranch": "feature-x",
+ "timestamp": "2026-01-01T00:05:00.000Z",
+ "sessionId": "sess-abc",
+ },
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ result = discover_claude_sessions(proj, "myproj")
+
+ assert len(result) == 1
+ record = result[0]
+ assert record.session_id == "sess-abc"
+ assert record.project_folder == "myproj"
+ assert record.provider == "claude"
+ assert record.branch_name == "feature-x"
+ assert record.created_at == "2026-01-01T00:00:00.000Z"
+ assert record.updated_at == "2026-01-01T00:05:00.000Z"
+ assert "Fix the failing test" in record.name
+ assert record.source_label == "native claude"
+
+
+def test_discover_claude_sessions_ignores_meta_messages_for_first_user_text(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+
+ session_file = claude_home / "projects" / "-tmp-myproj" / "sess-meta.jsonl"
+ _write_jsonl(
+ session_file,
+ [
+ {
+ "type": "user",
+ "isMeta": True,
+ "message": {"role": "user", "content": "ignored"},
+ "cwd": str(proj),
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ "sessionId": "sess-meta",
+ },
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "Actual user prompt"},
+ "cwd": str(proj),
+ "timestamp": "2026-01-01T00:01:00.000Z",
+ "sessionId": "sess-meta",
+ },
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ result = discover_claude_sessions(proj, "myproj")
+
+ assert len(result) == 1
+ assert "Actual user prompt" in result[0].name
+
+
+def test_discover_claude_sessions_ignores_malformed_lines(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+
+ session_file = claude_home / "projects" / "-tmp-myproj" / "sess-broken.jsonl"
+ session_file.parent.mkdir(parents=True, exist_ok=True)
+ session_file.write_text(
+ "not json at all\n"
+ + json.dumps(
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "still works"},
+ "cwd": str(proj),
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ result = discover_claude_sessions(proj, "myproj")
+
+ assert len(result) == 1
+ assert result[0].session_id == "sess-broken"
+
+
+def test_discover_claude_sessions_sorts_newest_first(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+
+ _write_jsonl(
+ claude_home / "projects" / "-tmp-myproj" / "sess-old.jsonl",
+ [
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "old"},
+ "cwd": str(proj),
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ }
+ ],
+ )
+ _write_jsonl(
+ claude_home / "projects" / "-tmp-myproj" / "sess-new.jsonl",
+ [
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "new"},
+ "cwd": str(proj),
+ "timestamp": "2026-06-01T00:00:00.000Z",
+ }
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ result = discover_claude_sessions(proj, "myproj")
+
+ assert [record.session_id for record in result] == ["sess-new", "sess-old"]
+
+
+def test_discover_native_project_sessions_includes_claude_records(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+
+ _write_jsonl(
+ claude_home / "projects" / "-tmp-myproj" / "sess-claude.jsonl",
+ [
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "hello"},
+ "cwd": str(proj),
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ }
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home), "COPILOT_HOME": str(tmp_path / "no-copilot")}):
+ with patch("coding_agent_telegram.native_codex_sessions.Path.home", return_value=tmp_path / "no-codex-home"):
+ sessions = discover_native_project_sessions(proj, "myproj")
+
+ assert "sess-claude" in sessions
+ assert sessions["sess-claude"].provider == "claude"
+
+
+def test_discover_native_project_sessions_can_filter_to_claude_only(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ proj = tmp_path / "myproj"
+ proj.mkdir()
+
+ _write_jsonl(
+ claude_home / "projects" / "-tmp-myproj" / "sess-claude.jsonl",
+ [
+ {
+ "type": "user",
+ "message": {"role": "user", "content": "hello"},
+ "cwd": str(proj),
+ "timestamp": "2026-01-01T00:00:00.000Z",
+ }
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ sessions = discover_native_project_sessions(proj, "myproj", provider="claude")
+
+ assert set(sessions.keys()) == {"sess-claude"}
diff --git a/tests/test_session_store.py b/tests/test_session_store.py
index bb33d92..9f26d9b 100644
--- a/tests/test_session_store.py
+++ b/tests/test_session_store.py
@@ -51,6 +51,32 @@ def test_set_pending_action_persists_and_clears(tmp_path: Path):
assert "pending_action" not in store.get_chat_state("bot-a", 123)
+def test_clear_all_pending_actions_removes_stale_entries_across_chats(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ store.set_pending_action("bot-a", 123, {"kind": "message", "user_message": "stuck"})
+ store.set_pending_action("bot-a", 456, {"kind": "message", "user_message": "also stuck"})
+ store.set_current_provider("bot-a", 789, "codex") # chat with no pending_action
+
+ cleared = store.clear_all_pending_actions()
+
+ assert cleared == 2
+ assert "pending_action" not in store.get_chat_state("bot-a", 123)
+ assert "pending_action" not in store.get_chat_state("bot-a", 456)
+
+
+def test_clear_all_pending_actions_returns_zero_when_none_present(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ store.set_current_provider("bot-a", 123, "codex")
+
+ assert store.clear_all_pending_actions() == 0
+
+
def test_load_empty_state_file_returns_default_state(tmp_path: Path):
state = tmp_path / "state.json"
backup = tmp_path / "state.json.bak"
diff --git a/tests/test_speech_to_text.py b/tests/test_speech_to_text.py
index e405d82..8490146 100644
--- a/tests/test_speech_to_text.py
+++ b/tests/test_speech_to_text.py
@@ -19,8 +19,10 @@ def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfi
allowed_chat_ids={123},
codex_bin="codex",
copilot_bin="copilot",
+ claude_bin="claude",
codex_model="",
copilot_model="",
+ claude_model="",
copilot_autopilot=True,
copilot_no_ask_user=True,
copilot_allow_all=True,
@@ -28,6 +30,9 @@ def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfi
copilot_allow_tools=(),
copilot_deny_tools=(),
copilot_available_tools=(),
+ claude_permission_mode="bypassPermissions",
+ claude_allowed_tools=(),
+ claude_disallowed_tools=(),
codex_approval_policy="never",
codex_sandbox_mode="workspace-write",
codex_skip_git_repo_check=False,