Chuyển đến nội dung
Claude Code CLI runtime cho chủ dự án

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 định claude), 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 định 127.0.0.1:0). Lúc khởi động, hango chạy claude --version và 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_by của agent là chủ dự án, và request đến từ chủ dự án. Bất kỳ ai khác nhận 422 provider_not_allowed. model được chuyển thẳng vào --model (ví dụ sonnet hoặc opus).
  • Tạo run cho một agent claude_cli bở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ới runtime_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êng claude_cli với mức đồng thời HANGO_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_cli cho 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 (--tools với tập rỗng). --strict-mcp-config với một --mcp-config được sinh ra chỉ trỏ vào bridge của hango, và --allowedTools chỉ 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-prompt với system_prompt củ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_KEY và mọi biến HANGO_* 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 --help củ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 /mcp vớ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/list trả các tool của agent được resolve giống cách vòng lặp resolve. Mọi tools/call chạy qua chuỗi WrapToolCall của Phase 7 trong scope tenant của run, nên audit, offload, idempotency và telemetry áp dụng không đổi. Tool task của subagent không được mở ra trong giai đoạn này.
  • Ánh xạ stream: các text delta của stream_event trở thành event text-delta của run trong hango. Mỗi message assistant hoà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. Event result đặt trạng thái kết thúc, stop_reason, iterations (từ num_turns) và usage củ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ới stop_reason = max_iterations khi 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_records cho mỗi message assistant với provider claude_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_usd của CLI được giữ trong JSON usage của run dưới tên api_equivalent_cost_usd, chỉ để so sánh. Các tổng hợp quota tenant của Phase 13 loại trừ provider claude_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/run không import internal/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 trong plan.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 config

Instrumentation

  • Span invoke_agent của run mang hango.agent.runtime = claude_cli. Một span con claudecli.process ghi 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 chat vớ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 span execute_tool thật có parent là claudecli.process, vì bridge tra span context của run theo token.
  • Metric: gauge hango.claudecli.processes, counter hango.claudecli.exits (attribute outcome = success|error|cancelled|killed|session_missing), counter hango.claudecli.bridge_calls (attribute tool, status), counter hango.claudecli.unauthorized (request tới bridge với token sai), counter hango.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_id và 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 trong testdata/.
  • 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ởi HANGO_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ộng internal/tool/builtin, thêm internal/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ỗi runtime_forbidden.
  • internal/run/create.go — lane claude_cli và 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ừ provider claude_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

  1. Ghi fixture: chạy tay claude -p --output-format stream-json --verbose --include-partial-messages vớ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ào testdata/. Ghi lại phiên bản CLI.
  2. Viết stream.go và 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).
  3. Viết bridge.go và test của nó: bắt tay initialize, tools/list, tools/call qua một chuỗi giả, token sai trả 401, và token đã release bị từ chối.
  4. Viết CLI giả trong testdata/fakecli để phát lại một fixture và gọi bridge, rồi viết executor.go và 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_KEY hay HANGO_*, --session-id ở lần chạy đầu và --resume sau đó, 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.
  5. 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ủ).
  6. Loại claude_cli khỏ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.
  7. Nối config, loopback listener, executor và thứ tự drain. Thêm span, metric và việc log stderr.
  8. Chạy HANGO_TEST_CLAUDE_CLI=1 go test ./internal/claudecli/ -run Integration trên máy của chủ dự án, rồi dùng agent của chủ dự án từ Telegram.
  9. Chạy một tác vụ hai lần, một lần trên agent anthropic và một lần trên agent claude_cli. Đặt hai trace Langfuse cạnh nhau trong learning doc và liệt kê mỗi bên cho thấy gì.
  10. 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 theo run_id, xem hango.claudecli.bridge_calls theo 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 anthropic và claude_cli rồ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ộng internal/tool/builtin nêu trong plan.md.
  • internal/run không import internal/claudecli (test trên go 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ới runtime_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_KEY cũng như biến HANGO_* nào (test).
  • Run đầu tiên của một session dùng --session-id và các run sau dùng --resume với cùng UUID (test).
  • Bridge từ chối token thiếu, sai hoặc đã release bằng 401 và phục vụ tools/list và tools/call cho 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_records với provider claude_cli và 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 anthropic và claude_cli của cùng một tác vụ cạnh nhau.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngDấu hiệuCách xử lý đã quyết định trước
Anthropic đổi điều khoản hoặc chặn kiểu dùng nàyMedium x MediumCLI báo lỗi auth, hoặc có thông báo chính sáchTắ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-jsonHigh x Mediumhango.claudecli.stream_parse_errors tăng hoặc tiến trình thoát vì flag saiEvent 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 subscriptionLow x HighMộ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ưởngMedium x MediumPhí 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 hangoMedium x LowRun 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ệcMedium x MediumTác dụng phụ của tool bị lặpCá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 bridgeLow x Mediumhango.claudecli.unauthorized lớn hơn khôngChỉ 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ử.