feat(coding-agent): implemented shared Python gateway for efficient kernel reuse

- Added Python shared gateway setting for resource-efficient kernel reuse across sessions.
- Enhanced Python tool with session-scoped kernel isolation and workdir-aware session IDs.
- Added Python tool cancellation support with timeout handling and proper cleanup.
- Expanded Python prelude with enhanced file operations, git utilities, and improved output handling.
- Fixed AI provider message transformation for proper tool call handling and error recovery.
This commit is contained in:
can1357
2026-01-18 21:07:02 +01:00
parent b40d2db72e
commit aac8187e05
28 changed files with 1263 additions and 350 deletions
+2 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added automatic retry logic for OpenAI Codex responses with configurable delay and max retries
@@ -16,6 +15,8 @@
### Fixed
- Fixed orphaned tool call handling to ensure proper tool_use/tool_result pairing for all assistant messages
- Fixed message transformation to insert synthetic tool results for errored/aborted assistant messages with tool calls
- Fixed tool prefix handling in Claude provider to use case-insensitive comparison
- Fixed Gemini 3 model handling to treat unsigned tool calls as context-only with anti-mimicry context
- Fixed message transformation to filter out empty error messages from conversation history
@@ -119,19 +119,30 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
existingToolResultIds = new Set();
}
// Track tool calls from this assistant message unless it errored
const assistantMsg = msg as AssistantMessage;
const isErroredAssistant = assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted";
const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[];
if (!isErroredAssistant && toolCalls.length > 0) {
pendingToolCalls = toolCalls;
existingToolResultIds = new Set();
} else if (isErroredAssistant) {
pendingToolCalls = [];
existingToolResultIds = new Set();
}
result.push(msg);
// For errored/aborted messages with tool calls, insert synthetic results immediately
// to maintain tool_use/tool_result pairing required by the API
if (isErroredAssistant && toolCalls.length > 0) {
for (const tc of toolCalls) {
result.push({
role: "toolResult",
toolCallId: tc.id,
toolName: tc.name,
content: [{ type: "text", text: "Tool execution was aborted" }],
isError: true,
timestamp: Date.now(),
} as ToolResultMessage);
}
} else if (!isErroredAssistant && toolCalls.length > 0) {
// Track tool calls to check for orphaned calls later
pendingToolCalls = toolCalls;
existingToolResultIds = new Set();
}
} else if (msg.role === "toolResult") {
existingToolResultIds.add(msg.toolCallId);
result.push(msg);
@@ -159,5 +170,22 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
}
}
// Handle orphaned tool calls at the end of the message array
// This can happen if the last message is an assistant with tool calls that never got results
if (pendingToolCalls.length > 0) {
for (const tc of pendingToolCalls) {
if (!existingToolResultIds.has(tc.id)) {
result.push({
role: "toolResult",
toolCallId: tc.id,
toolName: tc.name,
content: [{ type: "text", text: "No result provided" }],
isError: true,
timestamp: Date.now(),
} as ToolResultMessage);
}
}
}
return result;
}
+30 -12
View File
@@ -1,8 +1,14 @@
# Changelog
## [Unreleased]
### Added
- Added Python shared gateway setting to enable resource-efficient kernel reuse across sessions
- Added Python tool cancellation support with proper timeout and cleanup handling
- Added enhanced Python prelude helpers including file operations, text processing, and Git utilities
- Added Python tool documentation rendering with categorized helper functions
- Added session-scoped Python kernel isolation with workdir-aware session IDs
- Added structured status events for Python prelude functions with TUI rendering
- Added status event display system with operation icons and formatted descriptions
- Added support for rich output using IPython.display.display() in Python tool
@@ -42,6 +48,12 @@
### Changed
- Updated Python tool description to display categorized helper functions with improved formatting
- Enhanced Python kernel startup to use shared gateway by default for better resource utilization
- Improved Python prelude functions to emit structured status events instead of text output
- Updated agent prompts to use bash tool instead of exec for git operations
- Changed default Python tool mode from ipy-only to both to enable shell execution
- Enhanced Python gateway coordination with Windows environment support and stale process cleanup
- Updated Python prelude functions to emit structured status events instead of text output
- Enhanced Python tool renderer to display status events alongside output
- Improved Python tool output formatting with status event integration
@@ -67,18 +79,24 @@
### Fixed
- Fixed Python prelude `sh()` function failing with exit code 2 due to double-shell invocation from `shell=True`
- Fixed signal event listener handling in worker processes to prevent crashes when signal is not an EventTarget
- Fixed Python tool proxy execution in worker processes to use proper tool creation pattern
- Fixed Python tool session requirement when using proxy executor in worker processes
- Fixed WebSocket message handling to support both binary and JSON message formats
- Fixed Python gateway process cleanup and reference counting for shared instances
- Fixed duplicate custom message rendering in event controller
- Fixed --no-extensions flag handling
- Fixed model selection to respect remembered model roles
- Fixed auto-print mode detection when piped input is present
- Fixed shutdown flow to wait for pending renders
- Fixed auto-compaction failure handling to surface warnings instead of crashing
- Fixed Python kernel cancellation handling and WebSocket cleanup for in-flight executions
- Fixed Python tool session scoping to include workdir and honor sharedGateway settings
- Fixed gist sharing output draining to avoid truncated URLs
- Fixed streaming output byte accounting and UTF-8 decoder flushing
- Fixed Python prelude integration tests to detect virtual environments and cover helper exports
- Fixed Python kernel cancellation/timeout handling and WebSocket close cleanup for in-flight executions
- Fixed Python output byte accounting and UTF-8 decoder flushing in streaming output
- Fixed shared Python gateway coordination (Windows env allowlist, lock staleness, refcount recovery)
- Fixed Python tool session scoping to include workdir and honor sharedGateway settings
- Fixed subagent Python proxy session isolation and cancellation/timeout propagation
- Fixed print-mode cleanup to dispose Python sessions before exit
- Fixed gist share output draining to avoid truncated URLs
- Fixed explore agent tool list to use bash for git operations
- Fixed Python prelude integration tests to detect venv-only Python and cover helper exports
### Security
- Enhanced Python gateway environment filtering to exclude sensitive API keys and Windows system paths
## [5.5.0] - 2026-01-18
### Changed
@@ -5,6 +5,7 @@ import {
executePythonWithKernel,
getPreludeDocs,
type PythonKernelExecutor,
resetPreludeDocsCache,
warmPythonEnvironment,
} from "./python-executor";
import { type KernelExecuteOptions, type KernelExecuteResult, type PreludeHelper, PythonKernel } from "./python-kernel";
@@ -138,6 +139,7 @@ describe("executePythonWithKernel", () => {
afterEach(async () => {
await disposeAllKernelSessions();
resetPreludeDocsCache();
vi.restoreAllMocks();
});
@@ -119,6 +119,10 @@ export function getPreludeDocs(): PreludeHelper[] {
return cachedPreludeDocs ?? [];
}
export function resetPreludeDocsCache(): void {
cachedPreludeDocs = null;
}
async function createKernelSession(sessionId: string, cwd: string, useSharedGateway?: boolean): Promise<KernelSession> {
const kernel = await PythonKernel.start({ cwd, useSharedGateway });
const session: KernelSession = {
@@ -1,4 +1,16 @@
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:net";
import { delimiter, join } from "node:path";
import type { Subprocess } from "bun";
@@ -10,11 +22,13 @@ import { logger } from "./logger";
const GATEWAY_DIR_NAME = "python-gateway";
const GATEWAY_INFO_FILE = "gateway.json";
const GATEWAY_LOCK_FILE = "gateway.lock";
const GATEWAY_CLIENT_PREFIX = "client-";
const GATEWAY_STARTUP_TIMEOUT_MS = 30000;
const GATEWAY_IDLE_TIMEOUT_MS = 30000;
const GATEWAY_LOCK_TIMEOUT_MS = 5000;
const GATEWAY_LOCK_TIMEOUT_MS = GATEWAY_STARTUP_TIMEOUT_MS + 5000;
const GATEWAY_LOCK_RETRY_MS = 50;
const GATEWAY_LOCK_STALE_MS = 30000;
const GATEWAY_LOCK_STALE_MS = GATEWAY_STARTUP_TIMEOUT_MS * 2;
const GATEWAY_LOCK_HEARTBEAT_MS = 5000;
const HEALTH_CHECK_TIMEOUT_MS = 3000;
const DEFAULT_ENV_ALLOWLIST = new Set([
@@ -43,6 +57,46 @@ const DEFAULT_ENV_ALLOWLIST = new Set([
"CONDA_DEFAULT_ENV",
"VIRTUAL_ENV",
"PYTHONPATH",
"SYSTEMROOT",
"COMSPEC",
"WINDIR",
"USERPROFILE",
"LOCALAPPDATA",
"APPDATA",
"PROGRAMDATA",
"PATHEXT",
"USERNAME",
"HOMEDRIVE",
"HOMEPATH",
]);
const WINDOWS_ENV_ALLOWLIST = new Set([
"APPDATA",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATH",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"SESSIONNAME",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERDOMAIN_ROAMINGPROFILE",
"USERPROFILE",
"USERNAME",
"WINDIR",
]);
const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "OMP_"];
@@ -59,6 +113,29 @@ const DEFAULT_ENV_DENYLIST = new Set([
"MISTRAL_API_KEY",
]);
const CASE_INSENSITIVE_ENV = process.platform === "win32";
const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
const NORMALIZED_ALLOWLIST = new Set(
Array.from(ACTIVE_ENV_ALLOWLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
);
const NORMALIZED_DENYLIST = new Set(
Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
);
const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV
? DEFAULT_ENV_ALLOW_PREFIXES.map((prefix) => prefix.toUpperCase())
: DEFAULT_ENV_ALLOW_PREFIXES;
function normalizeEnvKey(key: string): string {
return CASE_INSENSITIVE_ENV ? key.toUpperCase() : key;
}
function resolvePathKey(env: Record<string, string | undefined>): string {
if (!CASE_INSENSITIVE_ENV) return "PATH";
const match = Object.keys(env).find((candidate) => candidate.toLowerCase() === "path");
return match ?? "PATH";
}
export interface GatewayInfo {
url: string;
pid: number;
@@ -69,6 +146,11 @@ export interface GatewayInfo {
venvPath?: string | null;
}
interface GatewayLockInfo {
pid: number;
startedAt: number;
}
interface AcquireResult {
url: string;
isShared: boolean;
@@ -78,17 +160,19 @@ let localGatewayProcess: Subprocess | null = null;
let localGatewayUrl: string | null = null;
let idleShutdownTimer: ReturnType<typeof setTimeout> | null = null;
let isCoordinatorInitialized = false;
let localClientFile: string | null = null;
function filterEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
const filtered: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) continue;
if (DEFAULT_ENV_DENYLIST.has(key)) continue;
if (DEFAULT_ENV_ALLOWLIST.has(key)) {
const normalizedKey = normalizeEnvKey(key);
if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
if (NORMALIZED_ALLOWLIST.has(normalizedKey)) {
filtered[key] = value;
continue;
}
if (DEFAULT_ENV_ALLOW_PREFIXES.some((prefix) => key.startsWith(prefix))) {
if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
filtered[key] = value;
}
}
@@ -114,7 +198,9 @@ async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
const binDir = process.platform === "win32" ? join(venvPath, "Scripts") : join(venvPath, "bin");
const pythonCandidate = join(binDir, process.platform === "win32" ? "python.exe" : "python");
if (await Bun.file(pythonCandidate).exists()) {
env.PATH = env.PATH ? `${binDir}${delimiter}${env.PATH}` : binDir;
const pathKey = resolvePathKey(env);
const currentPath = env[pathKey];
env[pathKey] = currentPath ? `${binDir}${delimiter}${currentPath}` : binDir;
return { pythonPath: pythonCandidate, env, venvPath };
}
}
@@ -162,6 +248,32 @@ function getGatewayLockPath(): string {
return join(getGatewayDir(), GATEWAY_LOCK_FILE);
}
function writeLockInfo(lockPath: string, fd: number): void {
const payload: GatewayLockInfo = { pid: process.pid, startedAt: Date.now() };
try {
writeFileSync(fd, JSON.stringify(payload));
} catch {
try {
writeFileSync(lockPath, JSON.stringify(payload));
} catch {
// Ignore lock write failures
}
}
}
function readLockInfo(lockPath: string): GatewayLockInfo | null {
try {
const raw = readFileSync(lockPath, "utf-8");
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;
}
function ensureGatewayDir(): void {
const dir = getGatewayDir();
if (!existsSync(dir)) {
@@ -176,9 +288,19 @@ async function withGatewayLock<T>(handler: () => Promise<T>): Promise<T> {
while (true) {
try {
const fd = openSync(lockPath, "wx");
const heartbeat = setInterval(() => {
try {
const now = new Date();
utimesSync(lockPath, now, now);
} catch {
// Ignore heartbeat errors
}
}, GATEWAY_LOCK_HEARTBEAT_MS);
try {
writeLockInfo(lockPath, fd);
return await handler();
} finally {
clearInterval(heartbeat);
try {
closeSync(fd);
unlinkSync(lockPath);
@@ -192,10 +314,16 @@ async function withGatewayLock<T>(handler: () => Promise<T>): Promise<T> {
let removedStale = false;
try {
const stat = statSync(lockPath);
if (Date.now() - stat.mtimeMs > GATEWAY_LOCK_STALE_MS) {
const lockInfo = readLockInfo(lockPath);
const lockPid = lockInfo?.pid;
const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - stat.mtimeMs;
const staleByTime = lockAgeMs > GATEWAY_LOCK_STALE_MS;
const staleByPid = lockPid !== undefined && !isPidRunning(lockPid);
const staleByMissingPid = lockPid === undefined && staleByTime;
if (staleByPid || staleByMissingPid) {
unlinkSync(lockPath);
removedStale = true;
logger.warn("Removed stale shared gateway lock", { path: lockPath });
logger.warn("Removed stale shared gateway lock", { path: lockPath, pid: lockPid });
}
} catch {
// Ignore stat errors; keep waiting
@@ -218,7 +346,24 @@ function readGatewayInfo(): GatewayInfo | null {
if (!existsSync(infoPath)) return null;
try {
const content = readFileSync(infoPath, "utf-8");
return JSON.parse(content) as GatewayInfo;
const parsed = JSON.parse(content) as Partial<GatewayInfo>;
if (!parsed || typeof parsed !== "object") return null;
if (typeof parsed.url !== "string" || typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") {
return null;
}
if (typeof parsed.cwd !== "string") return null;
const clients = pruneStaleClientInfos(listClientInfos());
const totalRefCount = clients.reduce((sum, client) => sum + client.info.refCount, 0);
const recoveredRefCount = clients.length > 0 ? totalRefCount : 0;
return {
url: parsed.url,
pid: parsed.pid,
startedAt: parsed.startedAt,
refCount: recoveredRefCount,
cwd: parsed.cwd,
pythonPath: typeof parsed.pythonPath === "string" ? parsed.pythonPath : undefined,
venvPath: typeof parsed.venvPath === "string" || parsed.venvPath === null ? parsed.venvPath : undefined,
};
} catch {
return null;
}
@@ -226,7 +371,9 @@ function readGatewayInfo(): GatewayInfo | null {
function writeGatewayInfo(info: GatewayInfo): void {
const infoPath = getGatewayInfoPath();
writeFileSync(infoPath, JSON.stringify(info, null, 2));
const tempPath = `${infoPath}.tmp`;
writeFileSync(tempPath, JSON.stringify(info, null, 2));
renameSync(tempPath, infoPath);
}
function clearGatewayInfo(): void {
@@ -249,6 +396,103 @@ function isPidRunning(pid: number): boolean {
}
}
interface GatewayClientInfo {
pid: number;
refCount: number;
updatedAt?: number;
}
function getClientFilePath(pid: number): string {
return join(getGatewayDir(), `${GATEWAY_CLIENT_PREFIX}${pid}.json`);
}
function readClientInfo(path: string): GatewayClientInfo | null {
try {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as GatewayClientInfo;
if (typeof parsed.pid !== "number" || typeof parsed.refCount !== "number") return null;
return parsed;
} catch {
return null;
}
}
function listClientInfos(): Array<{ path: string; info: GatewayClientInfo }> {
const dir = getGatewayDir();
if (!existsSync(dir)) return [];
const entries = readdirSync(dir);
const results: Array<{ path: string; info: GatewayClientInfo }> = [];
for (const entry of entries) {
if (!entry.startsWith(GATEWAY_CLIENT_PREFIX)) continue;
const path = join(dir, entry);
const info = readClientInfo(path);
if (!info) continue;
results.push({ path, info });
}
return results;
}
function pruneStaleClientInfos(
clients: Array<{ path: string; info: GatewayClientInfo }>,
): Array<{ path: string; info: GatewayClientInfo }> {
const active: Array<{ path: string; info: GatewayClientInfo }> = [];
for (const client of clients) {
if (!isPidRunning(client.info.pid)) {
try {
unlinkSync(client.path);
} catch {
// Ignore cleanup errors
}
continue;
}
active.push(client);
}
return active;
}
function updateLocalClientRefCount(delta: number): { totalRefCount: number; localRefCount: number } {
ensureGatewayDir();
const clients = pruneStaleClientInfos(listClientInfos());
const localPath = localClientFile ?? getClientFilePath(process.pid);
const localEntry = clients.find((client) => client.info.pid === process.pid);
const baseCount = localEntry?.info.refCount ?? 0;
const nextCount = Math.max(0, baseCount + delta);
const otherClients = clients.filter((client) => client.info.pid !== process.pid);
if (nextCount <= 0) {
if (localEntry) {
try {
unlinkSync(localEntry.path);
} catch {
// Ignore cleanup errors
}
}
if (localClientFile === localPath) {
localClientFile = null;
}
} else {
const payload: GatewayClientInfo = { pid: process.pid, refCount: nextCount, updatedAt: Date.now() };
writeFileSync(localPath, JSON.stringify(payload, null, 2));
localClientFile = localPath;
}
const totalRefCount =
otherClients.reduce((sum, client) => sum + client.info.refCount, 0) + (nextCount > 0 ? nextCount : 0);
return { totalRefCount, localRefCount: nextCount };
}
function clearClientFiles(): void {
const clients = listClientInfos();
for (const client of clients) {
try {
unlinkSync(client.path);
} catch {
// Ignore cleanup errors
}
}
localClientFile = null;
}
async function isGatewayHealthy(url: string): Promise<boolean> {
try {
const controller = new AbortController();
@@ -355,22 +599,33 @@ function scheduleIdleShutdown(): void {
try {
await withGatewayLock(async () => {
const info = readGatewayInfo();
if (info && info.refCount === 0) {
logger.debug("Shutting down idle shared gateway", { pid: info.pid });
if (localGatewayProcess) {
shutdownLocalGateway();
} else if (isPidRunning(info.pid)) {
try {
killProcessTree(info.pid);
} catch (err) {
logger.warn("Failed to kill idle shared gateway", {
error: err instanceof Error ? err.message : String(err),
pid: info.pid,
});
}
}
clearGatewayInfo();
if (!info) {
clearClientFiles();
return;
}
const clients = pruneStaleClientInfos(listClientInfos());
const totalRefCount = clients.reduce((sum, client) => sum + client.info.refCount, 0);
if (totalRefCount > 0) {
if (info.refCount !== totalRefCount) {
writeGatewayInfo({ ...info, refCount: totalRefCount });
}
return;
}
logger.debug("Shutting down idle shared gateway", { pid: info.pid });
if (localGatewayProcess) {
shutdownLocalGateway();
} else if (isPidRunning(info.pid)) {
try {
killProcessTree(info.pid);
} catch (err) {
logger.warn("Failed to kill idle shared gateway", {
error: err instanceof Error ? err.message : String(err),
pid: info.pid,
});
}
}
clearGatewayInfo();
clearClientFiles();
});
} catch (err) {
logger.warn("Failed to shutdown idle shared gateway", {
@@ -433,7 +688,8 @@ export async function acquireSharedGateway(cwd: string): Promise<AcquireResult |
});
return null;
}
const updatedInfo = { ...existingInfo, refCount: existingInfo.refCount + 1 };
const { totalRefCount } = updateLocalClientRefCount(1);
const updatedInfo = { ...existingInfo, refCount: totalRefCount };
writeGatewayInfo(updatedInfo);
cancelIdleShutdown();
logger.debug("Reusing shared gateway", { url: existingInfo.url, refCount: updatedInfo.refCount });
@@ -454,14 +710,16 @@ export async function acquireSharedGateway(cwd: string): Promise<AcquireResult |
}
}
clearGatewayInfo();
clearClientFiles();
}
const { url, pid, pythonPath, venvPath } = await startGatewayProcess(cwd);
const { totalRefCount } = updateLocalClientRefCount(1);
const info: GatewayInfo = {
url,
pid,
startedAt: Date.now(),
refCount: 1,
refCount: totalRefCount,
cwd,
pythonPath,
venvPath,
@@ -484,10 +742,11 @@ export async function releaseSharedGateway(): Promise<void> {
try {
await withGatewayLock(async () => {
const { totalRefCount } = updateLocalClientRefCount(-1);
const info = readGatewayInfo();
if (!info) return;
const newRefCount = Math.max(0, info.refCount - 1);
const newRefCount = Math.max(0, totalRefCount);
if (newRefCount === 0) {
const updatedInfo = { ...info, refCount: 0 };
writeGatewayInfo(updatedInfo);
@@ -538,12 +797,15 @@ export function getGatewayStatus(): GatewayStatus {
};
}
const active = isPidRunning(info.pid);
const clients = pruneStaleClientInfos(listClientInfos());
const clientRefCount = clients.reduce((sum, client) => sum + client.info.refCount, 0);
const refCount = clientRefCount > 0 ? clientRefCount : info.refCount;
return {
active,
shared: active && info.refCount > 1,
shared: active && refCount > 1,
url: info.url,
pid: info.pid,
refCount: info.refCount,
refCount,
cwd: info.cwd,
uptime: Date.now() - info.startedAt,
};
@@ -551,10 +813,20 @@ export function getGatewayStatus(): GatewayStatus {
export async function shutdownSharedGateway(): Promise<void> {
cancelIdleShutdown();
const info = readGatewayInfo();
if (info) {
clearGatewayInfo();
try {
await withGatewayLock(async () => {
const info = readGatewayInfo();
if (info) {
clearGatewayInfo();
}
clearClientFiles();
});
} catch (err) {
logger.warn("Failed to shutdown shared gateway", {
error: err instanceof Error ? err.message : String(err),
});
} finally {
shutdownLocalGateway();
isCoordinatorInitialized = false;
}
shutdownLocalGateway();
isCoordinatorInitialized = false;
}
+156 -14
View File
@@ -60,6 +60,56 @@ const DEFAULT_ENV_ALLOWLIST = new Set([
"CONDA_DEFAULT_ENV",
"VIRTUAL_ENV",
"PYTHONPATH",
"APPDATA",
"COMSPEC",
"COMPUTERNAME",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"SYSTEMDRIVE",
"SYSTEMROOT",
"USERDOMAIN",
"USERPROFILE",
"USERNAME",
"WINDIR",
]);
const WINDOWS_ENV_ALLOWLIST = new Set([
"APPDATA",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATH",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"SESSIONNAME",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERDOMAIN_ROAMINGPROFILE",
"USERPROFILE",
"USERNAME",
"WINDIR",
]);
const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "OMP_"];
@@ -76,6 +126,30 @@ const DEFAULT_ENV_DENYLIST = new Set([
"MISTRAL_API_KEY",
]);
const CASE_INSENSITIVE_ENV = process.platform === "win32";
const BASE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV
? new Set([...DEFAULT_ENV_ALLOWLIST, ...WINDOWS_ENV_ALLOWLIST])
: DEFAULT_ENV_ALLOWLIST;
const NORMALIZED_ALLOWLIST = new Set(
Array.from(BASE_ENV_ALLOWLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
);
const NORMALIZED_DENYLIST = new Set(
Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
);
const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV
? DEFAULT_ENV_ALLOW_PREFIXES.map((prefix) => prefix.toUpperCase())
: DEFAULT_ENV_ALLOW_PREFIXES;
function normalizeEnvKey(key: string): string {
return CASE_INSENSITIVE_ENV ? key.toUpperCase() : key;
}
function resolvePathKey(env: Record<string, string | undefined>): string {
if (!CASE_INSENSITIVE_ENV) return "PATH";
const match = Object.keys(env).find((candidate) => candidate.toLowerCase() === "path");
return match ?? "PATH";
}
export interface JupyterHeader {
msg_id: string;
session: string;
@@ -149,12 +223,14 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
const filtered: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) continue;
if (DEFAULT_ENV_DENYLIST.has(key)) continue;
if (DEFAULT_ENV_ALLOWLIST.has(key)) {
filtered[key] = value;
const normalizedKey = normalizeEnvKey(key);
if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
if (NORMALIZED_ALLOWLIST.has(normalizedKey)) {
const destKey = normalizedKey === "PATH" ? "PATH" : key;
filtered[destKey] = value;
continue;
}
if (DEFAULT_ENV_ALLOW_PREFIXES.some((prefix) => key.startsWith(prefix))) {
if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
filtered[key] = value;
}
}
@@ -180,7 +256,9 @@ async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
const binDir = process.platform === "win32" ? join(venvPath, "Scripts") : join(venvPath, "bin");
const pythonCandidate = join(binDir, process.platform === "win32" ? "python.exe" : "python");
if (await Bun.file(pythonCandidate).exists()) {
env.PATH = env.PATH ? `${binDir}${delimiter}${env.PATH}` : binDir;
const pathKey = resolvePathKey(env);
const currentPath = env[pathKey];
env[pathKey] = currentPath ? `${binDir}${delimiter}${currentPath}` : binDir;
return { pythonPath: pythonCandidate, env };
}
}
@@ -396,6 +474,7 @@ export class PythonKernel {
#heartbeatFailures = 0;
#messageHandlers = new Map<string, (msg: JupyterMessage) => void>();
#channelHandlers = new Map<string, Set<(msg: JupyterMessage) => void>>();
#pendingExecutions = new Map<string, (reason: string) => void>();
private constructor(
id: string,
@@ -647,25 +726,47 @@ export class PythonKernel {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
let settled = false;
const timeout = setTimeout(() => {
ws.close();
reject(new Error("WebSocket connection timeout"));
if (!settled) {
settled = true;
reject(new Error("WebSocket connection timeout"));
}
}, 10000);
ws.onopen = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
this.#ws = ws;
resolve();
};
ws.onerror = (event) => {
clearTimeout(timeout);
reject(new Error(`WebSocket error: ${event}`));
const error = new Error(`WebSocket error: ${event}`);
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(error);
return;
}
this.#alive = false;
this.#ws = null;
this.abortPendingExecutions(error.message);
};
ws.onclose = () => {
this.#alive = false;
this.#ws = null;
if (!settled) {
settled = true;
clearTimeout(timeout);
reject(new Error("WebSocket closed before connection"));
return;
}
this.abortPendingExecutions("WebSocket closed");
};
ws.onmessage = (event) => {
@@ -701,6 +802,16 @@ export class PythonKernel {
});
}
private abortPendingExecutions(reason: string): void {
if (this.#pendingExecutions.size === 0) return;
for (const cancel of this.#pendingExecutions.values()) {
cancel(reason);
}
this.#pendingExecutions.clear();
this.#messageHandlers.clear();
logger.warn("Aborted pending Python executions", { reason });
}
isAlive(): boolean {
return this.#alive && !this.#disposed && this.#ws?.readyState === WebSocket.OPEN;
}
@@ -742,27 +853,52 @@ export class PythonKernel {
let cancelled = false;
let timedOut = false;
using executionSignal = new ScopeSignal({ signal: options?.signal, timeout: options?.timeoutMs });
const executionSignal = new ScopeSignal({ signal: options?.signal, timeout: options?.timeoutMs });
return new Promise((resolve) => {
const cleanup = () => {
let resolved = false;
const finalize = () => {
if (resolved) return;
resolved = true;
this.#messageHandlers.delete(msgId);
this.#pendingExecutions.delete(msgId);
executionSignal[Symbol.dispose]();
resolve({ status, executionCount, error, cancelled, timedOut, stdinRequested });
};
const checkDone = () => {
if (replyReceived && idleReceived) {
cleanup();
finalize();
}
};
const cancelFromClose = (reason: string) => {
if (resolved) return;
cancelled = true;
timedOut = false;
if (options?.onChunk) {
void options.onChunk(`[kernel] ${reason}\n`);
}
finalize();
};
this.#pendingExecutions.set(msgId, cancelFromClose);
executionSignal.catch(async () => {
cancelled = true;
timedOut = executionSignal.timedOut();
await this.interrupt();
cleanup();
try {
await this.interrupt();
} finally {
finalize();
}
});
if (executionSignal.aborted) {
cancelFromClose("Execution aborted");
return;
}
this.#messageHandlers.set(msgId, async (response) => {
switch (response.header.msg_type) {
case "execute_reply": {
@@ -844,7 +980,12 @@ export class PythonKernel {
}
});
this.sendMessage(msg);
try {
this.sendMessage(msg);
} catch {
cancelled = true;
finalize();
}
});
}
@@ -909,6 +1050,7 @@ export class PythonKernel {
if (this.#disposed) return;
this.#disposed = true;
this.#alive = false;
this.abortPendingExecutions("Kernel shutdown");
if (this.#heartbeatTimer) {
clearInterval(this.#heartbeatTimer);
+256 -181
View File
@@ -19,14 +19,14 @@ if "__omp_prelude_loaded__" not in globals():
@_category("Navigation")
def pwd() -> Path:
"""Print and return current working directory."""
"""Return current working directory."""
p = Path.cwd()
_emit_status("pwd", path=str(p))
return p
@_category("Navigation")
def cd(path: str | Path) -> Path:
"""Change directory and print the new cwd."""
"""Change directory."""
p = Path(path).expanduser().resolve()
os.chdir(p)
_emit_status("cd", path=str(p))
@@ -37,9 +37,7 @@ if "__omp_prelude_loaded__" not in globals():
"""Get/set environment variables."""
if key is None:
items = dict(sorted(os.environ.items()))
for k, v in items.items():
print(f"{k}={v}")
_emit_status("env", count=len(items))
_emit_status("env", count=len(items), keys=list(items.keys())[:20])
return items
if value is not None:
os.environ[key] = value
@@ -50,21 +48,23 @@ if "__omp_prelude_loaded__" not in globals():
return val
@_category("File I/O")
def read(path: str | Path, *, limit: int | None = None) -> str:
"""Read file contents. Prints a short preview + length."""
def read(path: str | Path, *, offset: int = 1, limit: int | None = None) -> str:
"""Read file contents. offset/limit are 1-indexed line numbers."""
p = Path(path)
data = p.read_text(encoding="utf-8")
if limit is not None:
preview = data[:limit]
print(preview)
else:
print(data)
_emit_status("read", path=str(p), chars=len(data))
lines = data.splitlines(keepends=True)
if offset > 1 or limit is not None:
start = max(0, offset - 1)
end = start + limit if limit else len(lines)
lines = lines[start:end]
data = "".join(lines)
preview = data[:500]
_emit_status("read", path=str(p), chars=len(data), preview=preview)
return data
@_category("File I/O")
def write(path: str | Path, content: str) -> Path:
"""Write file contents (create parents). Prints bytes written."""
"""Write file contents (create parents)."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
@@ -73,7 +73,7 @@ if "__omp_prelude_loaded__" not in globals():
@_category("File I/O")
def append(path: str | Path, content: str) -> Path:
"""Append to file. Prints bytes appended."""
"""Append to file."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as f:
@@ -134,68 +134,177 @@ if "__omp_prelude_loaded__" not in globals():
"""List directory contents."""
p = Path(path)
items = sorted(p.iterdir())
for item in items:
suffix = "/" if item.is_dir() else ""
print(f"{item.name}{suffix}")
_emit_status("ls", path=str(p), count=len(items))
_emit_status("ls", path=str(p), count=len(items), items=[i.name + ("/" if i.is_dir() else "") for i in items[:20]])
return items
def _load_gitignore_patterns(base: Path) -> list[str]:
"""Load .gitignore patterns from base directory and parents."""
patterns: list[str] = []
# Always exclude these
patterns.extend(["**/.git", "**/.git/**", "**/node_modules", "**/node_modules/**"])
# Walk up to find .gitignore files
current = base.resolve()
for _ in range(20): # Limit depth
gitignore = current / ".gitignore"
if gitignore.exists():
try:
for line in gitignore.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
# Normalize pattern for fnmatch
if line.startswith("/"):
patterns.append(str(current / line[1:]))
else:
patterns.append(f"**/{line}")
except Exception:
pass
parent = current.parent
if parent == current:
break
current = parent
return patterns
def _match_gitignore(path: Path, patterns: list[str], base: Path) -> bool:
"""Check if path matches any gitignore pattern."""
import fnmatch
rel = str(path.relative_to(base)) if path.is_relative_to(base) else str(path)
abs_path = str(path.resolve())
for pat in patterns:
if pat.startswith("**/"):
# Match against any part of the path
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, pat[3:]):
return True
# Also check each path component
for part in path.parts:
if fnmatch.fnmatch(part, pat[3:]):
return True
elif fnmatch.fnmatch(abs_path, pat) or fnmatch.fnmatch(rel, pat):
return True
return False
@_category("Search")
def find(pattern: str, path: str | Path = ".", *, files_only: bool = True) -> list[Path]:
"""Recursive glob find. Defaults to files only."""
def find(
pattern: str,
path: str | Path = ".",
*,
type: str = "file",
limit: int = 1000,
hidden: bool = False,
sort_by_mtime: bool = False,
) -> list[Path]:
"""Recursive glob find. Respects .gitignore."""
p = Path(path)
matches = []
ignore_patterns = _load_gitignore_patterns(p)
matches: list[Path] = []
for m in p.rglob(pattern):
if files_only and m.is_dir():
if len(matches) >= limit:
break
# Skip hidden files unless requested
if not hidden and any(part.startswith(".") for part in m.parts):
continue
# Skip gitignored paths
if _match_gitignore(m, ignore_patterns, p):
continue
# Filter by type
if type == "file" and m.is_dir():
continue
if type == "dir" and not m.is_dir():
continue
matches.append(m)
matches = sorted(matches)
for m in matches:
print(str(m))
_emit_status("find", pattern=pattern, path=str(p), count=len(matches))
if sort_by_mtime:
matches.sort(key=lambda x: x.stat().st_mtime, reverse=True)
else:
matches.sort()
_emit_status("find", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
return matches
@_category("Search")
def grep(pattern: str, path: str | Path, *, ignore_case: bool = False, context: int = 0) -> list[tuple[int, str]]:
"""Grep a single file."""
flags = re.IGNORECASE if ignore_case else 0
rx = re.compile(pattern, flags)
def grep(
pattern: str,
path: str | Path,
*,
ignore_case: bool = False,
literal: bool = False,
context: int = 0,
) -> list[tuple[int, str]]:
"""Grep a single file. Returns (line_number, text) tuples."""
p = Path(path)
lines = p.read_text(encoding="utf-8").splitlines()
hits: list[tuple[int, str]] = []
if literal:
if ignore_case:
match_fn = lambda line: pattern.lower() in line.lower()
else:
match_fn = lambda line: pattern in line
else:
flags = re.IGNORECASE if ignore_case else 0
rx = re.compile(pattern, flags)
match_fn = lambda line: rx.search(line) is not None
match_lines: set[int] = set()
for i, line in enumerate(lines, 1):
if rx.search(line):
hits.append((i, line))
print(f"{i}: {line}")
if context:
start = max(0, i - 1 - context)
end = min(len(lines), i - 1 + context + 1)
for j in range(start, end):
if j + 1 == i:
continue
print(f"{j+1}- {lines[j]}")
_emit_status("grep", pattern=pattern, path=str(p), count=len(hits))
if match_fn(line):
match_lines.add(i)
# Expand with context
if context > 0:
expanded: set[int] = set()
for ln in match_lines:
for offset in range(-context, context + 1):
expanded.add(ln + offset)
output_lines = sorted(ln for ln in expanded if 1 <= ln <= len(lines))
else:
output_lines = sorted(match_lines)
hits = [(ln, lines[ln - 1]) for ln in output_lines]
_emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=[{"line": h[0], "text": h[1][:100]} for h in hits[:10]])
return hits
@_category("Search")
def rgrep(pattern: str, path: str | Path = ".", *, glob_pattern: str = "*", ignore_case: bool = False) -> list[tuple[Path, int, str]]:
"""Recursive grep across files matching glob_pattern."""
flags = re.IGNORECASE if ignore_case else 0
rx = re.compile(pattern, flags)
def rgrep(
pattern: str,
path: str | Path = ".",
*,
glob_pattern: str = "*",
ignore_case: bool = False,
literal: bool = False,
limit: int = 100,
hidden: bool = False,
) -> list[tuple[Path, int, str]]:
"""Recursive grep across files matching glob_pattern. Respects .gitignore."""
if literal:
if ignore_case:
match_fn = lambda line: pattern.lower() in line.lower()
else:
match_fn = lambda line: pattern in line
else:
flags = re.IGNORECASE if ignore_case else 0
rx = re.compile(pattern, flags)
match_fn = lambda line: rx.search(line) is not None
base = Path(path)
ignore_patterns = _load_gitignore_patterns(base)
hits: list[tuple[Path, int, str]] = []
for file_path in base.rglob(glob_pattern):
if len(hits) >= limit:
break
if file_path.is_dir():
continue
# Skip hidden files unless requested
if not hidden and any(part.startswith(".") for part in file_path.parts):
continue
# Skip gitignored paths
if _match_gitignore(file_path, ignore_patterns, base):
continue
try:
lines = file_path.read_text(encoding="utf-8").splitlines()
except Exception:
continue
for i, line in enumerate(lines, 1):
if rx.search(line):
if len(hits) >= limit:
break
if match_fn(line):
hits.append((file_path, i, line))
print(f"{file_path}:{i}: {line}")
_emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits))
_emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=[{"file": str(h[0]), "line": h[1], "text": h[2][:80]} for h in hits[:10]])
return hits
@_category("Text")
@@ -203,8 +312,7 @@ if "__omp_prelude_loaded__" not in globals():
"""Return the first n lines of text."""
lines = text.splitlines()[:n]
out = "\n".join(lines)
print(out)
_emit_status("head", lines=len(lines))
_emit_status("head", lines=len(lines), preview=out[:500])
return out
@_category("Text")
@@ -212,8 +320,7 @@ if "__omp_prelude_loaded__" not in globals():
"""Return the last n lines of text."""
lines = text.splitlines()[-n:]
out = "\n".join(lines)
print(out)
_emit_status("tail", lines=len(lines))
_emit_status("tail", lines=len(lines), preview=out[:500])
return out
@_category("Find/Replace")
@@ -244,26 +351,49 @@ if "__omp_prelude_loaded__" not in globals():
def __bool__(self):
return self.code == 0
def _make_shell_result(proc: subprocess.CompletedProcess[str]) -> ShellResult:
"""Create ShellResult and print output."""
if proc.stdout:
print(proc.stdout, end="" if proc.stdout.endswith("\n") else "\n")
if proc.stderr:
print(proc.stderr, end="" if proc.stderr.endswith("\n") else "\n")
def _make_shell_result(proc: subprocess.CompletedProcess[str], cmd: str) -> ShellResult:
"""Create ShellResult and emit status."""
output = proc.stdout + proc.stderr if proc.stderr else proc.stdout
_emit_status("sh", cmd=cmd[:80], code=proc.returncode, output=output[:500])
return ShellResult(proc.stdout, proc.stderr, proc.returncode)
import signal as _signal
def _run_with_interrupt(args: list[str], cwd: str | None, timeout: int | None, cmd: str) -> ShellResult:
"""Run subprocess with proper interrupt handling."""
proc = subprocess.Popen(
args,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True,
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except KeyboardInterrupt:
os.killpg(proc.pid, _signal.SIGINT)
try:
stdout, stderr = proc.communicate(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, _signal.SIGKILL)
stdout, stderr = proc.communicate()
result = subprocess.CompletedProcess(args, -_signal.SIGINT, stdout, stderr)
return _make_shell_result(result, cmd)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, _signal.SIGKILL)
stdout, stderr = proc.communicate()
result = subprocess.CompletedProcess(args, -_signal.SIGKILL, stdout, stderr)
return _make_shell_result(result, cmd)
result = subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
return _make_shell_result(result, cmd)
@_category("Shell")
def run(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
"""Run a shell command and print stdout/stderr."""
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
return _make_shell_result(proc)
"""Run a shell command."""
shell_path = shutil.which("bash") or shutil.which("sh") or "/bin/sh"
args = [shell_path, "-c", cmd]
return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
@_category("Shell")
def sh(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
@@ -272,7 +402,6 @@ if "__omp_prelude_loaded__" not in globals():
prefix = f"source '{snapshot}' 2>/dev/null && " if snapshot else ""
final = f"{prefix}{cmd}"
# Determine shell and args (mirroring shell.ts logic)
shell_path = os.environ.get("SHELL")
if not shell_path or not shutil.which(shell_path):
shell_path = shutil.which("bash") or shutil.which("zsh") or shutil.which("sh")
@@ -286,32 +415,22 @@ if "__omp_prelude_loaded__" not in globals():
text=True,
timeout=timeout,
)
return _make_shell_result(proc)
return _make_shell_result(proc, cmd)
raise RuntimeError("No suitable shell found")
no_login = os.environ.get("OMP_BASH_NO_LOGIN") or os.environ.get("CLAUDE_BASH_NO_LOGIN")
args = [shell_path, "-c", final] if no_login else [shell_path, "-l", "-c", final]
proc = subprocess.run(
args,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
)
return _make_shell_result(proc)
# --- Extended shell-like utilities ---
return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
@_category("File I/O")
def cat(*paths: str | Path, separator: str = "\n") -> str:
"""Concatenate multiple files and print. Like shell cat."""
"""Concatenate multiple files. Like shell cat."""
parts = []
for p in paths:
parts.append(Path(p).read_text(encoding="utf-8"))
out = separator.join(parts)
print(out)
_emit_status("cat", files=len(paths), chars=len(out))
_emit_status("cat", files=len(paths), chars=len(out), preview=out[:500])
return out
@_category("File I/O")
@@ -340,7 +459,6 @@ if "__omp_prelude_loaded__" not in globals():
lines = list(dict.fromkeys(lines))
lines = sorted(lines, reverse=reverse)
out = "\n".join(lines)
print(out)
_emit_status("sort_lines", lines=len(lines), unique=unique, reverse=reverse)
return out
@@ -362,15 +480,10 @@ if "__omp_prelude_loaded__" not in globals():
current = line
current_count = 1
groups.append((current_count, current))
_emit_status("uniq", groups=len(groups), count_mode=count)
if count:
for c, l in groups:
print(f"{c:>4} {l}")
_emit_status("uniq", groups=len(groups), count_mode=True)
return groups
out = "\n".join(line for _, line in groups)
print(out)
_emit_status("uniq", groups=len(groups))
return out
return "\n".join(line for _, line in groups)
@_category("Text")
def cols(text: str, *indices: int, sep: str | None = None) -> str:
@@ -381,13 +494,12 @@ if "__omp_prelude_loaded__" not in globals():
selected = [parts[i] for i in indices if i < len(parts)]
result_lines.append(" ".join(selected))
out = "\n".join(result_lines)
print(out)
_emit_status("cols", lines=len(result_lines), columns=list(indices))
return out
@_category("Navigation")
def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
"""Print directory tree."""
"""Return directory tree."""
base = Path(path)
lines = []
def walk(p: Path, prefix: str, depth: int):
@@ -406,8 +518,7 @@ if "__omp_prelude_loaded__" not in globals():
lines.append(str(base) + "/")
walk(base, "", 1)
out = "\n".join(lines)
print(out)
_emit_status("tree", path=str(base), entries=len(lines) - 1)
_emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
return out
@_category("Navigation")
@@ -423,35 +534,37 @@ if "__omp_prelude_loaded__" not in globals():
"mtime": datetime.fromtimestamp(s.st_mtime).isoformat(),
"mode": oct(s.st_mode),
}
for k, v in info.items():
print(f"{k}: {v}")
_emit_status("stat", path=str(p), size=s.st_size, is_dir=p.is_dir())
_emit_status("stat", path=str(p), size=s.st_size, is_dir=p.is_dir(), mtime=info["mtime"])
return info
@_category("Batch")
def diff(a: str | Path, b: str | Path) -> str:
"""Compare two files, print unified diff."""
"""Compare two files, return unified diff."""
import difflib
path_a, path_b = Path(a), Path(b)
lines_a = path_a.read_text(encoding="utf-8").splitlines(keepends=True)
lines_b = path_b.read_text(encoding="utf-8").splitlines(keepends=True)
result = difflib.unified_diff(lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b))
out = "".join(result)
if out:
print(out)
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=False)
else:
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=True)
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
return out
@_category("Search")
def glob_files(pattern: str, path: str | Path = ".") -> list[Path]:
"""Non-recursive glob (use find() for recursive)."""
def glob_files(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[Path]:
"""Non-recursive glob (use find() for recursive). Respects .gitignore."""
p = Path(path)
matches = sorted(p.glob(pattern))
for m in matches:
print(str(m))
_emit_status("glob", pattern=pattern, path=str(p), count=len(matches))
ignore_patterns = _load_gitignore_patterns(p)
matches: list[Path] = []
for m in p.glob(pattern):
# Skip hidden files unless requested
if not hidden and m.name.startswith("."):
continue
# Skip gitignored paths
if _match_gitignore(m, ignore_patterns, p):
continue
matches.append(m)
matches = sorted(matches)
_emit_status("glob", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
return matches
@_category("Batch")
@@ -475,29 +588,44 @@ if "__omp_prelude_loaded__" not in globals():
return count
@_category("Find/Replace")
def rsed(pattern: str, repl: str, path: str | Path = ".", *, glob_pattern: str = "*", flags: int = 0) -> int:
"""Recursive sed across files matching glob_pattern."""
def rsed(
pattern: str,
repl: str,
path: str | Path = ".",
*,
glob_pattern: str = "*",
flags: int = 0,
hidden: bool = False,
) -> int:
"""Recursive sed across files matching glob_pattern. Respects .gitignore."""
base = Path(path)
ignore_patterns = _load_gitignore_patterns(base)
total = 0
files_changed = 0
changed_files = []
for file_path in base.rglob(glob_pattern):
if file_path.is_dir():
continue
# Skip hidden files unless requested
if not hidden and any(part.startswith(".") for part in file_path.parts):
continue
# Skip gitignored paths
if _match_gitignore(file_path, ignore_patterns, base):
continue
try:
data = file_path.read_text(encoding="utf-8")
new, count = re.subn(pattern, repl, data, flags=flags)
if count > 0:
file_path.write_text(new, encoding="utf-8")
print(f"{file_path}: {count} replacements")
total += count
files_changed += 1
if len(changed_files) < 10:
changed_files.append({"file": str(file_path), "count": count})
except Exception:
continue
_emit_status("rsed", path=str(base), count=total, files=files_changed)
_emit_status("rsed", path=str(base), count=total, files=files_changed, changed=changed_files)
return total
# --- Line-based operations (sed-like) ---
@_category("Line ops")
def lines(path: str | Path, start: int = 1, end: int | None = None) -> str:
"""Extract line range from file (1-indexed, inclusive). Like sed -n 'N,Mp'."""
@@ -508,10 +636,9 @@ if "__omp_prelude_loaded__" not in globals():
start = max(1, start)
end = min(len(all_lines), end)
selected = all_lines[start - 1 : end]
out = "\n".join(f"{start + i}: {line}" for i, line in enumerate(selected))
print(out)
_emit_status("lines", path=str(p), start=start, end=end, count=len(selected))
return "\n".join(selected)
out = "\n".join(selected)
_emit_status("lines", path=str(p), start=start, end=end, count=len(selected), preview=out[:500])
return out
@_category("Line ops")
def delete_lines(path: str | Path, start: int, end: int | None = None) -> int:
@@ -562,8 +689,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("insert_at", path=str(p), line=line_num, lines_inserted=len(new_lines), position=pos)
return p
# --- Git helpers ---
def _git(*args: str, cwd: str | Path | None = None) -> tuple[int, str, str]:
"""Run git command, return (returncode, stdout, stderr)."""
result = subprocess.run(
@@ -604,33 +729,8 @@ if "__omp_prelude_loaded__" not in globals():
elif line.startswith("? "):
result["untracked"].append(line[2:])
# Pretty print
print(f"branch: {result['branch']}", end="")
if result["ahead"] or result["behind"]:
print(f" (+{result['ahead']}/-{result['behind']})", end="")
print()
if result["staged"]:
print(f"staged ({len(result['staged'])}):")
for f in result["staged"][:10]:
print(f" + {f}")
if len(result["staged"]) > 10:
print(f" ... and {len(result['staged']) - 10} more")
if result["modified"]:
print(f"modified ({len(result['modified'])}):")
for f in result["modified"][:10]:
print(f" M {f}")
if len(result["modified"]) > 10:
print(f" ... and {len(result['modified']) - 10} more")
if result["untracked"]:
print(f"untracked ({len(result['untracked'])}):")
for f in result["untracked"][:5]:
print(f" ? {f}")
if len(result["untracked"]) > 5:
print(f" ... and {len(result['untracked']) - 5} more")
clean = not any([result["staged"], result["modified"], result["untracked"]])
if clean:
print("working tree clean")
_emit_status("git_status", branch=result["branch"], staged=len(result["staged"]), modified=len(result["modified"]), untracked=len(result["untracked"]), clean=clean)
_emit_status("git_status", branch=result["branch"], staged=len(result["staged"]), modified=len(result["modified"]), untracked=len(result["untracked"]), clean=clean, files=result["staged"][:5] + result["modified"][:5])
return result
@_category("Git")
@@ -656,9 +756,8 @@ if "__omp_prelude_loaded__" not in globals():
if code != 0:
_emit_status("git_diff", error=err.strip())
return ""
print(out)
lines_count = len(out.splitlines()) if out else 0
_emit_status("git_diff", staged=staged, ref=ref, lines=lines_count)
_emit_status("git_diff", staged=staged, ref=ref, lines=lines_count, preview=out[:500])
return out
@_category("Git")
@@ -689,11 +788,7 @@ if "__omp_prelude_loaded__" not in globals():
if len(parts) >= 4:
commits.append({"sha": parts[0], "subject": parts[1], "author": parts[2], "date": parts[3]})
# Pretty print
for c in commits:
date_short = c["date"][:10]
print(f"{c['sha'][:8]} {date_short} {c['subject'][:60]}")
_emit_status("git_log", commits=len(commits))
_emit_status("git_log", commits=len(commits), entries=[{"sha": c["sha"][:8], "subject": c["subject"][:50]} for c in commits[:5]])
return commits
@_category("Git")
@@ -719,19 +814,7 @@ if "__omp_prelude_loaded__" not in globals():
_, stat_out, _ = _git("show", ref, "--stat", "--format=", cwd=cwd)
result["files"] = [l.strip() for l in stat_out.strip().splitlines() if l.strip()]
# Pretty print
print(f"commit {result['sha'][:12]}")
print(f"Author: {result['author']}")
print(f"Date: {result['date']}")
print(f"\n {result['subject']}")
if result["body"]:
for line in result["body"].splitlines()[:5]:
print(f" {line}")
if result["files"]:
print()
for f in result["files"][-5:]:
print(f" {f}")
_emit_status("git_show", ref=ref, sha=result["sha"][:12], files=len(result["files"]))
_emit_status("git_show", ref=ref, sha=result["sha"][:12], subject=result["subject"][:60], files=len(result["files"]))
return result
@_category("Git")
@@ -748,12 +831,10 @@ if "__omp_prelude_loaded__" not in globals():
start = max(1, start)
end = min(len(all_lines), end)
selected = all_lines[start - 1 : end]
out = "\n".join(f"{start + i}: {line}" for i, line in enumerate(selected))
print(out)
out = "\n".join(selected)
_emit_status("git_file_at", ref=ref, path=path, start=start, end=end, lines=len(selected))
return "\n".join(selected)
return out
print(out)
_emit_status("git_file_at", ref=ref, path=path, chars=len(out))
return out
@@ -779,13 +860,7 @@ if "__omp_prelude_loaded__" not in globals():
if is_current:
result["current"] = name
print(f"* {result['current']}")
for b in result["local"]:
if b != result["current"]:
print(f" {b}")
if result["remote"]:
print(f" ({len(result['remote'])} remote branches)")
_emit_status("git_branch", current=result["current"], local=len(result["local"]), remote=len(result["remote"]))
_emit_status("git_branch", current=result["current"], local=len(result["local"]), remote=len(result["remote"]), branches=result["local"][:10])
return result
@_category("Git")
@@ -1,7 +1,24 @@
import { describe, expect, it } from "bun:test";
import { createPythonTool } from "./tools/python";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { resetPreludeDocsCache, warmPythonEnvironment } from "./python-executor";
import { createPythonTool, getPythonToolDescription } from "./tools/python";
const pythonPath = Bun.which("python") ?? Bun.which("python3");
const resolvePythonPath = (): string | null => {
const venvPath = process.env.VIRTUAL_ENV;
const candidates = [venvPath, join(process.cwd(), ".venv"), join(process.cwd(), "venv")].filter(Boolean) as string[];
for (const candidate of candidates) {
const binDir = process.platform === "win32" ? "Scripts" : "bin";
const exeName = process.platform === "win32" ? "python.exe" : "python";
const pythonCandidate = join(candidate, binDir, exeName);
if (existsSync(pythonCandidate)) {
return pythonCandidate;
}
}
return Bun.which("python") ?? Bun.which("python3");
};
const pythonPath = resolvePythonPath();
const hasKernelDeps = (() => {
if (!pythonPath) return false;
const result = Bun.spawnSync(
@@ -31,12 +48,36 @@ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
"mv",
"cp",
"ls",
"cat",
"touch",
"find",
"grep",
"rgrep",
"head",
"tail",
"replace",
"sed",
"rsed",
"wc",
"sort_lines",
"uniq",
"cols",
"tree",
"stat",
"diff",
"glob_files",
"batch",
"lines",
"delete_lines",
"delete_matching",
"insert_at",
"git_status",
"git_diff",
"git_log",
"git_show",
"git_file_at",
"git_branch",
"git_has_changes",
"run",
"sh",
];
@@ -63,15 +104,37 @@ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
const tool = createPythonTool(session);
const code = `
helpers = ${JSON.stringify(helpers)}
missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]
print("HELPERS_OK=" + ("1" if not missing else "0"))
if missing:
print("MISSING=" + ",".join(missing))
`;
helpers = ${JSON.stringify(helpers)}
missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]
docs = __omp_prelude_docs__()
doc_names = [d.get("name") for d in docs]
doc_categories = [d.get("category") for d in docs]
print("HELPERS_OK=" + ("1" if not missing else "0"))
print("DOCS_OK=" + ("1" if "pwd" in doc_names and "Navigation" in doc_categories else "0"))
if missing:
print("MISSING=" + ",".join(missing))
`;
const result = await tool.execute("tool-call-1", { code });
const output = result.content.find((item) => item.type === "text")?.text ?? "";
expect(output).toContain("HELPERS_OK=1");
expect(output).toContain("DOCS_OK=1");
});
it("exposes prelude docs via warmup", async () => {
resetPreludeDocsCache();
const result = await warmPythonEnvironment(process.cwd(), undefined, false);
expect(result.ok).toBe(true);
const names = result.docs.map((doc) => doc.name);
expect(names).toContain("pwd");
});
it("renders prelude docs in python tool description", async () => {
resetPreludeDocsCache();
const result = await warmPythonEnvironment(process.cwd(), undefined, false);
expect(result.ok).toBe(true);
const description = getPythonToolDescription();
expect(description).toContain("pwd");
expect(description).not.toContain("Documentation unavailable");
});
});
@@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test";
import { SettingsManager } from "./settings-manager";
describe("SettingsManager python settings", () => {
it("defaults to ipy-only and session", () => {
it("defaults to both and session", () => {
const settings = SettingsManager.inMemory();
expect(settings.getPythonToolMode()).toBe("ipy-only");
expect(settings.getPythonToolMode()).toBe("both");
expect(settings.getPythonKernelMode()).toBe("session");
});
@@ -12,10 +12,19 @@ interface OutputFileSink {
export function createSanitizer(): TransformStream<Uint8Array, string> {
const decoder = new TextDecoder();
const sanitizeText = (text: string) => sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
return new TransformStream({
transform(chunk, controller) {
const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
controller.enqueue(text);
const text = sanitizeText(decoder.decode(chunk, { stream: true }));
if (text) {
controller.enqueue(text);
}
},
flush(controller) {
const text = sanitizeText(decoder.decode());
if (text) {
controller.enqueue(text);
}
},
});
}
@@ -40,7 +49,7 @@ export function createOutputSink(
): WritableStream<string> & {
dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
} {
const chunks: string[] = [];
const chunks: Array<{ text: string; bytes: number }> = [];
let chunkBytes = 0;
let totalBytes = 0;
let fullOutputPath: string | undefined;
@@ -48,22 +57,26 @@ export function createOutputSink(
const sink = new WritableStream<string>({
write(text) {
totalBytes += text.length;
const bytes = Buffer.byteLength(text, "utf-8");
totalBytes += bytes;
if (totalBytes > spillThreshold && !fullOutputPath) {
fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
const stream = Bun.file(fullOutputPath).writer();
chunks.forEach((chunk) => {
stream.write(chunk);
});
for (const chunk of chunks) {
stream.write(chunk.text);
}
fullOutputStream = stream;
}
fullOutputStream?.write(text);
chunks.push(text);
chunkBytes += text.length;
chunks.push({ text, bytes });
chunkBytes += bytes;
while (chunkBytes > maxBuffer && chunks.length > 1) {
chunkBytes -= chunks.shift()!.length;
const removed = chunks.shift();
if (removed) {
chunkBytes -= removed.bytes;
}
}
onChunk?.(text);
@@ -76,9 +89,10 @@ export function createOutputSink(
return Object.assign(sink, {
dump(annotation?: string) {
if (annotation) {
chunks.push(`\n\n${annotation}`);
const text = `\n\n${annotation}`;
chunks.push({ text, bytes: Buffer.byteLength(text, "utf-8") });
}
const full = chunks.join("");
const full = chunks.map((chunk) => chunk.text).join("");
const { content, truncated } = truncateTail(full);
return { output: truncated ? content : full, truncated, fullOutputPath };
},
+11 -5
View File
@@ -136,6 +136,7 @@ export interface ToolSession {
getBashInterceptorRules(): BashInterceptorRule[];
getPythonToolMode?(): "ipy-only" | "bash-only" | "both";
getPythonKernelMode?(): "session" | "per-call";
getPythonSharedGateway?(): boolean;
};
}
@@ -221,13 +222,18 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
reason: availability.reason,
});
} else if (!isTestEnv && getPreludeDocs().length === 0) {
void warmPythonEnvironment(session.cwd, session.getSessionFile?.() ?? `cwd:${session.cwd}`).catch((err) => {
logger.warn("Failed to warm Python environment", {
error: err instanceof Error ? err.message : String(err),
});
});
const sessionFile = session.getSessionFile?.() ?? undefined;
const warmSessionId = sessionFile ? `session:${sessionFile}:workdir:${session.cwd}` : `cwd:${session.cwd}`;
void warmPythonEnvironment(session.cwd, warmSessionId, session.settings?.getPythonSharedGateway?.()).catch(
(err) => {
logger.warn("Failed to warm Python environment", {
error: err instanceof Error ? err.message : String(err),
});
},
);
}
}
const effectiveMode = pythonAvailable ? pythonMode : "bash-only";
const allowBash = effectiveMode !== "ipy-only";
const allowPython = effectiveMode !== "bash-only";
@@ -54,7 +54,7 @@ describe("python tool execution", () => {
expect.objectContaining({
cwd: tempDir,
timeout: 5000,
sessionId: "session-file",
sessionId: `session:session-file:workdir:${tempDir}`,
kernelMode: "per-call",
reset: true,
}),
@@ -77,7 +77,8 @@ describe("python tool docs template", () => {
const tool = createPythonTool(createSession());
expect(tool.description).toContain("### File I/O");
expect(tool.description).toContain("- `read(path)` — Read file contents.");
expect(tool.description).toContain("read(path)");
expect(tool.description).toContain("Read file contents.");
spy.mockRestore();
});
+147 -3
View File
@@ -173,12 +173,15 @@ export function createPythonTool(
const jsonOutputs: unknown[] = [];
const images: ImageContent[] = [];
const sessionFile = session.getSessionFile?.() ?? undefined;
const sessionId = sessionFile ? `session:${sessionFile}:workdir:${commandCwd}` : `cwd:${commandCwd}`;
const executorOptions: PythonExecutorOptions = {
cwd: commandCwd,
timeout: timeout ? timeout * 1000 : undefined,
signal: controller.signal,
sessionId: session.getSessionFile?.() ?? `cwd:${session.cwd}`,
sessionId,
kernelMode: session.settings?.getPythonKernelMode?.() ?? "session",
useSharedGateway: session.settings?.getPythonSharedGateway?.() ?? true,
reset,
onChunk: (chunk) => {
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
@@ -357,7 +360,7 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
parts.push(`${data.chars} chars`);
break;
case "find":
case "rgrep":
case "glob":
parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
if (data.pattern) parts.push(`for "${truncate(String(data.pattern), 20, theme.format.ellipsis)}"`);
break;
@@ -365,12 +368,34 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
break;
case "rgrep":
parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
if (data.pattern) parts.push(`for "${truncate(String(data.pattern), 20, theme.format.ellipsis)}"`);
break;
case "ls":
parts.push(`${data.count} entr${(data.count as number) !== 1 ? "ies" : "y"}`);
break;
case "env":
if (data.action === "set") {
parts.push(`set ${data.key}=${truncate(String(data.value ?? ""), 30, theme.format.ellipsis)}`);
} else if (data.action === "get") {
parts.push(`${data.key}=${truncate(String(data.value ?? ""), 30, theme.format.ellipsis)}`);
} else {
parts.push(`${data.count} variable${(data.count as number) !== 1 ? "s" : ""}`);
}
break;
case "stat":
if (data.is_dir) {
parts.push("directory");
} else {
parts.push(`${data.size} bytes`);
}
if (data.path) parts.push(shortenPath(String(data.path)));
break;
case "replace":
case "sed":
parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
break;
case "rsed":
parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
@@ -419,6 +444,18 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
case "insert_at":
parts.push(`${data.lines_inserted} line${(data.lines_inserted as number) !== 1 ? "s" : ""} inserted`);
break;
case "cd":
case "pwd":
case "mkdir":
case "touch":
if (data.path) parts.push(shortenPath(String(data.path)));
break;
case "rm":
case "mv":
case "cp":
if (data.src) parts.push(`${shortenPath(String(data.src))} → ${shortenPath(String(data.dst))}`);
else if (data.path) parts.push(shortenPath(String(data.path)));
break;
default:
// Generic formatting for other operations
if (data.count !== undefined) {
@@ -433,6 +470,102 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
return `${icon} ${theme.fg("muted", op)}${desc ? ` ${theme.fg("dim", desc)}` : ""}`;
}
/** Format status event with expanded detail lines. */
function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): string[] {
const lines: string[] = [];
const { op, ...data } = event;
// Main status line
lines.push(formatStatusEvent(event, theme));
// Add detail lines for operations with list data
const addItems = (items: unknown[], formatter: (item: unknown) => string, max = 5) => {
const arr = Array.isArray(items) ? items : [];
for (let i = 0; i < Math.min(arr.length, max); i++) {
lines.push(` ${theme.fg("dim", formatter(arr[i]))}`);
}
if (arr.length > max) {
lines.push(` ${theme.fg("dim", `${theme.format.ellipsis} ${arr.length - max} more`)}`);
}
};
// Add preview lines (truncated content)
const addPreview = (preview: string, maxLines = 3) => {
const previewLines = String(preview).split("\n").slice(0, maxLines);
for (const line of previewLines) {
lines.push(` ${theme.fg("toolOutput", truncate(line, 80, theme.format.ellipsis))}`);
}
const totalLines = String(preview).split("\n").length;
if (totalLines > maxLines) {
lines.push(` ${theme.fg("dim", `${theme.format.ellipsis} ${totalLines - maxLines} more lines`)}`);
}
};
switch (op) {
case "find":
case "glob":
if (data.matches) addItems(data.matches as unknown[], (m) => String(m));
break;
case "ls":
if (data.items) addItems(data.items as unknown[], (m) => String(m));
break;
case "grep":
if (data.hits) {
addItems(data.hits as unknown[], (h) => {
const hit = h as { line: number; text: string };
return `${hit.line}: ${truncate(hit.text, 60, theme.format.ellipsis)}`;
});
}
break;
case "rgrep":
if (data.hits) {
addItems(data.hits as unknown[], (h) => {
const hit = h as { file: string; line: number; text: string };
return `${shortenPath(hit.file)}:${hit.line}: ${truncate(hit.text, 50, theme.format.ellipsis)}`;
});
}
break;
case "rsed":
if (data.changed) {
addItems(data.changed as unknown[], (c) => {
const change = c as { file: string; count: number };
return `${shortenPath(change.file)}: ${change.count} replacement${change.count !== 1 ? "s" : ""}`;
});
}
break;
case "env":
if (data.keys) addItems(data.keys as unknown[], (k) => String(k), 10);
break;
case "git_log":
if (data.entries) {
addItems(data.entries as unknown[], (e) => {
const entry = e as { sha: string; subject: string };
return `${entry.sha} ${truncate(entry.subject, 50, theme.format.ellipsis)}`;
});
}
break;
case "git_status":
if (data.files) addItems(data.files as unknown[], (f) => String(f));
break;
case "git_branch":
if (data.branches) addItems(data.branches as unknown[], (b) => String(b));
break;
case "read":
case "cat":
case "head":
case "tail":
case "tree":
case "diff":
case "lines":
case "git_diff":
case "sh":
if (data.preview) addPreview(String(data.preview));
break;
}
return lines;
}
/** Render status events as tree lines. */
function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: boolean): string[] {
if (events.length === 0) return [];
@@ -445,7 +578,18 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
for (let i = 0; i < displayCount; i++) {
const isLast = i === displayCount - 1 && (expanded || events.length <= maxCollapsed);
const branch = isLast ? theme.tree.last : theme.tree.branch;
lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme)}`);
if (expanded) {
// Show expanded details for each event
const eventLines = formatStatusEventExpanded(events[i], theme);
lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`);
const continueBranch = isLast ? " " : `${theme.tree.vertical} `;
for (let j = 1; j < eventLines.length; j++) {
lines.push(`${theme.fg("dim", continueBranch)}${eventLines[j]}`);
}
} else {
lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme)}`);
}
}
if (!expanded && events.length > maxCollapsed) {
@@ -29,6 +29,7 @@ import {
import type {
MCPToolCallRequest,
MCPToolMetadata,
PythonToolCallCancel,
PythonToolCallRequest,
SubagentWorkerRequest,
SubagentWorkerResponse,
@@ -60,6 +61,7 @@ export interface ExecutorOptions {
serialize: () => import("../../settings-manager").Settings;
getPythonToolMode?: () => "ipy-only" | "bash-only" | "both";
getPythonKernelMode?: () => "session" | "per-call";
getPythonSharedGateway?: () => boolean;
};
}
@@ -295,8 +297,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// Resolve and add model
const resolvedModel = await resolveModelPattern(modelOverride || agent.model, availableModels, serializedSettings);
const parentSessionFile = options.sessionFile ?? null;
const sessionFile = subtaskSessionFile ?? parentSessionFile;
const sessionFile = subtaskSessionFile ?? null;
const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
const pythonToolRequested = toolNames === undefined || toolNames.includes("python");
@@ -340,17 +341,49 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
let finalize: ((message: Extract<SubagentWorkerResponse, { type: "done" }>) => void) | null = null;
const listenerController = new AbortController();
const listenerSignal = listenerController.signal;
const withTimeout = async <T>(promise: Promise<T>, timeoutMs?: number): Promise<T> => {
if (timeoutMs === undefined) return promise;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Tool call timed out after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
};
const combineSignals = (signals: Array<AbortSignal | undefined>): AbortSignal | undefined => {
const filtered = signals.filter((value): value is AbortSignal => Boolean(value));
if (filtered.length === 0) return undefined;
if (filtered.length === 1) return filtered[0];
return AbortSignal.any(filtered);
};
const createTimeoutSignal = (timeoutMs?: number): AbortSignal | undefined => {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return undefined;
}
return AbortSignal.timeout(timeoutMs);
};
const pythonSessionFile = sessionFile ?? `subtask:${taskId}`;
const pythonToolSession: ToolSession = {
cwd,
hasUI: false,
enableLsp: false,
getSessionFile: () => parentSessionFile,
getSessionFile: () => pythonSessionFile,
getSessionSpawns: () => spawnsEnv,
settings: options.settingsManager as ToolSession["settings"],
settingsManager: options.settingsManager,
};
const pythonTool = pythonProxyEnabled ? createPythonTool(pythonToolSession) : null;
const pythonCallControllers = new Map<string, AbortController>();
// Accumulate usage incrementally from message_end events (no memory for streaming events)
const accumulatedUsage = {
@@ -400,6 +433,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (resolved) return;
abortSent = true;
abortReason = reason;
for (const controller of pythonCallControllers.values()) {
controller.abort();
}
pythonCallControllers.clear();
const abortMessage: SubagentWorkerRequest = { type: "abort" };
try {
worker.postMessage(abortMessage);
@@ -683,7 +720,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (!parsed) throw new Error(`Invalid MCP tool name: ${request.toolName}`);
const connection = mcpManager.getConnection(parsed.serverName);
if (!connection) throw new Error(`MCP server not connected: ${parsed.serverName}`);
const result = await callTool(connection, parsed.toolName, request.params);
const result = await withTimeout(callTool(connection, parsed.toolName, request.params), request.timeoutMs);
worker.postMessage({
type: "mcp_tool_result",
callId: request.callId,
@@ -698,6 +735,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
};
const getPythonCallTimeoutMs = (params: { timeout?: number }): number | undefined => {
const timeout = params.timeout;
if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
return Math.max(1000, Math.round(timeout * 1000) + 1000);
}
return undefined;
};
const handlePythonCall = async (request: PythonToolCallRequest) => {
if (!pythonTool) {
worker.postMessage({
@@ -707,11 +752,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
});
return;
}
const callController = new AbortController();
pythonCallControllers.set(request.callId, callController);
const timeoutMs = getPythonCallTimeoutMs(request.params as { timeout?: number });
const timeoutSignal = createTimeoutSignal(timeoutMs);
const combinedSignal = combineSignals([signal, callController.signal, timeoutSignal]);
try {
const result = await pythonTool.execute(
request.callId,
request.params as { code: string; timeout?: number; workdir?: string; reset?: boolean },
signal,
combinedSignal,
);
worker.postMessage({
type: "python_tool_result",
@@ -719,11 +769,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
result: { content: result.content ?? [], details: result.details },
});
} catch (error) {
const message =
timeoutSignal?.aborted && timeoutMs !== undefined
? `Python tool call timed out after ${timeoutMs}ms`
: error instanceof Error
? error.message
: String(error);
worker.postMessage({
type: "python_tool_result",
callId: request.callId,
error: error instanceof Error ? error.message : String(error),
error: message,
});
} finally {
pythonCallControllers.delete(request.callId);
}
};
const handlePythonCancel = (request: PythonToolCallCancel) => {
const controller = pythonCallControllers.get(request.callId);
if (controller) {
controller.abort();
}
};
@@ -738,6 +803,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
handlePythonCall(message as PythonToolCallRequest);
return;
}
if (message.type === "python_tool_cancel") {
handlePythonCancel(message as PythonToolCallCancel);
return;
}
if (message.type === "event") {
try {
processEvent(message.event);
@@ -24,6 +24,7 @@ export interface MCPToolCallRequest {
callId: string;
toolName: string;
params: Record<string, unknown>;
timeoutMs?: number;
}
/**
@@ -43,6 +44,7 @@ export interface PythonToolCallRequest {
type: "python_tool_call";
callId: string;
params: Record<string, unknown>;
timeoutMs?: number;
}
export interface PythonToolCallResponse {
@@ -56,6 +58,12 @@ export interface PythonToolCallResponse {
error?: string;
}
export interface PythonToolCallCancel {
type: "python_tool_cancel";
callId: string;
reason?: string;
}
export interface SubagentWorkerStartPayload {
cwd: string;
task: string;
@@ -78,10 +86,12 @@ export type SubagentWorkerRequest =
| { type: "start"; payload: SubagentWorkerStartPayload }
| { type: "abort" }
| MCPToolCallResponse
| PythonToolCallResponse;
| PythonToolCallResponse
| PythonToolCallCancel;
export type SubagentWorkerResponse =
| { type: "event"; event: AgentEvent }
| { type: "done"; exitCode: number; durationMs: number; error?: string; aborted?: boolean }
| MCPToolCallRequest
| PythonToolCallRequest;
| PythonToolCallRequest
| PythonToolCallCancel;
@@ -19,6 +19,7 @@ import type { TSchema } from "@sinclair/typebox";
import type { AgentSessionEvent } from "../../agent-session";
import { AuthStorage } from "../../auth-storage";
import type { CustomTool } from "../../custom-tools/types";
import { logger } from "../../logger";
import { ModelRegistry } from "../../model-registry";
import { parseModelPattern, parseModelString } from "../../model-resolver";
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
@@ -54,13 +55,12 @@ interface PendingMCPCall {
interface PendingPythonCall {
resolve: (result: PythonToolCallResponse["result"]) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
timeoutId?: ReturnType<typeof setTimeout>;
}
const pendingMCPCalls = new Map<string, PendingMCPCall>();
const pendingPythonCalls = new Map<string, PendingPythonCall>();
const MCP_CALL_TIMEOUT_MS = 60_000;
const PYTHON_CALL_TIMEOUT_MS = 300_000;
let mcpCallIdCounter = 0;
let pythonCallIdCounter = 0;
@@ -123,6 +123,7 @@ function callMCPToolViaParent(
callId,
toolName,
params,
timeoutMs,
} as SubagentWorkerResponse);
});
}
@@ -130,7 +131,7 @@ function callMCPToolViaParent(
function callPythonToolViaParent(
params: PythonToolParams,
signal?: AbortSignal,
timeoutMs = PYTHON_CALL_TIMEOUT_MS,
timeoutMs?: number,
): Promise<PythonToolCallResponse["result"]> {
return new Promise((resolve, reject) => {
const callId = generatePythonCallId();
@@ -139,13 +140,23 @@ function callPythonToolViaParent(
return;
}
const timeoutId = setTimeout(() => {
pendingPythonCalls.delete(callId);
reject(new Error(`Python call timed out after ${timeoutMs}ms`));
}, timeoutMs);
const sendCancel = (reason: string) => {
postMessageSafe({ type: "python_tool_cancel", callId, reason } as SubagentWorkerResponse);
};
const timeoutId =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? setTimeout(() => {
pendingPythonCalls.delete(callId);
sendCancel(`Python call timed out after ${timeoutMs}ms`);
reject(new Error(`Python call timed out after ${timeoutMs}ms`));
}, timeoutMs)
: undefined;
const cleanup = () => {
clearTimeout(timeoutId);
if (timeoutId) {
clearTimeout(timeoutId);
}
pendingPythonCalls.delete(callId);
};
@@ -154,6 +165,7 @@ function callPythonToolViaParent(
"abort",
() => {
cleanup();
sendCancel("Aborted");
reject(new Error("Aborted"));
},
{ once: true },
@@ -176,6 +188,7 @@ function callPythonToolViaParent(
type: "python_tool_call",
callId,
params,
timeoutMs,
} as SubagentWorkerResponse);
});
}
@@ -200,6 +213,22 @@ function handlePythonToolResult(response: PythonToolCallResponse): void {
}
}
function rejectPendingCalls(reason: string): void {
const error = new Error(reason);
const mcpCalls = Array.from(pendingMCPCalls.values());
const pythonCalls = Array.from(pendingPythonCalls.values());
pendingMCPCalls.clear();
pendingPythonCalls.clear();
for (const pending of mcpCalls) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
for (const pending of pythonCalls) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
}
function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
return {
name: metadata.name,
@@ -237,12 +266,12 @@ function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
};
}
function getPythonCallTimeoutMs(params: PythonToolParams): number {
function getPythonCallTimeoutMs(params: PythonToolParams): number | undefined {
const timeout = params.timeout;
if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
return timeout * 1000 + 5000;
return Math.max(1000, Math.round(timeout * 1000) + 1000);
}
return PYTHON_CALL_TIMEOUT_MS;
return undefined;
}
function createPythonProxyTool(): CustomTool<typeof pythonSchema> {
@@ -463,12 +492,16 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
logger.error("Extension sendMessage failed", {
error: e instanceof Error ? e.message : String(e),
});
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
logger.error("Extension sendUserMessage failed", {
error: e instanceof Error ? e.message : String(e),
});
});
},
appendEntry: (customType, data) => {
@@ -508,7 +541,7 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
},
);
extensionRunner.onError((err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
logger.error("Extension error", { path: err.extensionPath, error: err.error });
});
await extensionRunner.emit({ type: "session_start" });
}
@@ -570,6 +603,7 @@ Call complete now.`;
}
sessionAbortController.abort();
rejectPendingCalls("Worker finished");
if (runState.unsubscribe) {
try {
@@ -611,8 +645,10 @@ function handleAbort(): void {
const runState = activeRun;
if (!runState) {
pendingAbort = true;
rejectPendingCalls("Aborted");
return;
}
rejectPendingCalls("Aborted");
runState.abortController.abort();
if (runState.session) {
void runState.session.abort();
+3
View File
@@ -31,6 +31,7 @@ import { resolvePromptInput } from "./core/system-prompt";
import { printTimings, time } from "./core/timings";
import { initializeWithSettings } from "./discovery";
import { runMigrations, showDeprecationWarnings } from "./migrations";
import { runAsyncCleanup } from "./modes/cleanup";
import { InteractiveMode, installTerminalCrashHandlers, runPrintMode, runRpcMode } from "./modes/index";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme";
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
@@ -731,7 +732,9 @@ export async function main(args: string[]) {
initialMessage,
initialImages,
});
await session.dispose();
stopThemeWatcher();
await runAsyncCleanup();
if (process.stdout.writableLength > 0) {
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
}
@@ -269,6 +269,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
get: (sm) => sm.getPythonKernelMode(),
set: (sm, v) => sm.setPythonKernelMode(v as PythonKernelMode),
},
{
id: "pythonSharedGateway",
tab: "tools",
type: "boolean",
label: "Python shared gateway",
description: "Share IPython kernel gateway across pi instances",
get: (sm) => sm.getPythonSharedGateway(),
set: (sm, v) => sm.setPythonSharedGateway(v),
},
{
id: "editFuzzyMatch",
tab: "tools",
@@ -174,41 +174,38 @@ export class CommandController {
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
stdout: "pipe",
stderr: "pipe",
});
let stdout = "";
let stderr = "";
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await stdoutReader.read();
if (done) break;
stdout += decoder.decode(value);
}
} catch {}
})();
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
stderr += decoder.decode(value);
}
} catch {}
})();
proc.exited.then((code) => resolve({ stdout, stderr, code }));
proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
stdout: "pipe",
stderr: "pipe",
});
const readStream = async (stream: ReadableStream<Uint8Array> | null): Promise<string> => {
if (!stream) return "";
const reader = stream.getReader();
const decoder = new TextDecoder();
let output = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
} catch {
// Ignore read errors
} finally {
output += decoder.decode();
reader.releaseLock();
}
return output;
};
const [stdout, stderr, code] = await Promise.all([
readStream(proc.stdout as ReadableStream<Uint8Array> | null),
readStream(proc.stderr as ReadableStream<Uint8Array> | null),
proc.exited.catch(() => 1),
]);
const result = { stdout, stderr, code };
if (loader.signal.aborted) return;
restoreEditor();
@@ -178,4 +178,6 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
else resolve();
});
});
await session.dispose();
}
@@ -1,7 +1,7 @@
---
name: explore
description: Fast read-only codebase scout that returns compressed context for handoff
tools: read, grep, find, ls, exec
tools: read, grep, find, ls, bash
model: pi/smol, haiku, flash, mini
---
@@ -29,7 +29,7 @@ Guidelines:
- Use find for broad file pattern matching
- Use grep for searching file contents with regex
- Use read when you know the specific file path
- Use exec ONLY for git status/log/diff; use read/grep/find/ls tools for file and search operations
- Use bash ONLY for git status/log/diff; use read/grep/find/ls tools for file and search operations
- Spawn multiple parallel tool calls wherever possible—you are meant to be fast
- Return file paths as absolute paths in your final response
- Communicate findings directly as a message—do NOT create output files
@@ -1,7 +1,7 @@
---
name: plan
description: Software architect for complex multi-file architectural decisions. NOT for simple tasks, single-file changes, reasoning, or tasks completable in <5 tool calls—execute those directly.
tools: read, grep, find, ls, exec
tools: read, grep, find, ls, bash
spawns: explore
model: pi/slow, gpt-5.2-codex, gpt-5.2, codex, gpt
---
@@ -14,7 +14,7 @@ You are STRICTLY PROHIBITED from:
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write files
- Running commands that change system state (git add, git commit, npm install, pip install)
- Use exec ONLY for git status/log/diff; use read/grep/find/ls tools for file and search operations
- Use bash ONLY for git status/log/diff; use read/grep/find/ls tools for file and search operations
<context>
Another engineer will execute your plan without re-exploring the codebase. Your plan must be specific enough to implement directly.
@@ -1,7 +1,7 @@
---
name: reviewer
description: Code review specialist for quality and security analysis
tools: read, grep, find, ls, exec, report_finding
tools: read, grep, find, ls, bash, report_finding
spawns: explore, task
model: pi/slow, gpt-5.2-codex, gpt-5.2, codex, gpt
output:
@@ -43,7 +43,7 @@ You are a senior engineer reviewing a proposed code change. Your goal: identify
4. Call `report_finding` for each issue
5. Call `complete` with your verdict — **review is incomplete until `complete` is called**
Exec is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. No file modifications or builds.
Bash is read-only here: `git diff`, `git log`, `git show`, `gh pr diff`. No file modifications or builds.
# What to Flag
@@ -1,4 +1,4 @@
You are a worker agent for delegated tasks. You have FULL access to all tools (edit, write, exec, grep, read, etc.) - use them as needed to complete your task.
You are a worker agent for delegated tasks. You have FULL access to all tools (edit, write, bash, grep, read, etc.) - use them as needed to complete your task.
Finish only the assigned work and return the minimum useful result.
@@ -1,4 +1,4 @@
Executes Python code in a persistent IPython kernel with optional timeout.
Executes Python code in an IPython kernel (session or per-call) with optional timeout.
## When to use Python
@@ -40,9 +40,12 @@ All helpers auto-print results and return values for chaining.
{{#if categories.length}}
{{#each categories}}
### {{name}}
```
{{#each functions}}
- `{{name}}{{signature}}` — {{docstring}}
{{name}}{{signature}}
{{docstring}}
{{/each}}
```
{{/each}}
{{else}}
@@ -71,8 +74,18 @@ cols(read("data.tsv"), 0, 2, sep="\t")
## Notes
- Code executes as IPython cells; users see the full cell output (including rendered figures, tables, etc.)
- Kernel persists for the session; use `reset: true` to clear state
- Kernel persists for the session by default; per-call mode uses a fresh kernel each call. Use `reset: true` to clear state when session mode is active
- Use `workdir` parameter instead of `os.chdir()` in tool call
- Use `plt.show()` to display figures
- Use `display()` from IPython.display for rich output (HTML, Markdown, images, etc.)
- Output streams in real time, truncated after 50KB
## Rich output rendering
The user sees output like a Jupyter notebook—rich displays are fully rendered:
- `display(JSON(data))` → interactive JSON tree
- `display(HTML(...))` → rendered HTML
- `display(Markdown(...))` → formatted markdown
- `plt.show()` → inline figures
**You will see object repr** (e.g., `<IPython.core.display.JSON object>`) **but the user sees the rendered output.** Trust that `display()` calls work correctly—do not assume the user sees only the repr.
@@ -20,6 +20,7 @@ function createSettings(overrides?: Partial<ToolSession["settings"]>): ToolSessi
getBashInterceptorRules: () => [],
getPythonToolMode: () => "ipy-only",
getPythonKernelMode: () => "session",
getPythonSharedGateway: () => true,
...overrides,
};
}
@@ -81,7 +82,10 @@ describe("python tool settings", () => {
expect(executeSpy).toHaveBeenCalledWith(
"print(1)",
expect.objectContaining({ kernelMode: "per-call", sessionId: "session.json" }),
expect.objectContaining({
kernelMode: "per-call",
sessionId: `session:session.json:workdir:${testDir}`,
}),
);
});
});