Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/review-tutor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ With no argument, choose a source in the browser. The browser supports worktree,

## Model selection

The model dialog lists the session's scoped models when `--models` or the settings scope configures them. Otherwise, it lists all available models. Thinking levels are offered only for reasoning models. A scope entry with an explicit level, such as `gpt-5.6-sol:high`, pins the tutor to that level.
The model dialog lists the session's scoped models when `--models` or the settings scope configures them. Otherwise, it lists all available models. Thinking levels are offered only for reasoning models. Thinking levels are enforced by the server's model membership check. A scope entry with an explicit level, such as `gpt-5.6-sol:high`, pins the tutor to that level.

## Harness connectors

A harness connector owns model discovery, isolated invocation, and stream parsing while the shared runner owns process lifetime, bounds, and cancellation. Pi is the only registered connector today; Claude Code and Codex support is tracked in [#63](https://github.com/pickforge/pickforge-platform/issues/63). The `reviewTutorHarnessConnectors` flag defaults off. For local testing on main, set `REVIEW_TUTOR_FLAGS=reviewTutorHarnessConnectors` before starting Pi. Child processes receive only the shared environment allowlist plus keys explicitly declared by their connector, and runner failures redact common API keys, bearer credentials, and tokens before leaving the process boundary.
A harness connector owns model discovery, isolated invocation, and stream parsing while the shared runner owns process lifetime, bounds, and cancellation. Pi is registered by default; the Claude Code connector is available behind the connector flag, and Codex support is tracked in [#63](https://github.com/pickforge/pickforge-platform/issues/63). The `reviewTutorHarnessConnectors` flag defaults off. For local testing on main, set `REVIEW_TUTOR_FLAGS=reviewTutorHarnessConnectors` before starting Pi. Child processes receive only the shared environment allowlist plus keys explicitly declared by their connector, and runner failures redact common API keys, bearer credentials, and tokens before leaving the process boundary. The Claude Code connector forwards `CLAUDE_CONFIG_DIR` when present, but never forwards `ANTHROPIC_API_KEY`; users who rely on that environment key must sign in through Claude Code instead.

## Local data

Expand Down
188 changes: 188 additions & 0 deletions packages/review-tutor/src/connectors/claude-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { redact } from "./redact.ts";
import type {
ConnectorRequest,
Discovery,
DiscoveryDeps,
HarnessConnector,
ParseSink,
ParsedAnswer,
SpawnSpec,
} from "./types.ts";
import { ConnectorError } from "./types.ts";

const THINKING_LEVELS = ["low", "medium", "high", "xhigh"];
const MODELS = [
{ id: "claude-code:fable", label: "Claude Fable 5", thinkingLevels: THINKING_LEVELS },
{ id: "claude-code:opus", label: "Claude Opus 5", thinkingLevels: THINKING_LEVELS },
{ id: "claude-code:sonnet", label: "Claude Sonnet 5", thinkingLevels: THINKING_LEVELS },
];
const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob"]);

interface ClaudeEvent {
type?: unknown;
subtype?: unknown;
is_error?: unknown;
result?: unknown;
model?: unknown;
total_cost_usd?: unknown;
permissionMode?: unknown;
tools?: unknown;
usage?: unknown;
event?: {
type?: unknown;
delta?: { type?: unknown; text?: unknown };
};
message?: { content?: unknown };
}

function versionAtLeast(major: number, minor: number): boolean {
return major > 2 || (major === 2 && minor >= 1);
}

function assistantText(content: unknown): string | undefined {
if (!Array.isArray(content)) return undefined;
return content
.filter((block): block is { type: "text"; text: string } =>
block?.type === "text" && typeof block.text === "string")
.map((block) => block.text)
.join("");
}

function validateInit(event: ClaudeEvent): void {
if (event.type !== "system" || event.subtype !== "init") return;
const wrongMode = event.permissionMode !== undefined && event.permissionMode !== "dontAsk";
const wrongTools = Array.isArray(event.tools)
&& event.tools.some((tool) => typeof tool !== "string" || !READ_ONLY_TOOLS.has(tool));
if (wrongMode || wrongTools) {
throw new ConnectorError(
"Claude Code did not honour the read-only tool set; refusing to continue.",
);
}
}

function eventDelta(event: ClaudeEvent): string | undefined {
const delta = event.event?.delta;
return event.type === "stream_event"
&& event.event?.type === "content_block_delta"
&& delta?.type === "text_delta"
&& typeof delta.text === "string"
? delta.text
: undefined;
}

function eventUsage(event: ClaudeEvent): Record<string, number> | undefined {
if (!event.usage || typeof event.usage !== "object") return undefined;
const usage = event.usage as Record<string, unknown>;
const answerUsage: Record<string, number> = {};
if (typeof usage.input_tokens === "number") answerUsage.input_tokens = usage.input_tokens;
if (typeof usage.output_tokens === "number") answerUsage.output_tokens = usage.output_tokens;
const cost = typeof event.total_cost_usd === "number" ? event.total_cost_usd : usage.total_cost_usd;
if (typeof cost === "number") answerUsage.total_cost_usd = cost;
return answerUsage;
}

export class ClaudeCodeConnector implements HarnessConnector {
readonly id = "claude-code" as const;
readonly label = "Claude Code";
readonly envKeys = ["CLAUDE_CONFIG_DIR"] as const;
private model = "unknown";
private sawResult = false;

async discover(deps: DiscoveryDeps): Promise<Discovery> {
const output = await deps.which("claude");
if (output === undefined) {
return {
available: false,
reason: "Claude Code is not installed (claude not found on PATH).",
};
}
const match = output.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return { available: false, reason: "Claude Code version could not be parsed." };
}
const version = `${match[1]}.${match[2]}.${match[3]}`;
if (!versionAtLeast(Number(match[1]), Number(match[2]))) {
return {
available: false,
reason: `Claude Code ${version} is too old; 2.1.0 or newer is required.`,
};
}
return { available: true, version, models: MODELS.map((model) => ({ ...model })) };
}

spawnSpec(request: ConnectorRequest): SpawnSpec {
return {
command: "claude",
args: [
"-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
"--model", request.model, "--effort", request.thinking,
"--tools", "Read,Grep,Glob", "--permission-mode", "dontAsk",
"--strict-mcp-config", "--setting-sources", "", "--disable-slash-commands",
"--no-session-persistence", "--max-turns", "8",
],
};
}

parseLine(line: string, sink: ParseSink): void {
let event: ClaudeEvent;
try {
event = JSON.parse(line) as ClaudeEvent;
} catch {
return;
}

validateInit(event);
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") {
this.model = event.model;
}
const delta = eventDelta(event);
if (delta !== undefined) sink.delta(delta);
if (event.type === "assistant") {
const latest = assistantText(event.message?.content);
if (latest !== undefined) sink.final(latest);
}
if (event.type === "result") this.handleResult(event, sink);
}

private handleResult(event: ClaudeEvent, sink: ParseSink): void {
this.sawResult = true;
if (event.is_error === true || event.subtype !== "success") {
const message = this.failureMessage(event.result);
this.sawResult = false;
this.model = "unknown";
throw new ConnectorError(message);
}
const usage = eventUsage(event);
if (usage !== undefined) sink.usage(usage);
if (typeof event.result === "string") sink.final(event.result);
}

finish(sink: ParseSink): ParsedAnswer {
try {
if (!this.sawResult) {
throw new ConnectorError("Claude Code exited without a result.");
}
if (!sink.answer?.trim()) {
throw new ConnectorError("Claude Code returned an empty answer.");
}
return { answer: sink.answer, ...(sink.answerUsage ? { usage: sink.answerUsage } : {}) };
} finally {
this.sawResult = false;
this.model = "unknown";
}
}

private failureMessage(value: unknown): string {
const text = typeof value === "string" ? value : "Claude Code failed.";
if (/not logged in|please run \/login|authentication/i.test(text)) {
return "Claude Code is not logged in. Run `claude` once and sign in, then ask again.";
}
if (/rate limit|overloaded|429/i.test(text)) {
return "Claude Code is rate-limited right now. Try again in a few minutes.";
}
if (/unknown model|invalid model/i.test(text)) {
return `Claude Code rejected model ${this.model}.`;
}
return redact(text.slice(0, 200));
}
}
14 changes: 13 additions & 1 deletion packages/review-tutor/src/connectors/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { execFile } from "node:child_process";
import type { ReviewTutorFlags } from "../flags.ts";
import { ClaudeCodeConnector } from "./claude-code.ts";
import { PiConnector } from "./pi.ts";
import type {
Discovery,
Expand All @@ -15,16 +17,26 @@ export interface ConnectorRegistry {
discoveries(): Promise<Array<{ connector: HarnessConnector; discovery: Discovery }>>;
}

function which(command: string): Promise<string | undefined> {
return new Promise((resolve) => {
execFile(command, ["--version"], { encoding: "utf8", shell: false }, (error, stdout) => {
resolve(error ? undefined : stdout);
});
});
}

export function createConnectorRegistry(options: {
flags: ReviewTutorFlags;
piModels: ModelChoice[];
piVersion?: string;
which?: DiscoveryDeps["which"];
}): ConnectorRegistry {
const pi = new PiConnector();
const optionalConnectors: HarnessConnector[] = [];
const optionalConnectors: HarnessConnector[] = [new ClaudeCodeConnector()];
const dependencies: DiscoveryDeps = {
piModels: options.piModels,
...(options.piVersion ? { piVersion: options.piVersion } : {}),
which: options.which ?? which,
};

const connectors = (): HarnessConnector[] => [
Expand Down
1 change: 1 addition & 0 deletions packages/review-tutor/src/connectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface HarnessConnector {
export interface DiscoveryDeps {
piModels: ModelChoice[];
piVersion?: string;
which(command: string): Promise<string | undefined>;
}

export type Discovery =
Expand Down
Loading