Skip to content

Latest commit

 

History

History
388 lines (302 loc) · 22.3 KB

File metadata and controls

388 lines (302 loc) · 22.3 KB

PingPongCode — detailed reference

Supplement to README.md. This file holds the denser material that was too detailed for the quick-start README: full comparison tables, exact environment-variable mappings, and the SDK-reconciliation notes. Read the README first; jump here when you want the fine print.

three-modes

PingPongCode has three operating modes that layer on top of each other. All are off-by-default opt-ins, so the read-only path is the unchanged default — you only turn on what you need.

Mode comparison

Legacy read-only (default) Managed (CC_ALLOWED_PROJECT_ROOTS) Safe dev loop (CC_SAFE_DEV_LOOP_ENABLED=true) Two-phase (CC_TWO_PHASE_ENABLED=true)
Tools Read,Glob,Grep Read,Glob,Grep,Edit,Write,Bash risk-classifier decides (allow/ask/deny) plan phase read-only (plan mode); exec phase = safe-dev-loop
Working dir single WORKSPACE_DIR an authorized project (CC_ALLOWED_PROJECT_ROOTS entries, or CC_WORKSPACE_ROOT children that pass is_allowed) isolated Git worktree (main workspace untouched) same worktree across plan + exec (one task_run)
permission_mode dontAsk dontAsk (hardcoded, never from env) default + empty allowed_tools + PreToolUse hook plan (phase 1) → default (phase 2)
Write approvals ❌ (read-only) ❌ (dontAsk runs directly) ✅ sensitive calls via WeChat /cc approve|deny ✅ (phase 2); phase 1 writes denied
Plan-confirm gate ExitPlanMode/cc confirm|reject
Structured report ✅ (SDK output reconciled with Bridge git/test facts) ✅ (phase 2 only; phase 1 streams TextBlock to WeChat)
/cc keep|rollback
Session resume/handoff ✅ (shared with Terminal) ✅ (can hand off into a kept worktree)
Project/session locks
Reliable delivery (outbox) ❌ (direct send) ❌ (direct send) ✅ (retries on failure, never loses a report)
setting_sources [] ["project"] ["project"] ["project"]

Legacy read-only (default)

Claude can only Read/Glob/Grep against a single WORKSPACE_DIR. No file edits, no shell. Safe baseline for project analysis.

Managed mode (opt-in)

Set CC_ALLOWED_PROJECT_ROOTS to let WeChat and your desktop Terminal share the same local Claude Code sessions — Terminal-created sessions can be resumed from WeChat, WeChat-created sessions/projects can be continued in a Terminal via claude --resume <id>, and the bridge survives restarts. Write-capable (Edit/Write/Bash) but confined to allowed roots under a project+session lock. permission_mode is hardcoded to dontAsk (can't be opened to bypassPermissions/acceptEdits via .env).

Safe development loop (opt-in, requires managed mode)

Set CC_SAFE_DEV_LOOP_ENABLED=true for a full remote dev loop: each write task runs in an isolated git worktree, sensitive tool calls go through a WeChat approval gate, and a structured report is reconciled against Bridge-computed git/test facts. /cc keep preserves the branch; /cc rollback deletes it (after a second critical approval). This is not bypassPermissionsfull mode only widens which Bash requests escalate to ask vs deny; it never bypasses approvals. Legacy and managed paths are byte-identical when this is off (default).

How the opt-ins layer

The opt-ins layer: managed mode adds the resume/handoff workflow; the safe-dev-loop adds worktree isolation + approvals + reports on top; two-phase planning splits a task into plan-then-execute on top of the safe-dev-loop. Two-phase defaults to on but stays inert unless the safe-dev-loop is also enabled (and the backend is Claude); the others are off by default, so the legacy read-only path is the unchanged default.

To switch modes from WeChat, use /cc mode readonly|edit|full (the user's active_mode is persisted; edit/full route write tasks through the safe-dev-loop branch). To enable the modes at all, set the environment variables above in .env (see README.md → Install).

Two-phase planning (opt-in, requires safe dev loop + Claude backend)

Set CC_TWO_PHASE_ENABLED=true (on by default; requires CC_SAFE_DEV_LOOP_ENABLED=true AND cc_agent="claude"plan is a Claude SDK permission_mode with no Codex equivalent). When on, a bare /cc <task> (mode edit/full) splits into:

  1. Planning phase — a multi-turn query() in permission_mode="plan" (read-only) in the worktree. /cc say <msg> appends a turn. The model calls ExitPlanMode to submit its plan; you confirm or reject from WeChat. output_format is off in this phase (StructuredOutput is a CLI-injected terminal tool that competes with ExitPlanMode — see CLAUDE.md).
  2. Execution phase — after /cc confirm, the same task_run + worktree switches to default mode (writes allowed, classifier + approval gate) and runs a one-shot implementation. No second task is created.

Commands:

Command Purpose
/cc say <msg> append a follow-up message to the in-flight planning conversation
/cc confirm [code] approve the proposed plan → proceed to execution (0s teardown, straight to phase 2)
/cc reject [code] [reason] reject the plan → Claude revises it in the same planning conversation (the query does not end)
/cc cancel end the in-flight planning/execution conversation

Key behaviors: confirm triggers an immediate query teardown (does not wait for the model to finish its turn — that would let it attempt edits in plan mode). reject feeds the reason back to the model and the planning query continues; the model re-submits via ExitPlanMode with a new gate code. An empty ExitPlanMode plan (model calls it with {}) is auto-denied with a guiding message rather than blocking on an empty gate. Design doc + E2E findings: dev_docs/plans/two_phase_dev_loop_design.md.

configure

PingPongCode reads all configuration from a .env file at the repo root. The recommended way to create it is the interactive wizard; you can also copy the template and edit by hand.

The interactive wizard

python -m pingpong_bridge config init

The wizard is grouped, validated, and secrets-masked; a blank input keeps the existing value. Every proposed value is validated by constructing a Settings from current+proposed values and letting pydantic run all field_validator/model_validator — so security invariants (the ban on bypassPermissions/acceptEdits, WORKSPACE_DIR must be an existing absolute path, enum checks) can't be bypassed via the wizard. Writes use python-dotenv.set_key (updates one key in-place, preserves comments + untouched secrets).

There are also config show (grouped, masked display) and config edit <KEY> [VAL] (edit one field, validated) subcommands.

Manual .env

cp .env.example .env
$EDITOR .env

Required variables (legacy read-only mode)

Variable Purpose
GATEWAY_BASE_URL Anthropic-protocol gateway endpoint
GATEWAY_AUTH_MODE bearer (→ ANTHROPIC_AUTH_TOKEN) or x-api-key (→ ANTHROPIC_API_KEY)
GATEWAY_API_KEY gateway key
GATEWAY_MODEL model the gateway routes (vision model needed for images)
WORKSPACE_DIR existing absolute path Claude may read-only analyze

Managed-mode & safe-dev-loop variables

CC_ALLOWED_PROJECT_ROOTS, CC_WORKSPACE_ROOT, CC_SAFE_DEV_LOOP_ENABLED, CC_DEFAULT_MODE, CC_WORKTREE_*, CC_APPROVAL_TTL_SECONDS, CC_OUTBOX_*, etc. are documented in .env.example.

run

The README's Run section gives the four start-up steps (login → check-ilink → check-gateway → run). This section covers what happens right after run, and how to stop/restart.

First run: pairing

If ILINK_ALLOWED_USER_ID isn't set in .env and you haven't paired, the terminal shows a pairing code on first run — send /cc pair <code> from your phone to bind (10-minute, one-time). After pairing, the bound user id is remembered; subsequent runs skip this.

Your first task

Send any /cc task from WeChat, e.g.:

/cc read the current project's README; if absent, summarize the top-level directory. Read-only.

You'll get [CC] received task, processing. then the multi-segment result.

Stop / restart

pkill -f "pingpong_bridge run"

Config changes (.env) need a restart to take effect.

file-transfer

Bidirectional file/image transfer over the iLink CDN, AES-128-ECB encrypted both ways (no plain-URL path).

  • Inbound: WeChat sends an image and its caption as separate messages (no caption support), so the bridge holds the most recent inbound media in a sticky per-user buffer and attaches it to every subsequent /cc task until a new image arrives or /cc clear drops it. Attachments save under the effective cwd's .cc_attachments/ (0600, size-capped, cleaned up after the task); Claude opens them with the existing Read tool (images via vision — no new tools added).
  • Outbound: /cc send <relative-path> uploads a workspace file back to WeChat (image → image message, else file message). Path traversal (.., absolute) is rejected and confined to the workspace/active project.

resume

With managed mode on, WeChat and your desktop Terminal share the same local Claude Code sessions:

  • Terminal → WeChat: cd <project> && claude on the computer, work, exit → from WeChat /cc project <name>/cc sessions/cc resume 1/cc continue the task (resumes the original session).
  • WeChat → Terminal: WeChat /cc project <name>/cc new-session <title>/cc <task>/cc handoff → run the returned cd '<cwd>'\nclaude --resume '<id>' on the computer.
  • Bridge restart: selected project/session and the sync cursor persist in SQLite; /cc session still shows it after a restart.

The SDK session API is the only way sessions are read/written — never edit ~/.claude/projects/ JSONL directly. PathPolicy is the single path-safety gate (uses Path.is_relative_to, never string startswith, resolves symlinks). The claude agents --json live-session check is cooperative — it refuses takeover when a live session is detected but cannot stop a user manually running claude --resume <id>.

safe-dev-loop

When enabled, bare /cc <task> with active_mode in edit/full routes to the safe-dev-loop path:

WeChat /cc fix ...  (mode=edit/full)
  → create task_run → isolated git worktree (main workspace untouched; dirty source refused)
  → fork/new task session (cwd=worktree; never force-resume same session id across cwd)
  → Claude runs under permission_mode=default + empty allowed_tools + PreToolUse risk classifier
       allow → run   deny → refuse   ask → WeChat approval [CC][审批 A7K3]
                                       → /cc approve|deny [code] [reason] → can_use_tool returns
                                         (omit code when only one pending; deny may take a reason
                                          that is fed back to the model to guide a fallback)
  → run configured test command (argv array, shell=False, env scrubbed, timeout, log archived)
  → structured report (SDK output reconciled with Bridge git/test facts — Bridge facts win)
  → awaiting_decision
/cc diff | /cc tests | /cc report    ← inspect facts
/cc keep                             ← preserve worktree+branch; /cc handoff into it
/cc rollback                         ← second approval → delete worktree+branch; main workspace intact

Permission matrix (mode × tool)

Tool readonly edit full
Read/Glob/Grep ✅ allow ✅ allow ✅ allow
Edit/Write/NotebookEdit ❌ deny ✅ allow (in worktree) ✅ allow (in worktree)
configured test/lint commands ❌ deny ✅ allow (exact match) ✅ allow (exact match)
unknown Bash ❌ deny ❓ ask (approval) ❓ ask (approval)
install deps (pip/npm…) ❌ deny ❌ deny (unless allow_dependency_install) ❓ ask (if allow_dependency_install)
sudo/git push --force/rm -rf //curl|sh ❌ deny ❌ deny ❌ deny
WebSearch/WebFetch/mcp__* ❌ deny ❌ deny ❌ deny

Hard invariants always hold: WebSearch/WebFetch/mcp__* always forbidden; sudo/force-push/rm -rf //curl|sh/sensitive-path (~/.ssh, ~/.aws) access always denied; file writes confined to the worktree (symlink-escape checked); subprocess never shell=True; the main workspace is never mutated; fork_session is used as an option so the parent session is never modified.

SDK note: claude_agent_sdk 0.2.128 verified. The spec's assumption that can_use_tool fires for every tool is false — it only fires on "ask" and is shadowed by allowed_tools. The safe-dev-loop works around this with permission_mode="default" + empty allowed_tools + a PreToolUse hook as the classifier. In the single-phase safe-dev-loop, AskUserQuestion is disabled at the source (the CLI's interactive TTY prompt can't render headless, and a headless bug auto-resolves empty answers before can_use_tool is awaited — silently faking consent), so the spec's clarifying-question flow is deferred there (only the approval flow is real). The two-phase planning loop (above) intercepts AskUserQuestion instead — the classifier routes it to ask, the callback forwards the question to WeChat, and /cc answer injects the reply. Full details in docs/SDK_CLI_COMPATIBILITY.md.

Task, session, worktree, branch — how they relate

The whole safe-dev-loop rests on one design idea: a task is an auditable, rollback-able execution unit plus an isolated worktree of changes. A single write task produces three distinct things that live in different layers and have different lifetimes. Confusing them is the most common stumbling block for new users.

Thing What it is Where it lives Lifetime
task (task_6a746828-…) The execution record of this one write task — its state machine (received → running → awaiting_decision → kept/rolled_back), which worktree/session it used, the result. Bridge SQLite (task_runs table) One per task run; fixed once it ends
worktree + branch (cc/task-20260806-3ad7cf) An isolated git working copy on its own branch. The model's file edits land here; the main workspace is never touched. The git repo (under CC_WORKTREE_ROOT) Until /cc keep (preserved) or /cc rollback (deleted)
session (019fd6b6-…) The conversation history with the model (the Claude Code transcript / Codex thread). What the model "remembers." ~/.claude/projects/ (Claude) or ~/.codex/sessions/ (Codex); indexed in Bridge SQLite Permanent — survives across tasks, restarts
task_6a746828  (Bridge task record)
    ├── worktree branch: cc/task-20260806-3ad7cf   ← code edits live here
    └── session: 019fd6b6-dec2                      ← conversation lives here (forked from 5.2's session)

task ↔ worktree ↔ branch is 1:1:1 — one task creates one worktree on one branch. task ↔ session is NOT 1:1 — a session is a persistent conversation that multiple tasks can touch:

  • The first write task creates a session.
  • A later write task forks that session → a new session that carries the prior conversation's context, while the original session is left untouched (like a git branch off a commit). This is how consecutive tasks stay in context without polluting earlier conversations.
  • You can also /cc resume an old session to continue it directly.

The branch name in the report is about code, not conversation. When a task report shows 分支:cc/task-20260806-3ad7cf, that's the git branch holding the file edits — it tells you where the code changes are, not anything about the session.

The bridge never commits — that's your call

A deliberate design choice: the bridge edits files in the worktree but never runs git add/git commit/git push. The model's changes sit in the worktree as uncommitted modifications.

Why:

  • Committing is a judgment call. The model's edits aren't always worth keeping as-is; auto-committing would litter your git history.
  • You may want to tweak first. After /cc keep, you can hand off into the worktree (/cc handoff), adjust the code, and commit when you're satisfied — one clean commit instead of a bridge-made one plus your fixup.
  • Auto-merge is out of scope. The bridge isolates changes; deciding whether and how to merge them back to main is a human decision.

So the flow is:

/cc fix …           → bridge builds worktree, model edits files (NO commit)
/cc keep            → worktree + branch preserved (still no commit)
/cc handoff         → get a `cd '<worktree>' && claude --resume '<id>'` command
                       run it in a terminal → YOU git add / commit / merge

/cc rollback is the inverse: it deletes the worktree and branch outright — since nothing was committed, the changes are simply gone, main workspace untouched.

Progressive development: keep → continue (no Terminal merge)

A consequence of "each task starts a fresh worktree off main HEAD" is that two independent tasks don't see each other's changes. If you build feature A, then send a second task for feature B, B's worktree branches off the same HEAD as A's — B cannot see A's edits. For one-off tasks that's correct (isolation); for building a feature incrementally over several rounds it's friction, because the only way to make B see A is to /cc keep A, go to a Terminal, merge A's branch back to main, then send B.

/cc continue removes that friction. It's a worktree chain — no merge logic, no conflict resolution, just branching off a different point:

main HEAD  (== A's HEAD, since A never commits)
  └─ task A (kept)        worktree A: working tree has A's edits (uncommitted)
      └─ task B (continue)  worktree B: created off A's HEAD, then A's diff applied
          └─ task C (continue)  worktree C: created off B's HEAD, then B's diff applied

Each task's HEAD stays at main HEAD (nothing commits). The continuity between links is the uncommitted working-tree diff, applied from one worktree into the next — not git history.

How it works:

  1. /cc fix … → task A runs in worktree A (off main HEAD), model edits files. The edits are uncommitted — the bridge never commits (CLAUDE.md invariant), so A's HEAD never advances; the changes sit in A's working tree.
  2. /cc keep → A's worktree + branch are preserved (state kept).
  3. /cc continue → sets a one-shot context pointing at task A. No task runs yet — like /cc resume, it only selects the base. /cc session shows it.
  4. /cc add … → task B's worktree is created off A's HEAD (which, since A never committed, equals main HEAD). So the new worktree starts clean — it does NOT automatically see A's edits. To carry them over, the bridge captures A's uncommitted diff (git add -Agit diff --cachedgit reset --mixed, A's working tree is never touched) and applies it (git apply -) into B's worktree. Now B sees A's edits, without either task committing. B's session forks A's session, so the conversation is continuous too. The continue context is consumed (one-shot); the next task after B defaults back to main HEAD unless you /cc continue again.

The apply is guaranteed clean: B's base == A's HEAD, so B's initial working tree == A's HEAD state, and applying A's working-tree diff (relative to its HEAD) replays A's edits without conflict. If capture/apply fails (rare — e.g. a huge binary diff), it degrades gracefully: B runs from A's base without the inherited edits, and a notice is sent; the task is never blocked.

The chain extends indefinitely: keep → continue → keep → continue. Each link is an independent git worktree+branch — rolling back B doesn't touch A, and rolling back A doesn't touch B's already-created worktree. The code continuity between links is carried by diff-apply (working tree), not git history.

What it is NOT:

  • Not auto-merge. The bridge still never commits and never merges. continue carries uncommitted edits between worktrees via diff apply — no git history is created or merged.
  • Not a replacement for eventually merging back to main. When you're done with the chain, you still /cc handoff into the last worktree and commit/merge the branch into main yourself (once at the end, not once per task).
  • Not for readonly mode (no worktree to chain). continue requires edit/full.

/cc clear drops a pending continue context (next task back to main HEAD).

When to continue vs handoff — the "run my new code" question

A common point of confusion: after task A changes code, you send a follow-up like "run the new code" or "test what you just wrote." Does that follow-up need /cc continue? It depends on who acts (you or the model) and where the code lives:

  • You want to run the code yourself/cc handoff (into A's kept worktree), then run it in your Terminal. A's worktree is preserved with its edits; you don't need continue because you are the executor, not the model. No new task is created.
  • You want the model to run/test/analyze A's codeyes, continue. The model's execution environment is a worktree; a default new task branches off main HEAD and would NOT see A's edits (the worktree would be clean). continue makes B's worktree inherit A's edits via diff-apply, so the model can actually see and run the code A wrote.

The rule of thumb: if the model needs A's code in its working directory to do its job, continue; if only you need it, handoff.

Note: you can't "continue an awaiting_decision task directly" — continue requires kept (the worktree must be preserved first). So the flow for the second case is /cc keep (A) → /cc continue/cc test the new code. If A is still awaiting_decision and you send a follow-up task, it opens a fresh worktree off main HEAD and won't see A's changes.