diff --git a/package.json b/package.json
index d4e8bfa..e445f1a 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"packages/*"
],
"scripts": {
- "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/tauri-updater build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build",
+ "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/tauri-updater build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build && bun run --cwd packages/review-tutor build",
"test": "vitest run",
"test:supabase": "supabase test db supabase/tests/database --local && bun run supabase/tests/welcome-credits-concurrency.ts && bun test packages/billing/test/checkout-lifecycle.contract.test.ts && bun test packages/sync/test/lww.contract.test.ts && bun test packages/edge-shared/test/router-attempt.contract.test.ts",
"test:coverage": "vitest run --coverage",
diff --git a/packages/review-tutor/README.md b/packages/review-tutor/README.md
index 89d9ff7..7e53783 100644
--- a/packages/review-tutor/README.md
+++ b/packages/review-tutor/README.md
@@ -40,6 +40,21 @@ You can provide an initial source:
With no argument, choose a source in the browser. The browser supports worktree, staged, commit, range, GitHub PR URL, and pasted code sources. It opens automatically. If that fails, Pi shows a notification with the local URL to open.
+## Command line
+
+The same tutor runs without a Pi host. Build the CLI once, then run it from any shell inside a Git worktree:
+
+```bash
+bun run --cwd packages/review-tutor build
+node packages/review-tutor/dist/bin.js [source] [--no-open] [--detach] [--home
]
+```
+
+An installed package exposes it as `review-tutor`. `source` accepts the same values as the Pi command and defaults to `worktree`. The URL is the only line on stdout; discovery results go to stderr. Unknown flags print usage on stderr and exit 2.
+
+Without a Pi host, the Pi harness discovers itself through `pi --version` and `pi --list-models`, so the model list is whatever that Pi install offers. Claude Code and Codex are discovered exactly as they are under Pi.
+
+The foreground run stays attached until Ctrl-C or `SIGTERM`. `--detach` starts the server in its own process, prints the URL, and returns; that detached server exits by itself after 30 minutes with no page connected, or on `SIGTERM`.
+
## 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. 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.
diff --git a/packages/review-tutor/extensions/review-tutor.ts b/packages/review-tutor/extensions/review-tutor.ts
index c513fa1..716f6bb 100644
--- a/packages/review-tutor/extensions/review-tutor.ts
+++ b/packages/review-tutor/extensions/review-tutor.ts
@@ -1,128 +1,39 @@
-import { execFile as nodeExecFile } from "node:child_process";
import { realpath } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
+import {
+ createExecFileAdapter,
+ createServerLifecycle,
+ openInBrowser,
+ resolveRepository,
+ sourceFromArgument,
+} from "../src/cli-support.ts";
import { createConnectorRegistry } from "../src/connectors/registry.ts";
+import { defaultSkillPath } from "../src/paths.ts";
import type { ExecFile } from "../src/inputs.ts";
-import type { ModelChoice, SourceRequest } from "../src/protocol.ts";
-import {
- startReviewTutorServer,
- type ReviewTutorServer,
-} from "../src/server.ts";
+import type { ModelChoice } from "../src/protocol.ts";
+import { startReviewTutorServer } from "../src/server.ts";
-const skillPath = fileURLToPath(
- new URL("../skills/review-tutor/SKILL.md", import.meta.url),
-);
+export { createExecFileAdapter, createServerLifecycle } from "../src/cli-support.ts";
-type NodeExecFile = typeof nodeExecFile;
+const skillPath = defaultSkillPath();
-export function createExecFileAdapter(execFile: NodeExecFile = nodeExecFile): ExecFile {
- return (file, argv, options) => new Promise((resolve, reject) => {
- execFile(file, argv, {
+/** Repository resolution and browser opening stay on the host's own exec, with the host's timeouts. */
+export function piExecFile(pi: ExtensionAPI): ExecFile {
+ return async (file, argv, options) => {
+ const result = await pi.exec(file, argv, {
cwd: options.cwd,
- encoding: options.encoding,
- maxBuffer: options.maxBuffer,
- timeout: 30_000,
- shell: false,
- ...(options.signal ? { signal: options.signal } : {}),
- }, (error, stdout, stderr) => {
- if (error) {
- (error as Error & { stderr?: string }).stderr = stderr;
- reject(error);
- return;
- }
- resolve({ stdout, stderr });
+ ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
});
- });
-}
-
-export interface ServerLifecycle {
- start(
- factory: (signal: AbortSignal) => Promise,
- ): Promise;
- shutdown(): Promise;
-}
-
-export function createServerLifecycle(): ServerLifecycle {
- let server: ReviewTutorServer | undefined;
- let starting: Promise | undefined;
- let startupController: AbortController | undefined;
- let stopping: Promise | undefined;
- let shuttingDown = false;
-
- return {
- async start(factory) {
- if (server) return server;
- if (shuttingDown) return undefined;
- if (!starting) {
- const controller = new AbortController();
- const attempt = Promise.resolve().then(() => factory(controller.signal));
- startupController = controller;
- starting = attempt;
- void attempt.then(
- (started) => {
- server = started;
- },
- () => {},
- ).finally(() => {
- if (starting === attempt) starting = undefined;
- if (startupController === controller) startupController = undefined;
- });
- }
-
- const attempt = starting;
- try {
- const started = await attempt;
- if (shuttingDown) {
- if (server === started) server = undefined;
- await started.close();
- return undefined;
- }
- return started;
- } catch (error) {
- if (shuttingDown) return undefined;
- throw error;
- }
- },
-
- async shutdown() {
- if (stopping) {
- await stopping;
- return;
- }
-
- shuttingDown = true;
- const controller = startupController;
- controller?.abort();
- const attempt = starting;
- const current = server;
- server = undefined;
- const stop = (async () => {
- try {
- if (current) {
- await current.close();
- } else if (attempt) {
- const started = await attempt;
- if (server === started) server = undefined;
- await started.close();
- }
- } catch {
- // Shutdown must remain contained inside the extension.
- }
- })();
- stopping = stop;
- try {
- await stop;
- } finally {
- if (starting === attempt) starting = undefined;
- if (startupController === controller) startupController = undefined;
- if (stopping === stop) stopping = undefined;
- shuttingDown = false;
- }
- },
+ if (result.code !== 0) {
+ throw Object.assign(new Error(`${file} exited with code ${result.code}`), {
+ code: result.code,
+ stderr: result.stderr,
+ });
+ }
+ return { stdout: result.stdout, stderr: result.stderr };
};
}
@@ -146,27 +57,6 @@ function safeStatus(ctx: ExtensionCommandContext, value?: string): void {
}
}
-function sourceFromArgument(argument: string): SourceRequest | undefined {
- const value = argument.trim();
- if (!value) return undefined;
- if (/^https:\/\/github\.com\//.test(value)) {
- return { protocol: "rt/1", kind: "pr", url: value };
- }
- if (value === "worktree") return { protocol: "rt/1", kind: "worktree" };
- if (value === "staged") return { protocol: "rt/1", kind: "staged" };
-
- const range = value.split("...");
- if (range.length === 2) {
- return {
- protocol: "rt/1",
- kind: "range",
- from: range[0]!,
- to: range[1]!,
- };
- }
- return { protocol: "rt/1", kind: "commit", revision: value };
-}
-
function modelChoice(
model: { provider: string; id: string; name?: string; reasoning?: boolean },
pinnedThinkingLevel?: string,
@@ -190,37 +80,6 @@ export function modelChoices(ctx: ExtensionCommandContext): ModelChoice[] {
return ctx.modelRegistry.getAvailable().map((model) => modelChoice(model));
}
-async function repositoryRoot(pi: ExtensionAPI, cwd: string): Promise {
- const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], {
- cwd,
- timeout: 5_000,
- });
- if (result.code !== 0) {
- throw new Error(
- `repository resolution failed: expected git rev-parse to succeed, received code ${result.code}; run /review-tutor inside a Git worktree`,
- );
- }
- return realpath(result.stdout.trim());
-}
-
-async function openBrowser(pi: ExtensionAPI, url: string): Promise {
- const command = process.platform === "darwin"
- ? "open"
- : process.platform === "win32"
- ? "cmd"
- : "xdg-open";
- const args = process.platform === "win32"
- ? ["/c", "start", "", url]
- : [url];
-
- try {
- const result = await pi.exec(command, args, { timeout: 10_000 });
- return result.code === 0;
- } catch {
- return false;
- }
-}
-
function notifyBrowserResult(
ctx: ExtensionCommandContext,
opened: boolean,
@@ -236,6 +95,7 @@ function notifyBrowserResult(
export default function reviewTutorExtension(pi: ExtensionAPI): void {
const lifecycle = createServerLifecycle();
const execFile = createExecFileAdapter();
+ const hostExecFile = piExecFile(pi);
pi.registerCommand("review-tutor", {
description: "Open the local Review Tutor for a PR, diff, commit, or pasted code",
@@ -252,7 +112,7 @@ export default function reviewTutorExtension(pi: ExtensionAPI): void {
const server = await lifecycle.start(async (startupSignal) => {
const cwd = await realpath(ctx.cwd);
- const canonicalRepo = await repositoryRoot(pi, cwd);
+ const canonicalRepo = await resolveRepository(cwd, hostExecFile);
const piModels = modelChoices(ctx);
if (!piModels.length) {
throw new Error(
@@ -273,7 +133,7 @@ export default function reviewTutorExtension(pi: ExtensionAPI): void {
if (!server) return;
safeStatus(ctx, "Review Tutor running");
- const opened = await openBrowser(pi, server.url);
+ const opened = await openInBrowser(server.url, process.platform, hostExecFile);
notifyBrowserResult(ctx, opened, server.url);
} catch (error) {
safeStatus(ctx);
diff --git a/packages/review-tutor/package.json b/packages/review-tutor/package.json
index 91a3d85..331ec53 100644
--- a/packages/review-tutor/package.json
+++ b/packages/review-tutor/package.json
@@ -9,7 +9,18 @@
"extensions": ["extensions/review-tutor.ts"],
"skills": ["skills/review-tutor"]
},
+ "bin": {
+ "review-tutor": "dist/bin.js"
+ },
+ "files": [
+ "dist",
+ "src",
+ "extensions",
+ "skills",
+ "README.md"
+ ],
"scripts": {
+ "build": "tsup src/bin.ts --format esm --clean --splitting false --out-dir dist",
"test": "cd ../.. && vitest run packages/review-tutor/test",
"typecheck": "tsc -p ../../tsconfig.json --noEmit"
},
diff --git a/packages/review-tutor/src/bin.ts b/packages/review-tutor/src/bin.ts
new file mode 100644
index 0000000..e9b43aa
--- /dev/null
+++ b/packages/review-tutor/src/bin.ts
@@ -0,0 +1,12 @@
+#!/usr/bin/env node
+import { fileURLToPath } from "node:url";
+import { nodeCliDeps, runCli } from "./cli.ts";
+
+/**
+ * The installed `review-tutor` is a symlink into this file, so it can never
+ * compare argv[1] with its own URL; this entry exists only to be run.
+ */
+process.exitCode = await runCli(
+ process.argv.slice(2),
+ nodeCliDeps(fileURLToPath(import.meta.url)),
+);
diff --git a/packages/review-tutor/src/cli-support.ts b/packages/review-tutor/src/cli-support.ts
new file mode 100644
index 0000000..66c9c3f
--- /dev/null
+++ b/packages/review-tutor/src/cli-support.ts
@@ -0,0 +1,184 @@
+import { execFile as nodeExecFile } from "node:child_process";
+import { realpath } from "node:fs/promises";
+import type { ExecFile } from "./inputs.ts";
+import type { SourceRequest } from "./protocol.ts";
+import type { ReviewTutorServer } from "./server.ts";
+
+type NodeExecFile = typeof nodeExecFile;
+
+const COMMAND_BUFFER = 1024 * 1024;
+const REPOSITORY_TIMEOUT_MS = 5_000;
+const BROWSER_TIMEOUT_MS = 10_000;
+
+export function createExecFileAdapter(execFile: NodeExecFile = nodeExecFile): ExecFile {
+ return (file, argv, options) => new Promise((resolve, reject) => {
+ execFile(file, argv, {
+ cwd: options.cwd,
+ encoding: options.encoding,
+ maxBuffer: options.maxBuffer,
+ timeout: options.timeoutMs ?? 30_000,
+ shell: false,
+ ...(options.signal ? { signal: options.signal } : {}),
+ }, (error, stdout, stderr) => {
+ if (error) {
+ (error as Error & { stderr?: string }).stderr = stderr;
+ reject(error);
+ return;
+ }
+ resolve({ stdout, stderr });
+ });
+ });
+}
+
+export function sourceFromArgument(argument: string): SourceRequest | undefined {
+ const value = argument.trim();
+ if (!value) return undefined;
+ if (/^https:\/\/github\.com\//.test(value)) {
+ return { protocol: "rt/1", kind: "pr", url: value };
+ }
+ if (value === "worktree") return { protocol: "rt/1", kind: "worktree" };
+ if (value === "staged") return { protocol: "rt/1", kind: "staged" };
+
+ const range = value.split("...");
+ if (range.length === 2) {
+ return {
+ protocol: "rt/1",
+ kind: "range",
+ from: range[0]!,
+ to: range[1]!,
+ };
+ }
+ return { protocol: "rt/1", kind: "commit", revision: value };
+}
+
+export async function resolveRepository(cwd: string, execFile: ExecFile): Promise {
+ let stdout: string;
+ try {
+ ({ stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
+ cwd,
+ encoding: "utf8",
+ maxBuffer: COMMAND_BUFFER,
+ timeoutMs: REPOSITORY_TIMEOUT_MS,
+ }));
+ } catch (error) {
+ const code = (error as { code?: unknown }).code;
+ throw new Error(
+ `repository resolution failed: expected git rev-parse to succeed, received code ${String(code ?? "unknown")}; run /review-tutor inside a Git worktree`,
+ );
+ }
+ return realpath(stdout.trim());
+}
+
+export async function openInBrowser(
+ url: string,
+ platform: NodeJS.Platform,
+ execFile: ExecFile,
+): Promise {
+ const command = platform === "darwin"
+ ? "open"
+ : platform === "win32"
+ ? "cmd"
+ : "xdg-open";
+ const args = platform === "win32"
+ ? ["/c", "start", "", url]
+ : [url];
+
+ try {
+ await execFile(command, args, {
+ cwd: process.cwd(),
+ encoding: "utf8",
+ maxBuffer: COMMAND_BUFFER,
+ timeoutMs: BROWSER_TIMEOUT_MS,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export interface ServerLifecycle {
+ start(
+ factory: (signal: AbortSignal) => Promise,
+ ): Promise;
+ shutdown(): Promise;
+}
+
+export function createServerLifecycle(): ServerLifecycle {
+ let server: ReviewTutorServer | undefined;
+ let starting: Promise | undefined;
+ let startupController: AbortController | undefined;
+ let stopping: Promise | undefined;
+ let shuttingDown = false;
+
+ return {
+ async start(factory) {
+ if (server) return server;
+ if (shuttingDown) return undefined;
+ if (!starting) {
+ const controller = new AbortController();
+ const attempt = Promise.resolve().then(() => factory(controller.signal));
+ startupController = controller;
+ starting = attempt;
+ void attempt.then(
+ (started) => {
+ server = started;
+ },
+ () => {},
+ ).finally(() => {
+ if (starting === attempt) starting = undefined;
+ if (startupController === controller) startupController = undefined;
+ });
+ }
+
+ const attempt = starting;
+ try {
+ const started = await attempt;
+ if (shuttingDown) {
+ if (server === started) server = undefined;
+ await started.close();
+ return undefined;
+ }
+ return started;
+ } catch (error) {
+ if (shuttingDown) return undefined;
+ throw error;
+ }
+ },
+
+ async shutdown() {
+ if (stopping) {
+ await stopping;
+ return;
+ }
+
+ shuttingDown = true;
+ const controller = startupController;
+ controller?.abort();
+ const attempt = starting;
+ const current = server;
+ server = undefined;
+ const stop = (async () => {
+ try {
+ if (current) {
+ await current.close();
+ } else if (attempt) {
+ const started = await attempt;
+ if (server === started) server = undefined;
+ await started.close();
+ }
+ } catch {
+ // Shutdown must remain contained inside the caller.
+ }
+ })();
+ stopping = stop;
+ try {
+ await stop;
+ } finally {
+ if (starting === attempt) starting = undefined;
+ if (startupController === controller) startupController = undefined;
+ if (stopping === stop) stopping = undefined;
+ shuttingDown = false;
+ }
+ },
+ };
+}
diff --git a/packages/review-tutor/src/cli.ts b/packages/review-tutor/src/cli.ts
new file mode 100644
index 0000000..b86ff89
--- /dev/null
+++ b/packages/review-tutor/src/cli.ts
@@ -0,0 +1,267 @@
+import { spawn } from "node:child_process";
+import { realpath } from "node:fs/promises";
+import type { Readable } from "node:stream";
+import {
+ createExecFileAdapter,
+ openInBrowser,
+ resolveRepository,
+ sourceFromArgument,
+} from "./cli-support.ts";
+import { createConnectorRegistry, type ConnectorRegistry } from "./connectors/registry.ts";
+import type { ExecFile } from "./inputs.ts";
+import { defaultSkillPath } from "./paths.ts";
+import type { SourceRequest } from "./protocol.ts";
+import { startReviewTutorServer, type StartedReviewTutorServer } from "./server.ts";
+
+export const USAGE = [
+ "Usage: review-tutor [source] [--no-open] [--detach] [--home ]",
+ "",
+ " source worktree (default), staged, , ..., or a GitHub PR URL",
+ " --no-open print the URL without opening a browser",
+ " --detach leave the server running in the background and return immediately",
+ " --home store Review Tutor state under this absolute directory",
+].join("\n");
+
+/** A detached server with no page attached for this long has been abandoned. */
+export const IDLE_EXIT_MS = 30 * 60_000;
+const IDLE_CHECK_MS = 60_000;
+const HANDSHAKE_TIMEOUT_MS = 30_000;
+
+export class UsageError extends Error {}
+
+export interface CliOptions {
+ source?: SourceRequest;
+ open: boolean;
+ detach: boolean;
+ serveDetached: boolean;
+ help: boolean;
+ home?: string;
+}
+
+export interface DetachedChild {
+ readonly stdout: Readable | null;
+ once(event: string, listener: (...args: unknown[]) => void): unknown;
+ unref(): void;
+ kill(signal?: NodeJS.Signals): boolean;
+}
+
+export interface CliDeps {
+ stdout(text: string): void;
+ stderr(text: string): void;
+ cwd(): string;
+ execFile: ExecFile;
+ createRegistry(): ConnectorRegistry;
+ startServer(options: Parameters[0]): Promise;
+ openInBrowser(url: string): Promise;
+ signals(handler: () => void): void;
+ spawnDetached(args: string[]): DetachedChild;
+}
+
+function applyFlag(options: CliOptions, argv: readonly string[], index: number): number {
+ const flag = argv[index]!;
+ if (flag === "--no-open") options.open = false;
+ else if (flag === "--detach") options.detach = true;
+ else if (flag === "--serve-detached") options.serveDetached = true;
+ else if (flag === "--help" || flag === "-h") options.help = true;
+ else if (flag === "--home") {
+ const value = argv[index + 1];
+ if (value === undefined || value.startsWith("-")) {
+ throw new UsageError("--home requires an absolute directory path");
+ }
+ options.home = value;
+ return index + 1;
+ } else throw new UsageError(`unknown option ${flag}`);
+ return index;
+}
+
+const SOURCE_CHARACTERS = /^[A-Za-z0-9._/:@#~^-]+$/;
+
+export function parseArguments(argv: readonly string[]): CliOptions {
+ const options: CliOptions = { open: true, detach: false, serveDetached: false, help: false };
+ const positional: string[] = [];
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]!;
+ if (argument.startsWith("-")) index = applyFlag(options, argv, index);
+ else positional.push(argument);
+ }
+ if (positional.length > 1) throw new UsageError(`unexpected argument ${positional[1]!}`);
+ const source = positional[0] ?? "worktree";
+ // Sources are revisions, ranges, or GitHub URLs; anything else is refused before it can reach a shell or Git.
+ if (!SOURCE_CHARACTERS.test(source)) throw new UsageError("source may only contain letters, digits, and . _ / : @ # ~ ^ -");
+ options.source = sourceFromArgument(source);
+ return options;
+}
+
+/**
+ * Zero clients for one whole window. A page that connects and leaves entirely
+ * between two polls still shows up as a new connection generation, so it
+ * restarts the window instead of being missed.
+ */
+export class IdleTracker {
+ private idleSince: number | undefined;
+ private generation: number | undefined;
+
+ constructor(private readonly windowMs = IDLE_EXIT_MS) {}
+
+ expired(clientCount: number, generation: number, now: number): boolean {
+ const moved = this.generation !== undefined && generation !== this.generation;
+ this.generation = generation;
+ if (clientCount > 0 || moved) {
+ this.idleSince = undefined;
+ return false;
+ }
+ this.idleSince ??= now;
+ return now - this.idleSince >= this.windowMs;
+ }
+}
+
+function waitForIdle(server: StartedReviewTutorServer): Promise {
+ return new Promise((resolve) => {
+ const tracker = new IdleTracker();
+ const timer = setInterval(() => {
+ if (!tracker.expired(server.clientCount(), server.connectionGeneration(), Date.now())) return;
+ clearInterval(timer);
+ resolve();
+ }, IDLE_CHECK_MS);
+ timer.unref?.();
+ });
+}
+
+function message(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+async function discoverySummary(registry: ConnectorRegistry): Promise {
+ const discoveries = await registry.discoveries();
+ return discoveries.map(({ connector, discovery }) => discovery.available
+ ? `${connector.label}: ${discovery.models.length} model${discovery.models.length === 1 ? "" : "s"}`
+ : `${connector.label} unavailable: ${discovery.reason}`).join("\n");
+}
+
+/** The startup summary and the server must share one discovery pass so no harness is probed twice. */
+function memoizeDiscoveries(registry: ConnectorRegistry): ConnectorRegistry {
+ let cached: ReturnType | undefined;
+ return { ...registry, discoveries: () => (cached ??= registry.discoveries()) };
+}
+
+async function runServer(options: CliOptions, deps: CliDeps): Promise {
+ const cwd = await realpath(deps.cwd());
+ const canonicalRepo = await resolveRepository(cwd, deps.execFile);
+ const registry = memoizeDiscoveries(deps.createRegistry());
+ const server = await deps.startServer({
+ cwd,
+ canonicalRepo,
+ registry,
+ execFile: deps.execFile,
+ skillPath: defaultSkillPath(),
+ ...(options.source ? { initialSource: options.source } : {}),
+ ...(options.home ? { home: options.home } : {}),
+ });
+ // Armed before the browser call so a signal during that call still shuts the server down once.
+ const stopped = Promise.race([
+ new Promise((resolve) => deps.signals(resolve)),
+ ...(options.serveDetached ? [waitForIdle(server)] : []),
+ ]);
+ deps.stderr(`${await discoverySummary(registry)}\n`);
+ deps.stdout(`${server.url}\n`);
+ if (options.open && !options.serveDetached && !await deps.openInBrowser(server.url)) {
+ deps.stderr("Could not open a browser. Open the URL above.\n");
+ }
+ await stopped;
+ await server.close();
+ return 0;
+}
+
+function readUrlLine(child: DetachedChild, timeoutMs: number): Promise {
+ const stdout = child.stdout;
+ if (!stdout) {
+ return Promise.reject(new Error(
+ "detached start failed: expected a pipe on the detached server's stdout; retry without --detach",
+ ));
+ }
+ return new Promise((resolve, reject) => {
+ let buffer = "";
+ let timer: ReturnType;
+ const onData = (chunk: unknown): void => {
+ buffer += String(chunk);
+ const newline = buffer.indexOf("\n");
+ if (newline < 0) return;
+ clearTimeout(timer);
+ stdout.off("data", onData);
+ resolve(buffer.slice(0, newline));
+ };
+ const fail = (reason: string): void => {
+ clearTimeout(timer);
+ stdout.off("data", onData);
+ stdout.destroy();
+ child.unref();
+ reject(new Error(reason));
+ };
+ timer = setTimeout(() => {
+ child.kill("SIGTERM");
+ fail(`detached start failed: expected a URL within ${timeoutMs / 1000} seconds, received none; retry without --detach`);
+ }, timeoutMs);
+ stdout.on("data", onData);
+ child.once("error", () => fail("detached start failed: the detached server could not be started; retry without --detach"));
+ child.once("exit", () => fail("detached start failed: the detached server exited before reporting a URL; retry without --detach"));
+ });
+}
+
+async function runDetachedParent(
+ argv: readonly string[],
+ options: CliOptions,
+ deps: CliDeps,
+): Promise {
+ const child = deps.spawnDetached(["--serve-detached", ...argv]);
+ const url = await readUrlLine(child, HANDSHAKE_TIMEOUT_MS);
+ child.stdout?.destroy();
+ child.unref();
+ deps.stdout(`${url}\n`);
+ if (options.open && !await deps.openInBrowser(url)) {
+ deps.stderr("Could not open a browser. Open the URL above.\n");
+ }
+ return 0;
+}
+
+export async function runCli(argv: readonly string[], deps: CliDeps): Promise {
+ let options: CliOptions;
+ try {
+ options = parseArguments(argv);
+ } catch (error) {
+ deps.stderr(`review-tutor: ${message(error)}\n${USAGE}\n`);
+ return 2;
+ }
+ if (options.help) {
+ deps.stdout(`${USAGE}\n`);
+ return 0;
+ }
+ try {
+ return options.detach && !options.serveDetached
+ ? await runDetachedParent(argv, options, deps)
+ : await runServer(options, deps);
+ } catch (error) {
+ deps.stderr(`review-tutor failed: ${message(error)}\n`);
+ return 1;
+ }
+}
+
+export function nodeCliDeps(scriptPath: string): CliDeps {
+ const execFile = createExecFileAdapter();
+ return {
+ stdout: (text) => { process.stdout.write(text); },
+ stderr: (text) => { process.stderr.write(text); },
+ cwd: () => process.cwd(),
+ execFile,
+ createRegistry: () => createConnectorRegistry({}),
+ startServer: startReviewTutorServer,
+ openInBrowser: (url) => openInBrowser(url, process.platform, execFile),
+ signals: (handler) => {
+ process.once("SIGINT", handler);
+ process.once("SIGTERM", handler);
+ },
+ spawnDetached: (args) => spawn(process.execPath, [scriptPath, ...args], {
+ detached: true,
+ stdio: ["ignore", "pipe", "ignore"],
+ }),
+ };
+}
diff --git a/packages/review-tutor/src/connectors/codex.ts b/packages/review-tutor/src/connectors/codex.ts
index 384ecd9..4e2d56d 100644
--- a/packages/review-tutor/src/connectors/codex.ts
+++ b/packages/review-tutor/src/connectors/codex.ts
@@ -1,22 +1,24 @@
-import { execFile as nodeExecFile } from "node:child_process";
import type {
ConnectorRequest,
Discovery,
DiscoveryDeps,
- DiscoveryExecFile,
HarnessConnector,
ParseSink,
ParsedAnswer,
SpawnSpec,
} from "./types.ts";
+import {
+ BASE_DISCOVERY_ENV_KEYS,
+ createDiscoveryExecFile,
+ discoveryOptions,
+ scrubbedEnvironment,
+} from "./discovery.ts";
import { redact } from "./redact.ts";
import { ConnectorError } from "./types.ts";
-const MAX_CATALOG_BYTES = 1024 * 1024;
-const DISCOVERY_TIMEOUT_MS = 10_000;
const FALLBACK_LEVELS = ["low", "medium", "high"];
const MINIMUM_VERSION = [0, 140, 0] as const;
-const DISCOVERY_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "CODEX_HOME"] as const;
+const DISCOVERY_ENV_KEYS = [...BASE_DISCOVERY_ENV_KEYS, "CODEX_HOME"] as const;
interface CodexModel {
slug?: unknown;
@@ -35,18 +37,10 @@ interface CodexEvent {
}
export function discoveryEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
- return Object.fromEntries(DISCOVERY_ENV_KEYS.flatMap((key) => {
- const value = source[key];
- return value === undefined ? [] : [[key, value]];
- }));
+ return scrubbedEnvironment(DISCOVERY_ENV_KEYS, source);
}
-const defaultExecFile: DiscoveryExecFile = (file, args, options) => new Promise((resolve, reject) => {
- nodeExecFile(file, args, { ...options, env: discoveryEnvironment(), shell: false }, (error, stdout, stderr) => {
- if (error) reject(error);
- else resolve({ stdout, stderr });
- });
-});
+const defaultExecFile = createDiscoveryExecFile(DISCOVERY_ENV_KEYS);
function versionAtLeast(version: readonly number[]): boolean {
for (let index = 0; index < MINIMUM_VERSION.length; index += 1) {
@@ -147,13 +141,7 @@ export class CodexConnector implements HarnessConnector {
const execFile = deps.execFile ?? defaultExecFile;
let versionOutput: string;
try {
- const options = {
- encoding: "utf8" as const,
- maxBuffer: MAX_CATALOG_BYTES,
- signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
- timeout: DISCOVERY_TIMEOUT_MS,
- };
- ({ stdout: versionOutput } = await execFile("codex", ["--version"], options));
+ ({ stdout: versionOutput } = await execFile("codex", ["--version"], discoveryOptions()));
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT"
? { available: false, reason: "Codex is not installed (codex not found on PATH)." }
@@ -172,13 +160,7 @@ export class CodexConnector implements HarnessConnector {
};
}
try {
- const options = {
- encoding: "utf8" as const,
- maxBuffer: MAX_CATALOG_BYTES,
- signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
- timeout: DISCOVERY_TIMEOUT_MS,
- };
- const { stdout } = await execFile("codex", ["debug", "models"], options);
+ const { stdout } = await execFile("codex", ["debug", "models"], discoveryOptions());
return { available: true, version: versionLabel, models: modelChoices(parseModels(stdout)) };
} catch {
return { available: false, reason: "Codex could not list its models." };
diff --git a/packages/review-tutor/src/connectors/discovery.ts b/packages/review-tutor/src/connectors/discovery.ts
new file mode 100644
index 0000000..63c2e9b
--- /dev/null
+++ b/packages/review-tutor/src/connectors/discovery.ts
@@ -0,0 +1,46 @@
+import { execFile as nodeExecFile } from "node:child_process";
+import type { DiscoveryExecFile } from "./types.ts";
+
+export const MAX_DISCOVERY_BYTES = 1024 * 1024;
+export const DISCOVERY_TIMEOUT_MS = 10_000;
+
+/** Keys every harness may read; a connector adds only the ones its own CLI needs. */
+export const BASE_DISCOVERY_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL"] as const;
+
+export function scrubbedEnvironment(
+ keys: readonly string[],
+ source: NodeJS.ProcessEnv = process.env,
+): NodeJS.ProcessEnv {
+ return Object.fromEntries(keys.flatMap((key) => {
+ const value = source[key];
+ return value === undefined ? [] : [[key, value]];
+ }));
+}
+
+export function createDiscoveryExecFile(keys: readonly string[]): DiscoveryExecFile {
+ return (file, args, options) => new Promise((resolve, reject) => {
+ nodeExecFile(
+ file,
+ args,
+ { ...options, env: scrubbedEnvironment(keys), shell: false },
+ (error, stdout, stderr) => {
+ if (error) reject(error);
+ else resolve({ stdout, stderr });
+ },
+ );
+ });
+}
+
+export function discoveryOptions(): {
+ encoding: "utf8";
+ maxBuffer: number;
+ signal: AbortSignal;
+ timeout: number;
+} {
+ return {
+ encoding: "utf8",
+ maxBuffer: MAX_DISCOVERY_BYTES,
+ signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
+ timeout: DISCOVERY_TIMEOUT_MS,
+ };
+}
diff --git a/packages/review-tutor/src/connectors/pi.ts b/packages/review-tutor/src/connectors/pi.ts
index 80fabd5..99ff724 100644
--- a/packages/review-tutor/src/connectors/pi.ts
+++ b/packages/review-tutor/src/connectors/pi.ts
@@ -3,12 +3,55 @@ import type {
Discovery,
DiscoveryDeps,
HarnessConnector,
+ ModelChoice,
ParseSink,
ParsedAnswer,
SpawnSpec,
} from "./types.ts";
+import {
+ BASE_DISCOVERY_ENV_KEYS,
+ createDiscoveryExecFile,
+ discoveryOptions,
+} from "./discovery.ts";
import { ConnectorError } from "./types.ts";
+const MINIMUM_VERSION = [0, 83, 0] as const;
+const DISCOVERY_ENV_KEYS = [...BASE_DISCOVERY_ENV_KEYS, "XDG_CONFIG_HOME"] as const;
+const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
+const HEADER_COLUMNS = ["provider", "model", "context", "max-out", "thinking", "images"];
+
+const defaultExecFile = createDiscoveryExecFile(DISCOVERY_ENV_KEYS);
+
+function versionAtLeast(version: readonly number[]): boolean {
+ for (let index = 0; index < MINIMUM_VERSION.length; index += 1) {
+ const difference = version[index]! - MINIMUM_VERSION[index]!;
+ if (difference !== 0) return difference > 0;
+ }
+ return true;
+}
+
+function tableRows(output: string): string[][] {
+ const lines = output.split("\n")
+ .map((line) => line.trimEnd())
+ .filter((line) => line.trim() && !line.startsWith("Warning:"));
+ const header = lines.findIndex((line) => {
+ const columns = line.trim().split(/\s{2,}/);
+ return HEADER_COLUMNS.every((column, index) => columns[index] === column);
+ });
+ if (header < 0) return [];
+ return lines.slice(header + 1)
+ .map((line) => line.trim().split(/\s{2,}/))
+ .filter((columns) => columns.length >= HEADER_COLUMNS.length);
+}
+
+function listedModels(output: string): ModelChoice[] {
+ return tableRows(output).map(([provider, model, , , thinking]) => ({
+ id: `pi:${provider!}/${model!}`,
+ label: `${provider!}: ${model!}`,
+ thinkingLevels: thinking === "yes" ? [...REASONING_LEVELS] : ["off"],
+ }));
+}
+
interface PiContent {
type?: unknown;
text?: unknown;
@@ -57,11 +100,47 @@ export class PiConnector implements HarnessConnector {
readonly label = "Pi";
async discover(deps: DiscoveryDeps): Promise {
- return {
- available: true,
- version: deps.piVersion ?? "unknown",
- models: deps.piModels.map((model) => ({ ...model, id: `pi:${model.id}` })),
- };
+ if (deps.piModels) {
+ return {
+ available: true,
+ version: deps.piVersion ?? "unknown",
+ models: deps.piModels.map((model) => ({ ...model, id: `pi:${model.id}` })),
+ };
+ }
+ return this.discoverWithoutHost(deps.execFile ?? defaultExecFile);
+ }
+
+ private async discoverWithoutHost(execFile: NonNullable): Promise {
+ let versionOutput: string;
+ try {
+ ({ stdout: versionOutput } = await execFile("pi", ["--version"], discoveryOptions()));
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
+ ? { available: false, reason: "Pi is not installed (pi not found on PATH)." }
+ : { available: false, reason: "Pi could not report a supported version." };
+ }
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(versionOutput.trim());
+ if (!match) {
+ return { available: false, reason: "Pi could not report a supported version." };
+ }
+ const version = match.slice(1, 4).map(Number);
+ const versionLabel = version.join(".");
+ if (!versionAtLeast(version)) {
+ return {
+ available: false,
+ reason: `Pi ${versionLabel} is too old; version 0.83.0 or newer is required.`,
+ };
+ }
+ let models: ModelChoice[];
+ try {
+ const { stdout } = await execFile("pi", ["--list-models"], discoveryOptions());
+ models = listedModels(stdout);
+ } catch {
+ return { available: false, reason: "Pi could not list its models." };
+ }
+ return models.length
+ ? { available: true, version: versionLabel, models }
+ : { available: false, reason: "Pi could not list its models." };
}
spawnSpec(request: ConnectorRequest): SpawnSpec {
diff --git a/packages/review-tutor/src/connectors/registry.ts b/packages/review-tutor/src/connectors/registry.ts
index 5527a90..8a1358c 100644
--- a/packages/review-tutor/src/connectors/registry.ts
+++ b/packages/review-tutor/src/connectors/registry.ts
@@ -27,14 +27,14 @@ function which(command: string): Promise {
}
export function createConnectorRegistry(options: {
- piModels: ModelChoice[];
+ piModels?: ModelChoice[];
piVersion?: string;
which?: DiscoveryDeps["which"];
execFile?: DiscoveryExecFile;
}): ConnectorRegistry {
const registered: HarnessConnector[] = [new PiConnector(), new ClaudeCodeConnector(), new CodexConnector()];
const dependencies: DiscoveryDeps = {
- piModels: options.piModels,
+ ...(options.piModels ? { piModels: options.piModels } : {}),
...(options.piVersion ? { piVersion: options.piVersion } : {}),
which: options.which ?? which,
...(options.execFile ? { execFile: options.execFile } : {}),
diff --git a/packages/review-tutor/src/connectors/types.ts b/packages/review-tutor/src/connectors/types.ts
index 7e48d2e..1090334 100644
--- a/packages/review-tutor/src/connectors/types.ts
+++ b/packages/review-tutor/src/connectors/types.ts
@@ -21,7 +21,8 @@ export type DiscoveryExecFile = (
) => Promise<{ stdout: string; stderr: string }>;
export interface DiscoveryDeps {
- piModels: ModelChoice[];
+ /** Present only when a Pi host already holds the model snapshot; absent hosts make Pi discover itself. */
+ piModels?: ModelChoice[];
piVersion?: string;
which(command: string): Promise;
execFile?: DiscoveryExecFile;
diff --git a/packages/review-tutor/src/inputs.ts b/packages/review-tutor/src/inputs.ts
index 202dca3..3b438ba 100644
--- a/packages/review-tutor/src/inputs.ts
+++ b/packages/review-tutor/src/inputs.ts
@@ -10,6 +10,7 @@ export type ExecFile = (
maxBuffer: number;
encoding: "utf8";
signal?: AbortSignal;
+ timeoutMs?: number;
},
) => Promise<{ stdout: string; stderr: string }>;
diff --git a/packages/review-tutor/src/paths.ts b/packages/review-tutor/src/paths.ts
index d694745..0b512d6 100644
--- a/packages/review-tutor/src/paths.ts
+++ b/packages/review-tutor/src/paths.ts
@@ -1,6 +1,16 @@
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/**
+ * Both loaders sit exactly one directory below the package root: Pi imports
+ * `src/paths.ts` from TS source and the built CLI bundles this file into
+ * `dist/cli.js`, so one `..` reaches the shipped skill from either location.
+ */
+export function defaultSkillPath(): string {
+ return fileURLToPath(new URL("../skills/review-tutor/SKILL.md", import.meta.url));
+}
export interface StatePaths {
root: string;
diff --git a/packages/review-tutor/src/server.ts b/packages/review-tutor/src/server.ts
index 78060a2..843b11b 100644
--- a/packages/review-tutor/src/server.ts
+++ b/packages/review-tutor/src/server.ts
@@ -32,6 +32,12 @@ export interface ReviewTutorServer {
port: number;
}
+/** A started server also reports its SSE traffic, which is how a detached CLI knows it is idle. */
+export interface StartedReviewTutorServer extends ReviewTutorServer {
+ clientCount(): number;
+ connectionGeneration(): number;
+}
+
const responseHeaders = {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
@@ -152,7 +158,7 @@ function handleError(response: ServerResponse, error: unknown): void {
}
}
-export async function startReviewTutorServer(options: ServerOptions): Promise {
+export async function startReviewTutorServer(options: ServerOptions): Promise {
const token = randomBytes(32).toString("base64url");
const paths = resolveStatePaths(options.canonicalRepo, options.home);
await initProjectPaths(paths, options.canonicalRepo);
@@ -165,9 +171,10 @@ export async function startReviewTutorServer(options: ServerOptions): Promise | undefined;
const server = createServer(async (request, response) => {
@@ -197,7 +204,14 @@ export async function startReviewTutorServer(options: ServerOptions): Promise hub.clientCount,
+ connectionGeneration: () => hub.connectionGeneration,
+ };
}
async function loadInitialSource(options: ServerOptions, session: ReviewTutorSession): Promise {
diff --git a/packages/review-tutor/src/sse.ts b/packages/review-tutor/src/sse.ts
index 63161a2..e88c9d7 100644
--- a/packages/review-tutor/src/sse.ts
+++ b/packages/review-tutor/src/sse.ts
@@ -3,11 +3,21 @@ import type { SseEvent, SseEventType } from "./protocol.ts";
export class SseHub {
private next = 1;
+ private connections = 0;
private readonly ring: SseEvent[] = [];
private readonly clients = new Set();
constructor(private readonly capacity = 256) {}
+ get clientCount(): number {
+ return this.clients.size;
+ }
+
+ /** Counts every client ever attached, so a connection that lands between two polls is still visible. */
+ get connectionGeneration(): number {
+ return this.connections;
+ }
+
emit(type: SseEventType, data: unknown): SseEvent {
const event = { id: this.next++, type, data };
this.ring.push(event);
@@ -34,6 +44,7 @@ export class SseHub {
connect(response: ServerResponse, since: number): () => void {
if (this.isDead(response)) return () => {};
+ this.connections += 1;
this.clients.add(response);
for (const event of this.since(since)) {
if (!this.write(response, this.format(event))) {
diff --git a/packages/review-tutor/test/cli.test.ts b/packages/review-tutor/test/cli.test.ts
new file mode 100644
index 0000000..e2b70a6
--- /dev/null
+++ b/packages/review-tutor/test/cli.test.ts
@@ -0,0 +1,337 @@
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
+import { setTimeout as realSetTimeout } from "node:timers";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ IdleTracker,
+ parseArguments,
+ runCli,
+ USAGE,
+ type CliDeps,
+ type DetachedChild,
+} from "../src/cli.ts";
+import { createConnectorRegistry } from "../src/connectors/registry.ts";
+import type { ExecFile } from "../src/inputs.ts";
+import type { StartedReviewTutorServer } from "../src/server.ts";
+
+const URL_LINE = "http://127.0.0.1:4321/?session=token";
+const absentExecFile = async () => { throw Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); };
+
+class FakeChild extends EventEmitter implements DetachedChild {
+ stdout = new PassThrough();
+ unref = vi.fn();
+ kill = vi.fn(() => true);
+}
+
+function fakeServer(
+ close = vi.fn(async () => {}),
+ clientCount = () => 0,
+ connectionGeneration = () => 0,
+): StartedReviewTutorServer {
+ return { url: URL_LINE, token: "token", port: 4321, close, clientCount, connectionGeneration };
+}
+
+interface Harness {
+ deps: CliDeps;
+ out: string[];
+ err: string[];
+ close: ReturnType;
+ started: Array>;
+ children: FakeChild[];
+ signal(): void;
+}
+
+function harness(overrides: Partial = {}): Harness {
+ const out: string[] = [];
+ const err: string[] = [];
+ const close = vi.fn(async () => {});
+ const started: Array> = [];
+ const children: FakeChild[] = [];
+ const handlers: Array<() => void> = [];
+ const execFile: ExecFile = async () => ({ stdout: `${process.cwd()}\n`, stderr: "" });
+ const deps: CliDeps = {
+ stdout: (text) => { out.push(text); },
+ stderr: (text) => { err.push(text); },
+ cwd: () => process.cwd(),
+ execFile,
+ createRegistry: () => createConnectorRegistry({
+ piModels: [{ id: "provider/model", label: "Model", thinkingLevels: ["low"] }],
+ which: async () => undefined,
+ execFile: absentExecFile,
+ }),
+ startServer: async (options) => {
+ started.push(options as unknown as Record);
+ return fakeServer(close);
+ },
+ openInBrowser: vi.fn(async () => true),
+ signals: (handler) => { handlers.push(handler); },
+ spawnDetached: vi.fn(() => {
+ const child = new FakeChild();
+ children.push(child);
+ return child;
+ }),
+ ...overrides,
+ };
+ return {
+ deps,
+ out,
+ err,
+ close,
+ started,
+ children,
+ signal: () => { for (const handler of handlers.splice(0)) handler(); },
+ };
+}
+
+/** Yields to the real event loop under both timer modes so pending file-system work can land. */
+async function settle(check: () => boolean = () => true): Promise {
+ // node:timers keeps the real setTimeout even while the global one is faked, so
+ // file-system callbacks (realpath) can land on a slow runner before the next check.
+ const deadline = process.hrtime.bigint() + 5_000_000_000n;
+ while (process.hrtime.bigint() < deadline) {
+ await new Promise((resolve) => realSetTimeout(resolve, 2));
+ if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(1);
+ if (check()) return;
+ }
+ throw new Error("test barrier failed: expected condition within 5 s");
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("command line parsing", () => {
+ it("defaults to the worktree source", () => {
+ expect(parseArguments([])).toMatchObject({
+ source: { protocol: "rt/1", kind: "worktree" },
+ open: true,
+ detach: false,
+ serveDetached: false,
+ });
+ });
+
+ it.each([
+ [["staged"], { source: { protocol: "rt/1", kind: "staged" } }],
+ [["abc123"], { source: { protocol: "rt/1", kind: "commit", revision: "abc123" } }],
+ [["main...topic"], { source: { protocol: "rt/1", kind: "range", from: "main", to: "topic" } }],
+ [["--no-open"], { open: false }],
+ [["--detach"], { detach: true }],
+ [["--serve-detached"], { serveDetached: true }],
+ [["--home", "/tmp/state"], { home: "/tmp/state" }],
+ [["--detach", "--no-open", "staged"], { detach: true, open: false }],
+ ])("parses %j", (argv, expected) => {
+ expect(parseArguments(argv)).toMatchObject(expected);
+ });
+
+ it.each([["--nope"], ["--home"], ["--home", "--detach"], ["one", "two"]])(
+ "rejects %j",
+ (...argv) => {
+ expect(() => parseArguments(argv)).toThrow();
+ },
+ );
+
+ it.each([["$(id)"], ["a b"], ["x;y"], ["`ls`"], ["'q'"]])("refuses the source %j before it reaches Git or a shell", (source) => {
+ expect(() => parseArguments([source])).toThrow(/source may only contain/);
+ });
+
+ it.each([["worktree"], ["staged"], ["main...HEAD"], ["abc123"], ["https://github.com/pickforge/pickforge-platform/pull/83"], ["v0.12.0~1^2"]])(
+ "accepts the source %s",
+ (source) => {
+ expect(parseArguments([source]).source).toBeDefined();
+ },
+ );
+
+ it("reports usage on stderr and exits 2 for an unknown flag", async () => {
+ const { deps, out, err } = harness();
+ await expect(runCli(["--nope"], deps)).resolves.toBe(2);
+ expect(out).toEqual([]);
+ expect(err.join("")).toContain(USAGE);
+ });
+
+ it("prints usage on stdout for --help", async () => {
+ const { deps, out, err } = harness();
+ await expect(runCli(["--help"], deps)).resolves.toBe(0);
+ expect(out.join("")).toBe(`${USAGE}\n`);
+ expect(err).toEqual([]);
+ });
+});
+
+describe("foreground run", () => {
+ it("prints one URL line, summarises discovery, and closes on a signal", async () => {
+ const context = harness();
+ const run = runCli([], context.deps);
+ await settle(() => context.out.length > 0);
+ expect(context.out).toEqual([`${URL_LINE}\n`]);
+ expect(context.err.join("")).toBe(
+ "Pi: 1 model\nClaude Code unavailable: Claude Code is not installed (claude not found on PATH).\nCodex unavailable: Codex is not installed (codex not found on PATH).\n",
+ );
+ expect(context.deps.openInBrowser).toHaveBeenCalledWith(URL_LINE);
+ expect(context.started[0]).toMatchObject({ initialSource: { kind: "worktree" } });
+ expect(context.close).not.toHaveBeenCalled();
+
+ context.signal();
+ await expect(run).resolves.toBe(0);
+ expect(context.close).toHaveBeenCalledTimes(1);
+ expect(context.out).toEqual([`${URL_LINE}\n`]);
+ });
+
+ it("keeps the browser closed with --no-open and forwards --home", async () => {
+ const context = harness();
+ const run = runCli(["--no-open", "--home", "/tmp/review-tutor-cli"], context.deps);
+ await settle(() => context.out.length > 0);
+ expect(context.deps.openInBrowser).not.toHaveBeenCalled();
+ expect(context.started[0]).toMatchObject({ home: "/tmp/review-tutor-cli" });
+ context.signal();
+ await expect(run).resolves.toBe(0);
+ });
+
+ it("explains a browser that will not open", async () => {
+ const context = harness({ openInBrowser: vi.fn(async () => false) });
+ const run = runCli([], context.deps);
+ await settle(() => context.err.join("").includes("Could not open"));
+ expect(context.err.join("")).toContain("Could not open a browser.");
+ context.signal();
+ await expect(run).resolves.toBe(0);
+ });
+
+ it("shuts down once for a signal that arrives while the browser is still opening", async () => {
+ let openBrowser!: (opened: boolean) => void;
+ const context = harness({
+ openInBrowser: vi.fn(() => new Promise((resolve) => { openBrowser = resolve; })),
+ });
+ const run = runCli([], context.deps);
+ await settle(() => openBrowser !== undefined);
+
+ context.signal();
+ await settle();
+ expect(context.close).not.toHaveBeenCalled();
+
+ openBrowser(true);
+ await expect(run).resolves.toBe(0);
+ expect(context.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("exits 1 when the repository cannot be resolved", async () => {
+ const context = harness({
+ execFile: async () => { throw Object.assign(new Error("not a repository"), { code: 128 }); },
+ });
+ await expect(runCli([], context.deps)).resolves.toBe(1);
+ expect(context.err.join("")).toContain("repository resolution failed");
+ expect(context.out).toEqual([]);
+ });
+});
+
+describe("detached handshake", () => {
+ it("prints the child's URL, unrefs it, and returns immediately", async () => {
+ const context = harness();
+ const run = runCli(["--detach"], context.deps);
+ await settle(() => context.children.length > 0);
+ const child = context.children[0]!;
+ expect(context.deps.spawnDetached).toHaveBeenCalledWith(["--serve-detached", "--detach"]);
+ child.stdout.write(`${URL_LINE}\n`);
+ await expect(run).resolves.toBe(0);
+ expect(context.out).toEqual([`${URL_LINE}\n`]);
+ expect(child.unref).toHaveBeenCalledTimes(1);
+ expect(context.deps.openInBrowser).toHaveBeenCalledWith(URL_LINE);
+ expect(context.close).not.toHaveBeenCalled();
+ });
+
+ it("fails plainly when the child never reports a URL", async () => {
+ vi.useFakeTimers();
+ const context = harness();
+ const run = runCli(["--detach"], context.deps);
+ await settle(() => context.children.length > 0);
+ await vi.advanceTimersByTimeAsync(30_000);
+ await expect(run).resolves.toBe(1);
+ const child = context.children[0]!;
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+ expect(child.stdout.destroyed).toBe(true);
+ expect(child.unref).toHaveBeenCalledTimes(1);
+ expect(context.err.join("")).toContain("expected a URL within 30 seconds");
+ expect(context.out).toEqual([]);
+ });
+
+ it("fails when the child exits before reporting a URL", async () => {
+ const context = harness();
+ const run = runCli(["--detach"], context.deps);
+ await settle(() => context.children.length > 0);
+ context.children[0]!.emit("exit", 1, null);
+ await expect(run).resolves.toBe(1);
+ expect(context.err.join("")).toContain("exited before reporting a URL");
+ expect(context.children[0]!.stdout.destroyed).toBe(true);
+ expect(context.children[0]!.unref).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("detached idle exit", () => {
+ it("expires only after a whole window without clients", () => {
+ const tracker = new IdleTracker(1_000);
+ expect(tracker.expired(0, 0, 0)).toBe(false);
+ expect(tracker.expired(0, 0, 900)).toBe(false);
+ expect(tracker.expired(1, 1, 950)).toBe(false);
+ expect(tracker.expired(0, 1, 1_000)).toBe(false);
+ expect(tracker.expired(0, 1, 1_999)).toBe(false);
+ expect(tracker.expired(0, 1, 2_000)).toBe(true);
+ });
+
+ it("restarts the window for a connection that came and went between two polls", () => {
+ const tracker = new IdleTracker(1_000);
+ expect(tracker.expired(0, 3, 0)).toBe(false);
+ expect(tracker.expired(0, 4, 900)).toBe(false);
+ expect(tracker.expired(0, 4, 1_000)).toBe(false);
+ expect(tracker.expired(0, 4, 1_999)).toBe(false);
+ expect(tracker.expired(0, 4, 2_000)).toBe(true);
+ });
+
+ it("closes a detached server once it has been idle for the whole window", async () => {
+ vi.useFakeTimers();
+ let clients = 1;
+ let generation = 1;
+ const close = vi.fn(async () => {});
+ const context = harness({
+ startServer: async () => fakeServer(close, () => clients, () => generation),
+ });
+
+ const run = runCli(["--serve-detached", "--no-open"], context.deps);
+ await settle(() => context.out.length > 0);
+ await vi.advanceTimersByTimeAsync(40 * 60_000);
+ expect(close).not.toHaveBeenCalled();
+
+ clients = 0;
+ await vi.advanceTimersByTimeAsync(29 * 60_000);
+ expect(close).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(2 * 60_000);
+ await expect(run).resolves.toBe(0);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("delays exit by a full window when a page connects between two polls", async () => {
+ vi.useFakeTimers();
+ let generation = 1;
+ const close = vi.fn(async () => {});
+ const context = harness({
+ startServer: async () => fakeServer(close, () => 0, () => generation),
+ });
+
+ const run = runCli(["--serve-detached", "--no-open"], context.deps);
+ await settle(() => context.out.length > 0);
+ await vi.advanceTimersByTimeAsync(25 * 60_000);
+ generation += 1;
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
+ expect(close).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(25 * 60_000);
+ await expect(run).resolves.toBe(0);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("never opens a browser from the detached child", async () => {
+ const context = harness();
+ const run = runCli(["--serve-detached"], context.deps);
+ await settle(() => context.out.length > 0);
+ expect(context.deps.openInBrowser).not.toHaveBeenCalled();
+ expect(context.out).toEqual([`${URL_LINE}\n`]);
+ context.signal();
+ await expect(run).resolves.toBe(0);
+ });
+});
diff --git a/packages/review-tutor/test/connector-pi.test.ts b/packages/review-tutor/test/connector-pi.test.ts
new file mode 100644
index 0000000..556b0bf
--- /dev/null
+++ b/packages/review-tutor/test/connector-pi.test.ts
@@ -0,0 +1,117 @@
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it, vi } from "vitest";
+import { PiConnector } from "../src/connectors/pi.ts";
+import { createConnectorRegistry } from "../src/connectors/registry.ts";
+import type { DiscoveryDeps } from "../src/connectors/types.ts";
+
+const table = await readFile(
+ fileURLToPath(new URL("fixtures/pi/list-models.txt", import.meta.url)),
+ "utf8",
+);
+const REASONING = ["off", "minimal", "low", "medium", "high", "xhigh"];
+
+function fakeDiscovery(outputs: Record): DiscoveryDeps {
+ return {
+ which: async () => undefined,
+ execFile: vi.fn(async (_file, args) => {
+ const key = args.join(" ");
+ const output = outputs[key];
+ if (output instanceof Error) throw output;
+ if (output === undefined) throw new Error(`unexpected command: ${key}`);
+ return { stdout: output, stderr: "" };
+ }),
+ };
+}
+
+describe("Pi discovery without a host", () => {
+ it("reports a missing executable", async () => {
+ const missing = Object.assign(new Error("spawn pi ENOENT"), { code: "ENOENT" });
+ await expect(new PiConnector().discover(fakeDiscovery({ "--version": missing }))).resolves.toEqual({
+ available: false,
+ reason: "Pi is not installed (pi not found on PATH).",
+ });
+ });
+
+ it.each([
+ new Error("timed out"),
+ "not a version\n",
+ ])("distinguishes other version failures from a missing executable", async (failure) => {
+ await expect(new PiConnector().discover(fakeDiscovery({ "--version": failure }))).resolves.toEqual({
+ available: false,
+ reason: "Pi could not report a supported version.",
+ });
+ });
+
+ it("rejects versions below the supported extension API", async () => {
+ await expect(new PiConnector().discover(fakeDiscovery({ "--version": "0.82.9\n" }))).resolves.toEqual({
+ available: false,
+ reason: "Pi 0.82.9 is too old; version 0.83.0 or newer is required.",
+ });
+ });
+
+ it("parses the model table through bounded, scrubbed execution", async () => {
+ const deps = fakeDiscovery({ "--version": "0.84.2\n", "--list-models": table });
+ await expect(new PiConnector().discover(deps)).resolves.toEqual({
+ available: true,
+ version: "0.84.2",
+ models: [
+ { id: "pi:openai-codex/gpt-5.6-sol", label: "openai-codex: gpt-5.6-sol", thinkingLevels: REASONING },
+ { id: "pi:anthropic/claude-fable-5", label: "anthropic: claude-fable-5", thinkingLevels: REASONING },
+ { id: "pi:ollama/qwen3:8b", label: "ollama: qwen3:8b", thinkingLevels: REASONING },
+ { id: "pi:deepseek-official/deepseek-v4-flash", label: "deepseek-official: deepseek-v4-flash", thinkingLevels: REASONING },
+ { id: "pi:local/basic", label: "local: basic", thinkingLevels: ["off"] },
+ ],
+ });
+ expect(deps.execFile).toHaveBeenNthCalledWith(1, "pi", ["--version"], {
+ encoding: "utf8",
+ maxBuffer: 1024 * 1024,
+ signal: expect.any(AbortSignal),
+ timeout: 10_000,
+ });
+ expect(deps.execFile).toHaveBeenNthCalledWith(2, "pi", ["--list-models"], {
+ encoding: "utf8",
+ maxBuffer: 1024 * 1024,
+ signal: expect.any(AbortSignal),
+ timeout: 10_000,
+ });
+ expect(deps.execFile).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([
+ "openai-codex gpt-5.6-sol 272K 128K yes yes\n",
+ "provider model context max-out thinking images\n",
+ "",
+ new Error("timed out"),
+ ])("reports malformed or failing tables as unavailable", async (failure) => {
+ await expect(new PiConnector().discover(fakeDiscovery({
+ "--version": "0.84.2\n",
+ "--list-models": failure,
+ }))).resolves.toEqual({ available: false, reason: "Pi could not list its models." });
+ });
+
+ it("keeps the hosted path free of process execution", async () => {
+ const deps = fakeDiscovery({});
+ const hosted = { ...deps, piModels: [{ id: "provider/model", label: "Model", thinkingLevels: ["low"] }] };
+ await expect(new PiConnector().discover(hosted)).resolves.toEqual({
+ available: true,
+ version: "unknown",
+ models: [{ id: "pi:provider/model", label: "Model", thinkingLevels: ["low"] }],
+ });
+ expect(deps.execFile).not.toHaveBeenCalled();
+ });
+});
+
+describe("Pi registry without a host", () => {
+ it("discovers Pi through the registry when no model snapshot is supplied", async () => {
+ const registry = createConnectorRegistry({
+ which: async () => undefined,
+ execFile: fakeDiscovery({ "--version": "0.84.2\n", "--list-models": table }).execFile,
+ });
+ const discoveries = await registry.discoveries();
+ expect(discoveries.find(({ connector }) => connector.id === "pi")?.discovery).toMatchObject({
+ available: true,
+ version: "0.84.2",
+ });
+ });
+});
diff --git a/packages/review-tutor/test/connectors.test.ts b/packages/review-tutor/test/connectors.test.ts
index 08dcccb..3ccd11e 100644
--- a/packages/review-tutor/test/connectors.test.ts
+++ b/packages/review-tutor/test/connectors.test.ts
@@ -21,8 +21,10 @@ class FakeChild extends EventEmitter {
const models = [{ id: "anthropic/model", label: "Model", thinkingLevels: ["low"] }];
+const absentExecFile = async () => { throw Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); };
+
function registry() {
- return createConnectorRegistry({ piModels: models });
+ return createConnectorRegistry({ piModels: models, which: async () => undefined, execFile: absentExecFile });
}
const request = {
diff --git a/packages/review-tutor/test/extension-host-exec.test.ts b/packages/review-tutor/test/extension-host-exec.test.ts
new file mode 100644
index 0000000..a72f12e
--- /dev/null
+++ b/packages/review-tutor/test/extension-host-exec.test.ts
@@ -0,0 +1,79 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const url = "http://127.0.0.1:1/?session=secret";
+const startReviewTutorServer = vi.fn(async () => ({
+ url,
+ token: "secret",
+ port: 1,
+ close: async () => {},
+ clientCount: () => 0,
+ connectionGeneration: () => 0,
+}));
+const nodeExecFile = vi.fn(() => {
+ throw new Error("node:child_process must not run host operations under Pi");
+});
+
+vi.mock("../src/server.ts", () => ({ startReviewTutorServer }));
+vi.mock("node:child_process", () => ({ execFile: nodeExecFile }));
+
+const { default: reviewTutorExtension } = await import("../extensions/review-tutor.ts");
+
+const browserCommand = process.platform === "darwin"
+ ? "open"
+ : process.platform === "win32"
+ ? "cmd"
+ : "xdg-open";
+const browserArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
+const roots: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
+});
+
+async function runCommand() {
+ const cwd = await mkdtemp(join(tmpdir(), "review-tutor-host-"));
+ roots.push(cwd);
+ const exec = vi.fn(async (file: string) => ({
+ code: 0,
+ stdout: file === "git" ? `${cwd}\n` : "",
+ stderr: "",
+ }));
+ const notify = vi.fn();
+ const handlers = new Map Promise }>();
+ const pi = {
+ exec,
+ registerCommand: (name: string, spec: never) => { handlers.set(name, spec); },
+ on: () => {},
+ };
+ reviewTutorExtension(pi as never);
+ await handlers.get("review-tutor")!.handler("", {
+ mode: "tui",
+ cwd,
+ scopedModels: [],
+ modelRegistry: { getAvailable: () => [{ provider: "provider", id: "model", name: "Model", reasoning: true }] },
+ ui: { notify, setStatus: vi.fn() },
+ });
+ return { cwd, exec, notify };
+}
+
+describe("extension host execution", () => {
+ it("resolves the repository and opens the browser through pi.exec with the host timeouts", async () => {
+ const { cwd, exec, notify } = await runCommand();
+
+ expect(exec).toHaveBeenNthCalledWith(1, "git", ["rev-parse", "--show-toplevel"], {
+ cwd,
+ timeout: 5_000,
+ });
+ expect(exec).toHaveBeenNthCalledWith(2, browserCommand, browserArgs, {
+ cwd: process.cwd(),
+ timeout: 10_000,
+ });
+ expect(exec).toHaveBeenCalledTimes(2);
+ expect(nodeExecFile).not.toHaveBeenCalled();
+ expect(notify).toHaveBeenCalledWith("Review Tutor opened.", "info");
+ expect(startReviewTutorServer).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/review-tutor/test/fixtures/pi/list-models.txt b/packages/review-tutor/test/fixtures/pi/list-models.txt
new file mode 100644
index 0000000..1478e8d
--- /dev/null
+++ b/packages/review-tutor/test/fixtures/pi/list-models.txt
@@ -0,0 +1,8 @@
+Warning: No models match pattern "opencode-go/glm-5.3"
+
+provider model context max-out thinking images
+openai-codex gpt-5.6-sol 272K 128K yes yes
+anthropic claude-fable-5 1M 64K yes yes
+ollama qwen3:8b 256K 32K yes no
+deepseek-official deepseek-v4-flash 1M 384K yes no
+local basic 8K 4K no no