Claude Code CLI runtime cho chủ dự án
Tổng quan
Chủ dự án có một gói đăng ký (subscription) Claude và muốn các cuộc hội thoại của riêng mình chạy trên đó, trong khi gia đình vẫn dùng API key. Một subscription không thể nằm sau provider interface của hango: theo thông tin hiện có, Anthropic giới hạn các OAuth token của subscription chỉ cho Claude Code và Claude.ai, và để Messages API chấp nhận một token như vậy thì phải ngụy trang request thành Claude Code. Giai đoạn này làm như provider claude_cli của goclaw: chạy chính Claude Code CLI thật (claude -p) như một tiến trình con, đã đăng nhập bằng tài khoản chủ dự án trên máy host, và đưa các tool của hango cho nó qua một MCP server nhỏ trên loopback.
Chỉ chủ dự án được dùng. Một hango user id được cấu hình duy nhất có thể sở hữu các agent có provider = "claude_cli". Điều này được kiểm tra khi lưu agent, khi tạo run và một lần nữa khi run được claim. Thành viên gia đình không bao giờ chạm tới subscription, dù qua API hay qua Telegram.
Bài học là bạn phải từ bỏ những gì. Claude Code chạy vòng lặp riêng của nó, tự dựng prompt và tự quyết định compaction. hango vẫn sở hữu session, vòng đời của run, streaming, việc thực thi tool, audit và trace. Nhưng nó chỉ thấy đầu ra của model và các tool call, không thấy prompt đã được gửi. Learning doc so sánh cùng một tác vụ chạy theo cả hai cách.
Yêu cầu
Chức năng
- Cấu hình:
HANGO_CLAUDE_CLI_ENABLED(mặc định false),HANGO_CLAUDE_CLI_OWNER_USER_ID(bắt buộc khi bật),HANGO_CLAUDE_CLI_PATH(mặc địnhclaude),HANGO_CLAUDE_CLI_WORKDIR(thư mục cha cho các thư mục làm việc theo từng session),HANGO_CLAUDE_CLI_BRIDGE_ADDR(mặc định127.0.0.1:0). Lúc khởi động, hango chạyclaude --versionvà thất bại với thông báo rõ ràng nếu thiếu binary. - Kiểm tra agent:
provider = "claude_cli"chỉ được chấp nhận khi tính năng được bật,created_bycủa agent là chủ dự án, và request đến từ chủ dự án. Bất kỳ ai khác nhận 422provider_not_allowed.modelđược chuyển thẳng vào--model(ví dụsonnethoặcopus). - Tạo run cho một agent
claude_clibởi bất kỳ ai ngoài chủ dự án trả về 404, khớp với quy tắc visibility. Lúc claim, worker kiểm tra lại rằng người tạo run là chủ dự án và nếu không thì fail run vớiruntime_forbidden, để một thay đổi config về sau không thể để một run cũ đang xếp hàng chạy trên subscription. - Các run
claude_cliđi vào một lane scheduler riêngclaude_clivới mức đồng thờiHANGO_CLAUDE_CLI_MAX_CONCURRENT(mặc định 1), để một loạt tin nhắn dồn dập của chủ dự án không thể khởi động nhiều tiến trình CLI trên cùng một subscription. - Thực thi: worker giao một run
claude_clicho CLI executor thay vìagent.Run. Executor khởi động một tiến trình cho mỗi run:claude -p --output-format stream-json --verbose --include-partial-messages --model <model>--session-id <hango session id>ở lần chạy CLI đầu tiên của session, các lần sau là--resume <hango session id>. UUID session của hango chính là session id của Claude, nên không cần bảng ánh xạ.- Không có tool dựng sẵn nào của Claude Code (
--toolsvới tập rỗng).--strict-mcp-configvới một--mcp-configđược sinh ra chỉ trỏ vào bridge của hango, và--allowedToolschỉ nêu tên các tool của bridge. --setting-sourcesđược đặt để các setting, hook và file CLAUDE.md cá nhân của chủ dự án trong Claude Code không rò vào các run của hango.--append-system-promptvớisystem_promptcủa agent.- Input của người dùng được ghi vào stdin, không bao giờ đặt trong argv, nên không hiện trong
ps. - Thư mục làm việc là một thư mục rỗng theo từng session dưới
HANGO_CLAUDE_CLI_WORKDIR. - Môi trường là mức tối thiểu CLI cần (
HOME,PATH, locale).ANTHROPIC_API_KEYvà mọi biếnHANGO_*bị loại bỏ, để CLI không thể âm thầm fallback sang tính phí API hay đọc các bí mật của hango. - Kiểm chứng từng flag với
claude --helpcủa phiên bản đã cài. Integration test ghim phiên bản mà nó đã chạy.
- MCP bridge: một HTTP listener trên loopback phục vụ
POST /mcpvới bearer token riêng cho từng run (32 byte ngẫu nhiên, chỉ hợp lệ khi run đang thực thi). Nó cài đặt tập con của MCP mà CLI dùng: JSON-RPC 2.0 qua Streamable HTTP với response dạng JSON (initialize,notifications/initialized,ping,tools/list,tools/call).tools/listtrả các tool của agent được resolve giống cách vòng lặp resolve. Mọitools/callchạy qua chuỗiWrapToolCallcủa Phase 7 trong scope tenant của run, nên audit, offload, idempotency và telemetry áp dụng không đổi. Tooltaskcủa subagent không được mở ra trong giai đoạn này. - Ánh xạ stream: các text delta của
stream_eventtrở thành event text-delta của run trong hango. Mỗi messageassistanthoàn chỉnh được lưu thành một message row của hango. Mỗi kết quả tool mà bridge tạo ra được lưu thành một message row của tool. Eventresultđặt trạng thái kết thúc,stop_reason,iterations(từnum_turns) vàusagecủa run. - Giới hạn:
HANGO_MAX_ITERATIONSđược thực thi bằng cách đếm các message assistant, và tiến trình bị dừng vớistop_reason = max_iterationskhi vượt ngưỡng.HANGO_RUN_DEADLINEáp dụng như với mọi run. - Cancel và drain: hủy sẽ gửi SIGTERM tới process group, rồi SIGKILL sau 5 s. Một run được requeue (do drain, hoặc reclaim sau crash) resume session CLI bằng một continuation prompt cố định được đánh dấu
[hango: resumed after interruption]. Các tool call của bridge giữ idempotency của Phase 5, nên một tool call lặp lại trả về kết quả đã lưu. - Một lần resume thất bại vì CLI không có session đó (ví dụ sau khi chuyển host) làm fail run với
cli_session_missing. Telegram hiển thị nó là “Start a new conversation with /new.” - Usage: một row
usage_recordscho mỗi message assistant với providerclaude_cli, token do CLI báo cáo và chi phí bằng không, vì subscription không tính phí theo token.total_cost_usdcủa CLI được giữ trong JSONusagecủa run dưới tênapi_equivalent_cost_usd, chỉ để so sánh. Các tổng hợp quota tenant của Phase 13 loại trừ providerclaude_cli, nên việc chủ dự án dùng subscription không tiêu hao quota API của gia đình.
Phi chức năng
- Một package mới,
internal/claudecli, chứa executor, bộ phân tích stream và MCP bridge. Chỉ dùng thư viện chuẩn:os/exec,net/http,encoding/json. Không dùng MCP SDK, vì tập con này nhỏ và việc đọc JSON-RPC là một phần bài học. internal/runkhông importinternal/claudecli. Composition root inject executor vào worker dưới dạng một function value, giữ quy tắc phụ thuộc một chiều trongplan.md.- Bridge chỉ bind trên loopback và từ chối request không có token của run hiện tại. Token được so sánh trong thời gian hằng số (constant-time) và không bao giờ bị log.
- Deploy: hango phải chạy trên host nơi
claudeđã được cài và đăng nhập bằng tài khoản chủ dự án. Một container image có CLI và thư mục~/.claudeđược mount là việc làm tiếp theo. - Điều khoản: tính năng này chỉ dành cho việc dùng của chính người đăng ký. Nếu chính sách của Anthropic thay đổi, chuyển các agent của chủ dự án sang
provider = "anthropic"chỉ là đổi một field, và cờ tính năng tắt giai đoạn này mà không cần đổi code.
Kiến trúc
// internal/claudecli/executor.go
type Executor struct {
cfg Config
bridge *Bridge
tools ToolResolver // same resolution as the loop: registry + ToolProvider middleware
msgs store.MessageStore
usage store.UsageStore
}
// Execute matches the function type the run worker accepts for non-loop runtimes.
func (e *Executor) Execute(ctx context.Context, rc run.ExecContext) error
// internal/claudecli/bridge.go
type Bridge struct {
mu sync.Mutex
runs map[string]*bridgeRun // token -> run scope, tools, span context, sink
}
func (b *Bridge) Register(rc run.ExecContext, tools []tool.Tool) (url, token string, release func())worker claims run (lane claude_cli) ──▶ owner check ──▶ Executor.Execute
Bridge.Register ──▶ temp mcp-config.json (url + bearer token)
exec claude -p … --session-id|--resume <session uuid> (stdin = user input, env stripped)
stdout stream-json ──▶ parser
stream_event delta ──▶ run event (SSE, Telegram edits)
assistant message ──▶ message row, reconstructed chat span, usage row
result ──▶ terminal state, run usage
CLI ──POST /mcp tools/call──▶ Bridge ──▶ WrapToolCall chain (scope, audit, idempotency) ──▶ tool
release token, remove temp configInstrumentation
- Span
invoke_agentcủa run manghango.agent.runtime = claude_cli. Một span conclaudecli.processghi lại chế độ session (new|resume), phiên bản CLI, PID, exit code và thời lượng. - Mỗi message assistant tạo một span
chatvới model, mức dùng token vàhango.reconstructed = true, tính thời gian từ delta đầu tiên đến lúc message đến. Langfuse hiển thị các generation này chỉ có output, vì hango không bao giờ thấy prompt mà Claude Code đã gửi. Các tool call của bridge tạo ra các spanexecute_toolthật có parent làclaudecli.process, vì bridge tra span context của run theo token. - Metric: gauge
hango.claudecli.processes, counterhango.claudecli.exits(attributeoutcome=success|error|cancelled|killed|session_missing), counterhango.claudecli.bridge_calls(attributetool,status), counterhango.claudecli.unauthorized(request tới bridge với token sai), counterhango.claudecli.stream_parse_errors. - stderr của CLI được thu lại, cắt còn 8 KiB và ghi log ở mức warn khi exit code khác 0, kèm
run_idvàtrace_id.
Các file liên quan
Tạo mới
internal/claudecli/config.go— config, kiểm tra phiên bản lúc khởi động.internal/claudecli/executor.go,internal/claudecli/executor_test.go— dựng argv và môi trường, vòng đời tiến trình, cancel và kill, continuation khi resume. Test dùng một binary CLI giả build từtestdata/fakecli/main.go, phát lại stream-json đã ghi và gọi bridge.internal/claudecli/stream.go,internal/claudecli/stream_test.go— phân tích stream-json thành event của run, message row, span và usage, với các fixture đã ghi trongtestdata/.internal/claudecli/bridge.go,internal/claudecli/bridge_test.go— tập con MCP, registry token, kiểm tra token constant-time, điều phối tool qua chuỗi middleware.internal/claudecli/cli_integration_test.go— được chặn bởiHANGO_TEST_CLAUDE_CLI=1: một run thật gọi một tool của hango, khẳng định span của tool, các message row và một lần thoát sạch.docs/learning/19-claude-cli-runtime.md.
Sửa đổi
plan.md— trần package từ 19 lên 20 cộnginternal/tool/builtin, thêminternal/claudecli; quy tắc phụ thuộc một chiều có thêm ghi chú về việc inject executor.internal/run/worker.go— chọn executor theo provider của agent, kiểm tra lại chủ dự án lúc claim, lỗiruntime_forbidden.internal/run/create.go— laneclaude_clivà kiểm tra chủ dự án lúc tạo.internal/server/handlers_agents.go(hoặc phần kiểm tra của Phase 12 mà nó gọi) —provider_not_allowed.internal/store/pg/usage.go— các tổng hợp quota loại trừ providerclaude_cli.internal/config/config.go,internal/app/app.go— config, bridge listener, nối executor, đăng ký lane, thứ tự drain (bridge đóng sau khi scheduler drain xong).internal/telemetry/metrics_names.go— năm tên metric..env.example,README.md,docs/learning/README.md,docs/system-architecture.md,docs/deployment-guide.md(yêu cầu về host, CLI đã đăng nhập, ghi chú điều khoản).
Các bước triển khai
- Ghi fixture: chạy tay
claude -p --output-format stream-json --verbose --include-partial-messagesvới ba prompt (trả lời thuần, một lời gọi tool MCP, một lỗi) và lưu đầu ra vàotestdata/. Ghi lại phiên bản CLI. - Viết
stream.govà test của nó với các fixture, gồm cả các loại event lạ (được đếm và bỏ qua, không bao giờ gây lỗi chết). - Viết
bridge.govà test của nó: bắt tay initialize,tools/list,tools/callqua một chuỗi giả, token sai trả 401, và token đã release bị từ chối. - Viết CLI giả trong
testdata/fakecliđể phát lại một fixture và gọi bridge, rồi viếtexecutor.govà test của nó: argv không chứa input của người dùng, môi trường không cóANTHROPIC_API_KEYhayHANGO_*,--session-idở lần chạy đầu và--resumesau đó, cancel kill process group, giới hạn iteration dừng tiến trình, và một run được requeue gửi continuation prompt. - Sửa worker, việc tạo run và việc kiểm tra agent cho các kiểm tra chủ dự án và lane, với test cho từng đường từ chối (lưu agent bởi người không phải chủ, tạo run bởi người không phải chủ, một run đang xếp hàng mà người tạo không còn là chủ).
- Loại
claude_clikhỏi các tổng hợp quota, với một test chứng minh mức dùng CLI của chủ dự án không làm thay đổi tổng theo tháng của tenant. - Nối config, loopback listener, executor và thứ tự drain. Thêm span, metric và việc log stderr.
- Chạy
HANGO_TEST_CLAUDE_CLI=1 go test ./internal/claudecli/ -run Integrationtrên máy của chủ dự án, rồi dùng agent của chủ dự án từ Telegram. - Chạy một tác vụ hai lần, một lần trên agent
anthropicvà một lần trên agentclaude_cli. Đặt hai trace Langfuse cạnh nhau trong learning doc và liệt kê mỗi bên cho thấy gì. - Cập nhật docs và viết learning doc.
Dàn ý tài liệu học
docs/learning/19-claude-cli-runtime.md phải có:
- Khái niệm: một model provider trả lời một lời gọi; một agent runtime chạy cả một vòng lặp. Claude Code là một runtime, nên cắm nó vào là thay thế vòng lặp của hango, không chỉ thay model.
- Các lựa chọn thiết kế: tiến trình con CLI thay vì một OAuth token thô; tool qua một MCP bridge trên loopback để việc thực thi tool ở lại trong scope, audit và trace của hango; session id của hango được dùng lại làm session id của Claude; kiểm tra chỉ-chủ-dự án ở ba điểm; một lane riêng với mức đồng thời bằng một.
- Bạn từ bỏ những gì, minh họa bằng hai trace ở bước 9: không thấy prompt, không thấy compaction hay middleware, các span generation được dựng lại, và số token do CLI báo cáo thay vì do API.
- Đánh đổi: chi phí subscription so với khả năng observability; một tập con MCP viết tay so với một SDK; một tiến trình cho mỗi run so với một tiến trình CLI sống lâu.
- So sánh với goclaw:
internal/providers/claude_cli*.go, MCP bridge, hook và deny pattern của nó, và vì sao goclaw mở nó cho mọi agent trong khi hango giới hạn cho một người dùng. - So sánh với OpenClaw: các plugin harness (Codex app-server) như một cách khác để giao vòng lặp cho một runtime bên ngoài.
- Cách debug tầng này: bắt đầu từ
claudecli.process(kết quả exit, chế độ session), đọc stderr đã thu trong Loki theorun_id, xemhango.claudecli.bridge_callstheo status, và nhớ rằng bản thân prompt không có trong Langfuse. - Việc tiếp theo: một container image có CLI, mở tool subagent qua bridge, và các thư mục làm việc theo từng session giữ file qua các run.
- Bài tập: kill tiến trình CLI giữa chừng một run và xem run resume với continuation prompt; gọi bridge bằng một token cũ; chuyển một agent giữa
anthropicvàclaude_clirồi so sánh điểm eval của Phase 17 khi nó đã có. - Ngân sách thời gian: 12h triển khai + 2h viết learning doc.
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...đều pass, và danh sách package chính xác là 20 package cộnginternal/tool/builtinnêu trongplan.md. -
internal/runkhông importinternal/claudecli(test trêngo list -deps). - Người không phải chủ không thể lưu một agent
claude_cli(422), không thể tạo run trên nó (404), và một run đang xếp hàng mà người tạo không phải chủ thì fail vớiruntime_forbidden(test). - argv của CLI không bao giờ chứa input của người dùng, và môi trường của CLI không chứa
ANTHROPIC_API_KEYcũng như biếnHANGO_*nào (test). - Run đầu tiên của một session dùng
--session-idvà các run sau dùng--resumevới cùng UUID (test). - Bridge từ chối token thiếu, sai hoặc đã release bằng 401 và phục vụ
tools/listvàtools/callcho run hiện tại (test). - Một tool call của bridge ghi cùng audit và telemetry như một tool call của vòng lặp, và một tool call id lặp lại trả về kết quả đã lưu (test).
- Cancel dừng process group của CLI trong vòng 5 s, và một run được requeue resume với continuation prompt (test với CLI giả).
- Mức dùng CLI của chủ dự án ghi
usage_recordsvới providerclaude_clivà chi phí bằng không, và không làm thay đổi các tổng hợp quota của tenant (test). - Integration test bị chặn bởi cờ pass với CLI thật trên máy của chủ dự án, với phiên bản CLI được ghi trong learning doc.
- Learning doc cho thấy các trace
anthropicvàclaude_clicủa cùng một tác vụ cạnh nhau.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
| Anthropic đổi điều khoản hoặc chặn kiểu dùng này | Medium x Medium | CLI báo lỗi auth, hoặc có thông báo chính sách | Tắt cờ và chuyển các agent của chủ dự án sang provider = "anthropic"; không cần đổi code |
| Một bản cập nhật CLI đổi flag hoặc định dạng stream-json | High x Medium | hango.claudecli.stream_parse_errors tăng hoặc tiến trình thoát vì flag sai | Event lạ được bỏ qua và đếm; fixture và integration test ghim phiên bản; ghi lại fixture khi nâng cấp |
| Một thành viên gia đình chạm tới subscription | Low x High | Một run claude_cli mà người tạo không phải chủ | Ba lần kiểm tra chủ dự án cộng lỗi lúc claim; audit row cho thấy người tạo của mọi run |
| CLI fallback sang tính phí API qua một key thừa hưởng | Medium x Medium | Phí API bất ngờ | ANTHROPIC_API_KEY bị loại khỏi môi trường, được test bao phủ |
| Setting Claude Code cá nhân của chủ dự án làm đổi các run của hango | Medium x Low | Run hành xử khác với các fixture CLI | --setting-sources loại trừ setting của user và project; thư mục làm việc rỗng |
| Session được resume sau crash lặp lại công việc | Medium x Medium | Tác dụng phụ của tool bị lặp | Các lời gọi bridge giữ idempotency tool của Phase 5; continuation prompt được đánh dấu để thấy trong transcript |
| Một tiến trình cục bộ khác gọi bridge | Low x Medium | hango.claudecli.unauthorized lớn hơn không | Chỉ loopback, token ngẫu nhiên theo từng run, so sánh constant-time |
Rollback
Đặt HANGO_CLAUDE_CLI_ENABLED=false: việc kiểm tra agent từ chối claude_cli, và các run claude_cli đang xếp hàng fail với runtime_forbidden. Xóa internal/claudecli và phần nối dây của nó đưa binary về hành vi của Phase 18. Các row usage_records hiện có với provider claude_cli được giữ làm lịch sử.