feat(coding-agent/eval): added local python-runner subprocess execution

- Replaced Python execution with a local `python -u runner.py` subprocess and NDJSON stdin/stdout framing.
- Removed shared-gateway architecture, including coordinator lifecycle APIs, `useSharedGateway` wiring, and `jupyter` CLI/actions.
- Simplified setup checks to a plain Python 3 availability probe and removed automatic dependency-install fallbacks.
- Updated kernel cancellation and display processing to use status frames, SIGINT/SIGTERM escalation, and normalized output coercion.
- Added `python-runner` integration and display tests while deleting legacy websocket and kernel lifecycle test suites.
This commit is contained in:
can1357
2026-05-12 09:09:24 +02:00
parent dcf3f1c8a3
commit 8d144e17ec
26 changed files with 1735 additions and 3004 deletions
-1
View File
@@ -55,7 +55,6 @@ const commands: CommandEntry[] = [
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
{ name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
{ name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
{ name: "setup", load: () => import("./commands/setup").then(m => m.default) },
{ name: "shell", load: () => import("./commands/shell").then(m => m.default) },
@@ -1,106 +0,0 @@
/**
* Jupyter CLI command handlers.
*
* Handles `omp jupyter` subcommand for managing the shared Python gateway.
*/
import { APP_NAME } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { getGatewayStatus, shutdownSharedGateway } from "../eval/py/gateway-coordinator";
export type JupyterAction = "kill" | "status";
export interface JupyterCommandArgs {
action: JupyterAction;
}
export function parseJupyterArgs(args: string[]): JupyterCommandArgs | undefined {
if (args.length === 0 || args[0] !== "jupyter") {
return undefined;
}
const action = args[1] as JupyterAction | undefined;
if (!action || !["kill", "status"].includes(action)) {
return { action: "status" };
}
return { action };
}
export async function runJupyterCommand(cmd: JupyterCommandArgs): Promise<void> {
switch (cmd.action) {
case "kill":
await runKill();
break;
case "status":
await runStatus();
break;
}
}
async function runKill(): Promise<void> {
const status = await getGatewayStatus();
if (!status.active) {
console.log(chalk.dim("No Jupyter gateway is running"));
return;
}
console.log(`Killing Jupyter gateway (PID ${status.pid})...`);
await shutdownSharedGateway();
console.log(chalk.green("Jupyter gateway stopped"));
}
async function runStatus(): Promise<void> {
const status = await getGatewayStatus();
if (!status.active) {
console.log(chalk.dim("No Jupyter gateway is running"));
return;
}
console.log(chalk.bold("Jupyter Gateway Status\n"));
console.log(` ${chalk.green("●")} Running`);
console.log(` PID: ${status.pid}`);
console.log(` URL: ${status.url}`);
if (status.uptime !== null) {
console.log(` Uptime: ${formatUptime(status.uptime)}`);
}
if (status.pythonPath) {
console.log(` Python: ${status.pythonPath}`);
}
if (status.venvPath) {
console.log(` Venv: ${status.venvPath}`);
}
}
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
export function printJupyterHelp(): void {
console.log(`${chalk.bold(`${APP_NAME} jupyter`)} - Manage the shared Jupyter gateway
${chalk.bold("Usage:")}
${APP_NAME} jupyter <command>
${chalk.bold("Commands:")}
status Show gateway status (default)
kill Stop the running gateway
${chalk.bold("Examples:")}
${APP_NAME} jupyter # Show status
${APP_NAME} jupyter status # Show status
${APP_NAME} jupyter kill # Stop the gateway
`);
}
+14 -161
View File
@@ -21,7 +21,6 @@ export interface SetupCommandArgs {
const VALID_COMPONENTS: SetupComponent[] = ["python", "stt"];
const PYTHON_PACKAGES = ["jupyter_kernel_gateway", "ipykernel"];
const MANAGED_PYTHON_ENV = getPythonEnvDir();
/**
@@ -65,10 +64,6 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
interface PythonCheckResult {
available: boolean;
pythonPath?: string;
uvPath?: string;
pipPath?: string;
missingPackages: string[];
installedPackages: string[];
usingManagedEnv?: boolean;
managedEnvPath?: string;
}
@@ -85,8 +80,6 @@ function managedPythonPath(): string {
async function checkPythonSetup(): Promise<PythonCheckResult> {
const result: PythonCheckResult = {
available: false,
missingPackages: [],
installedPackages: [],
managedEnvPath: MANAGED_PYTHON_ENV,
};
@@ -94,109 +87,24 @@ async function checkPythonSetup(): Promise<PythonCheckResult> {
const managedPath = managedPythonPath();
const hasManagedEnv = await Bun.file(managedPath).exists();
result.uvPath = $which("uv") ?? undefined;
result.pipPath = $which("pip3") ?? $which("pip") ?? undefined;
const candidates = [systemPythonPath, hasManagedEnv ? managedPath : undefined].filter(
(candidate): candidate is string => !!candidate,
);
if (candidates.length === 0) {
const pythonPath = systemPythonPath ?? (hasManagedEnv ? managedPath : undefined);
if (!pythonPath) {
return result;
}
result.pythonPath = systemPythonPath ?? managedPath;
let bestMatch = {
pythonPath: candidates[0],
missingPackages: [...PYTHON_PACKAGES],
installedPackages: [] as string[],
usingManagedEnv: candidates[0] === managedPath,
};
for (const pythonPath of candidates) {
const installedPackages: string[] = [];
const missingPackages: string[] = [];
for (const pkg of PYTHON_PACKAGES) {
const moduleName = pkg === "jupyter_kernel_gateway" ? "kernel_gateway" : pkg;
const script = `import importlib.util; raise SystemExit(0 if importlib.util.find_spec('${moduleName}') else 1)`;
const check = await $`${pythonPath} -c ${script}`.quiet().nothrow();
if (check.exitCode === 0) {
installedPackages.push(pkg);
} else {
missingPackages.push(pkg);
}
}
if (missingPackages.length < bestMatch.missingPackages.length) {
bestMatch = {
pythonPath,
missingPackages,
installedPackages,
usingManagedEnv: pythonPath === managedPath,
};
}
if (missingPackages.length === 0) {
result.available = true;
result.pythonPath = pythonPath;
result.missingPackages = missingPackages;
result.installedPackages = installedPackages;
result.usingManagedEnv = pythonPath === managedPath;
return result;
}
}
result.pythonPath = bestMatch.pythonPath;
result.missingPackages = bestMatch.missingPackages;
result.installedPackages = bestMatch.installedPackages;
result.usingManagedEnv = bestMatch.usingManagedEnv;
const probe = await $`${pythonPath} -c "import sys;sys.exit(0)"`.quiet().nothrow();
result.pythonPath = pythonPath;
result.available = probe.exitCode === 0;
result.usingManagedEnv = pythonPath === managedPath;
return result;
}
/**
* Install Python packages using uv (preferred) or pip.
*/
async function installPythonPackages(
packages: string[],
pythonPath: string,
uvPath?: string,
pipPath?: string,
): Promise<{ success: boolean; usedManagedEnv: boolean }> {
if (uvPath) {
console.log(chalk.dim(`Installing via uv: ${packages.join(" ")}`));
const result = await $`${uvPath} pip install ${packages}`.nothrow();
if (result.exitCode === 0) {
return { success: true, usedManagedEnv: false };
}
}
if (pipPath) {
console.log(chalk.dim(`Installing via pip: ${packages.join(" ")}`));
const result = await $`${pipPath} install ${packages}`.nothrow();
if (result.exitCode === 0) {
return { success: true, usedManagedEnv: false };
}
}
console.log(chalk.dim(`Falling back to managed virtual environment: ${MANAGED_PYTHON_ENV}`));
if (uvPath) {
const createEnv = await $`${uvPath} venv ${MANAGED_PYTHON_ENV}`.quiet().nothrow();
if (createEnv.exitCode !== 0) {
return { success: false, usedManagedEnv: true };
}
const installInManagedEnv = await $`${uvPath} pip install --python ${MANAGED_PYTHON_ENV} ${packages}`.nothrow();
return { success: installInManagedEnv.exitCode === 0, usedManagedEnv: true };
}
const createEnv = await $`${pythonPath} -m venv ${MANAGED_PYTHON_ENV}`.quiet().nothrow();
if (createEnv.exitCode !== 0) {
return { success: false, usedManagedEnv: true };
}
const managedPython = managedPythonPath();
const installInManagedEnv = await $`${managedPython} -m pip install ${packages}`.nothrow();
return { success: installInManagedEnv.exitCode === 0, usedManagedEnv: true };
}
// Python installation helper removed: the subprocess runner has no Python
// package dependencies beyond a working interpreter. `omp setup python --check`
// remains as a probe; users install optional libs (pandas, matplotlib, ...)
// directly via pip or the in-process `%pip` magic.
/**
* Run the setup command.
@@ -232,67 +140,13 @@ async function handlePythonSetup(flags: { json?: boolean; check?: boolean }): Pr
console.log(chalk.dim(`Using managed environment: ${check.managedEnvPath}`));
}
if (check.uvPath) {
console.log(chalk.dim(`uv: ${check.uvPath}`));
} else if (check.pipPath) {
console.log(chalk.dim(`pip: ${check.pipPath}`));
}
if (check.installedPackages.length > 0) {
console.log(chalk.green(`${theme.status.success} Installed: ${check.installedPackages.join(", ")}`));
}
if (check.missingPackages.length === 0) {
if (check.available) {
console.log(chalk.green(`\n${theme.status.success} Python execution is ready`));
return;
}
console.log(chalk.yellow(`${theme.status.warning} Missing: ${check.missingPackages.join(", ")}`));
if (flags.check) {
process.exit(1);
}
if (!check.uvPath && !check.pipPath) {
console.error(chalk.red(`\n${theme.status.error} No package manager found`));
console.error(chalk.dim("Install uv (recommended) or pip:"));
console.error(chalk.dim(" curl -LsSf https://astral.sh/uv/install.sh | sh"));
process.exit(1);
}
console.log("");
const install = await installPythonPackages(check.missingPackages, check.pythonPath, check.uvPath, check.pipPath);
if (!install.success) {
console.error(chalk.red(`\n${theme.status.error} Installation failed`));
console.error(chalk.dim("Try installing manually:"));
if (install.usedManagedEnv) {
if (check.uvPath) {
console.error(chalk.dim(` uv venv ${MANAGED_PYTHON_ENV}`));
console.error(
chalk.dim(` uv pip install --python ${MANAGED_PYTHON_ENV} ${check.missingPackages.join(" ")}`),
);
} else {
console.error(chalk.dim(` ${check.pythonPath} -m venv ${MANAGED_PYTHON_ENV}`));
console.error(chalk.dim(` ${managedPythonPath()} -m pip install ${check.missingPackages.join(" ")}`));
}
} else {
console.error(chalk.dim(` ${check.uvPath ? "uv pip" : "pip"} install ${check.missingPackages.join(" ")}`));
}
process.exit(1);
}
const recheck = await checkPythonSetup();
if (recheck.available) {
console.log(chalk.green(`\n${theme.status.success} Python execution is ready`));
if (recheck.usingManagedEnv) {
console.log(chalk.dim(`Managed Python environment: ${recheck.managedEnvPath}`));
}
} else {
console.error(chalk.red(`\n${theme.status.error} Setup incomplete`));
console.error(chalk.dim(`Still missing: ${recheck.missingPackages.join(", ")}`));
process.exit(1);
}
console.error(chalk.red(`\n${theme.status.error} Python interpreter reported failure`));
process.exit(1);
}
async function handleSttSetup(flags: { json?: boolean; check?: boolean }): Promise<void> {
@@ -359,9 +213,8 @@ ${chalk.bold("Usage:")}
${APP_NAME} setup <component> [options]
${chalk.bold("Components:")}
python Install Jupyter kernel dependencies for Python code execution
python Verify a Python 3 interpreter is reachable for code execution
stt Install speech-to-text dependencies (openai-whisper, recording tools)
Packages: ${PYTHON_PACKAGES.join(", ")}
${chalk.bold("Options:")}
-c, --check Check if dependencies are installed without installing
@@ -1,32 +0,0 @@
/**
* Manage the shared Jupyter gateway.
*/
import { Args, Command } from "@oh-my-pi/pi-utils/cli";
import { type JupyterAction, type JupyterCommandArgs, runJupyterCommand } from "../cli/jupyter-cli";
import { initTheme } from "../modes/theme/theme";
const ACTIONS: JupyterAction[] = ["kill", "status"];
export default class Jupyter extends Command {
static description = "Manage the shared Jupyter gateway";
static args = {
action: Args.string({
description: "Jupyter action",
required: false,
options: ACTIONS,
}),
};
async run(): Promise<void> {
const { args } = await this.parse(Jupyter);
const action = (args.action ?? "status") as JupyterAction;
const cmd: JupyterCommandArgs = {
action,
};
await initTheme();
await runJupyterCommand(cmd);
}
}
@@ -1662,16 +1662,6 @@ export const SETTINGS_SCHEMA = {
},
},
"python.sharedGateway": {
type: "boolean",
default: true,
ui: {
tab: "editing",
label: "Shared Python Gateway",
description: "Share IPython kernel gateway across pi instances",
},
},
// ────────────────────────────────────────────────────────────────────────
// Tools
// ────────────────────────────────────────────────────────────────────────
@@ -1,28 +0,0 @@
export function getAbortReason(signal: AbortSignal | undefined, fallbackReason: string): Error {
if (signal?.reason instanceof Error) return signal.reason;
if (typeof signal?.reason === "string" && signal.reason.length > 0) {
return new Error(signal.reason);
}
return new Error(fallbackReason);
}
export function createCancellationError(name: "AbortError" | "TimeoutError", message: string): Error {
const error = new Error(message);
error.name = name;
return error;
}
export function getExecutionCancellationError(
result: { timedOut?: boolean },
signal: AbortSignal | undefined,
fallbackReason: string,
): Error {
if (signal?.aborted) {
return getAbortReason(signal, fallbackReason);
}
if (result.timedOut) {
return createCancellationError("TimeoutError", fallbackReason);
}
return createCancellationError("AbortError", fallbackReason);
}
@@ -0,0 +1,71 @@
/**
* Display bundle rendering shared between the Python runner output and the
* legacy Jupyter MIME conventions. Pure function, no kernel coupling.
*/
import { htmlToBasicMarkdown } from "../../web/scrapers/types";
/** Status event emitted by prelude helpers for TUI rendering. */
export interface PythonStatusEvent {
/** Operation name (e.g., "find", "read", "write") */
op: string;
/** Additional data fields (count, path, pattern, etc.) */
[key: string]: unknown;
}
export type KernelDisplayOutput =
| { type: "json"; data: unknown }
| { type: "image"; data: string; mimeType: string }
| { type: "markdown" }
| { type: "status"; event: PythonStatusEvent };
function normalizeDisplayText(text: string): string {
return text.endsWith("\n") ? text : `${text}\n`;
}
/** Render a MIME bundle into text + structured outputs. */
export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
text: string;
outputs: KernelDisplayOutput[];
}> {
// Accept both raw bundles ({"text/plain": ...}) and Jupyter-style
// content envelopes ({ data: {...} }) so callers don't need to unwrap.
const data =
(content.data as Record<string, unknown> | undefined) ?? (content as Record<string, unknown> | undefined);
if (!data) return { text: "", outputs: [] };
const outputs: KernelDisplayOutput[] = [];
// Status events bypass the text path entirely — they exist only for TUI hooks.
if (data["application/x-omp-status"] !== undefined) {
const statusData = data["application/x-omp-status"];
if (statusData && typeof statusData === "object" && "op" in statusData) {
outputs.push({ type: "status", event: statusData as PythonStatusEvent });
}
return { text: "", outputs };
}
if (typeof data["image/png"] === "string") {
outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" });
}
if (typeof data["image/jpeg"] === "string") {
outputs.push({ type: "image", data: data["image/jpeg"] as string, mimeType: "image/jpeg" });
}
if (data["application/json"] !== undefined) {
outputs.push({ type: "json", data: data["application/json"] });
}
// text/markdown takes precedence over text/plain (Markdown objects expose both
// where text/plain is just the repr).
if (typeof data["text/markdown"] === "string") {
outputs.push({ type: "markdown" });
return { text: normalizeDisplayText(String(data["text/markdown"])), outputs };
}
if (typeof data["text/plain"] === "string") {
return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
}
if (data["text/html"] !== undefined) {
const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
}
return { text: "", outputs };
}
+12 -88
View File
@@ -2,10 +2,9 @@ import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import type { JsStatusEvent } from "../js/shared/types";
import { shutdownSharedGateway } from "./gateway-coordinator";
import type { KernelDisplayOutput } from "./display";
import {
checkPythonKernelAvailability,
type KernelDisplayOutput,
type KernelExecuteOptions,
type KernelExecuteResult,
PythonKernel,
@@ -38,8 +37,6 @@ export interface PythonExecutorOptions {
kernelMode?: PythonKernelMode;
/** Restart the kernel before executing */
reset?: boolean;
/** Use shared gateway across pi instances (default: true) */
useSharedGateway?: boolean;
/** Session file path for accessing task outputs */
sessionFile?: string;
/**
@@ -102,7 +99,6 @@ interface KernelSession {
restartCount: number;
dead: boolean;
needsRestart: boolean;
kernelInvalidatedByRecovery: boolean;
disposing: boolean;
disposeCapacityPromise?: Promise<void>;
resolveDisposeCapacity?: () => void;
@@ -122,7 +118,6 @@ const disposingKernelSessions = new Set<KernelSession>();
let cleanupTimer: NodeJS.Timeout | null = null;
interface KernelSessionExecutionOptions {
useSharedGateway?: boolean;
sessionFile?: string;
artifactsDir?: string;
signal?: AbortSignal;
@@ -295,7 +290,6 @@ function buildKernelStartOptions(
return {
cwd,
env,
useSharedGateway: options.useSharedGateway,
signal: options.signal,
deadlineMs: options.deadlineMs,
};
@@ -379,7 +373,6 @@ function finishDisposingKernelSession(session: KernelSession): void {
session.disposeResultPromise = undefined;
session.disposeResultTimeoutMs = undefined;
session.nextDisposalRetryAt = undefined;
session.kernelInvalidatedByRecovery = false;
syncCleanupTimer();
}
@@ -503,58 +496,6 @@ async function ensureKernelAvailable(
}
}
function isResourceExhaustionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Too many open files") ||
message.includes("EMFILE") ||
message.includes("ENFILE") ||
message.includes("resource temporarily unavailable")
);
}
function clearSharedGatewayDisposingKernelSessionTracking(): void {
for (const session of Array.from(disposingKernelSessions.values())) {
if (!session.kernel.isSharedGateway) continue;
if (session.heartbeatTimer) {
clearInterval(session.heartbeatTimer);
session.heartbeatTimer = undefined;
}
disposingKernelSessions.delete(session);
session.resolveDisposeCapacity?.();
session.resolveDisposeCapacity = undefined;
session.disposeCapacityPromise = undefined;
session.resolveDisposeAttempt?.();
session.resolveDisposeAttempt = undefined;
session.disposeAttemptPromise = undefined;
session.disposeResultPromise = undefined;
session.disposeResultTimeoutMs = undefined;
session.nextDisposalRetryAt = undefined;
session.kernelInvalidatedByRecovery = false;
}
}
function markLiveKernelSessionsForRecovery(): void {
for (const session of kernelSessions.values()) {
if (session.heartbeatTimer) {
clearInterval(session.heartbeatTimer);
session.heartbeatTimer = undefined;
}
session.needsRestart = true;
session.kernelInvalidatedByRecovery = session.kernel.isSharedGateway;
session.restartCount = 0;
}
}
async function recoverFromResourceExhaustion(): Promise<void> {
logger.warn("Resource exhaustion detected, recovering by restarting shared gateway");
stopCleanupTimer();
markLiveKernelSessionsForRecovery();
clearSharedGatewayDisposingKernelSessionTracking();
await shutdownSharedGateway();
syncCleanupTimer();
}
function ensureKernelHeartbeat(session: KernelSession): void {
if (session.heartbeatTimer) return;
session.heartbeatTimer = setInterval(() => {
@@ -570,22 +511,12 @@ async function createKernelSession(
sessionId: string,
cwd: string,
options: KernelSessionExecutionOptions = {},
isRetry?: boolean,
): Promise<KernelSession> {
requireRemainingTimeoutMs(options.deadlineMs);
const env = buildKernelEnv(options);
const startOptions = buildKernelStartOptions(cwd, env, options);
let kernel: PythonKernel;
try {
kernel = await logger.time("createKernelSession:PythonKernel.start", PythonKernel.start, startOptions);
} catch (err) {
if (!isRetry && isResourceExhaustionError(err)) {
await recoverFromResourceExhaustion();
return createKernelSession(sessionId, cwd, options, true);
}
throw err;
}
const kernel = await logger.time("createKernelSession:PythonKernel.start", PythonKernel.start, startOptions);
const hasFallbackOwner = options.kernelOwnerId === undefined;
const initialOwnerId = options.kernelOwnerId ?? sessionId;
@@ -596,7 +527,6 @@ async function createKernelSession(
restartCount: 0,
dead: false,
needsRestart: false,
kernelInvalidatedByRecovery: false,
disposing: false,
disposeResultPromise: undefined,
nextDisposalRetryAt: undefined,
@@ -621,18 +551,16 @@ async function restartKernelSession(
}
requireRemainingTimeoutMs(options.deadlineMs);
try {
if (!session.kernelInvalidatedByRecovery) {
const deadKernel = session.dead || !session.kernel.isAlive();
const shutdownTimeoutMs = requireRemainingTimeoutMs(options.deadlineMs);
const shutdownResult = await session.kernel.shutdown({ signal: options.signal, timeoutMs: shutdownTimeoutMs });
if (!shutdownResult.confirmed && !deadKernel) {
throw new Error("Failed to confirm crashed kernel shutdown before restart");
}
if (!shutdownResult.confirmed) {
logger.warn("Proceeding with retained kernel restart after unconfirmed dead-kernel shutdown", {
sessionId: session.id,
});
}
const deadKernel = session.dead || !session.kernel.isAlive();
const shutdownTimeoutMs = requireRemainingTimeoutMs(options.deadlineMs);
const shutdownResult = await session.kernel.shutdown({ signal: options.signal, timeoutMs: shutdownTimeoutMs });
if (!shutdownResult.confirmed && !deadKernel) {
throw new Error("Failed to confirm crashed kernel shutdown before restart");
}
if (!shutdownResult.confirmed) {
logger.warn("Proceeding with retained kernel restart after unconfirmed dead-kernel shutdown", {
sessionId: session.id,
});
}
const env = buildKernelEnv(options);
const startOptions = buildKernelStartOptions(cwd, env, options);
@@ -640,7 +568,6 @@ async function restartKernelSession(
session.kernel = kernel;
session.dead = false;
session.needsRestart = false;
session.kernelInvalidatedByRecovery = false;
session.lastUsedAt = Date.now();
ensureKernelHeartbeat(session);
} catch (err) {
@@ -654,9 +581,6 @@ type KernelDisposalResult = { status: "confirmed" } | { status: "unconfirmed" }
type KernelDisposalWaitResult = KernelDisposalResult | { status: "timedOut" };
function createKernelDisposalResultPromise(session: KernelSession, timeoutMs?: number): Promise<KernelDisposalResult> {
if (session.kernelInvalidatedByRecovery) {
return Promise.resolve({ status: "confirmed" as const });
}
return Promise.resolve()
.then(() => session.kernel.shutdown(timeoutMs === undefined ? undefined : { timeoutMs }))
.then(
@@ -1,424 +0,0 @@
import * as fs from "node:fs";
import { createServer } from "node:net";
import * as path from "node:path";
import { Process } from "@oh-my-pi/pi-natives";
import { getPythonGatewayDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import { Settings } from "../../config/settings";
import { getOrCreateSnapshot } from "../../utils/shell-snapshot";
import { filterEnv, resolvePythonRuntime } from "./runtime";
const GATEWAY_INFO_FILE = "gateway.json";
const GATEWAY_LOCK_FILE = "gateway.lock";
const GATEWAY_STARTUP_TIMEOUT_MS = 30000;
const GATEWAY_LOCK_TIMEOUT_MS = GATEWAY_STARTUP_TIMEOUT_MS + 5000;
const GATEWAY_LOCK_RETRY_MS = 50;
const GATEWAY_LOCK_STALE_MS = GATEWAY_STARTUP_TIMEOUT_MS * 2;
const GATEWAY_LOCK_HEARTBEAT_MS = 5000;
const HEALTH_CHECK_TIMEOUT_MS = 3000;
export interface GatewayInfo {
url: string;
pid: number;
startedAt: number;
pythonPath?: string;
venvPath?: string | null;
}
interface GatewayLockInfo {
pid: number;
startedAt: number;
}
interface AcquireResult {
url: string;
isShared: boolean;
}
let localGatewayProcess: Subprocess | null = null;
let localGatewayUrl: string | null = null;
let isCoordinatorInitialized = false;
async function allocatePort(): Promise<number> {
const { promise, resolve, reject } = Promise.withResolvers<number>();
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
server.close((err: Error | null | undefined) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
} else {
server.close();
reject(new Error("Failed to allocate port"));
}
});
return promise;
}
function getGatewayDir(): string {
return getPythonGatewayDir();
}
function getGatewayInfoPath(): string {
return path.join(getGatewayDir(), GATEWAY_INFO_FILE);
}
function getGatewayLockPath(): string {
return path.join(getGatewayDir(), GATEWAY_LOCK_FILE);
}
async function writeLockInfo(lockPath: string): Promise<void> {
const payload: GatewayLockInfo = { pid: process.pid, startedAt: Date.now() };
try {
await Bun.write(lockPath, JSON.stringify(payload));
} catch {
// Ignore lock write failures
}
}
async function readLockInfo(lockPath: string): Promise<GatewayLockInfo | null> {
try {
const raw = await Bun.file(lockPath).text();
const parsed = JSON.parse(raw) as Partial<GatewayLockInfo>;
if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid)) {
return { pid: parsed.pid, startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0 };
}
} catch {
// Ignore parse errors
}
return null;
}
async function ensureGatewayDir(): Promise<void> {
const dir = getGatewayDir();
await fs.promises.mkdir(dir, { recursive: true });
}
async function withGatewayLock<T>(handler: () => Promise<T>): Promise<T> {
await ensureGatewayDir();
const lockPath = getGatewayLockPath();
const start = Date.now();
while (true) {
let fd: fs.promises.FileHandle | undefined;
try {
fd = await fs.promises.open(lockPath, "wx");
let heartbeatRunning = true;
const heartbeat = (async () => {
while (heartbeatRunning) {
await Bun.sleep(GATEWAY_LOCK_HEARTBEAT_MS);
if (!heartbeatRunning) break;
try {
const now = new Date();
await fs.promises.utimes(lockPath, now, now);
} catch {
// Ignore heartbeat errors
}
}
})();
try {
await writeLockInfo(lockPath);
return await handler();
} finally {
heartbeatRunning = false;
void heartbeat.catch(() => {}); // Don't await - let it die naturally
try {
await fd.close();
await fs.promises.unlink(lockPath);
} catch {
// Ignore lock cleanup errors
}
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === "EEXIST") {
let removedStale = false;
try {
const lockStat = await fs.promises.stat(lockPath);
const lockInfo = await readLockInfo(lockPath);
const lockPid = lockInfo?.pid;
const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - lockStat.mtimeMs;
const staleByTime = lockAgeMs > GATEWAY_LOCK_STALE_MS;
const staleByPid = lockPid !== undefined && !procmgr.isPidRunning(lockPid);
const staleByMissingPid = lockPid === undefined && staleByTime;
if (staleByPid || staleByMissingPid) {
await fs.promises.unlink(lockPath);
removedStale = true;
logger.warn("Removed stale shared gateway lock", { path: lockPath, pid: lockPid });
}
} catch {
// Ignore stat errors; keep waiting
}
if (!removedStale) {
if (Date.now() - start > GATEWAY_LOCK_TIMEOUT_MS) {
throw new Error("Timed out waiting for shared gateway lock");
}
await Bun.sleep(GATEWAY_LOCK_RETRY_MS);
}
continue;
}
throw err;
}
}
}
async function readGatewayInfo(): Promise<GatewayInfo | null> {
const infoPath = getGatewayInfoPath();
try {
const content = await Bun.file(infoPath).text();
const parsed = JSON.parse(content) as Partial<GatewayInfo>;
if (typeof parsed.url !== "string" || typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") {
return null;
}
return {
url: parsed.url,
pid: parsed.pid,
startedAt: parsed.startedAt,
pythonPath: typeof parsed.pythonPath === "string" ? parsed.pythonPath : undefined,
venvPath: typeof parsed.venvPath === "string" || parsed.venvPath === null ? parsed.venvPath : undefined,
};
} catch (err) {
if (isEnoent(err)) return null;
return null;
}
}
async function writeGatewayInfo(info: GatewayInfo): Promise<void> {
const infoPath = getGatewayInfoPath();
const tempPath = `${infoPath}.tmp`;
await Bun.write(tempPath, JSON.stringify(info, null, 2));
await fs.promises.rename(tempPath, infoPath);
}
async function clearGatewayInfo(): Promise<void> {
const infoPath = getGatewayInfoPath();
try {
await fs.promises.unlink(infoPath);
} catch {
// Ignore errors on cleanup (file may not exist)
}
}
async function isGatewayHealthy(url: string): Promise<boolean> {
try {
const response = await fetch(`${url}/api/kernelspecs`, {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
return response.ok;
} catch {
return false;
}
}
async function isGatewayAlive(info: GatewayInfo): Promise<boolean> {
if (!procmgr.isPidRunning(info.pid)) return false;
return await isGatewayHealthy(info.url);
}
async function startGatewayProcess(
cwd: string,
): Promise<{ url: string; pid: number; pythonPath: string; venvPath: string | null }> {
const settings = await Settings.init();
const { shell, env } = settings.getShellConfig();
const filteredEnv = filterEnv(env);
const runtime = resolvePythonRuntime(cwd, filteredEnv);
const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
logger.warn("Failed to resolve shell snapshot for shared Python gateway", {
error: err instanceof Error ? err.message : String(err),
});
return null;
});
const kernelEnv: Record<string, string | undefined> = {
...runtime.env,
PYTHONUNBUFFERED: "1",
PI_SHELL_SNAPSHOT: snapshotPath ?? undefined,
};
const gatewayPort = await allocatePort();
const gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
const gatewayProcess = Bun.spawn(
[
runtime.pythonPath,
"-m",
"kernel_gateway",
"--KernelGatewayApp.ip=127.0.0.1",
`--KernelGatewayApp.port=${gatewayPort}`,
"--KernelGatewayApp.port_retries=0",
"--KernelGatewayApp.allow_origin=*",
"--JupyterApp.answer_yes=true",
],
{
cwd,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
detached: true,
env: kernelEnv,
},
);
let exited = false;
gatewayProcess.exited
.catch(() => {})
.then(() => {
exited = true;
});
const startTime = Date.now();
while (Date.now() - startTime < GATEWAY_STARTUP_TIMEOUT_MS) {
if (exited) {
throw new Error("Gateway process exited during startup");
}
if (await isGatewayHealthy(gatewayUrl)) {
localGatewayProcess = gatewayProcess;
localGatewayUrl = gatewayUrl;
return {
url: gatewayUrl,
pid: gatewayProcess.pid,
pythonPath: runtime.pythonPath,
venvPath: runtime.venvPath ?? null,
};
}
await Bun.sleep(100);
}
gatewayProcess.kill();
throw new Error("Gateway startup timeout");
}
async function killGateway(pid: number, context: string): Promise<void> {
try {
await Process.fromPid(pid)?.terminate();
} catch (err) {
logger.warn("Failed to kill shared gateway process", {
error: err instanceof Error ? err.message : String(err),
pid,
context,
});
}
}
export async function acquireSharedGateway(cwd: string): Promise<AcquireResult | null> {
try {
return await withGatewayLock(async () => {
const existingInfo = await logger.time("acquireSharedGateway:readInfo", readGatewayInfo);
if (existingInfo) {
if (await logger.time("acquireSharedGateway:isAlive", isGatewayAlive, existingInfo)) {
localGatewayUrl = existingInfo.url;
isCoordinatorInitialized = true;
logger.debug("Reusing global Python gateway", { url: existingInfo.url });
return { url: existingInfo.url, isShared: true };
}
logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid });
if (procmgr.isPidRunning(existingInfo.pid)) {
await killGateway(existingInfo.pid, "stale");
}
await clearGatewayInfo();
}
const { url, pid, pythonPath, venvPath } = await logger.time(
"acquireSharedGateway:startGateway",
startGatewayProcess,
cwd,
);
const info: GatewayInfo = {
url,
pid,
startedAt: Date.now(),
pythonPath,
venvPath,
};
await writeGatewayInfo(info);
isCoordinatorInitialized = true;
logger.debug("Started global Python gateway", { url, pid });
return { url, isShared: true };
});
} catch (err) {
logger.warn("Failed to acquire shared gateway, falling back to local", {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
export async function releaseSharedGateway(): Promise<void> {
if (!isCoordinatorInitialized) return;
}
export async function getSharedGatewayUrl(): Promise<string | null> {
if (localGatewayUrl) return localGatewayUrl;
return (await readGatewayInfo())?.url ?? null;
}
export async function isSharedGatewayActive(): Promise<boolean> {
return (await getGatewayStatus()).active;
}
export interface GatewayStatus {
active: boolean;
url: string | null;
pid: number | null;
uptime: number | null;
pythonPath: string | null;
venvPath: string | null;
}
export async function getGatewayStatus(): Promise<GatewayStatus> {
const info = await readGatewayInfo();
if (!info) {
return {
active: false,
url: null,
pid: null,
uptime: null,
pythonPath: null,
venvPath: null,
};
}
const active = procmgr.isPidRunning(info.pid);
return {
active,
url: info.url,
pid: info.pid,
uptime: active ? Date.now() - info.startedAt : null,
pythonPath: info.pythonPath ?? null,
venvPath: info.venvPath ?? null,
};
}
export async function shutdownSharedGateway(): Promise<void> {
try {
await withGatewayLock(async () => {
const info = await readGatewayInfo();
if (!info) return;
if (procmgr.isPidRunning(info.pid)) {
await killGateway(info.pid, "shutdown");
}
await clearGatewayInfo();
});
} catch (err) {
logger.warn("Failed to shutdown shared gateway", {
error: err instanceof Error ? err.message : String(err),
});
} finally {
if (localGatewayProcess) {
await killGateway(localGatewayProcess.pid, "shutdown-local");
}
localGatewayProcess = null;
localGatewayUrl = null;
isCoordinatorInitialized = false;
}
}
@@ -25,7 +25,6 @@ export default {
},
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
const kernelMode = readSetting<PythonExecutorOptions["kernelMode"]>(opts.session, "python.kernelMode");
const executorOptions: PythonExecutorOptions = {
cwd: opts.cwd,
@@ -33,7 +32,6 @@ export default {
signal: opts.signal,
sessionId: namespaceSessionId(opts.sessionId),
kernelMode,
useSharedGateway,
sessionFile: opts.sessionFile,
artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
kernelOwnerId: opts.kernelOwnerId,
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
# OMP IPython prelude helpers
# OMP prelude helpers (loaded once into the runner namespace)
if "__omp_prelude_loaded__" not in globals():
__omp_prelude_loaded__ = True
from pathlib import Path
import os, json
from IPython.display import display as _ipy_display, JSON
# __omp_display is injected by runner.py before the prelude executes; it
# mirrors IPython's display() semantics with the same MIME bundle output.
_omp_display = __omp_display # type: ignore[name-defined]
_PRESENTABLE_REPRS = (
"_repr_mimebundle_",
@@ -18,21 +21,22 @@ if "__omp_prelude_loaded__" not in globals():
)
def display(value):
"""Render a value. Wraps plain dict/list values as interactive JSON."""
"""Render a value. Falls back to a JSON+text/plain bundle for plain dict/list/tuple."""
if any(hasattr(value, attr) for attr in _PRESENTABLE_REPRS):
_ipy_display(value)
_omp_display(value)
return
if isinstance(value, (dict, list, tuple)):
try:
_ipy_display(JSON(value))
bundle = {"application/json": value, "text/plain": repr(value)}
_omp_display(bundle, raw=True)
return
except Exception:
pass
_ipy_display(value)
_omp_display(value)
def _emit_status(op: str, **data):
"""Emit structured status event for TUI rendering."""
_ipy_display({"application/x-omp-status": {"op": op, **data}}, raw=True)
_omp_display({"application/x-omp-status": {"op": op, **data}}, raw=True)
def env(key: str | None = None, value: str | None = None):
+879
View File
@@ -0,0 +1,879 @@
"""OMP Python runner — subprocess wrapper used by the coding-agent host.
NDJSON protocol over stdin/stdout. Host writes one JSON object per line;
wrapper writes typed frames back.
Host -> wrapper:
{"id": str, "code": str, "silent": bool?, "storeHistory": bool?}
{"type": "exit"} # graceful shutdown
Wrapper -> host:
{"type": "started", "id": ...}
{"type": "stdout", "id": ..., "data": str}
{"type": "stderr", "id": ..., "data": str}
{"type": "display", "id": ..., "bundle": {<mime>: <value>}}
{"type": "result", "id": ..., "bundle": {<mime>: <value>}}
{"type": "error", "id": ..., "ename": str, "evalue": str, "traceback": [str]}
{"type": "done", "id": ..., "status": "ok"|"error",
"executionCount": int, "cancelled": bool}
The runner is intentionally self-contained: no third-party imports, no IPython.
Magics are translated by a small line-scanner before AST parsing; rich display
falls back through `_repr_*_` methods so pandas/PIL/plotly etc. still render
when installed.
"""
from __future__ import annotations
import ast
import base64
import builtins
import io
import json
import os
import re
import runpy
import shlex
import signal
import subprocess
import sys
import threading
import time
import traceback
from pathlib import Path
from typing import Any, Callable
# ---------------------------------------------------------------------------
# Frame writer
# ---------------------------------------------------------------------------
_RAW_STDOUT = sys.__stdout__
_RAW_STDERR = sys.__stderr__
_OUT_LOCK = threading.Lock()
def _json_default(o: Any) -> Any:
try:
return repr(o)
except Exception:
return f"<unrepr {type(o).__name__}>"
def _emit(frame: dict) -> None:
"""Serialize a frame and write it to the host as a single NDJSON line."""
line = json.dumps(frame, ensure_ascii=False, default=_json_default)
with _OUT_LOCK:
_RAW_STDOUT.write(line)
_RAW_STDOUT.write("\n")
_RAW_STDOUT.flush()
# ---------------------------------------------------------------------------
# User stdout/stderr proxies
# ---------------------------------------------------------------------------
class _StreamProxy(io.TextIOBase):
"""Emit each ``write()`` as a typed frame tied to the current request."""
def __init__(self, kind: str) -> None:
super().__init__()
self._kind = kind
def writable(self) -> bool: # noqa: D401 - protocol method
return True
def isatty(self) -> bool: # noqa: D401 - protocol method
return False
def write(self, data: Any) -> int: # type: ignore[override]
if not isinstance(data, str):
data = str(data)
if not data:
return 0
rid = _STATE.current_id
if rid is None:
_RAW_STDERR.write(data)
_RAW_STDERR.flush()
return len(data)
_emit({"type": self._kind, "id": rid, "data": data})
return len(data)
def flush(self) -> None: # noqa: D401 - protocol method
return None
# ---------------------------------------------------------------------------
# Runner state
# ---------------------------------------------------------------------------
class _RunnerState:
def __init__(self) -> None:
self.current_id: str | None = None
self.execution_count: int = 0
self.cancel_requested: bool = False
# User globals — kept across requests when running in session mode.
self.user_ns: dict[str, Any] = {
"__name__": "__main__",
"__doc__": None,
"__builtins__": builtins,
}
self.last_install_marker: int = 0
_STATE = _RunnerState()
# ---------------------------------------------------------------------------
# Magic source transformer
# ---------------------------------------------------------------------------
_MAGIC_LINE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<name>[A-Za-z_][A-Za-z_0-9]*)(?:[ \t]+(?P<args>.*))?$")
_ASSIGN_LINE_RE = re.compile(
r"^(?P<indent>[ \t]*)(?P<lhs>[A-Za-z_][A-Za-z_0-9.\[\], ]*?)\s*=\s*(?P<rhs>.+)$"
)
def _fold_continuations(lines: list[str], start: int) -> tuple[str, int]:
"""Fold trailing backslash continuations starting at ``start``. Returns
``(folded_text, lines_consumed)``."""
parts: list[str] = []
i = start
while i < len(lines):
line = lines[i]
if line.endswith("\\"):
parts.append(line[:-1])
i += 1
continue
parts.append(line)
i += 1
break
return ("".join(parts), i - start)
def _quote_arg(text: str) -> str:
"""Return a Python string literal that round-trips ``text`` exactly."""
return json.dumps(text, ensure_ascii=False)
def transform_cell(source: str) -> str:
"""Translate IPython-style magics + shell escapes into plain Python.
Rules
-----
* ``%name args`` -> ``__omp_magic("name", "args")``
* ``var = %name args`` -> ``var = __omp_magic("name", "args")``
* ``!cmd`` -> ``__omp_shell("cmd")``
* ``var = !cmd`` -> ``var = __omp_shell("cmd")``
* ``%%name args\\n<body>`` -> ``__omp_magic_cell("name", "args", "<body>")``
(cell magic must be the first non-whitespace token of a top-level line and
consumes the remainder of the cell)
Lines inside strings or comments are left alone — we operate on the raw
text before parsing, but the scanner only fires on the first token of each
physical line and never touches the body of triple-quoted strings because
those bodies are never first tokens themselves.
"""
if "%" not in source and "!" not in source:
return source
lines = source.splitlines()
out: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
# Cell magic — consumes from here to EOF.
if stripped.startswith("%%"):
head, _ = _split_magic_head(stripped[2:])
name, args = head
body_lines = lines[i + 1 :]
body = "\n".join(body_lines)
out.append(
f"{indent}__omp_magic_cell({_quote_arg(name)}, {_quote_arg(args)}, {_quote_arg(body)})"
)
return "\n".join(out)
# Line magic / shell at start of line.
if stripped.startswith("%") and not stripped.startswith("%%"):
folded, consumed = _fold_continuations(lines, i)
stripped_folded = folded.lstrip()
indent = folded[: len(folded) - len(stripped_folded)]
head, _ = _split_magic_head(stripped_folded[1:])
name, args = head
out.append(f"{indent}__omp_magic({_quote_arg(name)}, {_quote_arg(args)})")
i += consumed
continue
if stripped.startswith("!"):
folded, consumed = _fold_continuations(lines, i)
stripped_folded = folded.lstrip()
indent = folded[: len(folded) - len(stripped_folded)]
cmd = stripped_folded[1:].strip()
out.append(f"{indent}__omp_shell({_quote_arg(cmd)})")
i += consumed
continue
# Assignment forms: var = %magic / var = !cmd
m = _ASSIGN_LINE_RE.match(line)
if m:
rhs = m.group("rhs").strip()
if rhs.startswith("!"):
cmd = rhs[1:].strip()
out.append(f"{m.group('indent')}{m.group('lhs').rstrip()} = __omp_shell({_quote_arg(cmd)})")
i += 1
continue
if rhs.startswith("%") and not rhs.startswith("%%"):
head, _ = _split_magic_head(rhs[1:])
name, args = head
out.append(
f"{m.group('indent')}{m.group('lhs').rstrip()} = __omp_magic({_quote_arg(name)}, {_quote_arg(args)})"
)
i += 1
continue
out.append(line)
i += 1
return "\n".join(out)
def _split_magic_head(text: str) -> tuple[tuple[str, str], str]:
"""Split ``"name rest"`` into ``("name", "rest")``."""
text = text.lstrip()
if not text:
return ("", ""), ""
m = re.match(r"([A-Za-z_][A-Za-z_0-9]*)(?:\s+(.*))?$", text)
if not m:
return ("", text), ""
return (m.group(1), (m.group(2) or "").rstrip()), ""
# ---------------------------------------------------------------------------
# Magic registry
# ---------------------------------------------------------------------------
_LINE_MAGICS: dict[str, Callable[[str], Any]] = {}
_CELL_MAGICS: dict[str, Callable[[str, str], Any]] = {}
def line_magic(name: str) -> Callable[[Callable[[str], Any]], Callable[[str], Any]]:
def decorator(fn: Callable[[str], Any]) -> Callable[[str], Any]:
_LINE_MAGICS[name] = fn
return fn
return decorator
def cell_magic(name: str) -> Callable[[Callable[[str, str], Any]], Callable[[str, str], Any]]:
def decorator(fn: Callable[[str, str], Any]) -> Callable[[str, str], Any]:
_CELL_MAGICS[name] = fn
return fn
return decorator
def _emit_status(op: str, **data: Any) -> None:
bundle = {"application/x-omp-status": {"op": op, **data}}
rid = _STATE.current_id
if rid is None:
return
_emit({"type": "display", "id": rid, "bundle": bundle})
@line_magic("pip")
def _magic_pip(args: str) -> None:
argv = shlex.split(args) if args else ["--help"]
cmd = [sys.executable, "-m", "pip", *argv]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
installed_packages: list[str] = []
assert proc.stdout is not None
for raw_line in proc.stdout:
sys.stdout.write(raw_line)
m = re.search(r"Successfully installed\s+(.+)$", raw_line)
if m:
for token in m.group(1).split():
# Token is name-version; drop the version suffix.
pkg = token.rsplit("-", 1)[0]
installed_packages.append(pkg.replace("_", "-"))
proc.wait()
if installed_packages:
import importlib
importlib.invalidate_caches()
prefixes = {pkg.lower().replace("-", "_") for pkg in installed_packages}
for mod_name in list(sys.modules):
head = mod_name.split(".", 1)[0].lower()
if head in prefixes:
sys.modules.pop(mod_name, None)
_emit_status("pip", args=args, installed=installed_packages, exit_code=proc.returncode)
@line_magic("cd")
def _magic_cd(args: str) -> str:
path = os.path.expanduser(args.strip()) or os.path.expanduser("~")
os.chdir(path)
cwd = os.getcwd()
_emit_status("cd", path=cwd)
return cwd
@line_magic("pwd")
def _magic_pwd(_args: str) -> str:
cwd = os.getcwd()
_emit_status("pwd", path=cwd)
return cwd
@line_magic("ls")
def _magic_ls(args: str) -> list[str]:
target = os.path.expanduser(args.strip()) or "."
entries = sorted(os.listdir(target))
_emit_status("ls", path=os.path.abspath(target), count=len(entries))
return entries
@line_magic("env")
def _magic_env(args: str) -> Any:
args = args.strip()
if not args:
return dict(sorted(os.environ.items()))
if "=" in args:
key, value = args.split("=", 1)
os.environ[key.strip()] = value.strip()
return value.strip()
return os.environ.get(args)
@line_magic("set_env")
def _magic_set_env(args: str) -> str:
parts = args.split(None, 1)
if len(parts) != 2:
raise ValueError("Usage: %set_env KEY VALUE")
key, value = parts
os.environ[key] = value
return value
@line_magic("time")
def _magic_time(args: str) -> Any:
start = time.perf_counter()
result = eval(args, _STATE.user_ns)
elapsed = time.perf_counter() - start
sys.stdout.write(f"Wall time: {elapsed * 1000:.2f} ms\n")
_emit_status("time", elapsed_ms=round(elapsed * 1000, 3))
return result
@line_magic("timeit")
def _magic_timeit(args: str) -> None:
import timeit as _timeit
timer = _timeit.Timer(stmt=args, globals=_STATE.user_ns)
iters, total = timer.autorange()
per = total / iters
sys.stdout.write(f"{iters} loops, best of 1: {per * 1e6:.2f} us per loop\n")
_emit_status("timeit", loops=iters, total_ms=round(total * 1000, 3))
@line_magic("who")
def _magic_who(_args: str) -> list[str]:
names = sorted(
name
for name, value in _STATE.user_ns.items()
if not name.startswith("_") and not callable(value) or hasattr(value, "__class__")
)
return [n for n in names if not n.startswith("__")]
@line_magic("whos")
def _magic_whos(_args: str) -> list[tuple[str, str]]:
rows = []
for name in sorted(_STATE.user_ns):
if name.startswith("__"):
continue
value = _STATE.user_ns[name]
rows.append((name, type(value).__name__))
return rows
@line_magic("reset")
def _magic_reset(_args: str) -> None:
_STATE.user_ns.clear()
_STATE.user_ns.update({"__name__": "__main__", "__doc__": None, "__builtins__": builtins})
_install_builtins(_STATE.user_ns)
_emit_status("reset")
@line_magic("load")
def _magic_load(args: str) -> None:
path = Path(os.path.expanduser(args.strip()))
source = path.read_text(encoding="utf-8")
_emit({"type": "display", "id": _STATE.current_id, "bundle": {"text/plain": source}})
_exec_source(source, _STATE.user_ns)
@line_magic("run")
def _magic_run(args: str) -> None:
parts = shlex.split(args) if args else []
if not parts:
raise ValueError("Usage: %run <path>")
target = os.path.expanduser(parts[0])
saved_argv = sys.argv
try:
sys.argv = [target, *parts[1:]]
result_ns = runpy.run_path(target, run_name="__main__")
finally:
sys.argv = saved_argv
for name, value in result_ns.items():
if name.startswith("__"):
continue
_STATE.user_ns[name] = value
@cell_magic("bash")
def _magic_cell_bash(args: str, body: str) -> int:
return _run_shell_body(body, shell_arg="/bin/bash")
@cell_magic("sh")
def _magic_cell_sh(args: str, body: str) -> int:
return _run_shell_body(body, shell_arg="/bin/sh")
@cell_magic("capture")
def _magic_cell_capture(args: str, body: str) -> str:
"""Capture stdout/stderr of body; bind to ``args`` (a name) if provided."""
captured = io.StringIO()
saved_stdout, saved_stderr = sys.stdout, sys.stderr
sys.stdout = sys.stderr = captured
try:
_exec_source(body, _STATE.user_ns)
finally:
sys.stdout, sys.stderr = saved_stdout, saved_stderr
text = captured.getvalue()
name = args.strip()
if name:
_STATE.user_ns[name] = text
return text
@cell_magic("timeit")
def _magic_cell_timeit(args: str, body: str) -> None:
import timeit as _timeit
timer = _timeit.Timer(stmt=body, globals=_STATE.user_ns)
iters, total = timer.autorange()
per = total / iters
sys.stdout.write(f"{iters} loops, best of 1: {per * 1e6:.2f} us per loop\n")
_emit_status("timeit", loops=iters, total_ms=round(total * 1000, 3))
@cell_magic("writefile")
def _magic_cell_writefile(args: str, body: str) -> str:
path = Path(os.path.expanduser(args.strip()))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
_emit_status("writefile", path=str(path), bytes=len(body))
return str(path)
def _run_shell_body(body: str, *, shell_arg: str) -> int:
proc = subprocess.Popen(
[shell_arg, "-c", body],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None
for raw_line in proc.stdout:
sys.stdout.write(raw_line)
proc.wait()
return proc.returncode
def __omp_magic(name: str, args: str) -> Any:
fn = _LINE_MAGICS.get(name)
if fn is None:
raise NameError(f"UsageError: Line magic function '%{name}' not found.")
return fn(args)
def __omp_magic_cell(name: str, args: str, body: str) -> Any:
fn = _CELL_MAGICS.get(name)
if fn is None:
raise NameError(f"UsageError: Cell magic function '%%{name}' not found.")
return fn(args, body)
class _ShellResult(list):
"""Result of ``!cmd`` — list of stripped output lines."""
def __init__(self, lines: list[str], returncode: int) -> None:
super().__init__(lines)
self.returncode = returncode
@property
def n(self) -> str: # IPython compat
return "\n".join(self)
@property
def s(self) -> str: # IPython compat
return " ".join(self)
def __omp_shell(cmd: str) -> _ShellResult:
proc = subprocess.run(
cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if proc.stdout:
sys.stdout.write(proc.stdout)
lines = [line for line in (proc.stdout or "").splitlines()]
return _ShellResult(lines, proc.returncode)
# ---------------------------------------------------------------------------
# Display dispatch
# ---------------------------------------------------------------------------
_REPR_MIMES = [
("_repr_html_", "text/html"),
("_repr_markdown_", "text/markdown"),
("_repr_svg_", "image/svg+xml"),
("_repr_png_", "image/png"),
("_repr_jpeg_", "image/jpeg"),
("_repr_json_", "application/json"),
("_repr_latex_", "text/latex"),
]
def _coerce_image_bytes(value: Any) -> str:
if isinstance(value, (bytes, bytearray)):
return base64.b64encode(bytes(value)).decode("ascii")
if isinstance(value, str):
return value
return base64.b64encode(repr(value).encode("utf-8")).decode("ascii")
def _mime_bundle(value: Any) -> dict:
"""Build a Jupyter-style MIME bundle for ``value``.
Honors ``_repr_mimebundle_`` first, falls back to individual ``_repr_*_``
accessors, and always provides ``text/plain``.
"""
bundle: dict[str, Any] = {}
mimebundle = getattr(value, "_repr_mimebundle_", None)
if callable(mimebundle):
try:
data = mimebundle()
except Exception:
data = None
if isinstance(data, tuple):
data = data[0]
if isinstance(data, dict):
bundle.update({str(k): v for k, v in data.items()})
for attr, mime in _REPR_MIMES:
if mime in bundle:
continue
repr_fn = getattr(value, attr, None)
if not callable(repr_fn):
continue
try:
data = repr_fn()
except Exception:
continue
if data is None:
continue
if mime in ("image/png", "image/jpeg"):
bundle[mime] = _coerce_image_bytes(data)
else:
bundle[mime] = data
if "text/plain" not in bundle:
try:
bundle["text/plain"] = repr(value)
except Exception:
bundle["text/plain"] = f"<unrepr {type(value).__name__}>"
return bundle
def _emit_display(bundle: dict, *, kind: str = "display") -> None:
rid = _STATE.current_id
if rid is None:
return
_emit({"type": kind, "id": rid, "bundle": bundle})
def __omp_display(value: Any, *, raw: bool = False, kind: str = "display") -> None:
if raw:
if not isinstance(value, dict):
raise TypeError("display(..., raw=True) requires a MIME bundle dict")
bundle = {str(k): v for k, v in value.items()}
if "text/plain" not in bundle:
bundle["text/plain"] = ""
_emit_display(bundle, kind=kind)
return
_emit_display(_mime_bundle(value), kind=kind)
# ---------------------------------------------------------------------------
# Matplotlib post-cell flush
# ---------------------------------------------------------------------------
def _flush_matplotlib_figures() -> None:
plt = sys.modules.get("matplotlib.pyplot")
if plt is None:
return
try:
fignums = list(plt.get_fignums())
except Exception:
return
for num in fignums:
try:
fig = plt.figure(num)
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight")
data = base64.b64encode(buf.getvalue()).decode("ascii")
_emit_display({"image/png": data, "text/plain": f"<Figure {num}>"})
plt.close(fig)
except Exception:
continue
# Force a non-interactive backend before user code imports matplotlib. Set as
# environ default so the user can still override it explicitly.
os.environ.setdefault("MPLBACKEND", "Agg")
# ---------------------------------------------------------------------------
# Builtin injection
# ---------------------------------------------------------------------------
def _install_builtins(ns: dict) -> None:
ns["display"] = __omp_display
ns["__omp_display"] = __omp_display
ns["__omp_magic"] = __omp_magic
ns["__omp_magic_cell"] = __omp_magic_cell
ns["__omp_shell"] = __omp_shell
_install_builtins(_STATE.user_ns)
# ---------------------------------------------------------------------------
# Source execution (split last expression for rich display)
# ---------------------------------------------------------------------------
def _exec_source(source: str, ns: dict) -> None:
"""Compile + execute ``source``; if the last node is an expression, route
its value through ``__omp_display`` so dataframes/figures render rich."""
try:
module = ast.parse(source, mode="exec")
except SyntaxError:
raise
if not module.body:
return
last = module.body[-1]
if isinstance(last, ast.Expr):
body_module = ast.Module(body=module.body[:-1], type_ignores=[])
expr_module = ast.Expression(body=last.value)
ast.copy_location(expr_module, last)
body_code = compile(body_module, "<cell>", "exec")
expr_code = compile(expr_module, "<cell>", "eval")
exec(body_code, ns)
value = eval(expr_code, ns)
if value is not None:
__omp_display(value, kind="result")
return
code = compile(module, "<cell>", "exec")
exec(code, ns)
# ---------------------------------------------------------------------------
# Signal handling
# ---------------------------------------------------------------------------
def _install_idle_sigint() -> None:
try:
signal.signal(signal.SIGINT, signal.SIG_IGN)
except (OSError, ValueError):
# Some platforms (Windows in non-console mode) reject this; fine.
pass
def _install_exec_sigint() -> None:
try:
signal.signal(signal.SIGINT, signal.default_int_handler)
except (OSError, ValueError):
pass
def _start_parent_watchdog() -> None:
"""Self-terminate when the host process dies.
The main loop only exits when stdin EOFs, which only happens once user
code finishes and the next ``readline`` call returns. If the host gets
SIGKILL mid-execution (or any way that skips graceful shutdown) the
runner would otherwise outlive its parent and keep holding kernel
state. Poll ``os.getppid()`` instead and ``os._exit`` the moment we get
reparented \u2014 covers POSIX hosts. Windows has no reliable ppid
equivalent; there we still bail out on the next stdin read.
"""
if os.name != "posix":
return
original_ppid = os.getppid()
if original_ppid <= 1:
return
def watch() -> None:
while True:
try:
if os.getppid() != original_ppid:
os._exit(0)
except Exception:
return
time.sleep(10)
thread = threading.Thread(target=watch, name="omp-parent-watchdog", daemon=True)
thread.start()
# ---------------------------------------------------------------------------
# Request dispatch
# ---------------------------------------------------------------------------
def _handle_request(req: dict) -> None:
if req.get("type") == "exit":
sys.exit(0)
rid = str(req.get("id"))
code = req.get("code", "")
_STATE.current_id = rid
_STATE.cancel_requested = False
_STATE.execution_count += 1
_emit({"type": "started", "id": rid})
status: str = "ok"
cancelled = False
try:
transformed = transform_cell(code)
except SyntaxError as exc:
_emit_error(rid, exc)
_emit({
"type": "done",
"id": rid,
"status": "error",
"executionCount": _STATE.execution_count,
"cancelled": False,
})
_STATE.current_id = None
return
_install_exec_sigint()
try:
_exec_source(transformed, _STATE.user_ns)
except KeyboardInterrupt:
cancelled = True
status = "error"
_emit_error(rid, KeyboardInterrupt("Execution interrupted"))
except SystemExit:
raise
except BaseException as exc: # noqa: BLE001 - we want to surface every user error
status = "error"
_emit_error(rid, exc)
finally:
_install_idle_sigint()
try:
_flush_matplotlib_figures()
except Exception:
pass
_emit({
"type": "done",
"id": rid,
"status": status,
"executionCount": _STATE.execution_count,
"cancelled": cancelled,
})
_STATE.current_id = None
def _emit_error(rid: str, exc: BaseException) -> None:
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
_emit({
"type": "error",
"id": rid,
"ename": type(exc).__name__,
"evalue": str(exc),
"traceback": [line.rstrip("\n") for line in tb_lines],
})
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main() -> None:
sys.stdout = _StreamProxy("stdout")
sys.stderr = _StreamProxy("stderr")
_install_idle_sigint()
_start_parent_watchdog()
stdin = sys.__stdin__
if stdin is None:
return
for raw_line in stdin:
line = raw_line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError as exc:
_emit({
"type": "error",
"id": "",
"ename": "ProtocolError",
"evalue": f"Invalid JSON request: {exc}",
"traceback": [],
})
continue
try:
_handle_request(req)
except SystemExit:
return
if __name__ == "__main__":
main()
+3 -16
View File
@@ -160,19 +160,6 @@ export function resolveVenvPath(cwd: string): string | undefined {
return undefined;
}
/**
* Resolve the windowless Python executable (pythonw.exe) on Windows.
* Falls back to the regular Python path if pythonw.exe is not available.
*/
function resolveWindowlessPython(pythonPath: string): string {
if (process.platform !== "win32") return pythonPath;
const pythonwPath = pythonPath.replace(/python\.exe$/i, "pythonw.exe");
if (pythonwPath !== pythonPath && fs.existsSync(pythonwPath)) {
return pythonwPath;
}
return pythonPath;
}
/**
* Resolve Python runtime including executable path, environment, and venv detection.
*/
@@ -189,7 +176,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
const currentPath = env[pathKey];
env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
return {
pythonPath: resolveWindowlessPython(pythonCandidate),
pythonPath: pythonCandidate,
env,
venvPath,
};
@@ -205,7 +192,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
process.platform === "win32" ? path.join(managed.venvPath, "Scripts") : path.join(managed.venvPath, "bin");
env[pathKey] = currentPath ? `${managedBin}${path.delimiter}${currentPath}` : managedBin;
return {
pythonPath: resolveWindowlessPython(managed.pythonPath),
pythonPath: managed.pythonPath,
env,
venvPath: managed.venvPath,
};
@@ -216,7 +203,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
throw new Error("Python executable not found on PATH");
}
return {
pythonPath: resolveWindowlessPython(pythonPath),
pythonPath,
env,
};
}
@@ -14,7 +14,6 @@ import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { reset as resetCapabilities } from "../../capability";
import { clearClaudePluginRootsCache } from "../../discovery/helpers";
import { getGatewayStatus } from "../../eval/py/gateway-coordinator";
import { loadCustomShare } from "../../export/custom-share";
import type { CompactOptions } from "../../extensibility/extensions/types";
import {
@@ -402,28 +401,6 @@ export class CommandController {
}
}
const gateway = await getGatewayStatus();
info += `\n${theme.bold("Python Gateway")}\n`;
if (gateway.active) {
info += `${theme.fg("dim", "Status:")} ${theme.fg("success", "Active (Global)")}\n`;
info += `${theme.fg("dim", "URL:")} ${gateway.url}\n`;
info += `${theme.fg("dim", "PID:")} ${gateway.pid}\n`;
if (gateway.pythonPath) {
info += `${theme.fg("dim", "Python:")} ${gateway.pythonPath}\n`;
}
if (gateway.venvPath) {
info += `${theme.fg("dim", "Venv:")} ${gateway.venvPath}\n`;
}
if (gateway.uptime !== null) {
const uptimeSec = Math.floor(gateway.uptime / 1000);
const mins = Math.floor(uptimeSec / 60);
const secs = uptimeSec % 60;
info += `${theme.fg("dim", "Uptime:")} ${mins}m ${secs}s\n`;
}
} else {
info += `${theme.fg("dim", "Status:")} ${theme.fg("dim", "Inactive")}\n`;
}
if (this.ctx.lspServers && this.ctx.lspServers.length > 0) {
info += `\n${theme.bold("LSP Servers")}\n`;
for (const server of this.ctx.lspServers) {
@@ -6561,7 +6561,6 @@ export class AgentSession {
sessionId,
kernelOwnerId: this.#evalKernelOwnerId,
kernelMode: this.settings.get("python.kernelMode"),
useSharedGateway: this.settings.get("python.sharedGateway"),
onChunk,
signal: abortController.signal,
});