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.
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.
| 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"] |
Claude can only Read/Glob/Grep against a single WORKSPACE_DIR. No file edits, no shell. Safe baseline for project analysis.
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).
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 bypassPermissions — full 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).
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).
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:
- Planning phase — a multi-turn
query()inpermission_mode="plan"(read-only) in the worktree./cc say <msg>appends a turn. The model callsExitPlanModeto submit its plan; you confirm or reject from WeChat.output_formatis off in this phase (StructuredOutput is a CLI-injected terminal tool that competes with ExitPlanMode — seeCLAUDE.md). - Execution phase — after
/cc confirm, the same task_run + worktree switches todefaultmode (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.
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.
python -m pingpong_bridge config initThe 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.
cp .env.example .env
$EDITOR .env| 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 |
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.
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.
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.
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.
pkill -f "pingpong_bridge run"Config changes (.env) need a restart to take effect.
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
/cctask until a new image arrives or/cc cleardrops it. Attachments save under the effective cwd's.cc_attachments/(0600, size-capped, cleaned up after the task); Claude opens them with the existingReadtool (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.
With managed mode on, WeChat and your desktop Terminal share the same local Claude Code sessions:
- Terminal → WeChat:
cd <project> && claudeon 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 returnedcd '<cwd>'\nclaude --resume '<id>'on the computer. - Bridge restart: selected project/session and the sync cursor persist in
SQLite;
/cc sessionstill 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>.
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
| 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_sdk0.2.128 verified. The spec's assumption thatcan_use_toolfires for every tool is false — it only fires on "ask" and is shadowed byallowed_tools. The safe-dev-loop works around this withpermission_mode="default"+ emptyallowed_tools+ a PreToolUse hook as the classifier. In the single-phase safe-dev-loop,AskUserQuestionis disabled at the source (the CLI's interactive TTY prompt can't render headless, and a headless bug auto-resolves empty answers beforecan_use_toolis 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) interceptsAskUserQuestioninstead — the classifier routes it toask, the callback forwards the question to WeChat, and/cc answerinjects the reply. Full details indocs/SDK_CLI_COMPATIBILITY.md.
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 resumean 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.
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
mainis 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.
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:
/cc fix …→ task A runs in worktree A (off mainHEAD), model edits files. The edits are uncommitted — the bridge never commits (CLAUDE.md invariant), so A'sHEADnever advances; the changes sit in A's working tree./cc keep→ A's worktree + branch are preserved (statekept)./cc continue→ sets a one-shot context pointing at task A. No task runs yet — like/cc resume, it only selects the base./cc sessionshows it./cc add …→ task B's worktree is created off A'sHEAD(which, since A never committed, equals mainHEAD). 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 -A→git diff --cached→git 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. Thecontinuecontext is consumed (one-shot); the next task after B defaults back to mainHEADunless you/cc continueagain.
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.
continuecarries 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 handoffinto the last worktree and commit/merge the branch intomainyourself (once at the end, not once per task). - Not for
readonlymode (no worktree to chain).continuerequiresedit/full.
/cc clear drops a pending continue context (next task back to main HEAD).
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 needcontinuebecause you are the executor, not the model. No new task is created. - You want the model to run/test/analyze A's code → yes,
continue. The model's execution environment is a worktree; a default new task branches off mainHEADand would NOT see A's edits (the worktree would be clean).continuemakes 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.