This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
pair-code is a CLI harness that orchestrates two AI coding agents in a loop: a Mentor (plans, reviews) and an Executor (writes code). It drives both agents through the Claude Agent SDK (@anthropic-ai/claude-agent-sdk) in-process — it spawns one query() per turn rather than shelling out to installed agent CLIs. Each role binds to an Anthropic-compatible endpoint (base URL + key) declared in the environment, so any provider exposing that protocol (DeepSeek, GLM/Zhipu, Kimi, Qwen, MiniMax, OpenRouter, a LiteLLM gateway, or the official Anthropic API) can drive either role.
The terminal UI is built with Ink (React for CLIs).
npm run build # tsup → dist/ (ESM, node20 target, JSX via tsconfig, #!/usr/bin/env node banner)
npm run dev # tsup --watch
npm run start # build then run dist/index.js
npm run lint # eslint src
npm run typecheck # tsc --noEmit
npx tsx scripts/preview.tsx # render the UX frames with sample data (no TTY/network) — use to iterate on the UI- Focused CLI tests use Node's built-in test runner directly:
node --import tsx --test tests/*.test.tsx. Don't runnpm testunless a script is added. To verify broader changes, runnpm run typecheck, renderscripts/preview.tsx, and exercise the CLI manually against a real Anthropic-compatible endpoint. - No formatter is configured. Match surrounding style; don't add a formatter without asking.
- Node
>=20.0.0required.
Secrets come from one of three sources. The first two are equally ephemeral (process memory); the third persists only with explicit opt-in:
- Environment (preferred for repeat use):
…plus the implicit official endpoint via the standard
PAIR_PROFILE_<NAME>_BASE_URL # Anthropic-compatible endpoint PAIR_PROFILE_<NAME>_KEY # API key / bearer token (required) PAIR_PROFILE_<NAME>_MODEL # default model id (optional)
ANTHROPIC_API_KEY(+ optionalANTHROPIC_BASE_URL). - Interactively, session-only: if no profiles are configured (or via "+ Add an endpoint…" in any role picker), the user types a base URL + key, then chooses "No — this session only".
registerSessionProfile()holds it in an in-memoryMapfor the session — never written to disk. The profile name is auto-derived from the URL host (soapi.deepseek.com→deepseek, which also makes it match curated model suggestions). - Interactively, saved (opt-in): same flow, but the user chooses "Yes — save them".
persistProfile()writes the endpoint + model + key toconfig.jsonunder the user's config dir ($XDG_CONFIG_HOME/pair-codeor~/.config/pair-code), with the file at0600inside a0700dir — the only place a key touches disk./config → Manage saved credentialsdeletes them. Re-saving the same endpoint overwrites in place (key rotation).
A profile is "ready" only when its key is present, so the picker can only ever offer endpoints that can actually run. loadProfiles() merges env + session + saved profiles (secrets stripped); resolveProfile() returns the secret (session store → env → saved config, so an env key always shadows a saved one of the same name). At turn time providers.ts builds a per-call env overlay (ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY + ANTHROPIC_AUTH_TOKEN); because the loop is strictly sequential, two roles on different endpoints never collide.
Keep these boundaries — they're load-bearing:
index.ts— CLI entry; parses argv and renders the Ink<App>(also theproviderssubcommand +--help/--version)process.ts— pair engine:runTurn()wraps one SDKquery();runPairEngine()runs the mentor→executor→mentor loopprompts.ts— prompt templates (MENTOR_APPEND,EXECUTOR_APPEND,HANDOFF_PROMPTS,GREETING_PROMPTS) extracted fromprocess.tsproviders.ts— profile resolution across env + session + saved (loadProfiles/resolveProfile/profileEnv/addEndpoint/persistProfile/forgetProfile) + model suggestionsconfig.ts— the on-disk config:readConfig/writeConfig/configPath(atomic write,0600). Pure I/O — no profile/merge logic (that'sproviders.ts), no Reactstate.ts—PairStatemutations only (no rendering, no I/O beyondgit status)types.ts— shared types and unions (Profile,AgentRuntime,ToolEvent,PairStatus, …). No CLIProviderKind.ui.ts— theme tokens (colour hexes, icons, formatters) + display helpers (humanizeModelId,humanizeProfileName,inferTier). No rendering, no state.verdict.ts— verdict JSON parsing domain logic (parseVerdict,stripVerdictBlocks,extractJsonBlocks) extracted fromcomponents.tsxapp.tsx— Ink root: setup wizard (spec → per-role profile/model), session view, slash-command dispatchcomponents.tsx— presentational Ink components (Banner, StatusBar, AgentBar, MessageView, LiveTurn, ResultPanel, verdict chip, ErrorBoundary)inputs.tsx— interactive Ink inputs (Select,SearchSelect,TextPrompt,SlashInput) +useTextInputhook + fuzzy matchuseEngine.ts— bridges the engine's imperative callbacks to React state (live streaming buffers, throttled)
Don't mix concerns: no state mutation in components, no rendering in state.ts, no React in process.ts/providers.ts/state.ts/config.ts.
"type": "module"+moduleResolution: "bundler". Relative imports use.jsextensions even from.ts/.tsxsources.strict: true. Type every signature; prefer discriminated unions overany.- JSX:
react-jsx(automatic runtime) via tsconfig; React 19 + Ink 7. In.tsx, import theJSXtype explicitly (import type { JSX } from 'react') — React 19 moved it off the global namespace. - ESM only — no
require()/CommonJS.
Breaking any of these will silently corrupt the loop:
- Only the Mentor may emit
TASK_COMPLETE, on its own line. The Executor's system-prompt append forbids it. Without it the loop runs untilmaxIterations(default: unlimited /Infinity— a finite cap can be passed explicitly) or the user stops it. - Roles are asymmetric and enforced via the SDK. Executor:
permissionMode: 'bypassPermissions', full tools. Mentor: read-only —allowedTools: ['Read','Grep','Glob']+ everything elsedisallowedTools. The mentor inspects to verify but can never mutate. The Executor attaches its own build/test output as evidence because the mentor cannot run commands. - Turn timeout is 10 minutes (
TURN_TIMEOUT_MS); on timeout the turn'sAbortControlleraborts and the activequeryis interrupted. - Mentor reviews must include the structured JSON block (
{"verdict":…, "risk":…, "nextStep":{…}}).components.tsxparses it into a verdict chip; don't change the contract without updatingparseVerdict. - Streaming:
includePartialMessages: trueyieldsstream_eventtext deltas (live text) andassistantmessages carrytool_useblocks (the tool timeline); the finalresultmessage carries the canonical output +session_id+ usage. Readresult.result— don't re-harvest text from intermediate events. - Session continuity is per-turn: each turn starts a fresh
query()withresume: <sessionId>captured from the prior turn'ssystem/initorresultmessage.
Nothing to edit — declare PAIR_PROFILE_<NAME>_BASE_URL + _KEY (+ optional _MODEL) in the environment and it appears in the picker. Or add one at runtime via "+ Add an endpoint…" / /config and optionally save it to disk. To add curated model suggestions for a brand, extend SUGGESTIONS in providers.ts (users can always type a custom model id).
state.ts runs git via execSync to track modified files. The harness assumes a git repo; there's no fallback path.