From d620f07c1e5d50c8465fb9f66ebf938592cfb042 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 18 Jan 2026 19:54:35 +0100 Subject: [PATCH] feat(coding-agent/core): added Python gateway coordinator with shared resource management - Added shared Python gateway coordinator with file locking and automatic resource management. - Added comprehensive Git helpers and line-based operations to Python prelude. - Enhanced /status command to display Python gateway, LSP servers, and MCP connections. - Changed default Python tool mode from ipy-only to both for shell execution support. - Fixed Python tool proxy execution and signal handling in worker processes. --- .gitignore | 2 + packages/coding-agent/CHANGELOG.md | 13 +- .../src/core/python-gateway-coordinator.ts | 252 +++++++++--- .../coding-agent/src/core/python-prelude.py | 376 ++++++++++++++++-- .../src/core/python-prelude.test.ts | 2 +- .../coding-agent/src/core/settings-manager.ts | 6 +- .../src/core/tools/task/worker.ts | 74 ++-- packages/coding-agent/src/main.ts | 7 +- .../controllers/command-controller.ts | 43 +- .../src/modes/interactive/interactive-mode.ts | 5 +- .../src/modes/interactive/types.ts | 3 + .../coding-agent/src/prompts/tools/python.md | 11 + 12 files changed, 672 insertions(+), 122 deletions(-) diff --git a/.gitignore b/.gitignore index a38ba52d9..6493551c8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ out.html packages/ai/test/.temp-images/ changes/ + +__pycache__/ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8372152d4..15dc54f9a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,14 @@ # Changelog ## [Unreleased] - ### Added +- Added file locking mechanism for shared Python gateway to prevent race conditions +- Added Python gateway status monitoring with URL, PID, client count, and uptime information +- Added comprehensive Git helpers to Python prelude including status, diff, log, show, branch, and file operations +- Added line-based operations to Python prelude including line extraction, deletion, insertion, and pattern matching +- Added automatic categorization system for Python prelude functions with discoverable documentation +- Added enhanced `/status` command display showing Python gateway, LSP servers, and MCP server connections - Added shared Python gateway coordinator for resource-efficient kernel management across sessions - Added Python shared gateway setting with session-scoped kernel reuse and fallback behavior - Added automatic idle shutdown for shared Python gateway after 30 seconds of inactivity @@ -30,6 +35,10 @@ ### Changed +- Improved shared Python gateway coordination with environment validation and stale process cleanup +- Updated Python prelude to rename `bash()` function to `sh()` for consistency +- Changed default Python tool mode from "ipy-only" to "both" to enable both IPython and shell execution +- Enhanced Python gateway metadata tracking to include Python path and virtual environment information - Improved Python kernel startup to use shared gateway by default for better resource utilization - Updated Python tool to support proxy execution mode for worker processes - Enhanced Python kernel availability checking with faster validation @@ -47,6 +56,8 @@ ### Fixed +- 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 diff --git a/packages/coding-agent/src/core/python-gateway-coordinator.ts b/packages/coding-agent/src/core/python-gateway-coordinator.ts index 8161e9dca..0066cabcc 100644 --- a/packages/coding-agent/src/core/python-gateway-coordinator.ts +++ b/packages/coding-agent/src/core/python-gateway-coordinator.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { delimiter, join } from "node:path"; import type { Subprocess } from "bun"; @@ -9,8 +9,12 @@ import { logger } from "./logger"; const GATEWAY_DIR_NAME = "python-gateway"; const GATEWAY_INFO_FILE = "gateway.json"; +const GATEWAY_LOCK_FILE = "gateway.lock"; const GATEWAY_STARTUP_TIMEOUT_MS = 30000; const GATEWAY_IDLE_TIMEOUT_MS = 30000; +const GATEWAY_LOCK_TIMEOUT_MS = 5000; +const GATEWAY_LOCK_RETRY_MS = 50; +const GATEWAY_LOCK_STALE_MS = 30000; const HEALTH_CHECK_TIMEOUT_MS = 3000; const DEFAULT_ENV_ALLOWLIST = new Set([ @@ -61,6 +65,8 @@ export interface GatewayInfo { startedAt: number; refCount: number; cwd: string; + pythonPath?: string; + venvPath?: string | null; } interface AcquireResult { @@ -109,7 +115,7 @@ async function resolvePythonRuntime(cwd: string, baseEnv: Record { @@ -152,6 +158,10 @@ function getGatewayInfoPath(): string { return join(getGatewayDir(), GATEWAY_INFO_FILE); } +function getGatewayLockPath(): string { + return join(getGatewayDir(), GATEWAY_LOCK_FILE); +} + function ensureGatewayDir(): void { const dir = getGatewayDir(); if (!existsSync(dir)) { @@ -159,6 +169,50 @@ function ensureGatewayDir(): void { } } +async function withGatewayLock(handler: () => Promise): Promise { + ensureGatewayDir(); + const lockPath = getGatewayLockPath(); + const start = Date.now(); + while (true) { + try { + const fd = openSync(lockPath, "wx"); + try { + return await handler(); + } finally { + try { + closeSync(fd); + unlinkSync(lockPath); + } catch { + // Ignore lock cleanup errors + } + } + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === "EEXIST") { + let removedStale = false; + try { + const stat = statSync(lockPath); + if (Date.now() - stat.mtimeMs > GATEWAY_LOCK_STALE_MS) { + unlinkSync(lockPath); + removedStale = true; + logger.warn("Removed stale shared gateway lock", { path: lockPath }); + } + } 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; + } + } +} + function readGatewayInfo(): GatewayInfo | null { const infoPath = getGatewayInfoPath(); if (!existsSync(infoPath)) return null; @@ -214,7 +268,9 @@ async function isGatewayAlive(info: GatewayInfo): Promise { return await isGatewayHealthy(info.url); } -async function startGatewayProcess(cwd: string): Promise<{ url: string; pid: number }> { +async function startGatewayProcess( + cwd: string, +): Promise<{ url: string; pid: number; pythonPath: string; venvPath: string | null }> { const { shell, env } = await getShellConfig(); const filteredEnv = filterEnv(env); const runtime = await resolvePythonRuntime(cwd, filteredEnv); @@ -277,7 +333,12 @@ async function startGatewayProcess(cwd: string): Promise<{ url: string; pid: num if (await isGatewayHealthy(gatewayUrl)) { localGatewayProcess = gatewayProcess; localGatewayUrl = gatewayUrl; - return { url: gatewayUrl, pid: gatewayProcess.pid }; + return { + url: gatewayUrl, + pid: gatewayProcess.pid, + pythonPath: runtime.pythonPath, + venvPath: runtime.venvPath ?? null, + }; } await Bun.sleep(100); } @@ -291,13 +352,33 @@ function scheduleIdleShutdown(): void { clearTimeout(idleShutdownTimer); } idleShutdownTimer = setTimeout(async () => { - const info = readGatewayInfo(); - if (info && info.refCount === 0) { - logger.debug("Shutting down idle shared gateway", { pid: info.pid }); - shutdownLocalGateway(); - clearGatewayInfo(); + 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(); + } + }); + } catch (err) { + logger.warn("Failed to shutdown idle shared gateway", { + error: err instanceof Error ? err.message : String(err), + }); + } finally { + idleShutdownTimer = null; } - idleShutdownTimer = null; }, GATEWAY_IDLE_TIMEOUT_MS); } @@ -328,39 +409,68 @@ export async function acquireSharedGateway(cwd: string): Promise { + const existingInfo = readGatewayInfo(); + if (existingInfo && (await isGatewayAlive(existingInfo))) { + const { env } = await getShellConfig(); + const filteredEnv = filterEnv(env); + const runtime = await resolvePythonRuntime(cwd, filteredEnv); + const existingVenv = existingInfo.venvPath ?? null; + const runtimeVenv = runtime.venvPath ?? null; + if ( + existingInfo.cwd !== cwd || + !existingInfo.pythonPath || + existingInfo.pythonPath !== runtime.pythonPath || + existingVenv !== runtimeVenv + ) { + logger.debug("Shared gateway metadata mismatch", { + existingCwd: existingInfo.cwd, + requestedCwd: cwd, + existingPython: existingInfo.pythonPath, + runtimePython: runtime.pythonPath, + existingVenv, + runtimeVenv, + }); + return null; + } + const updatedInfo = { ...existingInfo, refCount: existingInfo.refCount + 1 }; + writeGatewayInfo(updatedInfo); + cancelIdleShutdown(); + logger.debug("Reusing shared gateway", { url: existingInfo.url, refCount: updatedInfo.refCount }); + isCoordinatorInitialized = true; + return { url: existingInfo.url, isShared: true }; + } - // Try to use existing gateway first (without lock for quick check) - const existingInfo = readGatewayInfo(); - if (existingInfo && (await isGatewayAlive(existingInfo))) { - // Increment ref count atomically - const updatedInfo = { ...existingInfo, refCount: existingInfo.refCount + 1 }; - writeGatewayInfo(updatedInfo); - cancelIdleShutdown(); - logger.debug("Reusing shared gateway", { url: existingInfo.url, refCount: updatedInfo.refCount }); + if (existingInfo) { + logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid }); + if (isPidRunning(existingInfo.pid)) { + try { + killProcessTree(existingInfo.pid); + } catch (err) { + logger.warn("Failed to kill stale shared gateway process", { + error: err instanceof Error ? err.message : String(err), + pid: existingInfo.pid, + }); + } + } + clearGatewayInfo(); + } + + const { url, pid, pythonPath, venvPath } = await startGatewayProcess(cwd); + const info: GatewayInfo = { + url, + pid, + startedAt: Date.now(), + refCount: 1, + cwd, + pythonPath, + venvPath, + }; + writeGatewayInfo(info); isCoordinatorInitialized = true; - return { url: existingInfo.url, isShared: true }; - } - - // Need to start new gateway - clean up stale info if any - if (existingInfo) { - logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid }); - clearGatewayInfo(); - } - - // Start new gateway - const { url, pid } = await startGatewayProcess(cwd); - const info: GatewayInfo = { - url, - pid, - startedAt: Date.now(), - refCount: 1, - cwd, - }; - writeGatewayInfo(info); - isCoordinatorInitialized = true; - logger.debug("Started shared gateway", { url, pid }); - return { url, isShared: true }; + logger.debug("Started shared 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), @@ -373,21 +483,22 @@ export async function releaseSharedGateway(): Promise { if (!isCoordinatorInitialized) return; try { - const info = readGatewayInfo(); - if (!info) return; + await withGatewayLock(async () => { + const info = readGatewayInfo(); + if (!info) return; - const newRefCount = Math.max(0, info.refCount - 1); - if (newRefCount === 0) { - // Schedule idle shutdown instead of immediate shutdown - const updatedInfo = { ...info, refCount: 0 }; - writeGatewayInfo(updatedInfo); - scheduleIdleShutdown(); - logger.debug("Scheduled idle shutdown for shared gateway", { pid: info.pid }); - } else { + const newRefCount = Math.max(0, info.refCount - 1); + if (newRefCount === 0) { + const updatedInfo = { ...info, refCount: 0 }; + writeGatewayInfo(updatedInfo); + scheduleIdleShutdown(); + logger.debug("Scheduled idle shutdown for shared gateway", { pid: info.pid }); + return; + } const updatedInfo = { ...info, refCount: newRefCount }; writeGatewayInfo(updatedInfo); logger.debug("Released shared gateway reference", { url: info.url, refCount: newRefCount }); - } + }); } catch (err) { logger.warn("Failed to release shared gateway", { error: err instanceof Error ? err.message : String(err), @@ -403,6 +514,41 @@ export function isSharedGatewayActive(): boolean { return localGatewayProcess !== null && localGatewayUrl !== null; } +export interface GatewayStatus { + active: boolean; + shared: boolean; + url: string | null; + pid: number | null; + refCount: number; + cwd: string | null; + uptime: number | null; +} + +export function getGatewayStatus(): GatewayStatus { + const info = readGatewayInfo(); + if (!info) { + return { + active: false, + shared: false, + url: null, + pid: null, + refCount: 0, + cwd: null, + uptime: null, + }; + } + const active = isPidRunning(info.pid); + return { + active, + shared: active && info.refCount > 1, + url: info.url, + pid: info.pid, + refCount: info.refCount, + cwd: info.cwd, + uptime: Date.now() - info.startedAt, + }; +} + export async function shutdownSharedGateway(): Promise { cancelIdleShutdown(); const info = readGatewayInfo(); diff --git a/packages/coding-agent/src/core/python-prelude.py b/packages/coding-agent/src/core/python-prelude.py index 4c9969143..465750333 100644 --- a/packages/coding-agent/src/core/python-prelude.py +++ b/packages/coding-agent/src/core/python-prelude.py @@ -5,12 +5,21 @@ if "__omp_prelude_loaded__" not in globals(): import os, sys, re, json, shutil, subprocess, glob, textwrap, inspect from datetime import datetime + def _category(cat: str): + """Decorator to tag a prelude function with its category.""" + def decorator(fn): + fn._omp_category = cat + return fn + return decorator + + @_category("Navigation") def pwd() -> Path: """Print and return current working directory.""" p = Path.cwd() print(str(p)) return p + @_category("Navigation") def cd(path: str | Path) -> Path: """Change directory and print the new cwd.""" p = Path(path).expanduser().resolve() @@ -18,6 +27,7 @@ if "__omp_prelude_loaded__" not in globals(): print(str(p)) return p + @_category("Shell") def env(key: str | None = None, value: str | None = None): """Get/set environment variables.""" if key is None: @@ -34,6 +44,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"{key}={val}") return val + @_category("File I/O") def read(path: str | Path, *, limit: int | None = None) -> str: """Read file contents. Prints a short preview + length.""" p = Path(path) @@ -47,6 +58,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[read {len(data)} chars from {p}]") return data + @_category("File I/O") def write(path: str | Path, content: str) -> Path: """Write file contents (create parents). Prints bytes written.""" p = Path(path) @@ -55,6 +67,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[wrote {len(content)} chars to {p}]") return p + @_category("File I/O") def append(path: str | Path, content: str) -> Path: """Append to file. Prints bytes appended.""" p = Path(path) @@ -64,6 +77,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[appended {len(content)} chars to {p}]") return p + @_category("File ops") def mkdir(path: str | Path) -> Path: """Create directory (parents=True).""" p = Path(path) @@ -71,6 +85,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[mkdir] {p}") return p + @_category("File ops") def rm(path: str | Path, *, recursive: bool = False) -> None: """Delete file or directory (recursive optional).""" p = Path(path) @@ -87,6 +102,7 @@ if "__omp_prelude_loaded__" not in globals(): else: print(f"[rm] {p} (missing)") + @_category("File ops") def mv(src: str | Path, dst: str | Path) -> Path: """Move or rename a file/directory.""" src_p = Path(src) @@ -96,6 +112,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[mv] {src_p} -> {dst_p}") return dst_p + @_category("File ops") def cp(src: str | Path, dst: str | Path) -> Path: """Copy a file or directory.""" src_p = Path(src) @@ -108,6 +125,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[cp] {src_p} -> {dst_p}") return dst_p + @_category("Navigation") def ls(path: str | Path = ".") -> list[Path]: """List directory contents.""" p = Path(path) @@ -118,6 +136,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[ls] {len(items)} entries in {p}") return items + @_category("Search") def find(pattern: str, path: str | Path = ".", *, files_only: bool = True) -> list[Path]: """Recursive glob find. Defaults to files only.""" p = Path(path) @@ -132,6 +151,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[find] {len(matches)} matches for '{pattern}' in {p}") 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 @@ -153,6 +173,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[grep] {len(hits)} matches in {p}") 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 @@ -173,6 +194,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[rgrep] {len(hits)} matches in {base}") return hits + @_category("Text") def head(text: str, n: int = 10) -> str: """Return the first n lines of text.""" lines = text.splitlines()[:n] @@ -181,6 +203,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[head] {len(lines)} lines") return out + @_category("Text") def tail(text: str, n: int = 10) -> str: """Return the last n lines of text.""" lines = text.splitlines()[-n:] @@ -189,6 +212,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[tail] {len(lines)} lines") return out + @_category("Find/Replace") def replace(path: str | Path, pattern: str, repl: str, *, regex: bool = False) -> int: """Replace text in a file (regex optional).""" p = Path(path) @@ -202,6 +226,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[replace] {count} replacements in {p}") return count + @_category("Shell") def run(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> subprocess.CompletedProcess[str]: """Run a shell command and print stdout/stderr.""" result = subprocess.run( @@ -219,7 +244,8 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[run] exit={result.returncode}") return result - def bash(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> subprocess.CompletedProcess[str]: + @_category("Shell") + def sh(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> subprocess.CompletedProcess[str]: """Run a shell command via bash when available; fallback when missing.""" snapshot = os.environ.get("OMP_SHELL_SNAPSHOT") prefix = f"source '{snapshot}' 2>/dev/null && " if snapshot else "" @@ -240,6 +266,7 @@ if "__omp_prelude_loaded__" not in globals(): # --- Extended shell-like utilities --- + @_category("File I/O") def cat(*paths: str | Path, separator: str = "\n") -> str: """Concatenate multiple files and print. Like shell cat.""" parts = [] @@ -250,6 +277,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[cat] {len(paths)} files, {len(out)} chars") return out + @_category("File I/O") def touch(path: str | Path) -> Path: """Create empty file or update mtime.""" p = Path(path) @@ -258,6 +286,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[touch] {p}") return p + @_category("Text") def wc(text: str) -> dict: """Word/line/char count.""" lines = text.splitlines() @@ -266,6 +295,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"{result['lines']} lines, {result['words']} words, {result['chars']} chars") return result + @_category("Text") def sort_lines(text: str, *, reverse: bool = False, unique: bool = False) -> str: """Sort lines of text.""" lines = text.splitlines() @@ -276,6 +306,7 @@ if "__omp_prelude_loaded__" not in globals(): print(out) return out + @_category("Text") def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]: """Remove duplicate adjacent lines (like uniq).""" lines = text.splitlines() @@ -300,6 +331,7 @@ if "__omp_prelude_loaded__" not in globals(): print(out) return out + @_category("Text") def cols(text: str, *indices: int, sep: str | None = None) -> str: """Extract columns from text (0-indexed). Like cut.""" result_lines = [] @@ -311,6 +343,7 @@ if "__omp_prelude_loaded__" not in globals(): print(out) return out + @_category("Navigation") def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str: """Print directory tree.""" base = Path(path) @@ -334,6 +367,7 @@ if "__omp_prelude_loaded__" not in globals(): print(out) return out + @_category("Navigation") def stat(path: str | Path) -> dict: """Get file/directory info.""" p = Path(path) @@ -350,6 +384,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"{k}: {v}") return info + @_category("Batch") def diff(a: str | Path, b: str | Path) -> str: """Compare two files, print unified diff.""" import difflib @@ -364,6 +399,7 @@ if "__omp_prelude_loaded__" not in globals(): print("[diff] files are identical") return out + @_category("Search") def glob_files(pattern: str, path: str | Path = ".") -> list[Path]: """Non-recursive glob (use find() for recursive).""" p = Path(path) @@ -373,6 +409,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[glob] {len(matches)} matches") return matches + @_category("Batch") def batch(paths: list[str | Path], fn) -> list: """Apply function to multiple files. Returns list of results.""" results = [] @@ -382,6 +419,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[batch] processed {len(paths)} files") return results + @_category("Find/Replace") def sed(path: str | Path, pattern: str, repl: str, *, flags: int = 0) -> int: """Regex replace in file (like sed -i). Returns count.""" p = Path(path) @@ -391,6 +429,7 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[sed] {count} replacements in {p}") 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.""" base = Path(path) @@ -410,33 +449,312 @@ if "__omp_prelude_loaded__" not in globals(): print(f"[rsed] {total} total replacements") 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'.""" + p = Path(path) + all_lines = p.read_text(encoding="utf-8").splitlines() + if end is None: + end = len(all_lines) + 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) + print(f"[lines] {start}-{end} ({len(selected)} lines) from {p}") + return "\n".join(selected) + + @_category("Line ops") + def delete_lines(path: str | Path, start: int, end: int | None = None) -> int: + """Delete line range from file (1-indexed, inclusive). Like sed -i 'N,Md'.""" + p = Path(path) + all_lines = p.read_text(encoding="utf-8").splitlines() + if end is None: + end = start + start = max(1, start) + end = min(len(all_lines), end) + count = end - start + 1 + new_lines = all_lines[: start - 1] + all_lines[end:] + p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8") + print(f"[delete_lines] removed lines {start}-{end} ({count} lines) from {p}") + return count + + @_category("Line ops") + def delete_matching(path: str | Path, pattern: str, *, regex: bool = True) -> int: + """Delete lines matching pattern. Like sed -i '/pattern/d'.""" + p = Path(path) + all_lines = p.read_text(encoding="utf-8").splitlines() + if regex: + rx = re.compile(pattern) + new_lines = [l for l in all_lines if not rx.search(l)] + else: + new_lines = [l for l in all_lines if pattern not in l] + count = len(all_lines) - len(new_lines) + p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8") + print(f"[delete_matching] removed {count} lines matching '{pattern}' from {p}") + return count + + @_category("Line ops") + def insert_at(path: str | Path, line_num: int, text: str, *, after: bool = True) -> Path: + """Insert text at line. after=True (sed 'Na\\'), after=False (sed 'Ni\\').""" + p = Path(path) + all_lines = p.read_text(encoding="utf-8").splitlines() + new_lines = text.splitlines() + line_num = max(1, min(len(all_lines) + 1, line_num)) + if after: + idx = min(line_num, len(all_lines)) + all_lines = all_lines[:idx] + new_lines + all_lines[idx:] + pos = "after" if line_num <= len(all_lines) - len(new_lines) else "at end" + else: + idx = line_num - 1 + all_lines = all_lines[:idx] + new_lines + all_lines[idx:] + pos = "before" + p.write_text("\n".join(all_lines) + "\n", encoding="utf-8") + print(f"[insert_at] inserted {len(new_lines)} lines {pos} line {line_num} in {p}") + 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( + ["git", *args], + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + ) + return result.returncode, result.stdout, result.stderr + + @_category("Git") + def git_status(*, cwd: str | Path | None = None) -> dict: + """Get structured git status: {branch, staged, modified, untracked, ahead, behind}.""" + code, out, err = _git("status", "--porcelain=v2", "--branch", cwd=cwd) + if code != 0: + print(f"[git_status] error: {err.strip()}") + return {} + + result: dict = {"branch": None, "staged": [], "modified": [], "untracked": [], "ahead": 0, "behind": 0} + for line in out.splitlines(): + if line.startswith("# branch.head "): + result["branch"] = line.split(" ", 2)[2] + elif line.startswith("# branch.ab "): + parts = line.split() + for p in parts[2:]: + if p.startswith("+"): + result["ahead"] = int(p[1:]) + elif p.startswith("-"): + result["behind"] = int(p[1:]) + elif line.startswith("1 ") or line.startswith("2 "): + parts = line.split(" ", 8) + xy = parts[1] + path = parts[-1] + if xy[0] != ".": + result["staged"].append(path) + if xy[1] != ".": + result["modified"].append(path) + 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") + if not any([result["staged"], result["modified"], result["untracked"]]): + print("working tree clean") + return result + + @_category("Git") + def git_diff( + *paths: str, + staged: bool = False, + ref: str | None = None, + stat: bool = False, + cwd: str | Path | None = None, + ) -> str: + """Show git diff. staged=True for --cached, ref for commit comparison.""" + args = ["diff"] + if stat: + args.append("--stat") + if staged: + args.append("--cached") + if ref: + args.append(ref) + if paths: + args.append("--") + args.extend(paths) + code, out, err = _git(*args, cwd=cwd) + if code != 0: + print(f"[git_diff] error: {err.strip()}") + return "" + print(out) + return out + + @_category("Git") + def git_log( + n: int = 10, + *, + oneline: bool = True, + ref_range: str | None = None, + paths: list[str] | None = None, + cwd: str | Path | None = None, + ) -> list[dict]: + """Get git log as list of {sha, subject, author, date}.""" + fmt = "%H%x00%s%x00%an%x00%aI" if not oneline else "%h%x00%s%x00%an%x00%aI" + args = ["log", f"-{n}", f"--format={fmt}"] + if ref_range: + args.append(ref_range) + if paths: + args.append("--") + args.extend(paths) + code, out, err = _git(*args, cwd=cwd) + if code != 0: + print(f"[git_log] error: {err.strip()}") + return [] + + commits = [] + for line in out.strip().splitlines(): + parts = line.split("\x00") + 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]}") + print(f"[git_log] {len(commits)} commits") + return commits + + @_category("Git") + def git_show(ref: str = "HEAD", *, stat: bool = True, cwd: str | Path | None = None) -> dict: + """Show commit details as {sha, subject, author, date, body, files}.""" + args = ["show", ref, "--format=%H%x00%s%x00%an%x00%aI%x00%b", "--no-patch"] + code, out, err = _git(*args, cwd=cwd) + if code != 0: + print(f"[git_show] error: {err.strip()}") + return {} + + parts = out.strip().split("\x00") + result = { + "sha": parts[0] if len(parts) > 0 else "", + "subject": parts[1] if len(parts) > 1 else "", + "author": parts[2] if len(parts) > 2 else "", + "date": parts[3] if len(parts) > 3 else "", + "body": parts[4].strip() if len(parts) > 4 else "", + "files": [], + } + + if stat: + _, 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}") + return result + + @_category("Git") + def git_file_at(ref: str, path: str, *, lines: tuple[int, int] | None = None, cwd: str | Path | None = None) -> str: + """Get file content at ref. Optional lines=(start, end) for range (1-indexed).""" + code, out, err = _git("show", f"{ref}:{path}", cwd=cwd) + if code != 0: + print(f"[git_file_at] error: {err.strip()}") + return "" + + if lines: + all_lines = out.splitlines() + start, end = lines + 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) + print(f"[git_file_at] {ref}:{path} lines {start}-{end}") + return "\n".join(selected) + + print(out) + print(f"[git_file_at] {ref}:{path} ({len(out)} chars)") + return out + + @_category("Git") + def git_branch(*, cwd: str | Path | None = None) -> dict: + """Get branches: {current, local, remote}.""" + code, out, _ = _git("branch", "-a", "--format=%(refname:short)%00%(HEAD)", cwd=cwd) + if code != 0: + return {"current": None, "local": [], "remote": []} + + result: dict = {"current": None, "local": [], "remote": []} + for line in out.strip().splitlines(): + parts = line.split("\x00") + name = parts[0] + is_current = len(parts) > 1 and parts[1] == "*" + if is_current: + result["current"] = name + if name.startswith("remotes/") or "/" in name and not name.startswith("feature/"): + result["remote"].append(name) + else: + result["local"].append(name) + 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)") + return result + + @_category("Git") + def git_has_changes(*, cwd: str | Path | None = None) -> bool: + """Check if there are uncommitted changes (staged or unstaged).""" + code, out, _ = _git("status", "--porcelain", cwd=cwd) + has_changes = bool(out.strip()) + print(f"[git_has_changes] {'yes' if has_changes else 'no'}") + return has_changes + def __omp_prelude_docs__() -> list[dict[str, str]]: - """Return prelude helper docs for templating.""" - categories = [ - ("File I/O", ["read", "write", "append", "touch", "cat"]), - ("File operations", ["cp", "mv", "rm", "mkdir"]), - ("Navigation", ["pwd", "cd", "ls", "tree", "stat"]), - ("Search", ["find", "glob_files", "grep", "rgrep"]), - ("Text processing", ["head", "tail", "sort_lines", "uniq", "cols", "wc"]), - ("Find and replace", ["replace", "sed", "rsed"]), - ("Batch operations", ["batch", "diff"]), - ("Shell bridge", ["run", "bash", "env"]), - ] + """Return prelude helper docs for templating. Discovers functions by _omp_category attribute.""" helpers: list[dict[str, str]] = [] - for category, names in categories: - for name in names: - obj = globals().get(name) - if not callable(obj): - continue - signature = str(inspect.signature(obj)) - doc = inspect.getdoc(obj) or "" - docline = doc.splitlines()[0] if doc else "" - helpers.append( - { - "name": name, - "signature": signature, - "docstring": docline, - "category": category, - } - ) - return helpers + for name, obj in globals().items(): + if not callable(obj) or not hasattr(obj, "_omp_category"): + continue + signature = str(inspect.signature(obj)) + doc = inspect.getdoc(obj) or "" + docline = doc.splitlines()[0] if doc else "" + helpers.append({ + "name": name, + "signature": signature, + "docstring": docline, + "category": obj._omp_category, + }) + return sorted(helpers, key=lambda h: (h["category"], h["name"])) diff --git a/packages/coding-agent/src/core/python-prelude.test.ts b/packages/coding-agent/src/core/python-prelude.test.ts index ffa7002db..7a4004ab1 100644 --- a/packages/coding-agent/src/core/python-prelude.test.ts +++ b/packages/coding-agent/src/core/python-prelude.test.ts @@ -38,7 +38,7 @@ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => { "tail", "replace", "run", - "bash", + "sh", ]; const session = { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index dd62d80c3..c53adde19 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -317,7 +317,7 @@ const DEFAULT_SETTINGS: Settings = { git: { enabled: false }, mcp: { enableProjectConfig: true }, lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false }, - python: { toolMode: "ipy-only", kernelMode: "session", sharedGateway: true }, + python: { toolMode: "both", kernelMode: "session", sharedGateway: true }, edit: { fuzzyMatch: true }, ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 }, voice: { @@ -406,7 +406,7 @@ function normalizePythonSettings(settings: PythonSettings | undefined): PythonSe toolMode: toolMode === "ipy-only" || toolMode === "bash-only" || toolMode === "both" ? toolMode - : (DEFAULT_SETTINGS.python?.toolMode ?? "ipy-only"), + : (DEFAULT_SETTINGS.python?.toolMode ?? "both"), kernelMode: kernelMode === "session" || kernelMode === "per-call" ? kernelMode @@ -1163,7 +1163,7 @@ export class SettingsManager { } getPythonToolMode(): PythonToolMode { - return this.settings.python?.toolMode ?? "ipy-only"; + return this.settings.python?.toolMode ?? "both"; } async setPythonToolMode(mode: PythonToolMode): Promise { diff --git a/packages/coding-agent/src/core/tools/task/worker.ts b/packages/coding-agent/src/core/tools/task/worker.ts index 370b1267b..e21d03310 100644 --- a/packages/coding-agent/src/core/tools/task/worker.ts +++ b/packages/coding-agent/src/core/tools/task/worker.ts @@ -25,7 +25,7 @@ import { createAgentSession, discoverAuthStorage, discoverModels } from "../../s import { SessionManager } from "../../session-manager"; import { SettingsManager } from "../../settings-manager"; import { untilAborted } from "../../utils"; -import { createPythonTool, type PythonProxyExecutor, type PythonToolDetails, type PythonToolParams } from "../python"; +import { getPythonToolDescription, type PythonToolDetails, type PythonToolParams, pythonSchema } from "../python"; import type { MCPToolCallResponse, MCPToolMetadata, @@ -95,14 +95,16 @@ function callMCPToolViaParent( pendingMCPCalls.delete(callId); }; - signal?.addEventListener( - "abort", - () => { - cleanup(); - reject(new Error("Aborted")); - }, - { once: true }, - ); + if (typeof signal?.addEventListener === "function") { + signal.addEventListener( + "abort", + () => { + cleanup(); + reject(new Error("Aborted")); + }, + { once: true }, + ); + } pendingMCPCalls.set(callId, { resolve: (result) => { @@ -147,14 +149,16 @@ function callPythonToolViaParent( pendingPythonCalls.delete(callId); }; - signal?.addEventListener( - "abort", - () => { - cleanup(); - reject(new Error("Aborted")); - }, - { once: true }, - ); + if (typeof signal?.addEventListener === "function") { + signal.addEventListener( + "abort", + () => { + cleanup(); + reject(new Error("Aborted")); + }, + { once: true }, + ); + } pendingPythonCalls.set(callId, { resolve: (result) => { @@ -241,19 +245,27 @@ function getPythonCallTimeoutMs(params: PythonToolParams): number { return PYTHON_CALL_TIMEOUT_MS; } -const pythonProxyExecutor: PythonProxyExecutor = async (params, signal) => { - const timeoutMs = getPythonCallTimeoutMs(params); - const result = await callPythonToolViaParent(params, signal, timeoutMs); +function createPythonProxyTool(): CustomTool { return { - content: - result?.content?.map((c) => - c.type === "text" - ? { type: "text" as const, text: c.text ?? "" } - : { type: "text" as const, text: JSON.stringify(c) }, - ) ?? [], - details: result?.details as PythonToolDetails | undefined, + name: "python", + label: "Python", + description: getPythonToolDescription(), + parameters: pythonSchema, + execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => { + const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams); + const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs); + return { + content: + result?.content?.map((c) => + c.type === "text" + ? { type: "text" as const, text: c.text ?? "" } + : { type: "text" as const, text: JSON.stringify(c) }, + ) ?? [], + details: result?.details as PythonToolDetails | undefined, + }; + }, }; -}; +} interface WorkerMessageEvent { data: T; @@ -383,9 +395,9 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): } // Create MCP/python proxy tools if provided - const mcpProxyTools = payload.mcpTools?.map(createMCPProxyTool) ?? []; - const pythonProxyTools = payload.pythonToolProxy - ? [createPythonTool(null, { proxyExecutor: pythonProxyExecutor })] + const mcpProxyTools: CustomTool[] = payload.mcpTools?.map(createMCPProxyTool) ?? []; + const pythonProxyTools: CustomTool[] = payload.pythonToolProxy + ? [createPythonProxyTool() as unknown as CustomTool] : []; const proxyTools = [...mcpProxyTools, ...pythonProxyTools]; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 16ac9b0b1..8d6c58bfc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -82,10 +82,11 @@ async function runInteractiveMode( initialMessages: string[], setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void, lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined, + mcpManager: import("./core/mcp/index").MCPManager | undefined, initialMessage?: string, initialImages?: ImageContent[], ): Promise { - const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers); + const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers, mcpManager); await mode.init(); @@ -636,7 +637,8 @@ export async function main(args: string[]) { } time("buildSessionOptions"); - const { session, setToolUIContext, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions); + const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = + await createAgentSession(sessionOptions); time("createAgentSession"); // Re-parse CLI args with extension flags and apply values @@ -706,6 +708,7 @@ export async function main(args: string[]) { parsed.messages, setToolUIContext, lspServers, + mcpManager, initialMessage, initialImages, ); diff --git a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts index f011dffe5..791d9f9eb 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts @@ -7,6 +7,7 @@ import { getDebugLogPath } from "../../../config"; import { loadCustomShare } from "../../../core/custom-share"; import type { CompactOptions } from "../../../core/extensions/types"; import { createCompactionSummaryMessage } from "../../../core/messages"; +import { getGatewayStatus } from "../../../core/python-gateway-coordinator"; import type { TruncationResult } from "../../../core/tools/truncate"; import { getChangelogPath, parseChangelog } from "../../../utils/changelog"; import { copyToClipboard } from "../../../utils/clipboard"; @@ -276,7 +277,47 @@ export class CommandController { if (stats.cost > 0) { info += `\n${theme.bold("Cost")}\n`; - info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`; + info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`; + } + + const gateway = getGatewayStatus(); + info += `\n${theme.bold("Python Gateway")}\n`; + if (gateway.active) { + const mode = gateway.shared ? "Shared" : "Local"; + info += `${theme.fg("dim", "Status:")} ${theme.fg("success", `Active (${mode})`)}\n`; + info += `${theme.fg("dim", "URL:")} ${gateway.url}\n`; + info += `${theme.fg("dim", "PID:")} ${gateway.pid}\n`; + info += `${theme.fg("dim", "Clients:")} ${gateway.refCount}\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) { + const statusColor = server.status === "ready" ? "success" : "error"; + info += `${theme.fg("dim", `${server.name}:`)} ${theme.fg(statusColor, server.status)} ${theme.fg("dim", `(${server.fileTypes.join(", ")})`)}\n`; + } + } + + if (this.ctx.mcpManager) { + const mcpServers = this.ctx.mcpManager.getConnectedServers(); + info += `\n${theme.bold("MCP Servers")}\n`; + if (mcpServers.length === 0) { + info += `${theme.fg("dim", "None connected")}\n`; + } else { + for (const name of mcpServers) { + const conn = this.ctx.mcpManager.getConnection(name); + const toolCount = conn?.tools?.length ?? 0; + info += `${theme.fg("dim", `${name}:`)} ${theme.fg("success", "connected")} ${theme.fg("dim", `(${toolCount} tools)`)}\n`; + } + } } this.ctx.chatContainer.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 848585205..7f214fbba 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -128,8 +128,9 @@ export class InteractiveMode implements InteractiveModeContext { private cleanupUnsubscribe?: () => void; private readonly version: string; private readonly changelogMarkdown: string | undefined; - private readonly lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = + public readonly lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = undefined; + public mcpManager?: import("../../core/mcp/index").MCPManager; private readonly toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void; private readonly commandController: CommandController; @@ -146,6 +147,7 @@ export class InteractiveMode implements InteractiveModeContext { changelogMarkdown: string | undefined = undefined, setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {}, lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = undefined, + mcpManager?: import("../../core/mcp/index").MCPManager, ) { this.session = session; this.sessionManager = session.sessionManager; @@ -156,6 +158,7 @@ export class InteractiveMode implements InteractiveModeContext { this.changelogMarkdown = changelogMarkdown; this.toolUiContextSetter = setToolUIContext; this.lspServers = lspServers; + this.mcpManager = mcpManager; this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor()); this.chatContainer = new Container(); diff --git a/packages/coding-agent/src/modes/interactive/types.ts b/packages/coding-agent/src/modes/interactive/types.ts index 11941a354..b0c17025a 100644 --- a/packages/coding-agent/src/modes/interactive/types.ts +++ b/packages/coding-agent/src/modes/interactive/types.ts @@ -6,6 +6,7 @@ import type { ExtensionUIContext } from "../../core/extensions/index"; import type { CompactOptions } from "../../core/extensions/types"; import type { HistoryStorage } from "../../core/history-storage"; import type { KeybindingsManager } from "../../core/keybindings"; +import type { MCPManager } from "../../core/mcp/index"; import type { SessionContext, SessionManager } from "../../core/session-manager"; import type { SettingsManager } from "../../core/settings-manager"; import type { VoiceSupervisor } from "../../core/voice-supervisor"; @@ -50,6 +51,8 @@ export interface InteractiveModeContext { agent: AgentSession["agent"]; voiceSupervisor: VoiceSupervisor; historyStorage?: HistoryStorage; + mcpManager?: MCPManager; + lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>; // State isInitialized: boolean; diff --git a/packages/coding-agent/src/prompts/tools/python.md b/packages/coding-agent/src/prompts/tools/python.md index f78cf5d9e..9bab4eae4 100644 --- a/packages/coding-agent/src/prompts/tools/python.md +++ b/packages/coding-agent/src/prompts/tools/python.md @@ -22,6 +22,17 @@ The distinction: Read/Grep/Find gather info for *your* decisions. Python execute - File operations → prelude helpers, not mv/cp/rm commands - Conditionals → Python if/else, not bash [[ ]] +**Shell commands:** Use `sh()` or `run()`, never raw `subprocess`: +```python +# Good +sh("bun run check") +run("cargo build --release") + +# Bad - never use subprocess directly +import subprocess +subprocess.run(["bun", "run", "check"], ...) +``` + ## Prelude helpers All helpers auto-print results and return values for chaining.