Low-level AI CLI spawn mechanics used by ralphctl's provider adapters — headless / interactive spawn, the file-based contract (`signals.json` + `sessionId` files), idle-stdout watchdog, exponential rate-limit backoff, and session resume for in-flight recovery. Use when modifying `src/integration/ai/providers/{claude,copilot,codex,opencode,grok}/` or `src/integration/ai/providers/_engine/`, debugging stdin hangs / slow Claude startups, or wiring a new code path that spawns an AI CLI directly.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add lukas-grigis/ralphctl --skill claude-integration --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Claude Integration?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/lukas-grigis-claude-integration)More formats (shields.io, HTML) on the badges page.
---
name: claude-integration
description: "Low-level AI CLI spawn mechanics used by ralphctl's provider adapters — headless / interactive spawn, the file-based contract (`signals.json` + `sessionId` files), idle-stdout watchdog, exponential rate-limit backoff, and session resume for in-flight recovery. Use when modifying `src/integration/ai/providers/{claude,copilot,codex,opencode,grok}/` or `src/integration/ai/providers/_engine/`, debugging stdin hangs / slow Claude startups, or wiring a new code path that spawns an AI CLI directly."
when_to_use: 'When touching a provider adapter, the shared `_engine/`, or a readiness probe; when diagnosing a rate-limit / resume / watchdog issue; when a new consumer needs to spawn an AI CLI outside the existing ports. Not needed for higher-level work — the flows already wrap all of this through the `HeadlessAiProvider` / `InteractiveAiProvider` ports.'
---
# AI provider integration (Claude / Copilot / Codex / OpenCode / Grok)
Covers what is **not** in `CLAUDE.md` or `.claude/docs/ARCHITECTURE.md`. For harness signals, exit codes,
sequential task execution, and check-script gating — see `CLAUDE.md`.
**Source of truth:**
- Provider adapters: `src/integration/ai/providers/{claude,copilot,codex,opencode,grok}/` — one folder per tool,
sibling-isolated. Each owns `headless.ts`, `interactive.ts`, and `model-availability-probe.ts` (Claude,
Copilot, OpenCode, and Grok also have `parse-stream.ts` for stream-format handling). OpenCode is the aggregator
backend: its model ids are `provider/model` and its catalog is discovered at runtime via `opencode models`,
so its probe is the one that cannot check against a static list. Grok's probe is passthrough (not
`grok models`).
- Shared engine: `src/integration/ai/providers/_engine/` — `spawn.ts` (the `ProviderSpawn` port + default
`node:child_process.spawn` impl), `run-headless-spawn.ts` (the headless wrapper that wires watchdog +
signals file + sessionId file + rate-limit backoff), `rate-limit-backoff.ts` (exponential retry policy),
`idle-watchdog.ts` (kills wedged children), `headless-ai-provider.ts` (the cross-tool port).
- Signal contract:
`src/integration/ai/contract/_engine/{validate-signals-file,render-sidecars,render-contract-section}.ts`
with per-kind Zod schemas under `src/integration/ai/contract/_engine/signals/<kind>/schema.ts`.
Each AI-spawning leaf composes `<leaf>.contract.ts` from these primitives; the AI writes
`signals.json` via its Write tool, the harness Zod-validates post-spawn.
- Composition: `src/application/bootstrap/provider-factory.ts` (`createAiProvider`) picks the concrete
adapter from `settings.ai.provider`.
## Spawn modes
There are two ports under `_engine/`:
```ts
import type { HeadlessAiProvider } from '@src/integration/ai/providers/_engine/headless-ai-provider.ts';
import type { InteractiveAiProvider } from '@src/integration/ai/providers/_engine/interactive-ai-provider.ts';
```
- **Headless** — `-p` (print) mode with prompt + signals output file. The shared
`runHeadlessSpawn(...)` helper handles arg construction, the file-based contract, the idle watchdog,
rate-limit backoff retry, and post-spawn parsing. Each per-tool adapter under `providers/<tool>/headless.ts`
customises the arg builder and JSON-shape parser.
- **Interactive** — TTY handoff, `stdio: 'inherit'`. The AI CLI takes over the terminal (alt-screen swap to
its own UI); the TUI's alt-screen state is suspended and restored on the way back. Used by `refine` /
`plan` / `ideate` / `readiness` flows that need a human at the keyboard.
**Prompt delivery — never the body in argv.** Headless claude / codex / opencode pipe the prompt through
stdin. Interactive cannot (stdin piping flips these CLIs out of interactive mode) and neither can copilot
or grok headless (neither CLI has a stdin prompt path), so those write the prompt to a file and pass a
POINTER at it — `_engine/prompt-pointer.ts` for copilot (`-p <pointer>`), `--prompt-file grok-prompt.md`
for grok (Grok does not read piped stdin as the prompt). Interactive grok never passes `--prompt-file`
(that forces headless) and takes a positional pointer instead. Argv caps at 32,767 bytes on Windows and a
rendered plan prompt clears that on its own; inlining it produced `spawn ENAMETOOLONG` before the CLI ever
started. `_engine/argv-budget.ts` names an overflow in the error rather than leaving a bare errno.
## File-based provider contract
ralphctl does NOT parse stdout for signals or session IDs. Instead, every spawn passes paths to two files:
- `signals.json` — structured JSON the adapter (or the AI's wrapper) writes during the run. The harness
reads it back post-spawn via `validateSignalsFile(...)` and dispatches to the parser registry.
- `sessionId` — a single-line text file containing the provider's session id. The harness reads it
post-spawn and persists on `Task.attempts[]` for resume.
This replaces v0.6.x's brittle stdout-parsing path. Provider JSON-shape drift no longer breaks the harness;
the contract is the file content.
The per-spawn audit / sandbox layout is:
```
<sprintDir>/<flow>/<unit>/rounds/<N>/{generator,evaluator}/
├── prompt.md ← rendered prompt (input); the CLI is pointed at this file, not handed its text
├── copilot-prompt.md ← copilot headless only: the exact prompt that spawn was given
├── grok-prompt.md ← grok headless only: `--prompt-file` body (Grok has no stdin prompt path)
├── signals.json ← parsed structured signals (output, provider-written)
├── session-id.txt ← provider's session id (output)
└── evaluation.md ← rendered evaluator verdict (evaluator role only)
```
## Permission modes (per-tool, NOT portable)
| Provider | Headless permission flag | Why |
| ---------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `claude-code` | `--permission-mode bypassPermissions` | Piped stdin can't answer prompts; `acceptEdits` hangs |
| `github-copilot` | `--allow-all-tools` | Copilot's permission model is all-or-nothing |
| `openai-codex` | per-session approval flow | Codex prompts for approval inline; sandbox handles it |
| `opencode` | `--auto` (or nothing) | No enforceable read-only mode — see the over-grant below |
| `xai-grok` | `--always-approve --sandbox off` | Per-gate `--disallowed-tools` (`search_replace` / both shell ids / `web_search,web_fetch`) plus `--no-subagents`; `write` stays allowed. No `--add-dir` — extra roots are a named over-grant |
**OpenCode has no enforceable permission gate.** `opencode run` exposes one approval control, `--auto`, and
omitting it does NOT make the run read-only — writes inside `--dir` land either way. The adapter forwards
`--auto` for `permissions.autoApprove`, and also whenever roots outside `--dir` are declared, because
`external_directory` is otherwise auto-rejected and there is no `--add-dir` equivalent. That over-grants
relative to the `--add-dir` adapters; the topology (`--dir` plus `outputDir`) is the real envelope. The
config-level `permission` block would scope it precisely but needs a generated per-session config file and
is not wired.
Interactive flows use lower-privilege modes where available (e.g. Claude's `acceptEdits`) because a human is
there to answer prompts.
The harness enforces actual safety through branch isolation, the per-task `checkScript` gate, and the
dirty-tree preflight — not the CLI permission gate.
## Idle-stdout watchdog
`src/integration/ai/providers/_engine/idle-watchdog.ts` kills a headless child whose stdout has been silent
past a configurable idle threshold. Prevents a stuck child from stranding the harness, whichever tool it
is. The watchdog timer resets on every stdout chunk; killing the child surfaces as a `RateLimitError`-
adjacent failure that the chain's retry policy handles. The threshold is operator-configurable via
`settings.harness.idleWatchdogMs` (60_000–3_600_000 ms, default 300_000 = 5 min); `provider-factory.ts`
threads it into each adapter's `deps.idleMs`. Tests lower it via the `idleMs` dep override to exercise the
watchdog path fast.
## Rate-limit retry (exponential backoff)
`src/integration/ai/providers/_engine/rate-limit-backoff.ts` retries on `RateLimitError` with exponential
delay. Per-spawn cap is `settings.harness.rateLimitRetries` (range 0–10). The retry loop captures the
provider's `sessionId` from the previous attempt and resumes that session on the next, so the AI continues
from where it stopped instead of restarting. The resume spelling is per-tool — `--resume <id>` (claude),
`--resume=<id>` (copilot), the `resume` subcommand arg (codex), `-s <id>` (opencode), `-r <id>` (grok) —
each adapter's `headless.ts` owns its own arg builder. Grok's stale-resume wording is "session not found"
/ 404 restore.
## Session resume contract
```bash
# Initial spawn (claude — adjust per provider)
claude -p --output-format stream-json --permission-mode bypassPermissions < prompt.md
# Provider writes session id to the sessionId file
# Resume later
claude -p --resume "<session-id>" --permission-mode bypassPermissions < followup-prompt.md
```
Implementation contract in `runHeadlessSpawn`:
- Post-spawn, `validateSignalsFile(...)` + the sessionId file's contents are returned as part of the spawn
result.
- `RateLimitError` carries the captured `sessionId` so the retry pass passes `--resume <id>` on the next
attempt.
- The per-task chain (in `src/application/flows/implement/`) persists `sessionId` on `Task.attempts[]` so
the apply-feedback flow can resume the right session.
## Known startup issues
| Symptom | Cause | Fix |
| ---------------------------------------- | ------------------------- | --------------------------------------------------------------------------- |
| Process stuck at 0 CPU after spawn | Stdin not closed | Confirm `child.stdin.end()` is called after writing the prompt |
| First spawn takes 1–2 min instead of ~5s | Bloated `~/.claude` cache | `rm -rf ~/.claude/plugins ~/.claude/debug` |
| Watchdog kills a healthy spawn | Idle threshold too tight | Raise `settings.harness.idleWatchdogMs` (60_000–3_600_000, default 300_000) |
Quick health checks:
```bash
du -sh ~/.claude # expect < 10 MB
time claude -p "yolo" # expect ~5s
```
## Relationship to ports
Business code never imports an adapter directly — it goes through `HeadlessAiProvider` / `InteractiveAiProvider`
ports from `_engine/`. When adding a new spawn code path, prefer extending the port + the per-tool adapter;
only drop into `runHeadlessSpawn` / `spawn` if a genuinely new spawn shape is needed.
Sibling-isolation rules apply: `providers/claude/`, `providers/copilot/`, `providers/codex/`,
`providers/opencode/`, and `providers/grok/` cannot import each other. Shared helpers (spawn, watchdog,
backoff, the file-based contract reader/writer) live in `providers/_engine/` and are the only legitimate
cross-tool seam.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!