Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/review-tutor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>]
```

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.
Expand Down
194 changes: 27 additions & 167 deletions packages/review-tutor/extensions/review-tutor.ts
Original file line number Diff line number Diff line change
@@ -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<ReviewTutorServer>,
): Promise<ReviewTutorServer | undefined>;
shutdown(): Promise<void>;
}

export function createServerLifecycle(): ServerLifecycle {
let server: ReviewTutorServer | undefined;
let starting: Promise<ReviewTutorServer> | undefined;
let startupController: AbortController | undefined;
let stopping: Promise<void> | 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 };
};
}

Expand All @@ -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,
Expand All @@ -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<string> {
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<boolean> {
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,
Expand All @@ -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",
Expand All @@ -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(
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions packages/review-tutor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 12 additions & 0 deletions packages/review-tutor/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
Loading