From b2c94bef1a679d6918b62413e3a24ff0ed9c0044 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 20 Jan 2026 04:34:29 +0100 Subject: [PATCH] refactor(deps): restructured codebase to use centralized pi-utils package - Created new @oh-my-pi/pi-utils workspace package with shared utilities for logging, process management, stream handling, and temporary directory management. - Migrated all packages to use centralized logger from @oh-my-pi/pi-utils instead of local winston implementations. - Replaced custom process spawning and stream reading implementations with standardized cspawn and readLines utilities across all modules. - Converted synchronous file operations and process spawning to async patterns using Bun shell syntax and fs/promises. - Added streaming edit abort functionality with configurable setting to abort on patch preview failures. - Updated test framework from vitest to bun:test across all test suites. --- AGENTS.md | 113 ++++- bun.lock | 27 +- packages/agent/CHANGELOG.md | 3 + packages/agent/package.json | 4 +- packages/agent/src/proxy.ts | 31 +- packages/ai/CHANGELOG.md | 6 + packages/ai/src/cli.ts | 6 +- .../ai/src/providers/google-gemini-cli.ts | 24 +- .../src/providers/openai-codex-responses.ts | 4 +- .../openai-codex/response-handler.ts | 47 +- packages/ai/src/utils/event-stream.ts | 6 +- packages/ai/src/utils/oauth/cursor.ts | 6 +- packages/ai/src/utils/oauth/github-copilot.ts | 24 +- .../ai/src/utils/oauth/google-gemini-cli.ts | 6 +- packages/ai/test/openai-codex-stream.test.ts | 24 +- packages/coding-agent/CHANGELOG.md | 19 + packages/coding-agent/package.json | 3 +- .../coding-agent/src/cli/session-picker.ts | 55 ++- packages/coding-agent/src/cli/setup-cli.ts | 23 +- packages/coding-agent/src/config.ts | 2 +- .../coding-agent/src/core/agent-session.ts | 122 +++-- .../coding-agent/src/core/agent-storage.ts | 2 +- .../coding-agent/src/core/auth-storage.ts | 2 +- .../coding-agent/src/core/bash-executor.ts | 222 ++++----- .../src/core/custom-commands/loader.ts | 2 +- .../src/core/custom-tools/loader.ts | 2 +- .../src/core/custom-tools/types.ts | 3 +- packages/coding-agent/src/core/exec.ts | 111 +---- .../coding-agent/src/core/extensions/index.ts | 8 +- .../src/core/extensions/loader.ts | 2 +- .../src/core/extensions/runner.ts | 2 +- .../coding-agent/src/core/extensions/types.ts | 4 +- .../src/core/extensions/wrapper.ts | 35 +- packages/coding-agent/src/core/frontmatter.ts | 2 +- .../coding-agent/src/core/history-storage.ts | 9 +- packages/coding-agent/src/core/hooks/index.ts | 4 +- .../coding-agent/src/core/hooks/loader.ts | 2 +- .../src/core/hooks/tool-wrapper.ts | 40 +- packages/coding-agent/src/core/hooks/types.ts | 3 +- packages/coding-agent/src/core/keybindings.ts | 2 +- packages/coding-agent/src/core/mcp/client.ts | 26 +- .../coding-agent/src/core/mcp/json-rpc.ts | 2 +- packages/coding-agent/src/core/mcp/loader.ts | 2 +- packages/coding-agent/src/core/mcp/manager.ts | 4 +- .../coding-agent/src/core/mcp/tool-cache.ts | 2 +- .../src/core/mcp/transports/http.ts | 100 ++-- .../coding-agent/src/core/model-registry.ts | 2 +- .../src/core/plugins/installer.ts | 24 +- .../coding-agent/src/core/prompt-templates.ts | 13 +- .../coding-agent/src/core/python-executor.ts | 41 +- .../src/core/python-gateway-coordinator.ts | 57 +-- .../coding-agent/src/core/python-kernel.ts | 451 +++++++++--------- packages/coding-agent/src/core/sdk.ts | 23 +- .../coding-agent/src/core/session-manager.ts | 2 +- .../coding-agent/src/core/settings-manager.ts | 31 +- packages/coding-agent/src/core/skills.ts | 2 +- .../src/core/ssh/connection-manager.ts | 52 +- .../coding-agent/src/core/ssh/ssh-executor.ts | 76 +-- .../coding-agent/src/core/ssh/sshfs-mount.ts | 47 +- .../src/core/storage-migration.ts | 2 +- .../coding-agent/src/core/streaming-output.ts | 314 +++++++----- .../coding-agent/src/core/system-prompt.ts | 198 +++++--- .../coding-agent/src/core/title-generator.ts | 2 +- packages/coding-agent/src/core/tools/ask.ts | 4 +- packages/coding-agent/src/core/tools/bash.ts | 6 +- .../coding-agent/src/core/tools/calculator.ts | 2 +- .../src/core/tools/exa/mcp-client.ts | 2 +- .../coding-agent/src/core/tools/exa/render.ts | 2 +- packages/coding-agent/src/core/tools/find.ts | 106 ++-- .../src/core/tools/gemini-image.ts | 2 +- packages/coding-agent/src/core/tools/grep.ts | 157 ++---- packages/coding-agent/src/core/tools/index.ts | 2 +- packages/coding-agent/src/core/tools/ls.ts | 2 +- .../coding-agent/src/core/tools/lsp/client.ts | 104 ++-- .../tools/lsp/clients/lsp-linter-client.ts | 2 +- .../coding-agent/src/core/tools/lsp/config.ts | 2 +- .../coding-agent/src/core/tools/lsp/index.ts | 6 +- .../coding-agent/src/core/tools/lsp/lspmux.ts | 2 +- .../src/core/tools/lsp/rust-analyzer.ts | 4 +- .../coding-agent/src/core/tools/lsp/utils.ts | 14 - .../coding-agent/src/core/tools/notebook.ts | 2 +- .../src/core/tools/patch/shared.ts | 7 +- .../coding-agent/src/core/tools/python.ts | 6 +- packages/coding-agent/src/core/tools/read.ts | 82 +--- .../src/core/tools/render-utils.ts | 5 - packages/coding-agent/src/core/tools/ssh.ts | 6 +- .../src/core/tools/task/model-resolver.ts | 16 +- .../src/core/tools/task/worker.ts | 287 +++++------ .../coding-agent/src/core/tools/todo-write.ts | 2 +- .../coding-agent/src/core/tools/truncate.ts | 4 +- .../coding-agent/src/core/tools/web-fetch.ts | 28 +- .../src/core/tools/web-scrapers/types.ts | 4 +- .../src/core/tools/web-scrapers/utils.ts | 27 +- .../src/core/tools/web-scrapers/youtube.ts | 51 +- .../src/core/tools/web-search/auth.ts | 2 +- packages/coding-agent/src/core/tools/write.ts | 2 +- packages/coding-agent/src/core/ttsr.ts | 2 +- packages/coding-agent/src/core/utils.ts | 188 +------- .../coding-agent/src/core/voice-controller.ts | 2 +- .../coding-agent/src/core/voice-supervisor.ts | 49 +- packages/coding-agent/src/core/voice.ts | 9 +- packages/coding-agent/src/discovery/codex.ts | 2 +- packages/coding-agent/src/index.ts | 8 +- packages/coding-agent/src/main.ts | 15 +- packages/coding-agent/src/migrations.ts | 2 +- packages/coding-agent/src/modes/cleanup.ts | 23 - packages/coding-agent/src/modes/index.ts | 47 +- .../components/extensions/state-manager.ts | 2 +- .../interactive/components/hook-editor.ts | 21 +- .../interactive/components/login-dialog.ts | 35 +- .../interactive/components/settings-defs.ts | 9 + .../interactive/components/status-line.ts | 81 ++-- .../interactive/components/todo-display.ts | 2 +- .../interactive/components/tool-execution.ts | 2 +- .../controllers/command-controller.ts | 134 ++---- .../controllers/extension-ui-controller.ts | 150 +++--- .../controllers/input-controller.ts | 23 +- .../src/modes/interactive/interactive-mode.ts | 21 +- .../src/modes/interactive/theme/theme.ts | 2 +- .../src/modes/interactive/types.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 214 ++++----- .../coding-agent/src/modes/rpc/rpc-mode.ts | 144 +++--- packages/coding-agent/src/utils/clipboard.ts | 198 +++----- .../coding-agent/src/utils/shell-snapshot.ts | 72 +-- packages/coding-agent/src/utils/shell.ts | 91 ++-- .../coding-agent/src/utils/tools-manager.ts | 113 ++--- .../test/agent-session-concurrent.test.ts | 6 +- .../agent-session-tree-navigation.test.ts | 2 +- .../core/python-executor-per-call.test.ts | 4 +- .../test/core/python-executor.test.ts | 4 +- .../test/core/python-kernel-env.test.ts | 6 +- .../test/core/python-kernel-session.test.ts | 16 +- .../test/core/python-kernel.lifecycle.test.ts | 19 +- .../test/core/python-modules.test.ts | 28 +- .../test/core/streaming-output.test.ts | 26 - .../test/extensions-discovery.test.ts | 69 +-- .../test/extensions-runner.test.ts | 115 +++-- packages/coding-agent/test/rpc.test.ts | 6 +- .../test/streaming-edit-abort.test.ts | 256 ++++++++++ .../test/streaming-render-debug.ts | 5 +- .../test/tools/lsp-batching.test.ts | 19 +- .../test/tools/python-execution.test.ts | 16 +- .../coding-agent/test/tools/python.test.ts | 7 +- .../test/worktree/collapse.test.ts | 32 +- .../test/worktree/operations.test.ts | 36 +- packages/git-tool/package.json | 3 + packages/git-tool/test/cache.test.ts | 19 +- packages/git-tool/test/helpers.ts | 74 +-- packages/git-tool/test/safety.test.ts | 58 +-- packages/pi-utils/package.json | 41 ++ packages/pi-utils/src/abortable.ts | 86 ++++ packages/pi-utils/src/index.ts | 7 + .../src/core => pi-utils/src}/logger.ts | 48 +- packages/pi-utils/src/postmortem.ts | 147 ++++++ packages/pi-utils/src/ptree.ts | 365 ++++++++++++++ packages/pi-utils/src/stream.ts | 241 ++++++++++ packages/pi-utils/src/temp.ts | 73 +++ packages/pi-utils/tsconfig.build.json | 9 + packages/react-edit-benchmark/generate.ts | 13 +- packages/react-edit-benchmark/index.ts | 25 +- packages/react-edit-benchmark/package.json | 1 + packages/react-edit-benchmark/runner.ts | 21 +- packages/react-edit-benchmark/tasks.ts | 11 +- .../react-edit-benchmark/test/verify.test.ts | 9 +- packages/tui/test/stdin-buffer.test.ts | 13 +- tsconfig.json | 2 + 166 files changed, 3821 insertions(+), 3215 deletions(-) delete mode 100644 packages/coding-agent/src/modes/cleanup.ts delete mode 100644 packages/coding-agent/test/core/streaming-output.test.ts create mode 100644 packages/coding-agent/test/streaming-edit-abort.test.ts create mode 100644 packages/pi-utils/package.json create mode 100644 packages/pi-utils/src/abortable.ts create mode 100644 packages/pi-utils/src/index.ts rename packages/{coding-agent/src/core => pi-utils/src}/logger.ts (80%) create mode 100644 packages/pi-utils/src/postmortem.ts create mode 100644 packages/pi-utils/src/ptree.ts create mode 100644 packages/pi-utils/src/stream.ts create mode 100644 packages/pi-utils/src/temp.ts create mode 100644 packages/pi-utils/tsconfig.build.json diff --git a/AGENTS.md b/AGENTS.md index d62db0548..11ed9ecc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,46 +20,117 @@ This project uses Bun. Use Bun APIs where they provide a cleaner alternative; us **NEVER spawn shell commands for operations that have proper APIs** (e.g., `Bun.spawnSync(["mkdir", "-p", dir])` — use `mkdirSync` instead). -### Where Bun Wins - -| Operation | Use | Not | -|-----------|-----|-----| -| File read/write | `Bun.file()`, `Bun.write()` | `readFileSync`, `writeFileSync` | -| File exists | `await Bun.file(path).exists()` | `existsSync` | -| Spawn process | `Bun.spawn()`, `Bun.spawnSync()` | `child_process` | -| Binary lookup | `Bun.which("git")` | `spawnSync(["which", "git"])` | -| HTTP server | `Bun.serve()` | `http.createServer()` | -| SQLite | `bun:sqlite` | `better-sqlite3` | -| Hashing | `Bun.hash()`, Web Crypto | `node:crypto` | -| Path resolution | `import.meta.dir`, `import.meta.path` | `fileURLToPath` dance | - -### Where node:fs Is Correct - -Bun has no native API for directory operations. Use `node:fs`: +### Process Execution +**Prefer Bun Shell** (`$` template literals) for simple commands: ```typescript -import { mkdirSync, readdirSync, rmdirSync } from "node:fs"; -mkdirSync(dir, { recursive: true }); +import { $ } from "bun"; + +// Capture output +const result = await $`git status`.cwd(dir).quiet().nothrow(); +if (result.exitCode === 0) { + const text = result.text(); +} + +// Fire and forget +$`rm ${tmpFile}`.quiet().nothrow(); ``` -### CLI and Scripts +**Use `Bun.spawn`/`Bun.spawnSync`** only when: +- Long-running processes (LSP servers, Python kernels, voice services) +- Streaming stdin/stdout/stderr required (SSE, JSON-RPC) +- Process control needed (signals, kill, complex lifecycle) -Use `bun`/`bunx` instead of `node`/`npm`/`npx`. Bun auto-loads `.env` files (no dotenv needed). +**Bun Shell methods:** +- `.quiet()` - suppress output (stdout/stderr to null) +- `.nothrow()` - don't throw on non-zero exit +- `.text()` - get stdout as string +- `.cwd(path)` - set working directory + +### Sleep + +**Prefer** `await Bun.sleep(ms)` +**Avoid** `new Promise((resolve) => setTimeout(resolve, ms))` + +### File I/O + +**Prefer Bun file APIs:** +```typescript +// Read +const text = await Bun.file(path).text(); +const data = await Bun.file(path).json(); +const exists = await Bun.file(path).exists(); + +// Write +await Bun.write(path, data); +``` + +**Use `node:fs/promises`** for directories (Bun has no native directory APIs): +```typescript +import { mkdir, rm, readdir } from "node:fs/promises"; + +await mkdir(path, { recursive: true }); +await rm(path, { recursive: true, force: true }); +const entries = await readdir(path); +``` + +**Avoid sync APIs** in async flows: +- Don't use `existsSync`/`readFileSync`/`writeFileSync` when async is possible +- Use sync only when required by a synchronous interface + +### Streams + +**Prefer centralized helpers:** +```typescript +import { readStream, readLines } from "./utils/stream"; + +// Read entire stream +const text = await readStream(child.stdout); + +// Line-by-line iteration +for await (const line of readLines(stream)) { + // process line +} +``` + +**Avoid manual reader loops** unless protocol requires it (SSE, streaming JSON-RPC). + +### Where Bun Wins + +| Operation | Use | Not | +| --------------- | ------------------------------------- | ------------------------------- | +| File read/write | `Bun.file()`, `Bun.write()` | `readFileSync`, `writeFileSync` | +| File exists | `await Bun.file(path).exists()` | `existsSync` | +| Spawn process | `$\`cmd\``, `Bun.spawn()` | `child_process` | +| Sleep | `Bun.sleep(ms)` | `setTimeout` promise | +| Binary lookup | `Bun.which("git")` | `spawnSync(["which", "git"])` | +| HTTP server | `Bun.serve()` | `http.createServer()` | +| SQLite | `bun:sqlite` | `better-sqlite3` | +| Hashing | `Bun.hash()`, Web Crypto | `node:crypto` | +| Path resolution | `import.meta.dir`, `import.meta.path` | `fileURLToPath` dance | ### Patterns **Subprocess streams** — cast when using pipe mode: + ```typescript const child = Bun.spawn(["cmd"], { stdout: "pipe", stderr: "pipe" }); const reader = (child.stdout as ReadableStream).getReader(); ``` **Password hashing** — built-in bcrypt/argon2: + ```typescript const hash = await Bun.password.hash("password", "bcrypt"); const valid = await Bun.password.verify("password", hash); ``` +### Anti-Patterns + +- `Bun.spawnSync([...])` for simple commands → use `$\`...\`` +- `new Promise((resolve) => setTimeout(resolve, ms))` → use `Bun.sleep(ms)` +- `existsSync/readFileSync/writeFileSync` in async code → use `Bun.file()` APIs +- Manual `child.stdout.getReader()` loops for non-streaming commands → use `readStream()` helper ## Logging @@ -68,7 +139,7 @@ const valid = await Bun.password.verify("password", hash); Use the centralized logger instead: ```typescript -import { logger } from "../core/logger"; +import { logger } from "@oh-my-pi/pi-utils"; logger.error("MCP request failed", { url, method }); logger.warn("Theme file invalid, using fallback", { path }); diff --git a/bun.lock b/bun.lock index 6df46eedb..e738411e0 100644 --- a/bun.lock +++ b/bun.lock @@ -26,8 +26,10 @@ "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*", }, "devDependencies": { + "@sinclair/typebox": "^0.34.46", "@types/node": "^24.3.0", "vitest": "^3.2.4", }, @@ -70,6 +72,7 @@ "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*", "@openai/agents": "^0.3.7", "@sinclair/typebox": "^0.34.46", "ajv": "^8.17.1", @@ -87,8 +90,6 @@ "node-html-parser": "^6.1.13", "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0", "zod": "^4.3.5", }, "devDependencies": { @@ -102,11 +103,26 @@ "packages/git-tool": { "name": "@oh-my-pi/pi-git-tool", "version": "6.7.670", + "dependencies": { + "@oh-my-pi/pi-utils": "workspace:*", + }, "devDependencies": { "@types/node": "^24.3.0", "vitest": "^3.2.4", }, }, + "packages/pi-utils": { + "name": "@oh-my-pi/pi-utils", + "version": "6.7.670", + "dependencies": { + "strip-ansi": "^7.1.2", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0", + }, + "devDependencies": { + "@types/node": "^24.3.0", + }, + }, "packages/react-edit-benchmark": { "name": "@oh-my-pi/react-edit-benchmark", "version": "0.0.1", @@ -116,6 +132,7 @@ "dependencies": { "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-coding-agent": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*", "diff": "^8.0.2", "prettier": "^3.3.3", }, @@ -324,6 +341,8 @@ "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@workspace:packages/tui"], + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@workspace:packages/pi-utils"], + "@oh-my-pi/react-edit-benchmark": ["@oh-my-pi/react-edit-benchmark@workspace:packages/react-edit-benchmark"], "@openai/agents": ["@openai/agents@0.3.9", "", { "dependencies": { "@openai/agents-core": "0.3.9", "@openai/agents-openai": "0.3.9", "@openai/agents-realtime": "0.3.9", "debug": "^4.4.0", "openai": "^6" }, "peerDependencies": { "zod": "^3.25.40 || ^4.0" } }, "sha512-YaKnqv0M6bCVvn47pThkFfyHz8xWJ+0Ll9ZnhvwJZ5gyPX0UxHIUeUs9SMG9BSvNuJNJHlc5uvfUDGYAmKJClw=="], @@ -1130,6 +1149,8 @@ "@oh-my-pi/pi-git-tool/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], + "@oh-my-pi/pi-utils/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], + "@types/ndjson/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], "@types/through/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], @@ -1196,6 +1217,8 @@ "@oh-my-pi/pi-git-tool/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@oh-my-pi/pi-utils/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/ndjson/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/through/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index fd9278e4a..14ed1d07a 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed + +- Updated proxy stream processing to use utility function for reading lines ## [6.2.0] - 2026-01-19 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index c56a9a425..638f23293 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", - "@oh-my-pi/pi-tui": "workspace:*" + "@oh-my-pi/pi-tui": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*" }, "keywords": [ "ai", @@ -34,6 +35,7 @@ "bun": ">=1.0.0" }, "devDependencies": { + "@sinclair/typebox": "^0.34.46", "@types/node": "^24.3.0", "vitest": "^3.2.4" } diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 3c44e03af..016575b0b 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -14,6 +14,7 @@ import { type ToolCall, } from "@oh-my-pi/pi-ai"; import { parseStreamingJson } from "@oh-my-pi/pi-ai/src/utils/json-parse"; +import { readSseEvents } from "@oh-my-pi/pi-utils"; // Create stream class matching ProxyMessageEventStream class ProxyMessageEventStream extends EventStream { @@ -148,33 +149,17 @@ export function streamProxy(model: Model, context: Context, options: ProxyS throw new Error(errorMessage); } - reader = response.body!.getReader() as ReadableStreamDefaultReader; - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader!.read(); - if (done) break; - + for await (const event of readSseEvents(response.body!)) { if (options.signal?.aborted) { throw new Error("Request aborted by user"); } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6).trim(); - if (data) { - const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; - const event = processProxyEvent(proxyEvent, partial); - if (event) { - stream.push(event); - } - } - } + const data = event.data?.trim(); + if (!data || data === "[DONE]") continue; + const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; + const parsedEvent = processProxyEvent(proxyEvent, partial); + if (parsedEvent) { + stream.push(parsedEvent); } } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a204d402c..cfa564fe6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `logout` command to CLI for OAuth provider logout @@ -12,6 +13,11 @@ ### Changed +- Updated Promise.withResolvers usage for better compatibility +- Replaced custom sleep implementations with Bun.sleep and abortableSleep +- Simplified SSE stream parsing using readLines utility +- Updated test framework from vitest to bun:test +- Replaced temp directory creation with createTempDirSync utility - Changed credential storage from auth.json to ~/.omp/agent/agent.db - Changed CLI command examples from npx to bunx - Refactored OAuth flows to use common callback server base class diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 86c2cb3dd..649e61038 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { createInterface } from "readline"; -import "./utils/migrate-env"; import { CliAuthStorage } from "./storage"; +import "./utils/migrate-env"; import { loginAnthropic } from "./utils/oauth/anthropic"; import { loginGitHubCopilot } from "./utils/oauth/github-copilot"; import { loginAntigravity } from "./utils/oauth/google-antigravity"; @@ -13,7 +13,9 @@ import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types"; const PROVIDERS = getOAuthProviders(); function prompt(rl: ReturnType, question: string): Promise { - return new Promise((resolve) => rl.question(question, resolve)); + const { promise, resolve } = Promise.withResolvers(); + rl.question(question, resolve); + return promise; } async function login(provider: OAuthProvider): Promise { diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 51719b24d..c9f9271cd 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -6,6 +6,7 @@ import { createHash } from "node:crypto"; import type { Content, ThinkingConfig } from "@google/genai"; +import { abortableSleep } from "@oh-my-pi/pi-utils"; import { calculateCost } from "../models"; import type { Api, @@ -301,23 +302,6 @@ function extractErrorMessage(errorText: string): string { return errorText; } -/** - * Sleep for a given number of milliseconds, respecting abort signal. - */ -function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Request was aborted")); - return; - } - const timeout = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { - clearTimeout(timeout); - reject(new Error("Request was aborted")); - }); - }); -} - interface CloudCodeAssistRequest { project: string; model: string; @@ -468,7 +452,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( // Use server-provided delay or exponential backoff const serverDelay = extractRetryDelay(errorText, response); const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt; - await sleep(delayMs, options?.signal); + await abortableSleep(delayMs, options?.signal); continue; } @@ -489,7 +473,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( // Network errors are retryable if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * 2 ** attempt; - await sleep(delayMs, options?.signal); + await abortableSleep(delayMs, options?.signal); continue; } throw lastError; @@ -769,7 +753,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (emptyAttempt > 0) { const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1); - await sleep(backoffMs, options?.signal); + await abortableSleep(backoffMs, options?.signal); if (!requestUrl) { throw new Error("Missing request URL"); diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 3095dbdc3..1f067d009 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -440,13 +440,13 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign } if (signal?.aborted) return response; const delay = getRetryDelayMs(response, attempt); - await new Promise((resolve) => setTimeout(resolve, delay)); + await Bun.sleep(delay); } catch (error) { if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) { throw error; } const delay = CODEX_RETRY_DELAY_MS * (attempt + 1); - await new Promise((resolve) => setTimeout(resolve, delay)); + await Bun.sleep(delay); } attempt += 1; } diff --git a/packages/ai/src/providers/openai-codex/response-handler.ts b/packages/ai/src/providers/openai-codex/response-handler.ts index e6cfb12fd..8c405f9ec 100644 --- a/packages/ai/src/providers/openai-codex/response-handler.ts +++ b/packages/ai/src/providers/openai-codex/response-handler.ts @@ -1,3 +1,5 @@ +import { readSseData } from "@oh-my-pi/pi-utils"; + export type CodexRateLimit = { used_percent?: number; window_minutes?: number; @@ -74,49 +76,8 @@ export async function* parseCodexSseStream(response: Response): AsyncGenerator | null { - const lines = chunk.split("\n"); - const dataLines: string[] = []; - - for (const line of lines) { - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trim()); - } - } - - if (dataLines.length === 0) return null; - const data = dataLines.join("\n").trim(); - if (!data || data === "[DONE]") return null; - - try { - return JSON.parse(data) as Record; - } catch { - return null; + for await (const data of readSseData>(response.body)) { + yield data; } } diff --git a/packages/ai/src/utils/event-stream.ts b/packages/ai/src/utils/event-stream.ts index 79dd8cf37..6fb17fe2d 100644 --- a/packages/ai/src/utils/event-stream.ts +++ b/packages/ai/src/utils/event-stream.ts @@ -12,9 +12,9 @@ export class EventStream implements AsyncIterable { private isComplete: (event: T) => boolean, private extractResult: (event: T) => R, ) { - this.finalResultPromise = new Promise((resolve) => { - this.resolveFinalResult = resolve; - }); + const { promise, resolve } = Promise.withResolvers(); + this.finalResultPromise = promise; + this.resolveFinalResult = resolve; } push(event: T): void { diff --git a/packages/ai/src/utils/oauth/cursor.ts b/packages/ai/src/utils/oauth/cursor.ts index 1ca6bb66e..d7432856f 100644 --- a/packages/ai/src/utils/oauth/cursor.ts +++ b/packages/ai/src/utils/oauth/cursor.ts @@ -10,10 +10,6 @@ const POLL_BASE_DELAY = 1000; const POLL_MAX_DELAY = 10000; const POLL_BACKOFF_MULTIPLIER = 1.2; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export interface CursorAuthParams { verifier: string; challenge: string; @@ -45,7 +41,7 @@ export async function pollCursorAuth( let consecutiveErrors = 0; for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { - await sleep(delay); + await Bun.sleep(delay); try { const response = await fetch(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 9f0acde77..1424a2885 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -2,6 +2,7 @@ * GitHub Copilot OAuth flow */ +import { abortableSleep } from "@oh-my-pi/pi-utils"; import { getModels } from "../../models"; import type { OAuthCredentials } from "./types"; @@ -136,29 +137,6 @@ async function startDeviceFlow(domain: string): Promise { }; } -/** - * Sleep that can be interrupted by an AbortSignal - */ -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Login cancelled")); - return; - } - - const timeout = setTimeout(resolve, ms); - - signal?.addEventListener( - "abort", - () => { - clearTimeout(timeout); - reject(new Error("Login cancelled")); - }, - { once: true }, - ); - }); -} - async function pollForGitHubAccessToken( domain: string, deviceCode: string, diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index fd2c319e6..1cbb518ff 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -47,10 +47,6 @@ interface GoogleRpcErrorResponse { }; } -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function getDefaultTier(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): { id?: string } { if (!allowedTiers || allowedTiers.length === 0) return { id: TIER_LEGACY }; const defaultTier = allowedTiers.find((t) => t.isDefault); @@ -74,7 +70,7 @@ async function pollOperation( while (true) { if (attempt > 0) { onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1})...`); - await wait(5000); + await Bun.sleep(5000); } const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 701ed4c72..d1966e850 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1,7 +1,5 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { streamOpenAICodexResponses } from "../src/providers/openai-codex-responses"; import type { Context, Model } from "../src/types"; @@ -20,8 +18,8 @@ afterEach(() => { describe("openai-codex streaming", () => { it("streams SSE responses into AssistantMessageEventStream", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); - process.env.OMP_CODING_AGENT_DIR = tempDir; + const tempDir = createTempDirSync("@pi-codex-stream-"); + process.env.OMP_CODING_AGENT_DIR = tempDir.path; const payload = Buffer.from( JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }), @@ -92,7 +90,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + global.fetch = fetchMock as unknown as typeof fetch; const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -131,8 +129,8 @@ describe("openai-codex streaming", () => { }); it("sets conversation_id/session_id headers and prompt_cache_key when sessionId is provided", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); - process.env.OMP_CODING_AGENT_DIR = tempDir; + const tempDir = createTempDirSync("@pi-codex-stream-"); + process.env.OMP_CODING_AGENT_DIR = tempDir.path; const payload = Buffer.from( JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }), @@ -207,7 +205,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + global.fetch = fetchMock as unknown as typeof fetch; const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -232,8 +230,8 @@ describe("openai-codex streaming", () => { }); it("does not set conversation_id/session_id headers when sessionId is not provided", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); - process.env.OMP_CODING_AGENT_DIR = tempDir; + const tempDir = createTempDirSync("@pi-codex-stream-"); + process.env.OMP_CODING_AGENT_DIR = tempDir.path; const payload = Buffer.from( JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }), @@ -302,7 +300,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + global.fetch = fetchMock as unknown as typeof fetch; const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 668b67a6e..70742c8e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,14 +1,33 @@ # Changelog ## [Unreleased] + +### Added + +- Added streaming abort setting to control edit tool behavior when patch preview fails + ### Changed +- Replaced internal logger with @oh-my-pi/pi-utils logger across all modules +- Updated process spawning to use cspawn and ptree utilities from pi-utils +- Migrated file operations to use async fs/promises and Bun file APIs +- Refactored promise handling to use Promise.withResolvers and utility functions +- Updated timeout and abort handling to use standardized utility functions - Refactored authentication login method to use OAuthController interface instead of individual callbacks ### Fixed +- Fixed Python package installation to handle async operations properly +- Fixed streaming output truncation to use consistent column limits +- Fixed shell command execution to properly handle process cleanup and timeouts +- Fixed SSH connection management to properly await async operations +- Fixed voice supervisor process cleanup to use proper async handling - Added automatic regex pattern validation in grep tool to handle invalid patterns by switching to literal mode +### Security + +- Updated temporary file cleanup to use secure async removal methods + ## [6.7.67] - 2026-01-19 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index de1fb9a04..1fb2a228b 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -44,6 +44,7 @@ "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*", "@openai/agents": "^0.3.7", "@sinclair/typebox": "^0.34.46", "ajv": "^8.17.1", @@ -61,8 +62,6 @@ "node-html-parser": "^6.1.13", "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 94d8ad702..56b6c4342 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -8,34 +8,33 @@ import { SessionSelectorComponent } from "../modes/interactive/components/sessio /** Show TUI session selector and return selected session path or null if cancelled */ export async function selectSession(sessions: SessionInfo[]): Promise { - return new Promise((resolve) => { - const ui = new TUI(new ProcessTerminal()); - let resolved = false; - - const selector = new SessionSelectorComponent( - sessions, - (path: string) => { - if (!resolved) { - resolved = true; - ui.stop(); - resolve(path); - } - }, - () => { - if (!resolved) { - resolved = true; - ui.stop(); - resolve(null); - } - }, - () => { + const { promise, resolve } = Promise.withResolvers(); + const ui = new TUI(new ProcessTerminal()); + let resolved = false; + const selector = new SessionSelectorComponent( + sessions, + (path: string) => { + if (!resolved) { + resolved = true; ui.stop(); - process.exit(0); - }, - ); + resolve(path); + } + }, + () => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(null); + } + }, + () => { + ui.stop(); + process.exit(0); + }, + ); - ui.addChild(selector); - ui.setFocus(selector.getSessionList()); - ui.start(); - }); + ui.addChild(selector); + ui.setFocus(selector.getSessionList()); + ui.start(); + return promise; } diff --git a/packages/coding-agent/src/cli/setup-cli.ts b/packages/coding-agent/src/cli/setup-cli.ts index 5d790cd5b..7a6dc10b4 100644 --- a/packages/coding-agent/src/cli/setup-cli.ts +++ b/packages/coding-agent/src/cli/setup-cli.ts @@ -4,6 +4,7 @@ * Handles `omp setup ` to install dependencies for optional features. */ +import { $ } from "bun"; import chalk from "chalk"; import { APP_NAME } from "../config"; import { theme } from "../modes/interactive/theme/theme"; @@ -89,10 +90,8 @@ async function checkPythonSetup(): Promise { for (const pkg of PYTHON_PACKAGES) { const moduleName = pkg === "jupyter_kernel_gateway" ? "kernel_gateway" : pkg; - const check = Bun.spawnSync( - [pythonPath, "-c", `import importlib.util; exit(0 if importlib.util.find_spec('${moduleName}') else 1)`], - { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, - ); + const script = `import importlib.util; raise SystemExit(0 if importlib.util.find_spec('${moduleName}') else 1)`; + const check = await $`${pythonPath} -c ${script}`.quiet().nothrow(); if (check.exitCode === 0) { result.installedPackages.push(pkg); } else { @@ -107,24 +106,16 @@ async function checkPythonSetup(): Promise { /** * Install Python packages using uv (preferred) or pip. */ -function installPythonPackages(packages: string[], uvPath?: string, pipPath?: string): boolean { +async function installPythonPackages(packages: string[], uvPath?: string, pipPath?: string): Promise { if (uvPath) { console.log(chalk.dim(`Installing via uv: ${packages.join(" ")}`)); - const result = Bun.spawnSync([uvPath, "pip", "install", ...packages], { - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }); + const result = await $`${uvPath} pip install ${packages}`.nothrow(); return result.exitCode === 0; } if (pipPath) { console.log(chalk.dim(`Installing via pip: ${packages.join(" ")}`)); - const result = Bun.spawnSync([pipPath, "install", ...packages], { - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }); + const result = await $`${pipPath} install ${packages}`.nothrow(); return result.exitCode === 0; } @@ -188,7 +179,7 @@ async function handlePythonSetup(flags: { json?: boolean; check?: boolean }): Pr } console.log(""); - const success = installPythonPackages(check.missingPackages, check.uvPath, check.pipPath); + const success = await installPythonPackages(check.missingPackages, check.uvPath, check.pipPath); if (!success) { console.error(chalk.red(`\n${theme.status.error} Installation failed`)); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 749795eea..1f0f60cf7 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; // Embed package.json at build time for config import packageJson from "../package.json" with { type: "json" }; -import { logger } from "./core/logger"; // ============================================================================= // App Config (from embedded package.json) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 20758b94b..deee4d528 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -16,6 +16,7 @@ import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@oh-my-pi/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai"; +import { abortableSleep, logger } from "@oh-my-pi/pi-utils"; import type { Rule } from "../capability/rule"; import { getAgentDbPath } from "../config"; import { theme } from "../modes/interactive/theme/theme"; @@ -47,7 +48,6 @@ import type { import type { CompactOptions, ContextUsage } from "./extensions/types"; import { extractFileMentions, generateFileMentionMessages } from "./file-mentions"; import type { HookCommandContext } from "./hooks/types"; -import { logger } from "./logger"; import type { BashExecutionMessage, CustomMessage } from "./messages"; import type { ModelRegistry } from "./model-registry"; import { parseModelString } from "./model-resolver"; @@ -59,6 +59,7 @@ import { expandSlashCommand, type FileSlashCommand } from "./slash-commands"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; import type { BashOperations } from "./tools/bash"; +import { normalizeDiff, ParseError, previewPatch } from "./tools/patch"; import { getArtifactsDir } from "./tools/task/artifacts"; import type { TodoItem } from "./tools/todo-write"; import type { TtsrManager } from "./ttsr"; @@ -271,6 +272,9 @@ export class AgentSession { private _pendingTtsrInjections: Rule[] = []; private _ttsrAbortPending = false; + private _streamingEditAbortTriggered = false; + private _streamingEditCheckedLineCounts = new Map(); + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; @@ -352,9 +356,10 @@ export class AgentSession { // Notify all listeners this._emit(event); - // TTSR: Reset buffer on turn start - if (event.type === "turn_start" && this._ttsrManager) { - this._ttsrManager.resetBuffer(); + if (event.type === "turn_start") { + this._resetStreamingEditState(); + // TTSR: Reset buffer on turn start + this._ttsrManager?.resetBuffer(); } // TTSR: Increment message count on turn end (for repeat-after-gap tracking) @@ -406,6 +411,10 @@ export class AgentSession { } } + if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_end") { + await this._maybeAbortStreamingEdit(event); + } + // Handle session persistence if (event.type === "message_end") { // Check if this is a hook/custom message @@ -525,6 +534,82 @@ export class AgentSession { return undefined; } + private _resetStreamingEditState(): void { + this._streamingEditAbortTriggered = false; + this._streamingEditCheckedLineCounts.clear(); + } + + private async _maybeAbortStreamingEdit(event: AgentEvent): Promise { + if (!this.settingsManager.getEditStreamingAbort()) return; + if (this._streamingEditAbortTriggered) return; + if (event.type !== "message_update") return; + const assistantEvent = event.assistantMessageEvent; + if (assistantEvent.type !== "toolcall_end") return; + if (event.message.role !== "assistant") return; + + const message = event.message as AssistantMessage; + if (!Array.isArray(message.content)) return; + const contentIndex = assistantEvent.contentIndex; + const block = message.content[contentIndex]; + if (!block || typeof block !== "object") return; + if ((block as { type?: string }).type !== "toolCall") return; + + const toolCall = block as { + id?: string; + name?: string; + arguments?: Record | null; + }; + if (toolCall.name !== "edit" || !toolCall.id) return; + + const args = toolCall.arguments; + if (!args || typeof args !== "object" || Array.isArray(args)) return; + if ("oldText" in args || "newText" in args) return; + + const path = typeof args.path === "string" ? args.path : undefined; + const diff = typeof args.diff === "string" ? args.diff : undefined; + const op = typeof args.op === "string" ? args.op : undefined; + if (!path || !diff) return; + if (op && op !== "update") return; + + if (!diff.includes("\n")) return; + const lastNewlineIndex = diff.lastIndexOf("\n"); + if (lastNewlineIndex < 0) return; + const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1); + if (diffForCheck.trim().length === 0) return; + + const normalizedDiff = normalizeDiff(diffForCheck); + if (!normalizedDiff) return; + const lines = normalizedDiff.split("\n"); + const hasChangeLine = lines.some((line) => line.startsWith("+") || line.startsWith("-")); + if (!hasChangeLine) return; + + const lineCount = lines.length; + const lastChecked = this._streamingEditCheckedLineCounts.get(toolCall.id); + if (lastChecked !== undefined && lineCount <= lastChecked) return; + this._streamingEditCheckedLineCounts.set(toolCall.id, lineCount); + + const rename = typeof args.rename === "string" ? args.rename : undefined; + try { + await previewPatch( + { path, op: "update", rename, diff: normalizedDiff }, + { + cwd: this.sessionManager.getCwd(), + allowFuzzy: this.settingsManager.getEditFuzzyMatch(), + fuzzyThreshold: this.settingsManager.getEditFuzzyThreshold(), + }, + ); + } catch (error) { + if (error instanceof ParseError) return; + this._streamingEditAbortTriggered = true; + logger.warn("Streaming edit aborted due to patch preview failure", { + toolCallId: toolCall.id, + path, + error: error instanceof Error ? error.message : String(error), + }); + this.agent.abort(); + } + } + /** Rewrite tool call arguments in agent state and persisted session history. */ private async _rewriteToolCallArgs(toolCallId: string, args: Record): Promise { let updated = false; @@ -2042,7 +2127,7 @@ export class AgentSession { error: message, model: `${candidate.provider}/${candidate.id}`, }); - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await Bun.sleep(delayMs); } } @@ -2223,9 +2308,9 @@ export class AgentSession { // Create retry promise on first attempt so waitForRetry() can await it // Ensure only one promise exists (avoid orphaned promises from concurrent calls) if (!this._retryPromise) { - this._retryPromise = new Promise((resolve) => { - this._retryResolve = resolve; - }); + const { promise, resolve } = Promise.withResolvers(); + this._retryPromise = promise; + this._retryResolve = resolve; } if (this._retryAttempt > settings.maxRetries) { @@ -2280,7 +2365,7 @@ export class AgentSession { } this._retryAbortController = new AbortController(); try { - await this._sleep(delayMs, this._retryAbortController.signal); + await abortableSleep(delayMs, this._retryAbortController.signal); } catch { // Aborted during sleep - emit end event so UI can clean up const attempt = this._retryAttempt; @@ -2307,25 +2392,6 @@ export class AgentSession { return true; } - /** - * Sleep helper that respects abort signal. - */ - private _sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Aborted")); - return; - } - - const timeout = setTimeout(resolve, ms); - - signal?.addEventListener("abort", () => { - clearTimeout(timeout); - reject(new Error("Aborted")); - }); - }); - } - /** * Cancel in-progress retry. */ diff --git a/packages/coding-agent/src/core/agent-storage.ts b/packages/coding-agent/src/core/agent-storage.ts index 0357a54ed..451aaea05 100644 --- a/packages/coding-agent/src/core/agent-storage.ts +++ b/packages/coding-agent/src/core/agent-storage.ts @@ -1,9 +1,9 @@ import { Database } from "bun:sqlite"; import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDbPath } from "../config"; import type { AuthCredential } from "./auth-storage"; -import { logger } from "./logger"; import type { Settings } from "./settings-manager"; /** Prepared SQLite statement type from bun:sqlite */ diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 77701a16a..233b05c00 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -17,9 +17,9 @@ import { type OAuthCredentials, type OAuthProvider, } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDbPath, getAuthPath } from "../config"; import { AgentStorage } from "./agent-storage"; -import { logger } from "./logger"; import { migrateJsonStorage } from "./storage-migration"; export type ApiKeyCredential = { diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 88b652909..d3e20b1fb 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -1,179 +1,137 @@ /** * Bash command execution with streaming support and cancellation. * - * This module provides a unified bash execution implementation used by: - * - AgentSession.executeBash() for interactive and RPC modes - * - Direct calls from modes that need bash execution + * Provides unified bash execution for AgentSession.executeBash() and direct calls. */ -import type { Subprocess } from "bun"; -import { getShellConfig, killProcessTree } from "../utils/shell"; +import { cspawn, Exception } from "@oh-my-pi/pi-utils"; +import { getShellConfig } from "../utils/shell"; import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot"; -import { OutputSink, pumpStream } from "./streaming-output"; +import { OutputSink } from "./streaming-output"; import type { BashOperations } from "./tools/bash"; -import { DEFAULT_MAX_BYTES } from "./tools/truncate"; -import { ScopeSignal } from "./utils"; - -// ============================================================================ -// Types -// ============================================================================ export interface BashExecutorOptions { - /** Working directory for command execution */ cwd?: string; - /** Timeout in milliseconds */ timeout?: number; - /** Callback for streaming output chunks (already sanitized) */ onChunk?: (chunk: string) => void; - /** AbortSignal for cancellation */ signal?: AbortSignal; } export interface BashResult { - /** Combined stdout + stderr output (sanitized, possibly truncated) */ output: string; - /** Process exit code (undefined if killed/cancelled) */ exitCode: number | undefined; - /** Whether the command was cancelled via signal */ cancelled: boolean; - /** Whether the output was truncated */ truncated: boolean; - /** Path to temp file containing full output (if output exceeded truncation threshold) */ fullOutputPath?: string; } -// ============================================================================ -// Implementation -// ============================================================================ - -/** - * Execute a bash command with optional streaming and cancellation support. - * - * Features: - * - Streams sanitized output via onChunk callback - * - Writes large output to temp file for later retrieval - * - Supports cancellation via AbortSignal - * - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines) - * - Truncates output if it exceeds the default max bytes - * - * @param command - The bash command to execute - * @param options - Optional streaming callback and abort signal - * @returns Promise resolving to execution result - */ export async function executeBash(command: string, options?: BashExecutorOptions): Promise { const { shell, args, env, prefix } = await getShellConfig(); - // Get or create shell snapshot (for aliases, functions, options) const snapshotPath = await getOrCreateSnapshot(shell, env); const snapshotPrefix = getSnapshotSourceCommand(snapshotPath); - // Build final command: snapshot + prefix + command const prefixedCommand = prefix ? `${prefix} ${command}` : command; const finalCommand = `${snapshotPrefix}${prefixedCommand}`; - using signal = new ScopeSignal(options); + const stream = new OutputSink({ onLine: options?.onChunk }); - const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], { + const child = cspawn([shell, ...args, finalCommand], { cwd: options?.cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", env, + signal: options?.signal, + timeout: options?.timeout, }); - signal.catch(() => { - killProcessTree(child.pid); - }); + // Pump streams - errors during abort/timeout are expected + await Promise.allSettled([ + child.stdout.pipeTo(stream.createWritable()), + child.stderr.pipeTo(stream.createWritable()), + ]) + .then(() => stream.close()) + .catch(() => {}); - const sink = new OutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk); - - const writer = sink.getWriter(); + // Wait for process exit try { - await Promise.all([ - pumpStream(child.stdout as ReadableStream, writer), - pumpStream(child.stderr as ReadableStream, writer), - ]); - } finally { - await writer.close(); - } - - // Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal - const exitCode = await child.exited; - - const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false)); - - if (signal.timedOut()) { - const secs = Math.round(options!.timeout! / 1000); + await child.exited; return { - exitCode: undefined, - cancelled: true, - ...sink.dump(`Command timed out after ${secs} seconds`), - }; - } - - return { - exitCode: cancelled ? undefined : exitCode, - cancelled, - ...sink.dump(), - }; -} - -/** - * Execute a bash command using custom BashOperations. - * Used for remote execution (SSH, containers, etc.). - */ -export async function executeBashWithOperations( - command: string, - cwd: string, - operations: BashOperations, - options?: BashExecutorOptions, -): Promise { - const sink = new OutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk); - const writer = sink.getWriter(); - - // Create a ReadableStream from the callback-based operations.exec - let streamController: ReadableStreamDefaultController; - const dataStream = new ReadableStream({ - start(controller) { - streamController = controller; - }, - }); - - const onData = (data: Buffer) => { - streamController.enqueue(new Uint8Array(data)); - }; - - // Start pumping the stream (will complete when stream closes) - const pumpPromise = pumpStream(dataStream, writer); - - try { - const result = await operations.exec(command, cwd, { - onData, - signal: options?.signal, - timeout: options?.timeout, - }); - - streamController!.close(); - await pumpPromise; - await writer.close(); - - const cancelled = options?.signal?.aborted ?? false; - - return { - exitCode: cancelled ? undefined : (result.exitCode ?? undefined), - cancelled, - ...sink.dump(), + exitCode: child.exitCode ?? 0, + cancelled: false, + ...stream.dump(), }; } catch (err) { - streamController!.close(); - await pumpPromise; - await writer.close(); + // Exception covers NonZeroExitError, AbortError, TimeoutError + if (err instanceof Exception) { + if (err.aborted) { + const isTimeout = err.message.includes("timed out"); + const annotation = isTimeout + ? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds` + : undefined; + return { + exitCode: undefined, + cancelled: true, + ...stream.dump(annotation), + }; + } - if (options?.signal?.aborted) { + // NonZeroExitError return { - exitCode: undefined, - cancelled: true, - ...sink.dump(), + exitCode: err.exitCode, + cancelled: false, + ...stream.dump(), + }; + } + + throw err; + } +} + +export async function executeBashWithOperations( + command: string, + cwd: string, + operations: BashOperations, + options?: BashExecutorOptions, +): Promise { + const stream = new OutputSink({ onLine: options?.onChunk }); + const writable = stream.createWritable(); + const writer = writable.getWriter(); + + const closeStreams = async () => { + try { + await writer.close(); + } catch {} + try { + await writable.close(); + } catch {} + try { + await stream.close(); + } catch {} + }; + + try { + const result = await operations.exec(command, cwd, { + onData: (data) => writer.write(data), + signal: options?.signal, + timeout: options?.timeout, + }); + + await closeStreams(); + + const cancelled = options?.signal?.aborted ?? false; + + return { + exitCode: cancelled ? undefined : (result.exitCode ?? undefined), + cancelled, + ...stream.dump(), + }; + } catch (err) { + await closeStreams(); + + if (options?.signal?.aborted) { + return { + exitCode: undefined, + cancelled: true, + ...stream.dump(), }; } diff --git a/packages/coding-agent/src/core/custom-commands/loader.ts b/packages/coding-agent/src/core/custom-commands/loader.ts index 267cc6da4..7f0c33245 100644 --- a/packages/coding-agent/src/core/custom-commands/loader.ts +++ b/packages/coding-agent/src/core/custom-commands/loader.ts @@ -7,11 +7,11 @@ import { type Dirent, existsSync, readdirSync } from "node:fs"; import * as path from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import * as typebox from "@sinclair/typebox"; import { getAgentDir, getConfigDirs } from "../../config"; import * as piCodingAgent from "../../index"; import { execCommand } from "../exec"; -import { logger } from "../logger"; import { ReviewCommand } from "./bundled/review"; import { WorktreeCommand } from "./bundled/wt"; import type { diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 896ef8ae1..ebe0724e7 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -6,6 +6,7 @@ */ import * as path from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import * as typebox from "@sinclair/typebox"; import { toolCapability } from "../../capability/tool"; import { type CustomTool, loadCapability } from "../../discovery"; @@ -15,7 +16,6 @@ import { theme } from "../../modes/interactive/theme/theme"; import type { ExecOptions } from "../exec"; import { execCommand } from "../exec"; import type { HookUIContext } from "../hooks/types"; -import { logger } from "../logger"; import { getAllPluginToolPaths } from "../plugins/loader"; import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types"; diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index ecabff643..f16917437 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -12,7 +12,6 @@ import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme"; import type { ExecOptions, ExecResult } from "../exec"; import type { HookUIContext } from "../hooks/types"; -import type { Logger } from "../logger"; import type { ModelRegistry } from "../model-registry"; import type { ReadonlySessionManager } from "../session-manager"; @@ -36,7 +35,7 @@ export interface CustomToolAPI { /** Whether UI is available (false in print/RPC mode) */ hasUI: boolean; /** File logger for error/warning/debug messages */ - logger: Logger; + logger: typeof import("@oh-my-pi/pi-utils").logger; /** Injected @sinclair/typebox module */ typebox: typeof import("@sinclair/typebox"); /** Injected pi-coding-agent exports */ diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index ca510aa90..6e2ce9448 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -2,8 +2,7 @@ * Shared command execution utilities for hooks and custom tools. */ -import type { Subprocess } from "bun"; -import { logger } from "./logger"; +import { ptree } from "@oh-my-pi/pi-utils"; /** * Options for executing shell commands. @@ -37,103 +36,17 @@ export async function execCommand( cwd: string, options?: ExecOptions, ): Promise { - return new Promise((resolve) => { - const proc: Subprocess = Bun.spawn([command, ...args], { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - let stdout = ""; - let stderr = ""; - let killed = false; - let timeoutId: Timer | undefined; - - const killProcess = () => { - if (!killed) { - killed = true; - proc.kill(); - // Force kill after 5 seconds if first kill doesn't work - setTimeout(() => { - try { - proc.kill(9); - } catch { - // Ignore if already dead - } - }, 5000); - } + const proc = ptree.cspawn([command, ...args], { + cwd, + signal: options?.signal, + timeout: options?.timeout, + }); + return proc.exited.then(async () => { + return { + stdout: await proc.stdout.text(), + stderr: await proc.stderr.text(), + code: proc.exitCode ?? 0, + killed: proc.exitReason instanceof ptree.AbortError, }; - - // Handle abort signal - if (options?.signal) { - if (options.signal.aborted) { - killProcess(); - } else { - options.signal.addEventListener("abort", killProcess, { once: true }); - } - } - - // Handle timeout - if (options?.timeout && options.timeout > 0) { - timeoutId = setTimeout(() => { - killProcess(); - }, options.timeout); - } - - // Read streams asynchronously - (async () => { - try { - const stdoutReader = (proc.stdout as ReadableStream).getReader(); - const stderrReader = (proc.stderr as ReadableStream).getReader(); - - // Read both streams and wait for process exit - const [stdoutResult, stderrResult, exitCode] = await Promise.all([ - (async () => { - const chunks: Uint8Array[] = []; - try { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - chunks.push(value); - } - } finally { - stdoutReader.releaseLock(); - } - return Buffer.concat(chunks).toString(); - })(), - (async () => { - const chunks: Uint8Array[] = []; - try { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - chunks.push(value); - } - } finally { - stderrReader.releaseLock(); - } - return Buffer.concat(chunks).toString(); - })(), - proc.exited, - ]); - - stdout = stdoutResult; - stderr = stderrResult; - - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - resolve({ stdout, stderr, code: exitCode ?? 0, killed }); - } catch (err) { - logger.debug("Process stream error", { error: String(err) }); - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - resolve({ stdout, stderr, code: 1, killed }); - } - })(); }); } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index ea8c1102c..17f402124 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -110,10 +110,4 @@ export { isReadToolResult, isWriteToolResult, } from "./types"; -export { - ExtensionToolWrapper, - RegisteredToolAdapter, - wrapRegisteredTool, - wrapRegisteredTools, - wrapToolWithExtensions, -} from "./wrapper"; +export { ExtensionToolWrapper, RegisteredToolAdapter, wrapRegisteredTool, wrapRegisteredTools } from "./wrapper"; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index e7a75ccbc..a432b1528 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -7,6 +7,7 @@ import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { KeyId } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import * as TypeBox from "@sinclair/typebox"; import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; @@ -16,7 +17,6 @@ import * as piCodingAgent from "../../index"; import { EventBus } from "../event-bus"; import type { ExecOptions } from "../exec"; import { execCommand } from "../exec"; -import { logger } from "../logger"; import type { CustomMessage } from "../messages"; import type { Extension, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 6d127558d..67a3baced 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -5,8 +5,8 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model } from "@oh-my-pi/pi-ai"; import type { KeyId } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import { type Theme, theme } from "../../modes/interactive/theme/theme"; -import { logger } from "../logger"; import type { ModelRegistry } from "../model-registry"; import type { SessionManager } from "../session-manager"; import type { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 01ff8c344..053f07830 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -33,8 +33,8 @@ import type { BashOperations } from "../tools/bash"; import type { EditToolDetails } from "../tools/patch"; export type { ExecOptions, ExecResult } from "../exec"; -export type { AgentToolResult, AgentToolUpdateCallback }; export type { AppAction, KeybindingsManager } from "../keybindings"; +export type { AgentToolResult, AgentToolUpdateCallback }; // ============================================================================ // UI Context @@ -633,7 +633,7 @@ export interface ExtensionAPI { // ========================================================================= /** File logger for error/warning/debug messages */ - logger: typeof import("../logger").logger; + logger: typeof import("@oh-my-pi/pi-utils").logger; /** Injected @sinclair/typebox module for defining tool parameters */ typebox: typeof import("@sinclair/typebox"); diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index d491e72cf..edc82beb6 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -4,6 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme"; import type { ExtensionRunner } from "./runner"; import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types"; @@ -70,16 +71,18 @@ export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: E * - Emits tool_call event before execution (can block) * - Emits tool_result event after execution (can modify result) */ -export class ExtensionToolWrapper implements AgentTool { +export class ExtensionToolWrapper + implements AgentTool +{ name: string; label: string; description: string; - parameters: unknown; - renderCall?: AgentTool["renderCall"]; - renderResult?: AgentTool["renderResult"]; + parameters: TParameters; + renderCall?: AgentTool["renderCall"]; + renderResult?: AgentTool["renderResult"]; constructor( - private tool: AgentTool, + private tool: AgentTool, private runner: ExtensionRunner, ) { this.name = tool.name; @@ -92,9 +95,9 @@ export class ExtensionToolWrapper implements AgentTool { async execute( toolCallId: string, - params: Record, + params: Static, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ) { // Emit tool_call event - extensions can block execution @@ -104,7 +107,7 @@ export class ExtensionToolWrapper implements AgentTool { type: "tool_call", toolName: this.tool.name, toolCallId, - input: params, + input: params as Record, })) as ToolCallEventResult | undefined; if (callResult?.block) { @@ -120,7 +123,7 @@ export class ExtensionToolWrapper implements AgentTool { } // Execute the actual tool - let result: { content: any; details?: T }; + let result: { content: any; details?: TDetails }; let executionError: Error | undefined; try { @@ -129,7 +132,7 @@ export class ExtensionToolWrapper implements AgentTool { executionError = err instanceof Error ? err : new Error(String(err)); result = { content: [{ type: "text", text: executionError.message }], - details: undefined as T, + details: undefined as TDetails, }; } @@ -139,7 +142,7 @@ export class ExtensionToolWrapper implements AgentTool { type: "tool_result", toolName: this.tool.name, toolCallId, - input: params, + input: params as Record, content: result.content, details: result.details, isError: !!executionError, @@ -147,7 +150,7 @@ export class ExtensionToolWrapper implements AgentTool { if (resultResult) { const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content; - const modifiedDetails = (resultResult.details ?? result.details) as T; + const modifiedDetails = (resultResult.details ?? result.details) as TDetails; // Extension can override error status if (resultResult.isError === true && !executionError) { @@ -176,11 +179,3 @@ export class ExtensionToolWrapper implements AgentTool { return result; } } - -/** - * Wrap a tool with extension callbacks for interception. - * @deprecated Use `new ExtensionToolWrapper()` directly - */ -export function wrapToolWithExtensions(tool: AgentTool, runner: ExtensionRunner): AgentTool { - return new ExtensionToolWrapper(tool, runner); -} diff --git a/packages/coding-agent/src/core/frontmatter.ts b/packages/coding-agent/src/core/frontmatter.ts index e0cf9575e..734c720c8 100644 --- a/packages/coding-agent/src/core/frontmatter.ts +++ b/packages/coding-agent/src/core/frontmatter.ts @@ -1,5 +1,5 @@ +import { logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; -import { logger } from "./logger"; function stripHtmlComments(content: string): string { return content.replace(//g, ""); diff --git a/packages/coding-agent/src/core/history-storage.ts b/packages/coding-agent/src/core/history-storage.ts index fe4174947..f32134d33 100644 --- a/packages/coding-agent/src/core/history-storage.ts +++ b/packages/coding-agent/src/core/history-storage.ts @@ -1,7 +1,8 @@ import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDir } from "../config"; -import { logger } from "./logger"; export interface HistoryEntry { id: number; @@ -134,11 +135,7 @@ END; private ensureDir(dbPath: string): void { const dir = dirname(dbPath); - const result = Bun.spawnSync(["mkdir", "-p", dir]); - if (result.exitCode !== 0) { - const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : ""; - throw new Error(`Failed to create history directory: ${dir} ${stderr}`.trim()); - } + mkdirSync(dir, { recursive: true }); } private normalizeLimit(limit: number): number { diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 1780603a1..8823408bd 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -1,4 +1,5 @@ // biome-ignore assist/source/organizeImports: biome is not smart +export type { ReadonlySessionManager, UsageStatistics } from "../session-manager"; export { discoverAndLoadHooks, loadHooks, @@ -11,6 +12,5 @@ export { type SendMessageHandler, } from "./loader"; export { execCommand, HookRunner, type HookErrorListener } from "./runner"; -export { HookToolWrapper, wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper"; +export { HookToolWrapper } from "./tool-wrapper"; export * from "./types"; -export type { UsageStatistics, ReadonlySessionManager } from "../session-manager"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index eea27454a..3f3197b8f 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -3,13 +3,13 @@ */ import * as path from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import * as typebox from "@sinclair/typebox"; import { hookCapability } from "../../capability/hook"; import type { Hook } from "../../discovery"; import { loadCapability } from "../../discovery"; import { expandPath } from "../../discovery/helpers"; import * as piCodingAgent from "../../index"; -import { logger } from "../logger"; import type { HookMessage } from "../messages"; import type { SessionManager } from "../session-manager"; import { execCommand } from "./runner"; diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts index 31214fa47..fc5fc5024 100644 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -3,6 +3,7 @@ */ import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { Static, TSchema } from "@sinclair/typebox"; import type { HookRunner } from "./runner"; import type { ToolCallEventResult, ToolResultEventResult } from "./types"; @@ -14,16 +15,18 @@ import type { ToolCallEventResult, ToolResultEventResult } from "./types"; * - Emits tool_result event after execution (can modify result) * - Forwards onUpdate callback to wrapped tool for progress streaming */ -export class HookToolWrapper implements AgentTool { +export class HookToolWrapper + implements AgentTool +{ name: string; label: string; description: string; - parameters: unknown; - renderCall?: AgentTool["renderCall"]; - renderResult?: AgentTool["renderResult"]; + parameters: TParameters; + renderCall?: AgentTool["renderCall"]; + renderResult?: AgentTool["renderResult"]; constructor( - private tool: AgentTool, + private tool: AgentTool, private hookRunner: HookRunner, ) { this.name = tool.name; @@ -36,9 +39,9 @@ export class HookToolWrapper implements AgentTool { async execute( toolCallId: string, - params: Record, + params: Static, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ) { // Emit tool_call event - hooks can block execution @@ -49,7 +52,7 @@ export class HookToolWrapper implements AgentTool { type: "tool_call", toolName: this.tool.name, toolCallId, - input: params, + input: params as Record, })) as ToolCallEventResult | undefined; if (callResult?.block) { @@ -75,7 +78,7 @@ export class HookToolWrapper implements AgentTool { type: "tool_result", toolName: this.tool.name, toolCallId, - input: params, + input: params as Record, content: result.content, details: result.details, isError: false, @@ -85,7 +88,7 @@ export class HookToolWrapper implements AgentTool { if (resultResult) { return { content: resultResult.content ?? result.content, - details: (resultResult.details ?? result.details) as T, + details: (resultResult.details ?? result.details) as TDetails, }; } } @@ -98,7 +101,7 @@ export class HookToolWrapper implements AgentTool { type: "tool_result", toolName: this.tool.name, toolCallId, - input: params, + input: params as Record, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], details: undefined, isError: true, @@ -108,18 +111,3 @@ export class HookToolWrapper implements AgentTool { } } } - -/** - * Wrap all tools with hook callbacks. - */ -export function wrapToolsWithHooks(tools: AgentTool[], hookRunner: HookRunner): AgentTool[] { - return tools.map((tool) => new HookToolWrapper(tool, hookRunner)); -} - -/** - * Backward compatibility alias - use HookToolWrapper directly. - * @deprecated Use HookToolWrapper class instead - */ -export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRunner): AgentTool { - return new HookToolWrapper(tool, hookRunner); -} diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 2e2241437..568152e2b 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -11,7 +11,6 @@ import type { Component, TUI } from "@oh-my-pi/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme"; import type { CompactionPreparation, CompactionResult } from "../compaction/index"; import type { ExecOptions, ExecResult } from "../exec"; -import type { Logger } from "../logger"; import type { HookMessage } from "../messages"; import type { ModelRegistry } from "../model-registry"; import type { @@ -742,7 +741,7 @@ export interface HookAPI { exec(command: string, args: string[], options?: ExecOptions): Promise; /** File logger for error/warning/debug messages */ - logger: Logger; + logger: typeof import("@oh-my-pi/pi-utils").logger; /** Injected @sinclair/typebox module */ typebox: typeof import("@sinclair/typebox"); /** Injected pi-coding-agent exports */ diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 02ff4d19e..785545013 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -9,8 +9,8 @@ import { matchesKey, setEditorKeybindings, } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDir } from "../config"; -import { logger } from "./logger"; /** * Application-level actions (coding agent specific). diff --git a/packages/coding-agent/src/core/mcp/client.ts b/packages/coding-agent/src/core/mcp/client.ts index 5ef0ca69b..a650f11cf 100644 --- a/packages/coding-agent/src/core/mcp/client.ts +++ b/packages/coding-agent/src/core/mcp/client.ts @@ -36,19 +36,19 @@ const CLIENT_INFO = { /** Wrap a promise with a timeout */ function withTimeout(promise: Promise, ms: number, message: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(message)), ms); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); + const { promise: wrapped, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + return wrapped; } /** diff --git a/packages/coding-agent/src/core/mcp/json-rpc.ts b/packages/coding-agent/src/core/mcp/json-rpc.ts index aee1e360c..4843bd71b 100644 --- a/packages/coding-agent/src/core/mcp/json-rpc.ts +++ b/packages/coding-agent/src/core/mcp/json-rpc.ts @@ -5,7 +5,7 @@ * without maintaining persistent connections. */ -import { logger } from "../logger"; +import { logger } from "@oh-my-pi/pi-utils"; /** Parse SSE response format (lines starting with "data: ") */ export function parseSSE(text: string): unknown { diff --git a/packages/coding-agent/src/core/mcp/loader.ts b/packages/coding-agent/src/core/mcp/loader.ts index c73c4263b..3664bb90b 100644 --- a/packages/coding-agent/src/core/mcp/loader.ts +++ b/packages/coding-agent/src/core/mcp/loader.ts @@ -4,9 +4,9 @@ * Integrates MCP tool discovery with the custom tools system. */ +import { logger } from "@oh-my-pi/pi-utils"; import { AgentStorage } from "../agent-storage"; import type { LoadedCustomTool } from "../custom-tools/types"; -import { logger } from "../logger"; import { type MCPLoadResult, MCPManager } from "./manager"; import { parseMCPToolName } from "./tool-bridge"; import { MCPToolCache } from "./tool-cache"; diff --git a/packages/coding-agent/src/core/mcp/manager.ts b/packages/coding-agent/src/core/mcp/manager.ts index 5677aa33b..29e9bdc53 100644 --- a/packages/coding-agent/src/core/mcp/manager.ts +++ b/packages/coding-agent/src/core/mcp/manager.ts @@ -5,9 +5,9 @@ * Handles tool loading and lifecycle. */ +import { logger } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import type { CustomTool } from "../custom-tools/types"; -import { logger } from "../logger"; import { connectToServer, disconnectServer, listTools } from "./client"; import { loadAllMCPConfigs, validateServerConfig } from "./config"; import type { MCPToolDetails } from "./tool-bridge"; @@ -47,7 +47,7 @@ function trackPromise(promise: Promise): TrackedPromise { } function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return Bun.sleep(ms); } /** Result of loading MCP tools */ diff --git a/packages/coding-agent/src/core/mcp/tool-cache.ts b/packages/coding-agent/src/core/mcp/tool-cache.ts index ff507c131..64e619f19 100644 --- a/packages/coding-agent/src/core/mcp/tool-cache.ts +++ b/packages/coding-agent/src/core/mcp/tool-cache.ts @@ -4,8 +4,8 @@ * Stores tool definitions per server in agent.db for fast startup. */ +import { logger } from "@oh-my-pi/pi-utils"; import type { AgentStorage } from "../agent-storage"; -import { logger } from "../logger"; import type { MCPServerConfig, MCPToolDefinition } from "./types"; const CACHE_VERSION = 1; diff --git a/packages/coding-agent/src/core/mcp/transports/http.ts b/packages/coding-agent/src/core/mcp/transports/http.ts index 2462c924f..2522da31e 100644 --- a/packages/coding-agent/src/core/mcp/transports/http.ts +++ b/packages/coding-agent/src/core/mcp/transports/http.ts @@ -12,19 +12,7 @@ function generateId(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36); } -/** Parse SSE data line */ -function parseSSELine(line: string): { event?: string; data?: string; id?: string } | null { - if (line.startsWith("data:")) { - return { data: line.slice(5).trim() }; - } - if (line.startsWith("event:")) { - return { event: line.slice(6).trim() }; - } - if (line.startsWith("id:")) { - return { id: line.slice(3).trim() }; - } - return null; -} +import { readSseEvents } from "@oh-my-pi/pi-utils"; /** * HTTP transport for MCP servers. @@ -95,30 +83,17 @@ export class HttpTransport implements MCPTransport { } // Read SSE stream - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (this._connected) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - const parsed = parseSSELine(line); - if (parsed?.data && parsed.data !== "[DONE]") { - try { - const message = JSON.parse(parsed.data); - if ("method" in message && !("id" in message)) { - this.onNotification?.(message.method, message.params); - } - } catch { - // Ignore parse errors - } + for await (const event of readSseEvents(response.body)) { + if (!this._connected) break; + const data = event.data?.trim(); + if (!data || data === "[DONE]") continue; + try { + const message = JSON.parse(data); + if ("method" in message && !("id" in message)) { + this.onNotification?.(message.method, message.params); } + } catch { + // Ignore parse errors } } } catch (error) { @@ -192,44 +167,31 @@ export class HttpTransport implements MCPTransport { throw new Error("No response body"); } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; let result: T | undefined; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + for await (const event of readSseEvents(response.body)) { + const data = event.data?.trim(); + if (!data || data === "[DONE]") continue; + try { + const message = JSON.parse(data) as JsonRpcResponse; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - const parsed = parseSSELine(line); - if (parsed?.data && parsed.data !== "[DONE]") { - try { - const message = JSON.parse(parsed.data) as JsonRpcResponse; - - // Handle our response - if ("id" in message && message.id === expectedId) { - if (message.error) { - throw new Error(`MCP error ${message.error.code}: ${message.error.message}`); - } - result = message.result as T; - } - // Handle notifications - else if ("method" in message && !("id" in message)) { - const notification = message as { method: string; params?: unknown }; - this.onNotification?.(notification.method, notification.params); - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("MCP error")) { - throw error; - } - // Ignore other parse errors + // Handle our response + if ("id" in message && message.id === expectedId) { + if (message.error) { + throw new Error(`MCP error ${message.error.code}: ${message.error.message}`); } + result = message.result as T; } + // Handle notifications + else if ("method" in message && !("id" in message)) { + const notification = message as { method: string; params?: unknown }; + this.onNotification?.(notification.method, notification.params); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("MCP error")) { + throw error; + } + // Ignore other parse errors } } diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index bff2d1e48..673648b88 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -11,10 +11,10 @@ import { type Model, normalizeDomain, } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import AjvModule from "ajv"; import type { AuthStorage } from "./auth-storage"; -import { logger } from "./logger"; const Ajv = (AjvModule as any).default || AjvModule; diff --git a/packages/coding-agent/src/core/plugins/installer.ts b/packages/coding-agent/src/core/plugins/installer.ts index a341e0d44..56d6db8d5 100644 --- a/packages/coding-agent/src/core/plugins/installer.ts +++ b/packages/coding-agent/src/core/plugins/installer.ts @@ -38,8 +38,9 @@ export async function installPlugin(packageName: string): Promise { } export async function listPlugins(): Promise { - const pkgJsonPath = join(PLUGINS_DIR, "package.json"); - if (!(await Bun.file(pkgJsonPath).exists())) { + const pkgJsonPath = Bun.file(join(PLUGINS_DIR, "package.json")); + if (!(await pkgJsonPath.exists())) { return []; } - const pkg = await Bun.file(pkgJsonPath).json(); + const pkg = await pkgJsonPath.json(); const deps = pkg.dependencies || {}; const plugins: InstalledPlugin[] = []; for (const [name, _version] of Object.entries(deps)) { - const pluginPkgPath = join(PLUGINS_DIR, "node_modules", name, "package.json"); - if (await Bun.file(pluginPkgPath).exists()) { - const pluginPkg = await Bun.file(pluginPkgPath).json(); + const path = join(PLUGINS_DIR, "node_modules", name); + const fpkg = Bun.file(join(path, "package.json")); + if (await fpkg.exists()) { + const pkg = await fpkg.json(); plugins.push({ name, - version: pluginPkg.version, - path: join(PLUGINS_DIR, "node_modules", name), - manifest: pluginPkg.omp || pluginPkg.pi || { version: pluginPkg.version }, + version: pkg.version, + path, + manifest: pkg.omp || pkg.pi || { version: pkg.version }, enabledFeatures: null, enabled: true, }); diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 94ab6ccda..79b6fad36 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,8 +1,8 @@ import { join, resolve } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import Handlebars from "handlebars"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config"; import { parseFrontmatter } from "./frontmatter"; -import { logger } from "./logger"; /** * Represents a prompt template loaded from a markdown file @@ -371,14 +371,6 @@ async function loadTemplatesFromDir( subdir: string = "", ): Promise { const templates: PromptTemplate[] = []; - - try { - const stat = await Bun.file(`${dir}/.`).exists(); - if (!stat) return templates; - } catch { - return templates; - } - try { const glob = new Bun.Glob("**/*"); const entries = []; @@ -440,6 +432,9 @@ async function loadTemplatesFromDir( } } } catch (error) { + if (!Bun.file(dir).exists()) { + return []; + } logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) }); } diff --git a/packages/coding-agent/src/core/python-executor.ts b/packages/coding-agent/src/core/python-executor.ts index 4576327da..bf09d03aa 100644 --- a/packages/coding-agent/src/core/python-executor.ts +++ b/packages/coding-agent/src/core/python-executor.ts @@ -1,4 +1,4 @@ -import { logger } from "./logger"; +import { logger, sanitizeText } from "@oh-my-pi/pi-utils"; import { checkPythonKernelAvailability, type KernelDisplayOutput, @@ -7,9 +7,7 @@ import { type PreludeHelper, PythonKernel, } from "./python-kernel"; -import { OutputSink, sanitizeText } from "./streaming-output"; -import { DEFAULT_MAX_BYTES } from "./tools/truncate"; - +import { OutputSink } from "./streaming-output"; export type PythonKernelMode = "session" | "per-call"; export interface PythonExecutorOptions { @@ -212,21 +210,30 @@ async function executeWithKernel( code: string, options: PythonExecutorOptions | undefined, ): Promise { - const sink = new OutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk); - const writer = sink.getWriter(); + const sink = new OutputSink({ onLine: options?.onChunk }); const displayOutputs: KernelDisplayOutput[] = []; try { - const result = await kernel.execute(code, { - signal: options?.signal, - timeoutMs: options?.timeout, - onChunk: async (text) => { - await writer.write(sanitizeText(text)); - }, - onDisplay: async (output) => { - displayOutputs.push(output); - }, - }); + const writable = sink.createStringWritable(); + const writer = writable.getWriter(); + let result: KernelExecuteResult; + try { + result = await kernel.execute(code, { + signal: options?.signal, + timeoutMs: options?.timeout, + onChunk: (text) => { + writer.write(sanitizeText(text)); + }, + onDisplay: (output) => { + displayOutputs.push(output); + }, + }); + } catch (err) { + await writer.abort(err); + throw err; + } finally { + await writer.close().catch(() => {}); + } if (result.cancelled) { const secs = options?.timeout ? Math.round(options.timeout / 1000) : undefined; @@ -263,8 +270,6 @@ async function executeWithKernel( const error = err instanceof Error ? err : new Error(String(err)); logger.error("Python execution failed", { error: error.message }); throw error; - } finally { - await writer.close(); } } diff --git a/packages/coding-agent/src/core/python-gateway-coordinator.ts b/packages/coding-agent/src/core/python-gateway-coordinator.ts index 4b26ad7fe..3354cea21 100644 --- a/packages/coding-agent/src/core/python-gateway-coordinator.ts +++ b/packages/coding-agent/src/core/python-gateway-coordinator.ts @@ -13,11 +13,11 @@ import { } from "node:fs"; import { createServer } from "node:net"; import { delimiter, join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import type { Subprocess } from "bun"; import { getAgentDir } from "../config"; import { getShellConfig, killProcessTree } from "../utils/shell"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; -import { logger } from "./logger"; const GATEWAY_DIR_NAME = "python-gateway"; const GATEWAY_INFO_FILE = "gateway.json"; @@ -213,27 +213,28 @@ async function resolvePythonRuntime(cwd: string, baseEnv: Record { - return await new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address && typeof address === "object") { - const port = address.port; - server.close((err: Error | null | undefined) => { - if (err) { - reject(err); - } else { - resolve(port); - } - }); - } else { - server.close(); - reject(new Error("Failed to allocate port")); - } - }); + const { promise, resolve, reject } = Promise.withResolvers(); + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + server.close((err: Error | null | undefined) => { + if (err) { + reject(err); + } else { + resolve(port); + } + }); + } else { + server.close(); + reject(new Error("Failed to allocate port")); + } }); + + return promise; } function getGatewayDir(): string { @@ -587,7 +588,7 @@ async function startGatewayProcess( await Bun.sleep(100); } - killProcessTree(gatewayProcess.pid); + await killProcessTree(gatewayProcess.pid); throw new Error("Gateway startup timeout"); } @@ -613,10 +614,10 @@ function scheduleIdleShutdown(): void { } logger.debug("Shutting down idle shared gateway", { pid: info.pid }); if (localGatewayProcess) { - shutdownLocalGateway(); + await shutdownLocalGateway(); } else if (isPidRunning(info.pid)) { try { - killProcessTree(info.pid); + await killProcessTree(info.pid); } catch (err) { logger.warn("Failed to kill idle shared gateway", { error: err instanceof Error ? err.message : String(err), @@ -644,10 +645,10 @@ function cancelIdleShutdown(): void { } } -function shutdownLocalGateway(): void { +async function shutdownLocalGateway(): Promise { if (localGatewayProcess) { try { - killProcessTree(localGatewayProcess.pid); + await killProcessTree(localGatewayProcess.pid); } catch (err) { logger.warn("Failed to kill shared gateway process", { error: err instanceof Error ? err.message : String(err), @@ -701,7 +702,7 @@ export async function acquireSharedGateway(cwd: string): Promise { error: err instanceof Error ? err.message : String(err), }); } finally { - shutdownLocalGateway(); + await shutdownLocalGateway(); isCoordinatorInitialized = false; } } diff --git a/packages/coding-agent/src/core/python-kernel.ts b/packages/coding-agent/src/core/python-kernel.ts index db32d387e..3fc3160d4 100644 --- a/packages/coding-agent/src/core/python-kernel.ts +++ b/packages/coding-agent/src/core/python-kernel.ts @@ -1,15 +1,14 @@ import { createServer } from "node:net"; import { delimiter, join } from "node:path"; -import type { Subprocess } from "bun"; +import { logger } from "@oh-my-pi/pi-utils"; +import { $, type Subprocess } from "bun"; import { nanoid } from "nanoid"; import { getShellConfig, killProcessTree } from "../utils/shell"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; -import { logger } from "./logger"; import { acquireSharedGateway, releaseSharedGateway } from "./python-gateway-coordinator"; import { loadPythonModules } from "./python-modules"; import { PYTHON_PRELUDE } from "./python-prelude"; import { htmlToBasicMarkdown } from "./tools/web-scrapers/types"; -import { ScopeSignal } from "./utils"; const TEXT_ENCODER = new TextEncoder(); const TEXT_DECODER = new TextDecoder(); @@ -285,14 +284,9 @@ export async function checkPythonKernelAvailability(cwd: string): Promise { - return await new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address && typeof address === "object") { - const port = address.port; - server.close((err: Error | null | undefined) => { - if (err) { - reject(err); - } else { - resolve(port); - } - }); - } else { - server.close(); - reject(new Error("Failed to allocate port")); - } - }); + const { promise, resolve, reject } = Promise.withResolvers(); + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + server.close((err: Error | null | undefined) => { + if (err) { + reject(err); + } else { + resolve(port); + } + }); + } else { + server.close(); + reject(new Error("Failed to allocate port")); + } }); + + return promise; } function normalizeDisplayText(text: string): string { @@ -681,7 +676,7 @@ export class PythonKernel { if (gatewayProcess && gatewayUrl) break; - killProcessTree(candidateProcess.pid); + await killProcessTree(candidateProcess.pid); lastError = exited ? "Kernel gateway process exited during startup" : "Kernel gateway failed to start"; } @@ -696,7 +691,7 @@ export class PythonKernel { }); if (!createResponse.ok) { - killProcessTree(gatewayProcess.pid); + await killProcessTree(gatewayProcess.pid); throw new Error(`Failed to create kernel: ${await createResponse.text()}`); } @@ -727,83 +722,84 @@ export class PythonKernel { wsUrl += `?token=${encodeURIComponent(this.#authToken)}`; } - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl); - ws.binaryType = "arraybuffer"; - let settled = false; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + let settled = false; - const timeout = setTimeout(() => { - ws.close(); - if (!settled) { - settled = true; - reject(new Error("WebSocket connection timeout")); - } - }, 10000); + const timeout = setTimeout(() => { + ws.close(); + if (!settled) { + settled = true; + reject(new Error("WebSocket connection timeout")); + } + }, 10000); - ws.onopen = () => { - if (settled) return; + ws.onopen = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + this.#ws = ws; + resolve(); + }; + + ws.onerror = (event) => { + const error = new Error(`WebSocket error: ${event}`); + if (!settled) { settled = true; clearTimeout(timeout); - this.#ws = ws; - resolve(); - }; + reject(error); + return; + } + this.#alive = false; + this.#ws = null; + this.abortPendingExecutions(error.message); + }; - ws.onerror = (event) => { - const error = new Error(`WebSocket error: ${event}`); - if (!settled) { - settled = true; - clearTimeout(timeout); - reject(error); + 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) => { + let msg: JupyterMessage | null = null; + if (event.data instanceof ArrayBuffer) { + msg = deserializeWebSocketMessage(event.data); + } else if (typeof event.data === "string") { + try { + msg = JSON.parse(event.data) as JupyterMessage; + } catch { return; } - this.#alive = false; - this.#ws = null; - this.abortPendingExecutions(error.message); - }; + } + if (!msg) return; - 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"); - }; + if (TRACE_IPC) { + logger.debug("Kernel IPC recv", { channel: msg.channel, msgType: msg.header.msg_type }); + } - ws.onmessage = (event) => { - let msg: JupyterMessage | null = null; - if (event.data instanceof ArrayBuffer) { - msg = deserializeWebSocketMessage(event.data); - } else if (typeof event.data === "string") { - try { - msg = JSON.parse(event.data) as JupyterMessage; - } catch { - return; - } - } - if (!msg) return; + const parentId = (msg.parent_header as { msg_id?: string }).msg_id; + if (parentId) { + const handler = this.#messageHandlers.get(parentId); + if (handler) handler(msg); + } - if (TRACE_IPC) { - logger.debug("Kernel IPC recv", { channel: msg.channel, msgType: msg.header.msg_type }); + const channelHandlers = this.#channelHandlers.get(msg.channel); + if (channelHandlers) { + for (const handler of channelHandlers) { + handler(msg); } + } + }; - const parentId = (msg.parent_header as { msg_id?: string }).msg_id; - if (parentId) { - const handler = this.#messageHandlers.get(parentId); - if (handler) handler(msg); - } - - const channelHandlers = this.#channelHandlers.get(msg.channel); - if (channelHandlers) { - for (const handler of channelHandlers) { - handler(msg); - } - } - }; - }); + return promise; } private abortPendingExecutions(reason: string): void { @@ -857,140 +853,163 @@ export class PythonKernel { let cancelled = false; let timedOut = false; - const executionSignal = new ScopeSignal({ signal: options?.signal, timeout: options?.timeoutMs }); + const controller = new AbortController(); + const onAbort = () => { + controller.abort(options?.signal?.reason ?? new Error("Aborted")); + }; + if (options?.signal) { + if (options.signal.aborted) { + onAbort(); + } else { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + } + const timeoutId = + typeof options?.timeoutMs === "number" && options.timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + controller.abort(new Error("Timeout")); + }, options.timeoutMs) + : undefined; - return new Promise((resolve) => { - 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 { promise, resolve } = Promise.withResolvers(); - const checkDone = () => { - if (replyReceived && idleReceived) { - finalize(); - } - }; + let resolved = false; + const finalize = () => { + if (resolved) return; + resolved = true; + this.#messageHandlers.delete(msgId); + this.#pendingExecutions.delete(msgId); + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", onAbort); + } + resolve({ status, executionCount, error, cancelled, timedOut, stdinRequested }); + }; - const cancelFromClose = (reason: string) => { - if (resolved) return; - cancelled = true; - timedOut = false; - if (options?.onChunk) { - void options.onChunk(`[kernel] ${reason}\n`); - } + const checkDone = () => { + if (replyReceived && idleReceived) { finalize(); - }; + } + }; - this.#pendingExecutions.set(msgId, cancelFromClose); + const cancelFromClose = (reason: string) => { + if (resolved) return; + cancelled = true; + timedOut = false; + if (options?.onChunk) { + void options.onChunk(`[kernel] ${reason}\n`); + } + finalize(); + }; - executionSignal.catch(async () => { - cancelled = true; - timedOut = executionSignal.timedOut(); + this.#pendingExecutions.set(msgId, cancelFromClose); + + const onExecutionAbort = () => { + cancelled = true; + void (async () => { try { await this.interrupt(); } finally { finalize(); } - }); + })(); + }; + controller.signal.addEventListener("abort", onExecutionAbort, { once: true }); - if (executionSignal.aborted) { - cancelFromClose("Execution aborted"); - return; - } + if (controller.signal.aborted) { + cancelFromClose("Execution aborted"); + return promise; + } - this.#messageHandlers.set(msgId, async (response) => { - switch (response.header.msg_type) { - case "execute_reply": { - replyReceived = true; - const replyStatus = response.content.status; - status = replyStatus === "error" ? "error" : "ok"; - if (typeof response.content.execution_count === "number") { - executionCount = response.content.execution_count; - } - checkDone(); - break; - } - case "stream": { - const text = String(response.content.text ?? ""); - if (text && options?.onChunk) { - await options.onChunk(text); - } - break; - } - case "execute_result": - case "display_data": { - const { text, outputs } = this.renderDisplay(response.content); - if (text && options?.onChunk) { - await options.onChunk(text); - } - if (outputs.length > 0 && options?.onDisplay) { - for (const output of outputs) { - await options.onDisplay(output); - } - } - break; - } - case "error": { - const traceback = Array.isArray(response.content.traceback) - ? response.content.traceback.map((line: unknown) => String(line)) - : []; - error = { - name: String(response.content.ename ?? "Error"), - value: String(response.content.evalue ?? ""), - traceback, - }; - const text = traceback.length > 0 ? `${traceback.join("\n")}\n` : `${error.name}: ${error.value}\n`; - if (options?.onChunk) { - await options.onChunk(text); - } - break; - } - case "status": { - const state = response.content.execution_state; - if (state === "idle") { - idleReceived = true; - checkDone(); - } - break; - } - case "input_request": { - stdinRequested = true; - if (options?.onChunk) { - await options.onChunk( - "[stdin] Kernel requested input. Interactive stdin is not supported; provide input programmatically.\n", - ); - } - this.sendMessage({ - channel: "stdin", - header: { - msg_id: nanoid(), - session: this.sessionId, - username: this.username, - date: new Date().toISOString(), - msg_type: "input_reply", - version: "5.5", - }, - parent_header: response.header as unknown as Record, - metadata: {}, - content: { value: "" }, - }); - break; + this.#messageHandlers.set(msgId, async (response) => { + switch (response.header.msg_type) { + case "execute_reply": { + replyReceived = true; + const replyStatus = response.content.status; + status = replyStatus === "error" ? "error" : "ok"; + if (typeof response.content.execution_count === "number") { + executionCount = response.content.execution_count; } + checkDone(); + break; + } + case "stream": { + const text = String(response.content.text ?? ""); + if (text && options?.onChunk) { + await options.onChunk(text); + } + break; + } + case "execute_result": + case "display_data": { + const { text, outputs } = this.renderDisplay(response.content); + if (text && options?.onChunk) { + await options.onChunk(text); + } + if (outputs.length > 0 && options?.onDisplay) { + for (const output of outputs) { + await options.onDisplay(output); + } + } + break; + } + case "error": { + const traceback = Array.isArray(response.content.traceback) + ? response.content.traceback.map((line: unknown) => String(line)) + : []; + error = { + name: String(response.content.ename ?? "Error"), + value: String(response.content.evalue ?? ""), + traceback, + }; + const text = traceback.length > 0 ? `${traceback.join("\n")}\n` : `${error.name}: ${error.value}\n`; + if (options?.onChunk) { + await options.onChunk(text); + } + break; + } + case "status": { + const state = response.content.execution_state; + if (state === "idle") { + idleReceived = true; + checkDone(); + } + break; + } + case "input_request": { + stdinRequested = true; + if (options?.onChunk) { + await options.onChunk( + "[stdin] Kernel requested input. Interactive stdin is not supported; provide input programmatically.\n", + ); + } + this.sendMessage({ + channel: "stdin", + header: { + msg_id: nanoid(), + session: this.sessionId, + username: this.username, + date: new Date().toISOString(), + msg_type: "input_reply", + version: "5.5", + }, + parent_header: response.header as unknown as Record, + metadata: {}, + content: { value: "" }, + }); + break; } - }); - - try { - this.sendMessage(msg); - } catch { - cancelled = true; - finalize(); } }); + + try { + this.sendMessage(msg); + } catch { + cancelled = true; + finalize(); + } + return promise; } async introspectPrelude(): Promise { @@ -1079,7 +1098,7 @@ export class PythonKernel { await releaseSharedGateway(); } else if (this.gatewayProcess) { try { - killProcessTree(this.gatewayProcess.pid); + await killProcessTree(this.gatewayProcess.pid); } catch (err: unknown) { logger.warn("Failed to terminate gateway process", { error: err instanceof Error ? err.message : String(err), diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index b0a966ccf..5a6c26ebb 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -30,14 +30,14 @@ import { join } from "node:path"; import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import chalk from "chalk"; // Import discovery to register all providers on startup -import "../discovery"; +import { logger, postmortem } from "@oh-my-pi/pi-utils"; +import chalk from "chalk"; import { loadCapability } from "../capability/index"; import { type Rule, ruleCapability } from "../capability/rule"; import { getAgentDir, getConfigDirPaths } from "../config"; +import "../discovery"; import { initializeWithSettings } from "../discovery"; -import { registerAsyncCleanup } from "../modes/cleanup"; import { AgentSession } from "./agent-session"; import { AuthStorage } from "./auth-storage"; import { CursorExecHandlers } from "./cursor/exec-bridge"; @@ -52,15 +52,14 @@ import { type ExtensionContext, type ExtensionFactory, ExtensionRunner, + ExtensionToolWrapper, type ExtensionUIContext, type LoadExtensionsResult, loadExtensionFromFactory, loadExtensions, type ToolDefinition, wrapRegisteredTools, - wrapToolWithExtensions, } from "./extensions/index"; -import { logger } from "./logger"; import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp/index"; import { convertToLlm } from "./messages"; import { ModelRegistry } from "./model-registry"; @@ -212,12 +211,11 @@ export type { FileSlashCommand } from "./slash-commands"; export type { Tool } from "./tools/index"; export { + // Individual tool classes (for custom usage) + BashTool, // Tool classes and factories BUILTIN_TOOLS, createTools, - type ToolSession, - // Individual tool classes (for custom usage) - BashTool, EditTool, FindTool, GitTool, @@ -227,6 +225,7 @@ export { PythonTool, ReadTool, WriteTool, + type ToolSession, }; // Helper Functions @@ -441,7 +440,7 @@ async function cleanupSshResources(): Promise { function registerSshCleanup(): void { if (sshCleanupRegistered) return; sshCleanupRegistered = true; - registerAsyncCleanup(() => cleanupSshResources()); + postmortem.register("ssh-cleanup", cleanupSshResources); } let pythonCleanupRegistered = false; @@ -449,9 +448,7 @@ let pythonCleanupRegistered = false; function registerPythonCleanup(): void { if (pythonCleanupRegistered) return; pythonCleanupRegistered = true; - registerAsyncCleanup(async () => { - await disposeAllKernelSessions(); - }); + postmortem.register("python-cleanup", disposeAllKernelSessions); } function customToolToDefinition(tool: CustomTool): ToolDefinition { @@ -872,7 +869,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } if (extensionRunner) { for (const tool of toolRegistry.values()) { - toolRegistry.set(tool.name, wrapToolWithExtensions(tool, extensionRunner)); + toolRegistry.set(tool.name, new ExtensionToolWrapper(tool, extensionRunner)); } } if (model?.provider === "cursor") { diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 72c0132d6..b0af99c86 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,10 +1,10 @@ import { basename, join, resolve } from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; import { nanoid } from "nanoid"; import { getAgentDir as getDefaultAgentDir } from "../config"; import { resizeImage } from "../utils/image-resize"; -import { logger } from "./logger"; import { type BashExecutionMessage, type CustomMessage, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 44a10446e..1cea8b8b6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,11 +1,11 @@ -import { existsSync, readFileSync, renameSync } from "node:fs"; +import { rename } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { type Settings as SettingsItem, settingsCapability } from "../capability/settings"; import { getAgentDbPath, getAgentDir } from "../config"; import { loadCapability } from "../discovery"; import type { SymbolPreset } from "../modes/interactive/theme/theme"; import { AgentStorage } from "./agent-storage"; -import { logger } from "./logger"; export interface CompactionSettings { enabled?: boolean; // default: true @@ -125,6 +125,7 @@ export interface EditSettings { fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation) fuzzyThreshold?: number; // default: 0.95 (similarity threshold for fuzzy matching) patchMode?: boolean; // default: true (use codex-style apply-patch format instead of oldText/newText) + streamingAbort?: boolean; // default: false (abort streaming edit tool calls when patch preview fails) } export type { SymbolPreset }; @@ -322,7 +323,7 @@ const DEFAULT_SETTINGS: Settings = { mcp: { enableProjectConfig: true }, lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false }, python: { toolMode: "both", kernelMode: "session", sharedGateway: true }, - edit: { fuzzyMatch: true, fuzzyThreshold: 0.95 }, + edit: { fuzzyMatch: true, fuzzyThreshold: 0.95, streamingAbort: false }, ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 }, voice: { enabled: false, @@ -514,7 +515,7 @@ export class SettingsManager { */ static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise { const storage = AgentStorage.open(getAgentDbPath(agentDir)); - SettingsManager.migrateLegacySettingsFile(storage, agentDir); + await SettingsManager.migrateLegacySettingsFile(storage, agentDir); // Use capability API to load user-level settings from all providers const result = await loadCapability(settingsCapability.id, { cwd }); @@ -577,21 +578,21 @@ export class SettingsManager { return SettingsManager.migrateSettings(settings as Record); } - private static migrateLegacySettingsFile(storage: AgentStorage, agentDir: string): void { + private static async migrateLegacySettingsFile(storage: AgentStorage, agentDir: string): Promise { const settingsPath = join(agentDir, "settings.json"); - if (!existsSync(settingsPath)) return; + const settingsFile = Bun.file(settingsPath); + if (!(await settingsFile.exists())) return; if (storage.getSettings() !== null) return; try { - const content = readFileSync(settingsPath, "utf-8"); - const parsed = JSON.parse(content); + const parsed = JSON.parse(await settingsFile.text()); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return; } const migrated = SettingsManager.migrateSettings(parsed as Record); storage.saveSettings(migrated); try { - renameSync(settingsPath, `${settingsPath}.bak`); + await rename(settingsPath, `${settingsPath}.bak`); } catch (error) { logger.warn("SettingsManager failed to backup settings.json", { error: String(error) }); } @@ -1301,6 +1302,18 @@ export class SettingsManager { await this.save(); } + getEditStreamingAbort(): boolean { + return this.settings.edit?.streamingAbort ?? false; + } + + async setEditStreamingAbort(enabled: boolean): Promise { + if (!this.globalSettings.edit) { + this.globalSettings.edit = {}; + } + this.globalSettings.edit.streamingAbort = enabled; + await this.save(); + } + getNormativeRewrite(): boolean { return this.settings.normativeRewrite ?? false; } diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 00990d24a..2eac2e28d 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -1,13 +1,13 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { realpath } from "node:fs/promises"; import { basename, join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { minimatch } from "minimatch"; import { skillCapability } from "../capability/skill"; import type { SourceMeta } from "../capability/types"; import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery"; import { loadCapability } from "../discovery"; import { parseFrontmatter } from "./frontmatter"; -import { logger } from "./logger"; import type { SkillsSettings } from "./settings-manager"; // Re-export SkillFrontmatter for backward compatibility diff --git a/packages/coding-agent/src/core/ssh/connection-manager.ts b/packages/coding-agent/src/core/ssh/connection-manager.ts index bcc226b7f..207c97400 100644 --- a/packages/coding-agent/src/core/ssh/connection-manager.ts +++ b/packages/coding-agent/src/core/ssh/connection-manager.ts @@ -1,8 +1,9 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; +import { $ } from "bun"; import { CONFIG_DIR_NAME } from "../../config"; -import { logger } from "../logger"; export interface SSHConnectionTarget { name: string; @@ -107,32 +108,17 @@ function buildCommonArgs(host: SSHConnectionTarget): string[] { return args; } -function decodeOutput(buffer?: Uint8Array): string { - if (!buffer || buffer.length === 0) return ""; - return new TextDecoder().decode(buffer).trim(); +async function runSshSync(args: string[]): Promise<{ exitCode: number | null; stderr: string }> { + const result = await $`ssh ${args}`.nothrow(); + return { exitCode: result.exitCode, stderr: result.stderr.toString().trim() }; } -function runSshSync(args: string[]): { exitCode: number | null; stderr: string } { - const result = Bun.spawnSync(["ssh", ...args], { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - - return { exitCode: result.exitCode, stderr: decodeOutput(result.stderr) }; -} - -function runSshCaptureSync(args: string[]): { exitCode: number | null; stdout: string; stderr: string } { - const result = Bun.spawnSync(["ssh", ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - +async function runSshCaptureSync(args: string[]): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + const result = await $`ssh ${args}`.nothrow(); return { exitCode: result.exitCode, - stdout: decodeOutput(result.stdout), - stderr: decodeOutput(result.stderr), + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), }; } @@ -266,7 +252,7 @@ async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Pr async function probeHostInfo(host: SSHConnectionTarget): Promise { const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"'; - const result = runSshCaptureSync(buildRemoteCommand(host, command)); + const result = await runSshCaptureSync(buildRemoteCommand(host, command)); if (result.exitCode !== 0 && !result.stdout) { logger.debug("SSH host probe failed", { host: host.name, error: result.stderr }); const fallback: SSHHostInfo = { @@ -329,11 +315,11 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise { const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash"); let compatShell: SSHHostInfo["compatShell"]; if (os === "windows" && host.compat !== false) { - const bashProbe = runSshCaptureSync(buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"')); + const bashProbe = await runSshCaptureSync(buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"')); if (bashProbe.exitCode === 0 && bashProbe.stdout.includes("OMP_BASH_OK")) { compatShell = "bash"; } else { - const shProbe = runSshCaptureSync(buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"')); + const shProbe = await runSshCaptureSync(buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"')); if (shProbe.exitCode === 0 && shProbe.stdout.includes("OMP_SH_OK")) { compatShell = "sh"; } @@ -406,7 +392,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise validateKeyPermissions(host.keyPath); const target = buildSshTarget(host); - const check = runSshSync(["-O", "check", ...buildCommonArgs(host), target]); + const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]); if (check.exitCode === 0) { activeHosts.set(key, host); if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) { @@ -415,7 +401,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise return; } - const start = runSshSync(["-M", "-N", "-f", ...buildCommonArgs(host), target]); + const start = await runSshSync(["-M", "-N", "-f", ...buildCommonArgs(host), target]); if (start.exitCode !== 0) { const detail = start.stderr ? `: ${start.stderr}` : ""; throw new Error(`Failed to start SSH master for ${target}${detail}`); @@ -435,24 +421,24 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise } } -function closeConnectionInternal(host: SSHConnectionTarget): void { +async function closeConnectionInternal(host: SSHConnectionTarget): Promise { const target = buildSshTarget(host); - runSshSync(["-O", "exit", ...buildCommonArgs(host), target]); + await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]); } export async function closeConnection(hostName: string): Promise { const host = activeHosts.get(hostName); if (!host) { - closeConnectionInternal({ name: hostName, host: hostName }); + await closeConnectionInternal({ name: hostName, host: hostName }); return; } - closeConnectionInternal(host); + await closeConnectionInternal(host); activeHosts.delete(hostName); } export async function closeAllConnections(): Promise { for (const [name, host] of Array.from(activeHosts.entries())) { - closeConnectionInternal(host); + await closeConnectionInternal(host); activeHosts.delete(name); } } diff --git a/packages/coding-agent/src/core/ssh/ssh-executor.ts b/packages/coding-agent/src/core/ssh/ssh-executor.ts index 2c88e85b9..2f32ebabe 100644 --- a/packages/coding-agent/src/core/ssh/ssh-executor.ts +++ b/packages/coding-agent/src/core/ssh/ssh-executor.ts @@ -1,9 +1,5 @@ -import type { Subprocess } from "bun"; -import { killProcessTree } from "../../utils/shell"; -import { logger } from "../logger"; -import { OutputSink, pumpStream } from "../streaming-output"; -import { DEFAULT_MAX_BYTES } from "../tools/truncate"; -import { ScopeSignal } from "../utils"; +import { cspawn, logger, ptree } from "@oh-my-pi/pi-utils"; +import { OutputSink } from "../streaming-output"; import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager"; import { hasSshfs, mountRemote } from "./sshfs-mount"; @@ -59,8 +55,6 @@ export async function executeSSH( } } - using signal = new ScopeSignal(options); - let resolvedCommand = command; if (options?.compatEnabled) { const info = await ensureHostInfo(host); @@ -70,43 +64,53 @@ export async function executeSSH( logger.warn("SSH compat enabled without detected compat shell", { host: host.name }); } } - const child: Subprocess = Bun.spawn(["ssh", ...buildRemoteCommand(host, resolvedCommand)], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + + const child = cspawn(["ssh", ...buildRemoteCommand(host, resolvedCommand)], { + signal: options?.signal, + timeout: options?.timeout, }); - signal.catch(() => { - killProcessTree(child.pid); - }); + const sink = new OutputSink({ onLine: options?.onChunk }); - const sink = new OutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk); - - const writer = sink.getWriter(); try { - await Promise.all([ - pumpStream(child.stdout as ReadableStream, writer), - pumpStream(child.stderr as ReadableStream, writer), + await Promise.allSettled([ + child.stdout.pipeTo(sink.createWritable()), + child.stderr.pipeTo(sink.createWritable()), ]); } finally { - await writer.close(); + await sink.close(); } - const exitCode = await child.exited; - const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false)); - - if (signal.timedOut()) { - const secs = Math.round(options!.timeout! / 1000); + try { + await child.exited; + const exitCode = child.exitCode ?? 0; return { - exitCode: undefined, - cancelled: true, - ...sink.dump(`SSH command timed out after ${secs} seconds`), + exitCode, + cancelled: false, + ...sink.dump(), }; + } catch (err) { + if (err instanceof ptree.Exception) { + if (err instanceof ptree.TimeoutError) { + return { + exitCode: undefined, + cancelled: true, + ...sink.dump(`SSH command timed out after ${Math.round(options!.timeout! / 1000)} seconds`), + }; + } + if (err.aborted) { + return { + exitCode: undefined, + cancelled: true, + ...sink.dump(`SSH command aborted: ${err.message}`), + }; + } + return { + exitCode: err.exitCode, + cancelled: false, + ...sink.dump(`Unexpected error: ${err.message}`), + }; + } + throw err; } - - return { - exitCode: cancelled ? undefined : exitCode, - cancelled, - ...sink.dump(), - }; } diff --git a/packages/coding-agent/src/core/ssh/sshfs-mount.ts b/packages/coding-agent/src/core/ssh/sshfs-mount.ts index 37e2b9c54..324e1ac5f 100644 --- a/packages/coding-agent/src/core/ssh/sshfs-mount.ts +++ b/packages/coding-agent/src/core/ssh/sshfs-mount.ts @@ -1,8 +1,9 @@ import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; +import { $ } from "bun"; import { CONFIG_DIR_NAME } from "../../config"; -import { logger } from "../logger"; import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager"; const REMOTE_DIR = join(homedir(), CONFIG_DIR_NAME, "remote"); @@ -22,11 +23,6 @@ function ensureDir(path: string, mode = 0o700): void { } } -function decodeOutput(buffer?: Uint8Array): string { - if (!buffer || buffer.length === 0) return ""; - return new TextDecoder().decode(buffer).trim(); -} - function getMountName(host: SSHConnectionTarget): string { const raw = (host.name ?? host.host).trim(); const sanitized = raw.replace(/[^a-zA-Z0-9._-]+/g, "_"); @@ -72,24 +68,16 @@ function buildSshfsArgs(host: SSHConnectionTarget): string[] { return args; } -function unmountPath(path: string): boolean { +async function unmountPath(path: string): Promise { const fusermount = Bun.which("fusermount") ?? Bun.which("fusermount3"); if (fusermount) { - const result = Bun.spawnSync([fusermount, "-u", path], { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); + const result = await $`${fusermount} -u ${path}`.quiet().nothrow(); if (result.exitCode === 0) return true; } const umount = Bun.which("umount"); if (!umount) return false; - const result = Bun.spawnSync([umount, path], { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); + const result = await $`${umount} ${path}`.quiet().nothrow(); return result.exitCode === 0; } @@ -97,14 +85,10 @@ export function hasSshfs(): boolean { return Bun.which("sshfs") !== null; } -export function isMounted(path: string): boolean { +export async function isMounted(path: string): Promise { const mountpoint = Bun.which("mountpoint"); if (!mountpoint) return false; - const result = Bun.spawnSync([mountpoint, "-q", path], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); + const result = await $`${mountpoint} -q ${path}`.quiet().nothrow(); return result.exitCode === 0; } @@ -117,20 +101,17 @@ export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"): const mountPath = getMountPath(host); ensureDir(mountPath); - if (isMounted(mountPath)) { + if (await isMounted(mountPath)) { mountedPaths.add(mountPath); return mountPath; } const target = `${buildSshTarget(host)}:${remotePath}`; - const result = Bun.spawnSync(["sshfs", ...buildSshfsArgs(host), target, mountPath], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const args = buildSshfsArgs(host); + const result = await $`sshfs ${args} ${target} ${mountPath}`.nothrow(); if (result.exitCode !== 0) { - const detail = decodeOutput(result.stderr); + const detail = result.stderr.toString().trim(); const suffix = detail ? `: ${detail}` : ""; throw new Error(`Failed to mount ${target}${suffix}`); } @@ -141,12 +122,12 @@ export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"): export async function unmountRemote(host: SSHConnectionTarget): Promise { const mountPath = getMountPath(host); - if (!isMounted(mountPath)) { + if (!(await isMounted(mountPath))) { mountedPaths.delete(mountPath); return false; } - const success = unmountPath(mountPath); + const success = await unmountPath(mountPath); if (success) { mountedPaths.delete(mountPath); } @@ -156,7 +137,7 @@ export async function unmountRemote(host: SSHConnectionTarget): Promise export async function unmountAll(): Promise { for (const mountPath of Array.from(mountedPaths)) { - unmountPath(mountPath); + await unmountPath(mountPath); } mountedPaths.clear(); } diff --git a/packages/coding-agent/src/core/storage-migration.ts b/packages/coding-agent/src/core/storage-migration.ts index 7216cc8cf..c80eaaa1a 100644 --- a/packages/coding-agent/src/core/storage-migration.ts +++ b/packages/coding-agent/src/core/storage-migration.ts @@ -4,10 +4,10 @@ * Original JSON files are backed up to .bak and removed after successful migration. */ +import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDbPath } from "../config"; import { AgentStorage } from "./agent-storage"; import type { AuthCredential, AuthCredentialEntry, AuthStorageData } from "./auth-storage"; -import { logger } from "./logger"; import type { Settings } from "./settings-manager"; /** Paths configuration for the storage migration process. */ diff --git a/packages/coding-agent/src/core/streaming-output.ts b/packages/coding-agent/src/core/streaming-output.ts index 6c08a2263..e76d63623 100644 --- a/packages/coding-agent/src/core/streaming-output.ts +++ b/packages/coding-agent/src/core/streaming-output.ts @@ -1,154 +1,208 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; +import { sanitizeText } from "@oh-my-pi/pi-utils"; import { nanoid } from "nanoid"; -import stripAnsi from "strip-ansi"; -import { truncateTail } from "./tools/truncate"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_COLUMN } from "./tools/truncate"; -/** - * Sanitize binary output for display/storage. - * Removes characters that crash string-width or cause display issues: - * - Control characters (except tab, newline, carriage return) - * - Lone surrogates - * - Unicode Format characters (crash string-width due to a bug) - * - Characters with undefined code points - */ -export function sanitizeBinaryOutput(str: string): string { - // Use Array.from to properly iterate over code points (not code units) - // This handles surrogate pairs correctly and catches edge cases where - // codePointAt() might return undefined - return Array.from(str) - .filter((char) => { - // Filter out characters that cause string-width to crash - // This includes: - // - Unicode format characters - // - Lone surrogates (already filtered by Array.from) - // - Control chars except \t \n \r - // - Characters with undefined code points - - const code = char.codePointAt(0); - - // Skip if code point is undefined (edge case with invalid strings) - if (code === undefined) return false; - - // Allow tab, newline, carriage return - if (code === 0x09 || code === 0x0a || code === 0x0d) return true; - - // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) - if (code <= 0x1f) return false; - - // Filter out Unicode format characters - if (code >= 0xfff9 && code <= 0xfffb) return false; - - return true; - }) - .join(""); -} - -/** - * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings. - */ -export function sanitizeText(text: string): string { - return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, ""); -} - -interface OutputFileSink { - write(data: string): number | Promise; - end(): void; -} - -export function createSanitizer(): TransformStream { - const decoder = new TextDecoder(); - return new TransformStream({ - transform(chunk, controller) { - 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); - } - }, - }); -} - -export async function pumpStream(readable: ReadableStream, writer: WritableStreamDefaultWriter) { - const reader = readable.pipeThrough(createSanitizer()).getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - await writer.write(value); - } - } finally { - reader.releaseLock(); - } -} - -export interface OutputSinkDump { +export interface OutputResult { output: string; truncated: boolean; fullOutputPath?: string; } +export interface OutputSinkOptions { + allocateFilePath?: () => string; + spillThreshold?: number; + maxColumn?: number; + onLine?: (line: string) => void; + onChunk?: (chunk: string) => void; +} + +function defaultFilePathAllocator(): string { + return join(tmpdir(), `omp-${nanoid()}.log`); +} + +/** + * Line-buffered output sink with file spill support. + * + * Uses a single string buffer with line position tracking. + * When memory limit exceeded, spills ~half to file in one batch operation. + */ export class OutputSink { - private readonly stream: WritableStream; - private readonly chunks: Array<{ text: string; bytes: number }> = []; - private chunkBytes = 0; - private totalBytes = 0; - private fullOutputPath: string | undefined; - private fullOutputStream: OutputFileSink | undefined; + private buffer = ""; + private lineEnds: number[] = []; // String index after each \n - constructor( - private readonly spillThreshold: number, - private readonly maxBuffer: number, - private readonly onChunk?: (text: string) => void, - ) { - this.stream = new WritableStream({ - write: (text) => { - const bytes = Buffer.byteLength(text, "utf-8"); - this.totalBytes += bytes; + private fileSink?: Bun.FileSink; + private filePath?: string; - if (this.totalBytes > this.spillThreshold && !this.fullOutputPath) { - this.fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`); - const stream = Bun.file(this.fullOutputPath).writer(); - for (const chunk of this.chunks) { - stream.write(chunk.text); - } - this.fullOutputStream = stream; - } - this.fullOutputStream?.write(text); + private readonly allocateFilePath: () => string; + private readonly spillThreshold: number; + private readonly maxColumn: number; + private readonly onLine?: (line: string) => void; + private readonly onChunk?: (chunk: string) => void; - this.chunks.push({ text, bytes }); - this.chunkBytes += bytes; - while (this.chunkBytes > this.maxBuffer && this.chunks.length > 1) { - const removed = this.chunks.shift(); - if (removed) { - this.chunkBytes -= removed.bytes; - } - } + constructor(options?: OutputSinkOptions) { + const { + allocateFilePath = defaultFilePathAllocator, + spillThreshold = DEFAULT_MAX_BYTES, + maxColumn = DEFAULT_MAX_COLUMN, + onLine, + onChunk, + } = options ?? {}; - this.onChunk?.(text); - }, - close: () => { - this.fullOutputStream?.end(); + this.allocateFilePath = allocateFilePath; + this.spillThreshold = spillThreshold; + this.maxColumn = maxColumn; + this.onLine = onLine; + this.onChunk = onChunk; + } + + private pushLine(line: string, term?: string): void { + while (line.length > this.maxColumn) { + this.pushLine(line.slice(0, this.maxColumn), "--\n"); + line = line.slice(this.maxColumn); + } + + this.buffer += line; + if (term) { + this.buffer += term; + } + + this.lineEnds.push(this.buffer.length); + this.onLine?.(line); + + if (this.buffer.length > this.spillThreshold) { + this.spillHalf(); + } + } + + private pushChunk(line: string): void { + this.onChunk?.(line); + this.pushLine(line); + } + + private getFileSink(): Bun.FileSink { + if (!this.fileSink) { + const filePath = this.allocateFilePath(); + this.filePath = filePath; + this.fileSink = Bun.file(filePath).writer(); + } + return this.fileSink; + } + + private spillHalf(): void { + const target = this.buffer.length >>> 1; + + // Binary search: first line ending >= target + let lo = 0; + let hi = this.lineEnds.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (this.lineEnds[mid] < target) { + lo = mid + 1; + } else { + hi = mid; + } + } + + // Clamp: evict at least 1 line, keep at least 1 line + const splitIdx = Math.max(1, Math.min(lo, this.lineEnds.length - 1)); + const splitPos = this.lineEnds[splitIdx - 1]; + + // Write evicted portion to file + this.getFileSink().write(this.buffer.slice(0, splitPos)); + + // Truncate buffer, shift line positions + this.buffer = this.buffer.slice(splitPos); + const remaining = this.lineEnds.length - splitIdx; + for (let i = 0; i < remaining; i++) { + this.lineEnds[i] = this.lineEnds[i + splitIdx] - splitPos; + } + this.lineEnds.length = remaining; + } + + createWritable(): WritableStream { + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + let buf = ""; + + const flushLines = () => { + let start = 0; + while (true) { + const nl = buf.indexOf("\n", start); + if (nl === -1) break; + this.pushChunk(buf.slice(start, nl + 1)); + start = nl + 1; + } + buf = buf.slice(start); + }; + + const finalize = () => { + buf += sanitizeText(decoder.decode()); + flushLines(); + buf = buf.trimEnd(); + if (buf) { + this.pushChunk(`${buf}\n`); + } + }; + + return new WritableStream({ + write: (chunk) => { + buf += sanitizeText(decoder.decode(chunk, { stream: true })); + flushLines(); }, + close: finalize, + abort: finalize, }); } - getWriter(): WritableStreamDefaultWriter { - return this.stream.getWriter(); + createStringWritable(): WritableStream { + let buf = ""; + + const flushLines = () => { + let start = 0; + while (true) { + const nl = buf.indexOf("\n", start); + if (nl === -1) break; + this.pushChunk(buf.slice(start, nl + 1)); + start = nl + 1; + } + buf = buf.slice(start); + }; + + const finalize = () => { + flushLines(); + buf = buf.trimEnd(); + if (buf) { + this.pushChunk(`${buf}\n`); + } + }; + + return new WritableStream({ + write: (chunk) => { + buf += sanitizeText(chunk); + flushLines(); + }, + close: finalize, + abort: finalize, + }); } - dump(annotation?: string): OutputSinkDump { + async close(): Promise { + await this.fileSink?.end(); + } + + dump(annotation?: string): OutputResult { + let output = this.buffer; if (annotation) { - const text = `\n\n${annotation}`; - this.chunks.push({ text, bytes: Buffer.byteLength(text, "utf-8") }); + output += `\n${annotation}\n`; } - const full = this.chunks.map((chunk) => chunk.text).join(""); - const { content, truncated } = truncateTail(full); - return { output: truncated ? content : full, truncated, fullOutputPath: this.fullOutputPath }; + if (!this.filePath) { + return { output, truncated: false }; + } + return { + output, + truncated: true, + fullOutputPath: this.filePath, + }; } } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index cd504f2b7..00753f37a 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -2,9 +2,10 @@ * System prompt construction and project context loading */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { $ } from "bun"; import chalk from "chalk"; import { contextFileCapability } from "../capability/context-file"; import { systemPromptCapability } from "../capability/system-prompt"; @@ -16,15 +17,6 @@ import type { SkillsSettings } from "./settings-manager"; import { loadSkills, type Skill } from "./skills"; import type { ToolName } from "./tools/index"; -/** - * Execute a git command synchronously and return stdout or null on failure. - */ -function execGit(args: string[], cwd: string): string | null { - const result = Bun.spawnSync(["git", ...args], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); - if (result.exitCode !== 0) return null; - return result.stdout.toString().trim() || null; -} - interface GitContext { isRepo: boolean; currentBranch: string; @@ -37,31 +29,36 @@ interface GitContext { * Load git context for the system prompt. * Returns structured git data or null if not in a git repo. */ -export function loadGitContext(cwd: string): GitContext | null { +export async function loadGitContext(cwd: string): Promise { + const git = (...args: string[]) => + $`git ${args}` + .cwd(cwd) + .quiet() + .text() + .catch(() => null) + .then((text) => text?.trim() ?? null); + // Check if inside a git repo - const isGitRepo = execGit(["rev-parse", "--is-inside-work-tree"], cwd); + const isGitRepo = await git("rev-parse", "--is-inside-work-tree"); if (isGitRepo !== "true") return null; // Get current branch - const currentBranch = execGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd); + const currentBranch = await git("rev-parse", "--abbrev-ref", "HEAD"); if (!currentBranch) return null; // Detect main branch (check for 'main' first, then 'master') let mainBranch = "main"; - const mainExists = execGit(["rev-parse", "--verify", "main"], cwd); + const mainExists = await git("rev-parse", "--verify", "main"); if (mainExists === null) { - const masterExists = execGit(["rev-parse", "--verify", "master"], cwd); + const masterExists = await git("rev-parse", "--verify", "master"); if (masterExists !== null) mainBranch = "master"; } // Get git status (porcelain format for parsing) - const gitStatus = execGit(["status", "--porcelain"], cwd); - const status = gitStatus?.trim() || "(clean)"; + const status = (await git("status", "--porcelain")) || "(clean)"; // Get recent commits - const recentCommits = execGit(["log", "--oneline", "-5"], cwd); - const commits = recentCommits?.trim() || "(no commits)"; - + const commits = (await git("log", "--oneline", "-5")) || "(no commits)"; return { isRepo: true, currentBranch, @@ -94,18 +91,6 @@ const toolDescriptions: Record = { report_finding: "Report a finding during code review", }; -function execCommand(args: string[]): string | null { - const result = Bun.spawnSync(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); - if (result.exitCode !== 0) return null; - const output = result.stdout.toString().trim(); - return output.length > 0 ? output : null; -} - -function execIfExists(command: string, args: string[]): string | null { - if (!Bun.which(command)) return null; - return execCommand([command, ...args]); -} - function firstNonEmpty(values: Array): string | null { for (const value of values) { const trimmed = value?.trim(); @@ -209,18 +194,27 @@ function getOsName(): string { } } -function getKernelVersion(): string { +async function getKernelVersion(): Promise { if (process.platform === "win32") { - return execCommand(["cmd", "/c", "ver"]) ?? "unknown"; + return await $`ver` + .quiet() + .text() + .catch(() => "unknown"); + } else { + return await $`uname -sr` + .quiet() + .text() + .catch(() => "unknown"); } - - return execCommand(["uname", "-sr"]) ?? "unknown"; } -function getOsDistro(): string | null { +async function getOsDistro(): Promise { switch (process.platform) { case "win32": { - const output = execIfExists("wmic", ["os", "get", "Caption,Version", "/value"]); + const output = await $`wmic os get Caption,Version /value` + .quiet() + .text() + .catch(() => null); if (!output) return null; const parsed = parseKeyValueOutput(output); const caption = parsed.Caption; @@ -229,15 +223,32 @@ function getOsDistro(): string | null { return caption ?? version ?? null; } case "darwin": { - const name = firstNonEmptyLine(execIfExists("sw_vers", ["-productName"])); - const version = firstNonEmptyLine(execIfExists("sw_vers", ["-productVersion"])); + const name = firstNonEmptyLine( + await $`sw_vers -productName` + .quiet() + .text() + .catch(() => null), + ); + const version = firstNonEmptyLine( + await $`sw_vers -productVersion` + .quiet() + .text() + .catch(() => null), + ); if (name && version) return `${name} ${version}`.trim(); return name ?? version ?? null; } case "linux": { - const lsb = firstNonEmptyLine(execIfExists("lsb_release", ["-ds"])); + const lsb = firstNonEmptyLine( + await $`lsb_release -ds` + .quiet() + .text() + .catch(() => null), + ); if (lsb) return stripQuotes(lsb); - const osRelease = execIfExists("cat", ["/etc/os-release"]); + const osRelease = await Bun.file("/etc/os-release") + .text() + .catch(() => null); if (!osRelease) return null; const parsed = parseKeyValueOutput(osRelease); const pretty = parsed.PRETTY_NAME ?? parsed.NAME; @@ -255,17 +266,28 @@ function getCpuArch(): string { return process.arch || "unknown"; } -function getCpuModel(): string | null { +async function getCpuModel(): Promise { switch (process.platform) { case "win32": { - const output = execIfExists("wmic", ["cpu", "get", "Name"]); + const output = await $`wmic cpu get Name` + .quiet() + .text() + .catch(() => null); return output ? parseWmicTable(output, "Name") : null; } case "darwin": { - return firstNonEmptyLine(execIfExists("sysctl", ["-n", "machdep.cpu.brand_string"])); + return firstNonEmptyLine( + await $`sysctl -n machdep.cpu.brand_string` + .quiet() + .text() + .catch(() => null), + ); } case "linux": { - const lscpu = execIfExists("lscpu", []); + const lscpu = await $`lscpu` + .quiet() + .text() + .catch(() => null); if (lscpu) { const match = lscpu .split("\n") @@ -273,7 +295,9 @@ function getCpuModel(): string | null { .find((line) => line.toLowerCase().startsWith("model name:")); if (match) return match.split(":").slice(1).join(":").trim(); } - const cpuInfo = execIfExists("cat", ["/proc/cpuinfo"]); + const cpuInfo = await Bun.file("/proc/cpuinfo") + .text() + .catch(() => null); if (!cpuInfo) return null; for (const line of cpuInfo.split("\n")) { const [key, ...rest] = line.split(":"); @@ -290,14 +314,20 @@ function getCpuModel(): string | null { } } -function getGpuModel(): string | null { +async function getGpuModel(): Promise { switch (process.platform) { case "win32": { - const output = execIfExists("wmic", ["path", "win32_VideoController", "get", "name"]); + const output = await $`wmic path win32_VideoController get name` + .quiet() + .text() + .catch(() => null); return output ? parseWmicTable(output, "Name") : null; } case "linux": { - const output = execIfExists("lspci", []); + const output = await $`lspci` + .quiet() + .text() + .catch(() => null); if (!output) return null; const gpus: Array<{ name: string; priority: number }> = []; for (const line of output.split("\n")) { @@ -426,39 +456,42 @@ function getSystemInfoCachePath(): string { return join(homedir(), ".omp", "system_info.json"); } -function loadSystemInfoCache(): SystemInfoCache | null { +async function loadSystemInfoCache(): Promise { try { const cachePath = getSystemInfoCachePath(); if (!existsSync(cachePath)) return null; - const content = readFileSync(cachePath, "utf-8"); - return JSON.parse(content) as SystemInfoCache; + const content = await Bun.file(cachePath).json(); + return content as SystemInfoCache; } catch { return null; } } -function saveSystemInfoCache(info: SystemInfoCache): void { +async function saveSystemInfoCache(info: SystemInfoCache): Promise { try { const cachePath = getSystemInfoCachePath(); - const dir = join(homedir(), ".omp"); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(cachePath, JSON.stringify(info, null, "\t"), "utf-8"); + await Bun.write(cachePath, JSON.stringify(info, null, "\t")); } catch { // Silently ignore cache write failures } } -function collectSystemInfo(): SystemInfoCache { +async function collectSystemInfo(): Promise { + const [distro, cpu, gpu, disk, kernel] = await Promise.all([ + getOsDistro(), + getCpuModel(), + getGpuModel(), + getDiskInfo(), + getKernelVersion(), + ]); return { os: getOsName(), - distro: getOsDistro() ?? "unknown", - kernel: getKernelVersion(), + distro: distro ?? "unknown", + kernel: kernel ?? "unknown", arch: getCpuArch(), - cpu: getCpuModel() ?? "unknown", - gpu: getGpuModel() ?? "unknown", - disk: getDiskInfo() ?? "unknown", + cpu: cpu ?? "unknown", + gpu: gpu ?? "unknown", + disk: disk ?? "unknown", }; } @@ -470,10 +503,13 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)}TB`; } -function getDiskInfo(): string | null { +async function getDiskInfo(): Promise { switch (process.platform) { case "win32": { - const output = execIfExists("wmic", ["logicaldisk", "get", "Caption,Size,FreeSpace", "/format:csv"]); + const output = await $`wmic logicaldisk get Caption,Size,FreeSpace /format:csv` + .quiet() + .text() + .catch(() => null); if (!output) return null; const lines = output.split("\n").filter((l) => l.trim() && !l.startsWith("Node")); const disks: string[] = []; @@ -492,7 +528,10 @@ function getDiskInfo(): string | null { } case "linux": case "darwin": { - const output = execIfExists("df", ["-h", "/"]); + const output = await $`df -h /` + .quiet() + .text() + .catch(() => null); if (!output) return null; const lines = output.split("\n"); if (lines.length < 2) return null; @@ -508,12 +547,12 @@ function getDiskInfo(): string | null { } } -function getEnvironmentInfo(): Array<{ label: string; value: string }> { +async function getEnvironmentInfo(): Promise> { // Load cached system info or collect fresh - let sysInfo = loadSystemInfoCache(); + let sysInfo = await loadSystemInfoCache(); if (!sysInfo) { - sysInfo = collectSystemInfo(); - saveSystemInfoCache(sysInfo); + sysInfo = await collectSystemInfo(); + await saveSystemInfoCache(sysInfo); } return [ @@ -532,14 +571,15 @@ function getEnvironmentInfo(): Array<{ label: string; value: string }> { } /** Resolve input as file path or literal string */ -export function resolvePromptInput(input: string | undefined, description: string): string | undefined { +export async function resolvePromptInput(input: string | undefined, description: string): Promise { if (!input) { return undefined; } - if (existsSync(input)) { + const file = Bun.file(input); + if (await file.exists()) { try { - return readFileSync(input, "utf-8"); + return await file.text(); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; @@ -649,8 +689,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): rules, } = options; const resolvedCwd = cwd ?? process.cwd(); - const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); - const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); + const resolvedCustomPrompt = await resolvePromptInput(customPrompt, "system prompt"); + const resolvedAppendPrompt = await resolvePromptInput(appendSystemPrompt, "append system prompt"); // Load SYSTEM.md customization (prepended to prompt) const systemPromptCustomization = await loadSystemPromptFiles({ cwd: resolvedCwd }); @@ -697,7 +737,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): (skillsSettings?.enabled !== false ? (await loadSkills({ ...skillsSettings, cwd: resolvedCwd })).skills : []); // Get git context - const git = loadGitContext(resolvedCwd); + const git = await loadGitContext(resolvedCwd); // Filter skills to only include those with read tool const hasRead = tools?.has("read"); @@ -722,7 +762,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): return renderPromptTemplate(systemPromptTemplate, { tools: toolNamesArray, toolDescriptions: toolDescriptionsArray, - environment: getEnvironmentInfo(), + environment: await getEnvironmentInfo(), systemPromptCustomization: systemPromptCustomization ?? "", contextFiles, agentsMdSearch, diff --git a/packages/coding-agent/src/core/title-generator.ts b/packages/coding-agent/src/core/title-generator.ts index 642f1a484..7046a4c08 100644 --- a/packages/coding-agent/src/core/title-generator.ts +++ b/packages/coding-agent/src/core/title-generator.ts @@ -4,8 +4,8 @@ import type { Api, Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" }; -import { logger } from "./logger"; import type { ModelRegistry } from "./model-registry"; import { parseModelString, SMOL_MODEL_PRIORITY } from "./model-resolver"; import { renderPromptTemplate } from "./prompt-templates"; diff --git a/packages/coding-agent/src/core/tools/ask.ts b/packages/coding-agent/src/core/tools/ask.ts index d767ec8d5..5e851d002 100644 --- a/packages/coding-agent/src/core/tools/ask.ts +++ b/packages/coding-agent/src/core/tools/ask.ts @@ -24,7 +24,7 @@ import askDescription from "../../prompts/tools/ask.md" with { type: "text" }; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; import type { ToolSession } from "./index"; -import { createToolUIKit } from "./render-utils"; +import { ToolUIKit } from "./render-utils"; // ============================================================================= // Types @@ -324,7 +324,7 @@ interface AskRenderArgs { export const askToolRenderer = { renderCall(args: AskRenderArgs, uiTheme: Theme): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const label = ui.title("Ask"); // Multi-part questions diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index ae98dc34c..4da3bf38c 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -12,7 +12,7 @@ import { renderPromptTemplate } from "../prompt-templates"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; -import { createToolUIKit } from "./render-utils"; +import { ToolUIKit } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; export const BASH_DEFAULT_PREVIEW_LINES = 10; @@ -196,7 +196,7 @@ export const BASH_PREVIEW_LINES = 10; export const bashToolRenderer = { renderCall(args: BashRenderArgs, uiTheme: Theme): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const command = args.command || uiTheme.format.ellipsis; const prompt = uiTheme.fg("accent", "$"); const cwd = process.cwd(); @@ -231,7 +231,7 @@ export const bashToolRenderer = { options: RenderResultOptions & { renderContext?: BashRenderContext }, uiTheme: Theme, ): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const { renderContext } = options; const details = result.details; diff --git a/packages/coding-agent/src/core/tools/calculator.ts b/packages/coding-agent/src/core/tools/calculator.ts index e81d29b36..571731063 100644 --- a/packages/coding-agent/src/core/tools/calculator.ts +++ b/packages/coding-agent/src/core/tools/calculator.ts @@ -1,12 +1,12 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { untilAborted } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme"; import calculatorDescription from "../../prompts/tools/calculator.md" with { type: "text" }; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; -import { untilAborted } from "../utils"; import type { ToolSession } from "./index"; import { formatCount, diff --git a/packages/coding-agent/src/core/tools/exa/mcp-client.ts b/packages/coding-agent/src/core/tools/exa/mcp-client.ts index 1d6f3ba0e..20b323af0 100644 --- a/packages/coding-agent/src/core/tools/exa/mcp-client.ts +++ b/packages/coding-agent/src/core/tools/exa/mcp-client.ts @@ -6,9 +6,9 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; +import { logger } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import type { CustomTool, CustomToolResult } from "../../custom-tools/types"; -import { logger } from "../../logger"; import { callMCP } from "../../mcp/json-rpc"; import type { ExaRenderDetails, diff --git a/packages/coding-agent/src/core/tools/exa/render.ts b/packages/coding-agent/src/core/tools/exa/render.ts index 474700fac..0433051be 100644 --- a/packages/coding-agent/src/core/tools/exa/render.ts +++ b/packages/coding-agent/src/core/tools/exa/render.ts @@ -6,9 +6,9 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import type { Theme } from "../../../modes/interactive/theme/theme"; import type { RenderResultOptions } from "../../custom-tools/types"; -import { logger } from "../../logger"; import { formatCount, formatExpandHint, diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 6a03d49aa..d6a11b06b 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -3,6 +3,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { StringEnum } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { ptree, untilAborted } from "@oh-my-pi/pi-utils"; import type { Static } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox"; import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme"; @@ -10,10 +11,9 @@ import findDescription from "../../prompts/tools/find.md" with { type: "text" }; import { ensureTool } from "../../utils/tools-manager"; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; -import { ScopeSignal, untilAborted } from "../utils"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; -import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils"; +import { PREVIEW_LIMITS, ToolUIKit } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate"; const findSchema = Type.Object({ @@ -63,51 +63,35 @@ export interface FindToolOptions { operations?: FindOperations; } -async function captureCommandOutput( - command: string, - args: string[], - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> { - const child = Bun.spawn([command, ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); +export interface FdResult { + stdout: string; + stderr: string; + exitCode: number | null; +} - using scope = new ScopeSignal(signal ? { signal } : undefined); - scope.catch(() => { - child.kill(); - }); +/** + * Run fd command and capture output. + * + * @throws Error with message "Operation aborted" if signal is aborted + */ +export async function runFd(fdPath: string, args: string[], signal?: AbortSignal): Promise { + const child = ptree.cspawn([fdPath, ...args], { signal }); - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const stdoutDecoder = new TextDecoder(); - const stderrDecoder = new TextDecoder(); - let stdout = ""; - let stderr = ""; + let stdout: string; + try { + stdout = await child.nothrow().text(); + } catch (err) { + if (err instanceof ptree.Exception && err.aborted) { + throw new Error("Operation aborted"); + } + throw err; + } - await Promise.all([ - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - stdout += stdoutDecoder.decode(value, { stream: true }); - } - stdout += stdoutDecoder.decode(); - })(), - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += stderrDecoder.decode(value, { stream: true }); - } - stderr += stderrDecoder.decode(); - })(), - ]); - - const exitCode = await child.exited; - - return { stdout, stderr, exitCode, aborted: scope.aborted }; + return { + stdout, + stderr: child.peekStderr(), + exitCode: child.exitCode, + }; } export class FindTool implements AgentTool { @@ -263,24 +247,17 @@ export class FindTool implements AgentTool { "--absolute-path", searchPath, ]; - const { stdout: gitignoreStdout, aborted: gitignoreAborted } = await captureCommandOutput( - fdPath, - gitignoreArgs, - signal, - ); - if (gitignoreAborted) { - throw new Error("Operation aborted"); - } + const { stdout: gitignoreStdout } = await runFd(fdPath, gitignoreArgs, signal); for (const rawLine of gitignoreStdout.split("\n")) { const file = rawLine.trim(); if (!file) continue; gitignoreFiles.add(file); } } catch (err) { - if (signal?.aborted) { - throw err instanceof Error ? err : new Error("Operation aborted"); + if (err instanceof Error && err.message === "Operation aborted") { + throw err; } - // Ignore lookup errors + // Ignore other lookup errors } for (const gitignorePath of gitignoreFiles) { @@ -291,20 +268,11 @@ export class FindTool implements AgentTool { args.push(effectivePattern, searchPath); // Run fd - const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal); - - if (aborted) { - throw new Error("Operation aborted"); - } - + const { stdout, stderr, exitCode } = await runFd(fdPath, args, signal); const output = stdout.trim(); - if (exitCode !== 0) { - const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`; - // fd returns non-zero for some errors but may still have partial output - if (!output) { - throw new Error(errorMsg); - } + if (exitCode !== 0 && !output) { + throw new Error(stderr.trim() || `fd exited with code ${exitCode ?? -1}`); } if (!output) { @@ -421,7 +389,7 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS; export const findToolRenderer = { inline: true, renderCall(args: FindRenderArgs, uiTheme: Theme): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const label = ui.title("Find"); let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "*")}`; @@ -442,7 +410,7 @@ export const findToolRenderer = { { expanded }: RenderResultOptions, uiTheme: Theme, ): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const details = result.details; if (result.isError || details?.error) { diff --git a/packages/coding-agent/src/core/tools/gemini-image.ts b/packages/coding-agent/src/core/tools/gemini-image.ts index 9fb65615f..78a111b70 100644 --- a/packages/coding-agent/src/core/tools/gemini-image.ts +++ b/packages/coding-agent/src/core/tools/gemini-image.ts @@ -1,13 +1,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { StringEnum } from "@oh-my-pi/pi-ai"; +import { untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { nanoid } from "nanoid"; import geminiImageDescription from "../../prompts/tools/gemini-image.md" with { type: "text" }; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime"; import type { CustomTool } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; -import { untilAborted } from "../utils"; import { resolveReadPath } from "./path-utils"; import { getEnv } from "./web-search/auth"; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 7eda26866..56a89174c 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -3,21 +3,22 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { StringEnum } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { ptree, readLines } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; -import type { Subprocess } from "bun"; +import { $ } from "bun"; import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme"; import grepDescription from "../../prompts/tools/grep.md" with { type: "text" }; import { ensureTool } from "../../utils/tools-manager"; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; -import { ScopeSignal, untilAborted } from "../utils"; +import { untilAborted } from "../utils"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; -import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils"; +import { PREVIEW_LIMITS, ToolUIKit } from "./render-utils"; import { DEFAULT_MAX_BYTES, + DEFAULT_MAX_COLUMN, formatSize, - GREP_MAX_LINE_LENGTH, type TruncationResult, truncateHead, truncateLine, @@ -139,14 +140,9 @@ export class GrepTool implements AgentTool { // Run ripgrep against /dev/null with the pattern - this validates regex syntax // without searching any files - const proc = Bun.spawn([rgPath, "--no-config", "--quiet", "--", pattern, "/dev/null"], { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; + const result = await $`${rgPath} --no-config --quiet -- ${pattern} /dev/null`.quiet().nothrow(); + const stderr = result.stderr?.toString() ?? ""; + const exitCode = result.exitCode ?? 0; // Exit code 1 = no matches (pattern is valid), 0 = matches found // Exit code 2 = error (often regex parse error) @@ -278,17 +274,11 @@ export class GrepTool implements AgentTool { args.push("--", pattern, searchPath); - const child: Subprocess = Bun.spawn([rgPath, ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const child = ptree.cspawn([rgPath, ...args], { signal }); - let stderr = ""; let matchCount = 0; let matchLimitReached = false; let linesTruncated = false; - let aborted = false; let killedDueToLimit = false; const outputLines: string[] = []; const files = new Set(); @@ -308,49 +298,18 @@ export class GrepTool implements AgentTool { fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1); }; - const stopChild = (dueToLimit: boolean = false) => { - killedDueToLimit = dueToLimit; - child.kill(); - }; - - using signalScope = new ScopeSignal(signal ? { signal } : undefined); - signalScope.catch(() => { - aborted = true; - stopChild(); - }); - // For simple output modes (files_with_matches, count), process text directly if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") { - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - let stdout = ""; - - await Promise.all([ - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - stdout += decoder.decode(value, { stream: true }); - } - })(), - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - })(), - ]); - - const exitCode = await child.exited; - - if (aborted) { - throw new Error("Operation aborted"); - } + const stdout = await child.text().catch((x) => { + if (x instanceof ptree.Exception && x.exitCode === 1) { + return ""; + } + return Promise.reject(x); + }); + const exitCode = child.exitCode ?? 0; if (exitCode !== 0 && exitCode !== 1) { - const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`; + const errorMsg = child.peekStderr().trim() || `ripgrep exited with code ${exitCode}`; throw new Error(errorMsg); } @@ -527,57 +486,43 @@ export class GrepTool implements AgentTool { if (matchCount >= effectiveLimit) { matchLimitReached = true; - stopChild(true); + killedDueToLimit = true; + child.kill("SIGKILL"); } } }; - // Read streams using Bun's ReadableStream API - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - let stdoutBuffer = ""; - - await Promise.all([ - // Process stdout line by line - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - - stdoutBuffer += decoder.decode(value, { stream: true }); - const lines = stdoutBuffer.split("\n"); - // Keep the last incomplete line in the buffer - stdoutBuffer = lines.pop() ?? ""; - - for (const line of lines) { - await processLine(line); - } - } - // Process any remaining content - if (stdoutBuffer.trim()) { - await processLine(stdoutBuffer); - } - })(), - // Collect stderr - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - })(), - ]); - - const exitCode = await child.exited; - - if (aborted) { - throw new Error("Operation aborted"); + // Process stdout line by line + try { + for await (const line of readLines(child.stdout)) { + await processLine(line); + } + } catch (err) { + if (err instanceof ptree.Exception && err.aborted) { + throw new Error("Operation aborted"); + } + // Stream may close early if we killed due to limit - that's ok + if (!killedDueToLimit) { + throw err; + } } - if (!killedDueToLimit && exitCode !== 0 && exitCode !== 1) { - const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`; - throw new Error(errorMsg); + // Wait for process to exit + try { + await child.exited; + } catch (err) { + if (err instanceof ptree.Exception) { + if (err.aborted) { + throw new Error("Operation aborted"); + } + // Non-zero exit is ok if we killed due to limit or exit code 1 (no matches) + if (!killedDueToLimit && err.exitCode !== 1) { + const errorMsg = child.peekStderr().trim() || `ripgrep exited with code ${err.exitCode}`; + throw new Error(errorMsg); + } + } else { + throw err; + } } if (matchCount === 0) { @@ -639,7 +584,7 @@ export class GrepTool implements AgentTool { } if (linesTruncated) { - notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`); + notices.push(`Some lines truncated to ${DEFAULT_MAX_COLUMN} chars. Use read tool to see full lines`); details.linesTruncated = true; } @@ -679,7 +624,7 @@ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; export const grepToolRenderer = { inline: true, renderCall(args: GrepRenderArgs, uiTheme: Theme): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const label = ui.title("Grep"); let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "?")}`; @@ -708,7 +653,7 @@ export const grepToolRenderer = { { expanded }: RenderResultOptions, uiTheme: Theme, ): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const details = result.details; if (result.isError || details?.error) { diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index f5c3bac1d..ea0e57318 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -62,8 +62,8 @@ export { export { WriteTool, type WriteToolDetails } from "./write"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { logger } from "@oh-my-pi/pi-utils"; import type { EventBus } from "../event-bus"; -import { logger } from "../logger"; import { getPreludeDocs, warmPythonEnvironment } from "../python-executor"; import { checkPythonKernelAvailability } from "../python-kernel"; import type { BashInterceptorRule } from "../settings-manager"; diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index a03ac4051..7894122ee 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,10 +1,10 @@ import nodePath from "node:path"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { type Component, Text } from "@oh-my-pi/pi-tui"; +import { untilAborted } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme"; import type { RenderResultOptions } from "../custom-tools/types"; -import { untilAborted } from "../utils"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; import { diff --git a/packages/coding-agent/src/core/tools/lsp/client.ts b/packages/coding-agent/src/core/tools/lsp/client.ts index 042669f9b..7003371d9 100644 --- a/packages/coding-agent/src/core/tools/lsp/client.ts +++ b/packages/coding-agent/src/core/tools/lsp/client.ts @@ -1,5 +1,5 @@ import * as fs from "node:fs"; -import { logger } from "../../logger"; +import { logger } from "@oh-my-pi/pi-utils"; import { applyWorkspaceEdit } from "./edits"; import { getLspmuxCommand, isLspmuxSupported } from "./lspmux"; import type { @@ -711,63 +711,63 @@ export async function sendRequest( client.lastActivity = Date.now(); - return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined; - const cleanup = () => { - if (signal) { - signal.removeEventListener("abort", abortHandler); - } - }; - const abortHandler = () => { - if (client.pendingRequests.has(id)) { - client.pendingRequests.delete(id); - } - if (timeout) clearTimeout(timeout); - cleanup(); - const reason = signal?.reason instanceof Error ? signal.reason : new Error("Operation aborted"); - reject(reason); - }; - - // Set timeout - timeout = setTimeout(() => { - if (client.pendingRequests.has(id)) { - client.pendingRequests.delete(id); - const err = new Error(`LSP request ${method} timed out`); - cleanup(); - reject(err); - } - }, timeoutMs); + const { promise, resolve, reject } = Promise.withResolvers(); + let timeout: ReturnType | undefined; + const cleanup = () => { if (signal) { - signal.addEventListener("abort", abortHandler, { once: true }); - if (signal.aborted) { - abortHandler(); - return; - } + signal.removeEventListener("abort", abortHandler); } - - // Register pending request with timeout wrapper - client.pendingRequests.set(id, { - resolve: (result) => { - if (timeout) clearTimeout(timeout); - cleanup(); - resolve(result); - }, - reject: (err) => { - if (timeout) clearTimeout(timeout); - cleanup(); - reject(err); - }, - method, - }); - - // Write request - writeMessage(client.process.stdin as import("bun").FileSink, request).catch((err) => { - if (timeout) clearTimeout(timeout); + }; + const abortHandler = () => { + if (client.pendingRequests.has(id)) { client.pendingRequests.delete(id); + } + if (timeout) clearTimeout(timeout); + cleanup(); + const reason = signal?.reason instanceof Error ? signal.reason : new Error("Operation aborted"); + reject(reason); + }; + + // Set timeout + timeout = setTimeout(() => { + if (client.pendingRequests.has(id)) { + client.pendingRequests.delete(id); + const err = new Error(`LSP request ${method} timed out`); cleanup(); reject(err); - }); + } + }, timeoutMs); + if (signal) { + signal.addEventListener("abort", abortHandler, { once: true }); + if (signal.aborted) { + abortHandler(); + return; + } + } + + // Register pending request with timeout wrapper + client.pendingRequests.set(id, { + resolve: (result) => { + if (timeout) clearTimeout(timeout); + cleanup(); + resolve(result); + }, + reject: (err) => { + if (timeout) clearTimeout(timeout); + cleanup(); + reject(err); + }, + method, }); + + // Write request + writeMessage(client.process.stdin as import("bun").FileSink, request).catch((err) => { + if (timeout) clearTimeout(timeout); + client.pendingRequests.delete(id); + cleanup(); + reject(err); + }); + return promise; } /** diff --git a/packages/coding-agent/src/core/tools/lsp/clients/lsp-linter-client.ts b/packages/coding-agent/src/core/tools/lsp/clients/lsp-linter-client.ts index a55fba1f1..e983b8131 100644 --- a/packages/coding-agent/src/core/tools/lsp/clients/lsp-linter-client.ts +++ b/packages/coding-agent/src/core/tools/lsp/clients/lsp-linter-client.ts @@ -84,7 +84,7 @@ export class LspLinterClient implements LinterClient { if (diagnostics !== undefined) { return diagnostics; } - await new Promise((resolve) => setTimeout(resolve, 100)); + await Bun.sleep(100); } return client.diagnostics.get(uri) ?? []; diff --git a/packages/coding-agent/src/core/tools/lsp/config.ts b/packages/coding-agent/src/core/tools/lsp/config.ts index 3c12650bf..7df824e84 100644 --- a/packages/coding-agent/src/core/tools/lsp/config.ts +++ b/packages/coding-agent/src/core/tools/lsp/config.ts @@ -1,9 +1,9 @@ import { homedir } from "node:os"; import { basename, extname, join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { globSync } from "glob"; import { getConfigDirPaths } from "../../../config"; -import { logger } from "../../logger"; import { BiomeClient } from "./clients/biome-client"; import DEFAULTS from "./defaults.json" with { type: "json" }; import type { ServerConfig } from "./types"; diff --git a/packages/coding-agent/src/core/tools/lsp/index.ts b/packages/coding-agent/src/core/tools/lsp/index.ts index cb000f385..94a140c6f 100644 --- a/packages/coding-agent/src/core/tools/lsp/index.ts +++ b/packages/coding-agent/src/core/tools/lsp/index.ts @@ -2,12 +2,11 @@ import type { Dirent } from "node:fs"; import { existsSync, statSync } from "node:fs"; import path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import { logger, once, untilAborted } from "@oh-my-pi/pi-utils"; import type { BunFile } from "bun"; import { type Theme, theme } from "../../../modes/interactive/theme/theme"; import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" }; -import { logger } from "../../logger"; import { renderPromptTemplate } from "../../prompt-templates"; -import { once, untilAborted } from "../../utils"; import type { ToolSession } from "../index"; import { resolveToCwd } from "../path-utils"; import { @@ -57,7 +56,6 @@ import { formatLocation, formatSymbolInformation, formatWorkspaceEdit, - sleep, symbolKindToIcon, uriToFile, } from "./utils"; @@ -340,7 +338,7 @@ async function waitForDiagnostics( const diagnostics = client.diagnostics.get(uri); const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion; if (diagnostics !== undefined && versionOk) return diagnostics; - await sleep(100); + await Bun.sleep(100); } return client.diagnostics.get(uri) ?? []; } diff --git a/packages/coding-agent/src/core/tools/lsp/lspmux.ts b/packages/coding-agent/src/core/tools/lsp/lspmux.ts index 5b97a4fb1..5675facd3 100644 --- a/packages/coding-agent/src/core/tools/lsp/lspmux.ts +++ b/packages/coding-agent/src/core/tools/lsp/lspmux.ts @@ -1,7 +1,7 @@ import { homedir, platform } from "node:os"; import { join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { TOML } from "bun"; -import { logger } from "../../logger"; /** * lspmux integration for LSP server multiplexing. diff --git a/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts b/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts index dea3bff4a..3bb04dfc4 100644 --- a/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts +++ b/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts @@ -1,6 +1,6 @@ import { sendNotification, sendRequest } from "./client"; import type { Diagnostic, ExpandMacroResult, LspClient, RelatedTest, Runnable, WorkspaceEdit } from "./types"; -import { fileToUri, sleep } from "./utils"; +import { fileToUri } from "./utils"; /** * Run flycheck (cargo check) and collect diagnostics. @@ -39,7 +39,7 @@ export async function flycheck(client: LspClient, file?: string): Promise { - return Bun.sleep(ms); -} - -/** - * Check if a command exists in PATH. - */ -export async function commandExists(command: string): Promise { - return Bun.which(command) !== null; -} - /** * Truncate a string to a maximum length with ellipsis. */ diff --git a/packages/coding-agent/src/core/tools/notebook.ts b/packages/coding-agent/src/core/tools/notebook.ts index d4811bfb3..bb03cf1e2 100644 --- a/packages/coding-agent/src/core/tools/notebook.ts +++ b/packages/coding-agent/src/core/tools/notebook.ts @@ -2,11 +2,11 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { StringEnum } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme"; import type { RenderResultOptions } from "../custom-tools/types"; import type { ToolSession } from "../sdk"; -import { untilAborted } from "../utils"; import { resolveToCwd } from "./path-utils"; import { formatCount, diff --git a/packages/coding-agent/src/core/tools/patch/shared.ts b/packages/coding-agent/src/core/tools/patch/shared.ts index 29b03c894..0913b32e6 100644 --- a/packages/coding-agent/src/core/tools/patch/shared.ts +++ b/packages/coding-agent/src/core/tools/patch/shared.ts @@ -10,12 +10,11 @@ import { getLanguageFromPath, type Theme } from "../../../modes/interactive/them import type { RenderResultOptions } from "../../custom-tools/types"; import type { FileDiagnosticsResult } from "../lsp/index"; import { - createToolUIKit, formatExpandHint, formatStatusIcon, getDiffStats, shortenPath, - type ToolUIKit, + ToolUIKit, truncateDiffByHunk, } from "../render-utils"; import type { RenderCallOptions } from "../renderers"; @@ -157,7 +156,7 @@ export const editToolRenderer = { mergeCallAndResult: true, renderCall(args: EditRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const rawPath = args.file_path || args.path || ""; const filePath = shortenPath(rawPath); const editLanguage = getLanguageFromPath(rawPath) ?? "text"; @@ -190,7 +189,7 @@ export const editToolRenderer = { uiTheme: Theme, args?: EditRenderArgs, ): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const { expanded, renderContext } = options; const rawPath = args?.file_path || args?.path || ""; const filePath = shortenPath(rawPath); diff --git a/packages/coding-agent/src/core/tools/python.ts b/packages/coding-agent/src/core/tools/python.ts index 9ee61f0e0..72ee787be 100644 --- a/packages/coding-agent/src/core/tools/python.ts +++ b/packages/coding-agent/src/core/tools/python.ts @@ -13,7 +13,7 @@ import { executePython, getPreludeDocs, type PythonExecutorOptions } from "../py import type { PreludeHelper, PythonStatusEvent } from "../python-kernel"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; -import { createToolUIKit, getTreeBranch, getTreeContinuePrefix, shortenPath, truncate } from "./render-utils"; +import { getTreeBranch, getTreeContinuePrefix, shortenPath, ToolUIKit, truncate } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; export const PYTHON_DEFAULT_PREVIEW_LINES = 10; @@ -610,7 +610,7 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: export const pythonToolRenderer = { renderCall(args: PythonRenderArgs, uiTheme: Theme): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const code = args.code || uiTheme.format.ellipsis; const prompt = uiTheme.fg("accent", ">>>"); const cwd = process.cwd(); @@ -642,7 +642,7 @@ export const pythonToolRenderer = { options: RenderResultOptions & { renderContext?: PythonRenderContext }, uiTheme: Theme, ): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const { renderContext } = options; const details = result.details; diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index c51a17794..e50849a83 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -4,6 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { ptree, untilAborted } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import { CONFIG_DIR_NAME } from "../../config"; import type { Theme } from "../../modes/interactive/theme/theme"; @@ -14,7 +15,7 @@ import { ensureTool } from "../../utils/tools-manager"; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; import type { ToolSession } from "../sdk"; -import { ScopeSignal, untilAborted } from "../utils"; +import { runFd } from "./find"; import { LsTool } from "./ls"; import { resolveReadPath, resolveToCwd } from "./path-utils"; import { shortenPath, wrapBrackets } from "./render-utils"; @@ -153,53 +154,6 @@ function similarityScore(a: string, b: string): number { return 1 - distance / maxLen; } -async function captureCommandOutput( - command: string, - args: string[], - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> { - const child = Bun.spawn([command, ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - using scope = new ScopeSignal(signal ? { signal } : undefined); - scope.catch(() => { - child.kill(); - }); - - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const stdoutDecoder = new TextDecoder(); - const stderrDecoder = new TextDecoder(); - let stdout = ""; - let stderr = ""; - - await Promise.all([ - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - stdout += stdoutDecoder.decode(value, { stream: true }); - } - stdout += stdoutDecoder.decode(); - })(), - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += stderrDecoder.decode(value, { stream: true }); - } - stderr += stderrDecoder.decode(); - })(), - ]); - - const exitCode = await child.exited; - - return { stdout, stderr, exitCode, aborted: scope.aborted }; -} - async function listCandidateFiles( searchRoot: string, signal?: AbortSignal, @@ -238,10 +192,7 @@ async function listCandidateFiles( ".git", searchRoot, ]; - const { stdout, aborted } = await captureCommandOutput(fdPath, gitignoreArgs, signal); - if (aborted) { - throw new Error("Operation aborted"); - } + const { stdout } = await runFd(fdPath, gitignoreArgs, signal); const output = stdout.trim(); if (output) { const nestedGitignores = output @@ -269,17 +220,11 @@ async function listCandidateFiles( args.push(".", searchRoot); - const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal); - - if (aborted) { - throw new Error("Operation aborted"); - } - + const { stdout, stderr, exitCode } = await runFd(fdPath, args, signal); const output = stdout.trim(); if (exitCode !== 0 && !output) { - const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`; - return { files: [], truncated: false, error: errorMsg }; + return { files: [], truncated: false, error: stderr.trim() || `fd exited with code ${exitCode ?? -1}` }; } if (!output) { @@ -400,17 +345,22 @@ async function convertWithMarkitdown( return { content: "", ok: false, error: "markitdown not found (uv/pip unavailable)" }; } - const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(cmd, [filePath], signal); - - if (aborted) { - throw new Error("Operation aborted"); + const child = ptree.cspawn([cmd, filePath], { signal }); + let stdout: string; + try { + stdout = await child.nothrow().text(); + } catch (err) { + if (err instanceof ptree.Exception && err.aborted) { + throw new Error("Operation aborted"); + } + throw err; } - if (exitCode === 0 && stdout.length > 0) { + if (child.exitCode === 0 && stdout.length > 0) { return { content: stdout, ok: true }; } - return { content: "", ok: false, error: stderr.trim() || "Conversion failed" }; + return { content: "", ok: false, error: child.peekStderr().trim() || "Conversion failed" }; } const readSchema = Type.Object({ diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index bb31203a6..1ff400b4a 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -324,11 +324,6 @@ export class ToolUIKit { } } -/** @deprecated Use `new ToolUIKit(theme)` instead */ -export function createToolUIKit(theme: Theme): ToolUIKit { - return new ToolUIKit(theme); -} - interface ParsedDiagnostic { filePath: string; line: number; diff --git a/packages/coding-agent/src/core/tools/ssh.ts b/packages/coding-agent/src/core/tools/ssh.ts index 37fff3335..febeaef3f 100644 --- a/packages/coding-agent/src/core/tools/ssh.ts +++ b/packages/coding-agent/src/core/tools/ssh.ts @@ -13,7 +13,7 @@ import type { SSHHostInfo } from "../ssh/connection-manager"; import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager"; import { executeSSH } from "../ssh/ssh-executor"; import type { ToolSession } from "./index"; -import { createToolUIKit } from "./render-utils"; +import { ToolUIKit } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; const sshSchema = Type.Object({ @@ -245,7 +245,7 @@ interface SshRenderContext { export const sshToolRenderer = { renderCall(args: SshRenderArgs, uiTheme: Theme): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const host = args.host || uiTheme.format.ellipsis; const command = args.command || uiTheme.format.ellipsis; const text = ui.title(`[${host}] $ ${command}`); @@ -260,7 +260,7 @@ export const sshToolRenderer = { options: RenderResultOptions & { renderContext?: SshRenderContext }, uiTheme: Theme, ): Component { - const ui = createToolUIKit(uiTheme); + const ui = new ToolUIKit(uiTheme); const { expanded, renderContext } = options; const details = result.details; const lines: string[] = []; diff --git a/packages/coding-agent/src/core/tools/task/model-resolver.ts b/packages/coding-agent/src/core/tools/task/model-resolver.ts index 98614b950..cc2b7d8fb 100644 --- a/packages/coding-agent/src/core/tools/task/model-resolver.ts +++ b/packages/coding-agent/src/core/tools/task/model-resolver.ts @@ -11,6 +11,7 @@ * - "omp/slow" or "pi/slow" → configured slow model from settings */ +import { $ } from "bun"; import { type Settings as SettingsFile, settingsCapability } from "../../../capability/settings"; import { loadCapability } from "../../../discovery"; import type { Settings as SettingsData } from "../../settings-manager"; @@ -29,7 +30,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000; * Returns models in "provider/modelId" format. * Caches the result for performance. */ -export function getAvailableModels(): string[] { +export async function getAvailableModels(): Promise { const now = Date.now(); if (cachedModels !== null && now < cacheExpiry) { return cachedModels; @@ -37,20 +38,17 @@ export function getAvailableModels(): string[] { try { const ompCommand = resolveOmpCommand(); - const result = Bun.spawnSync([ompCommand.cmd, ...ompCommand.args, "--list-models"], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const result = await $`${ompCommand.cmd} ${ompCommand.args} --list-models`.quiet().nothrow(); + const stdout = result.stdout?.toString() ?? ""; - if (result.exitCode !== 0 || !result.stdout) { + if (result.exitCode !== 0 || !stdout.trim()) { cachedModels = []; cacheExpiry = now + CACHE_TTL_MS; return cachedModels; } // Parse output: skip header line, extract provider/model - const lines = result.stdout.toString().trim().split("\n"); + const lines = stdout.trim().split("\n"); cachedModels = lines .slice(1) // Skip header .map((line) => { @@ -151,7 +149,7 @@ export async function resolveModelPattern( return undefined; } - const models = availableModels ?? getAvailableModels(); + const models = availableModels ?? (await getAvailableModels()); if (models.length === 0) { // Fallback: return pattern as-is if we can't get available models return pattern; diff --git a/packages/coding-agent/src/core/tools/task/worker.ts b/packages/coding-agent/src/core/tools/task/worker.ts index 3bc4249c0..cfebe6cc7 100644 --- a/packages/coding-agent/src/core/tools/task/worker.ts +++ b/packages/coding-agent/src/core/tools/task/worker.ts @@ -15,19 +15,18 @@ import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" }; 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 { renderPromptTemplate } from "../../prompt-templates"; import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk"; import { SessionManager } from "../../session-manager"; import { SettingsManager } from "../../settings-manager"; -import { untilAborted } from "../../utils"; import { type LspToolDetails, lspSchema } from "../lsp/types"; import { getPythonToolDescription, type PythonToolDetails, type PythonToolParams, pythonSchema } from "../python"; import type { @@ -94,54 +93,58 @@ function callMCPToolViaParent( signal?: AbortSignal, timeoutMs = MCP_CALL_TIMEOUT_MS, ): Promise<{ content: Array<{ type: string; text?: string; [key: string]: unknown }>; isError?: boolean }> { - return new Promise((resolve, reject) => { - const callId = generateMCPCallId(); - if (signal?.aborted) { - reject(new Error("Aborted")); - return; - } + const { promise, resolve, reject } = Promise.withResolvers<{ + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + isError?: boolean; + }>(); + const callId = generateMCPCallId(); + if (signal?.aborted) { + reject(new Error("Aborted")); + return promise; + } - const timeoutId = setTimeout(() => { - pendingMCPCalls.delete(callId); - reject(new Error(`MCP call timed out after ${timeoutMs}ms`)); - }, timeoutMs); + const timeoutId = setTimeout(() => { + pendingMCPCalls.delete(callId); + reject(new Error(`MCP call timed out after ${timeoutMs}ms`)); + }, timeoutMs); - const cleanup = () => { - clearTimeout(timeoutId); - pendingMCPCalls.delete(callId); - }; + const cleanup = () => { + clearTimeout(timeoutId); + pendingMCPCalls.delete(callId); + }; - if (typeof signal?.addEventListener === "function") { - signal.addEventListener( - "abort", - () => { - cleanup(); - reject(new Error("Aborted")); - }, - { once: true }, - ); - } - - pendingMCPCalls.set(callId, { - resolve: (result) => { + if (typeof signal?.addEventListener === "function") { + signal.addEventListener( + "abort", + () => { cleanup(); - resolve(result ?? { content: [] }); + reject(new Error("Aborted")); }, - reject: (error) => { - cleanup(); - reject(error); - }, - timeoutId, - }); + { once: true }, + ); + } - postMessageSafe({ - type: "mcp_tool_call", - callId, - toolName, - params, - timeoutMs, - } as SubagentWorkerResponse); + pendingMCPCalls.set(callId, { + resolve: (result) => { + cleanup(); + resolve(result ?? { content: [] }); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + timeoutId, }); + + postMessageSafe({ + type: "mcp_tool_call", + callId, + toolName, + params, + timeoutMs, + } as SubagentWorkerResponse); + + return promise; } function callPythonToolViaParent( @@ -149,64 +152,65 @@ function callPythonToolViaParent( signal?: AbortSignal, timeoutMs?: number, ): Promise { - return new Promise((resolve, reject) => { - const callId = generatePythonCallId(); - if (signal?.aborted) { - reject(new Error("Aborted")); - return; + const { promise, resolve, reject } = Promise.withResolvers(); + const callId = generatePythonCallId(); + if (signal?.aborted) { + reject(new Error("Aborted")); + return promise; + } + + 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 = () => { + if (timeoutId) { + clearTimeout(timeoutId); } + pendingPythonCalls.delete(callId); + }; - 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 = () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - pendingPythonCalls.delete(callId); - }; - - if (typeof signal?.addEventListener === "function") { - signal.addEventListener( - "abort", - () => { - cleanup(); - sendCancel("Aborted"); - reject(new Error("Aborted")); - }, - { once: true }, - ); - } - - pendingPythonCalls.set(callId, { - resolve: (result) => { + if (typeof signal?.addEventListener === "function") { + signal.addEventListener( + "abort", + () => { cleanup(); - resolve(result ?? { content: [] }); + sendCancel("Aborted"); + reject(new Error("Aborted")); }, - reject: (error) => { - cleanup(); - reject(error); - }, - timeoutId, - }); + { once: true }, + ); + } - postMessageSafe({ - type: "python_tool_call", - callId, - params, - timeoutMs, - } as SubagentWorkerResponse); + pendingPythonCalls.set(callId, { + resolve: (result) => { + cleanup(); + resolve(result ?? { content: [] }); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + timeoutId, }); + + postMessageSafe({ + type: "python_tool_call", + callId, + params, + timeoutMs, + } as SubagentWorkerResponse); + + return promise; } function callLspToolViaParent( @@ -214,58 +218,59 @@ function callLspToolViaParent( signal?: AbortSignal, timeoutMs?: number, ): Promise { - return new Promise((resolve, reject) => { - const callId = generateLspCallId(); - if (signal?.aborted) { - reject(new Error("Aborted")); - return; + const { promise, resolve, reject } = Promise.withResolvers(); + const callId = generateLspCallId(); + if (signal?.aborted) { + reject(new Error("Aborted")); + return promise; + } + + const timeoutId = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) + ? setTimeout(() => { + pendingLspCalls.delete(callId); + reject(new Error(`LSP call timed out after ${timeoutMs}ms`)); + }, timeoutMs) + : undefined; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); } + pendingLspCalls.delete(callId); + }; - const timeoutId = - typeof timeoutMs === "number" && Number.isFinite(timeoutMs) - ? setTimeout(() => { - pendingLspCalls.delete(callId); - reject(new Error(`LSP call timed out after ${timeoutMs}ms`)); - }, timeoutMs) - : undefined; - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - pendingLspCalls.delete(callId); - }; - - if (typeof signal?.addEventListener === "function") { - signal.addEventListener( - "abort", - () => { - cleanup(); - reject(new Error("Aborted")); - }, - { once: true }, - ); - } - - pendingLspCalls.set(callId, { - resolve: (result) => { + if (typeof signal?.addEventListener === "function") { + signal.addEventListener( + "abort", + () => { cleanup(); - resolve(result ?? { content: [] }); + reject(new Error("Aborted")); }, - reject: (error) => { - cleanup(); - reject(error); - }, - timeoutId, - }); + { once: true }, + ); + } - postMessageSafe({ - type: "lsp_tool_call", - callId, - params, - timeoutMs, - } as SubagentWorkerResponse); + pendingLspCalls.set(callId, { + resolve: (result) => { + cleanup(); + resolve(result ?? { content: [] }); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + timeoutId, }); + + postMessageSafe({ + type: "lsp_tool_call", + callId, + params, + timeoutMs, + } as SubagentWorkerResponse); + + return promise; } function handleMCPToolResult(response: MCPToolCallResponse): void { diff --git a/packages/coding-agent/src/core/tools/todo-write.ts b/packages/coding-agent/src/core/tools/todo-write.ts index 60d8da95f..bdc3e1950 100644 --- a/packages/coding-agent/src/core/tools/todo-write.ts +++ b/packages/coding-agent/src/core/tools/todo-write.ts @@ -5,12 +5,12 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import { StringEnum } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import chalk from "chalk"; import type { Theme } from "../../modes/interactive/theme/theme"; import todoWriteDescription from "../../prompts/tools/todo-write.md" with { type: "text" }; import type { RenderResultOptions } from "../custom-tools/types"; -import { logger } from "../logger"; import { renderPromptTemplate } from "../prompt-templates"; import type { ToolSession } from "../sdk"; import { ensureArtifactsDir, getArtifactsDir } from "./task/artifacts"; diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index 4ac08f53a..d4839bf65 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -11,7 +11,7 @@ export const DEFAULT_MAX_LINES = 2000; export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB -export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line +export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line export interface TruncationResult { /** The truncated content */ @@ -282,7 +282,7 @@ export function truncateStringToBytesFromStart(str: string, maxBytes: number): { */ export function truncateLine( line: string, - maxChars: number = GREP_MAX_LINE_LENGTH, + maxChars: number = DEFAULT_MAX_COLUMN, ): { text: string; wasTruncated: boolean } { if (line.length <= maxChars) { return { text: line, wasTruncated: false }; diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index 47f6520e9..78deb08c7 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -1,9 +1,11 @@ +import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; +import { $ } from "bun"; import { nanoid } from "nanoid"; import { parse as parseHtml } from "node-html-parser"; import { type Theme, theme } from "../../modes/interactive/theme/theme"; @@ -69,25 +71,21 @@ const CONVERTIBLE_EXTENSIONS = new Set([ // Utilities // ============================================================================= -type SpawnSyncOptions = NonNullable[1]>; - /** * Execute a command and return stdout */ -function exec( + +async function exec( cmd: string, args: string[], options?: { timeout?: number; input?: string | Buffer }, -): { stdout: string; stderr: string; ok: boolean } { - const stdin = (options?.input ?? "ignore") as SpawnSyncOptions["stdin"]; - const result = Bun.spawnSync([cmd, ...args], { - stdin, - stdout: "pipe", - stderr: "pipe", - }); +): Promise<{ stdout: string; stderr: string; ok: boolean }> { + void options; + const result = await $`${cmd} ${args}`.quiet().nothrow(); + const decoder = new TextDecoder(); return { - stdout: result.stdout?.toString() ?? "", - stderr: result.stderr?.toString() ?? "", + stdout: result.stdout ? decoder.decode(result.stdout) : "", + stderr: result.stderr ? decoder.decode(result.stderr) : "", ok: result.exitCode === 0, }; } @@ -420,7 +418,7 @@ async function renderHtmlToText( if (lynx) { const normalizedPath = tmpFile.replace(/\\/g, "/"); const fileUrl = normalizedPath.startsWith("/") ? `file://${normalizedPath}` : `file:///${normalizedPath}`; - const result = exec("lynx", ["-dump", "-nolist", "-width", "120", fileUrl], { timeout }); + const result = await exec("lynx", ["-dump", "-nolist", "-width", "120", fileUrl], { timeout }); if (result.ok) { return { content: result.stdout, ok: true, method: "lynx" }; } @@ -429,7 +427,7 @@ async function renderHtmlToText( // Fall back to html2text (auto-install via uv/pip) const html2text = await ensureTool("html2text", true); if (html2text) { - const result = exec(html2text, [tmpFile], { timeout }); + const result = await exec(html2text, [tmpFile], { timeout }); if (result.ok) { return { content: result.stdout, ok: true, method: "html2text" }; } @@ -438,7 +436,7 @@ async function renderHtmlToText( return { content: "", ok: false, method: "none" }; } finally { try { - await Bun.$`rm ${tmpFile}`.quiet(); + await rm(tmpFile, { force: true }); } catch {} } } diff --git a/packages/coding-agent/src/core/tools/web-scrapers/types.ts b/packages/coding-agent/src/core/tools/web-scrapers/types.ts index 90818f0d8..62b3d84b4 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/types.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/types.ts @@ -158,9 +158,7 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom } } - const decoder = new TextDecoder(); - const content = decoder.decode(Buffer.concat(chunks)); - + const content = Buffer.concat(chunks).toString("utf-8"); if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) { continue; } diff --git a/packages/coding-agent/src/core/tools/web-scrapers/utils.ts b/packages/coding-agent/src/core/tools/web-scrapers/utils.ts index 29f89251d..0525d07d5 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/utils.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/utils.ts @@ -1,5 +1,7 @@ +import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; +import { $ } from "bun"; import { nanoid } from "nanoid"; import { ensureTool } from "../../../utils/tools-manager"; import { createRequestSignal } from "./types"; @@ -13,18 +15,17 @@ interface ExecResult { exitCode: number; } -type SpawnSyncOptions = NonNullable[1]>; - -function exec(cmd: string, args: string[], options?: { timeout?: number; input?: string | Buffer }): ExecResult { - const stdin = (options?.input ?? "ignore") as SpawnSyncOptions["stdin"]; - const result = Bun.spawnSync([cmd, ...args], { - stdin, - stdout: "pipe", - stderr: "pipe", - }); +async function exec( + cmd: string, + args: string[], + options?: { timeout?: number; input?: string | Buffer }, +): Promise { + void options; + const result = await $`${cmd} ${args}`.quiet().nothrow(); + const decoder = new TextDecoder(); return { - stdout: result.stdout?.toString() ?? "", - stderr: result.stderr?.toString() ?? "", + stdout: result.stdout ? decoder.decode(result.stdout) : "", + stderr: result.stderr ? decoder.decode(result.stderr) : "", ok: result.exitCode === 0, exitCode: result.exitCode ?? -1, }; @@ -71,7 +72,7 @@ export async function convertWithMarkitdown( try { await Bun.write(tmpFile, content); - const result = exec(markitdown, [tmpFile], { timeout }); + const result = await exec(markitdown, [tmpFile], { timeout }); if (!result.ok) { const stderr = result.stderr.trim(); return { @@ -83,7 +84,7 @@ export async function convertWithMarkitdown( return { content: result.stdout, ok: true }; } finally { try { - await Bun.$`rm ${tmpFile}`.quiet(); + await rm(tmpFile, { force: true }); } catch {} } } diff --git a/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts b/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts index fee3ab915..4b672ceae 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts @@ -1,6 +1,7 @@ import { unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { cspawn } from "@oh-my-pi/pi-utils"; import type { FileSink } from "bun"; import { nanoid } from "nanoid"; import { ensureTool } from "../../../utils/tools-manager"; @@ -15,12 +16,21 @@ async function exec( args: string[], options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal }, ): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> { - const proc = Bun.spawn([cmd, ...args], { - stdin: options?.input ? "pipe" : "ignore", - stdout: "pipe", - stderr: "pipe", - timeout: options?.timeout, - signal: options?.signal, + const controller = new AbortController(); + const onAbort = () => controller.abort(options?.signal?.reason ?? new Error("Aborted")); + if (options?.signal) { + if (options.signal.aborted) { + onAbort(); + } else { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + } + const timeoutId = + options?.timeout && options.timeout > 0 + ? setTimeout(() => controller.abort(new Error("Timeout")), options.timeout) + : undefined; + const proc = cspawn([cmd, ...args], { + signal: controller.signal, }); if (options?.input && proc.stdin) { @@ -37,17 +47,34 @@ async function exec( } } - const [stdout, stderr] = await Promise.all([ - (proc.stdout as ReadableStream).text(), - (proc.stderr as ReadableStream).text(), + const [stdout, stderr, exitResult] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + (async () => { + try { + await proc.exited; + return proc.exitCode ?? 0; + } catch (err) { + if (err && typeof err === "object" && "exitCode" in err) { + const exitValue = (err as { exitCode?: number }).exitCode; + if (typeof exitValue === "number") { + return exitValue; + } + } + throw err instanceof Error ? err : new Error(String(err)); + } + })(), ]); - const exitCode = await proc.exited; + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", onAbort); + } return { stdout, stderr, - ok: exitCode === 0, - exitCode, + ok: exitResult === 0, + exitCode: exitResult, }; } diff --git a/packages/coding-agent/src/core/tools/web-search/auth.ts b/packages/coding-agent/src/core/tools/web-search/auth.ts index 0ac29bf49..b21335c97 100644 --- a/packages/coding-agent/src/core/tools/web-search/auth.ts +++ b/packages/coding-agent/src/core/tools/web-search/auth.ts @@ -11,10 +11,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { buildAnthropicHeaders as buildProviderAnthropicHeaders } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDbPath, getConfigDirPaths } from "../../../config"; import { AgentStorage } from "../../agent-storage"; import type { AuthCredential, AuthCredentialEntry, AuthStorageData } from "../../auth-storage"; -import { logger } from "../../logger"; import { migrateJsonStorage } from "../../storage-migration"; import type { AnthropicAuthConfig, AnthropicOAuthCredential, ModelsJson } from "./types"; diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 25fb97d5b..1662bf5b9 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -7,13 +7,13 @@ import type { } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; +import { untilAborted } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme"; import writeDescription from "../../prompts/tools/write.md" with { type: "text" }; import type { RenderResultOptions } from "../custom-tools/types"; import { renderPromptTemplate } from "../prompt-templates"; import type { ToolSession } from "../sdk"; -import { untilAborted } from "../utils"; import { createLspWritethrough, type FileDiagnosticsResult, diff --git a/packages/coding-agent/src/core/ttsr.ts b/packages/coding-agent/src/core/ttsr.ts index 0a05439d9..05753d9e1 100644 --- a/packages/coding-agent/src/core/ttsr.ts +++ b/packages/coding-agent/src/core/ttsr.ts @@ -6,8 +6,8 @@ * injected as a system reminder, and the request is retried. */ +import { logger } from "@oh-my-pi/pi-utils"; import type { Rule } from "../capability/rule"; -import { logger } from "./logger"; import type { TtsrSettings } from "./settings-manager"; interface TtsrEntry { diff --git a/packages/coding-agent/src/core/utils.ts b/packages/coding-agent/src/core/utils.ts index f25d6d613..51f1ac80e 100644 --- a/packages/coding-agent/src/core/utils.ts +++ b/packages/coding-agent/src/core/utils.ts @@ -1,187 +1 @@ -// Utility constant for representing aborted operations -const kAbortError = new Error("Operation aborted"); - -/** - * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during - * execution, the promise is rejected with a standard error. - * - * @param signal - Optional AbortSignal to cancel the operation - * @param pr - Function returning a promise to run - * @returns Promise resolving as `pr` would, or rejecting on abort - */ -export function untilAborted(signal: AbortSignal | undefined | null, pr: () => Promise): Promise { - if (!signal) { - return pr(); - } - - if (signal.aborted) { - return Promise.reject(kAbortError); - } - - return new Promise((resolve, reject) => { - const listener = () => reject(kAbortError); - signal.addEventListener("abort", listener, { once: true }); - - signal.throwIfAborted(); - - pr() - .then(resolve, reject) - .finally(() => { - signal.removeEventListener("abort", listener); - }); - }); -} - -/** - * Memoizes a function with no arguments, calling it once and caching the result. - * - * @param fn - Function to be called once - * @returns A function that returns the cached result of `fn` - */ -export function once(fn: () => T): () => T { - let store = undefined as { value: T } | undefined; - return () => { - if (store) { - return store.value; - } - const value = fn(); - store = { value }; - return value; - }; -} - -// ScopeSignal is a cancellation/helper utility similar to AbortController but -// allows composition of an existing AbortSignal and/or a timeout. It exposes a -// simple API for cancellation observation (finally, catch). -interface ScopeSignalOptions { - signal?: AbortSignal; - timeout?: number; -} - -const kTimeoutReason = new Error("Timeout"); -const kDisposedReason = new Error("Disposed"); - -/** - * Type of signal exit (None = disposed, TimedOut = timed out, Aborted = underlying signal aborted) - */ -enum ExitReason { - None = 0, - TimedOut = 1, - Aborted = 2, -} - -/** - * ScopeSignal: composable cancellation for async work–observes an external AbortSignal and/or a timeout. - * - * Use .finally(fn) to register a one-time callback invoked on *any* exit (abort, timeout, or manual dispose). - * Use .catch(fn) to register a one-time callback invoked only on abort/timeout. - * - * Disposing ScopeSignal disables further callbacks. - */ -export class ScopeSignal implements Disposable { - #signal: AbortSignal | undefined; - #timer: NodeJS.Timeout | undefined; - #exit = undefined as ExitReason | undefined; - #onAbort: (() => void) | undefined; - #callbacks?: (() => void)[]; - #reason: unknown | undefined; - - /** - * Provides abort/timeout reason (Error or user-defined). - */ - get reason(): unknown | undefined { - return this.#reason; - } - - /** - * True if exited due to external AbortSignal or timeout. - */ - get aborted(): boolean { - return this.#exit !== undefined && this.#exit > ExitReason.None; - } - - /** - * True if this ScopeSignal timed out (not external abort). - */ - timedOut(): boolean { - return this.#exit === ExitReason.TimedOut; - } - - /** - * Create a new ScopeSignal, optionally observing an AbortSignal and/or auto-aborting after a timeout (ms). - */ - constructor(options?: ScopeSignalOptions) { - const { signal, timeout } = options ?? {}; - - if (signal?.aborted) { - this.#abort(ExitReason.Aborted, signal.reason); // Immediately abort if already-aborted - return; - } - if (timeout && timeout <= 0) { - this.#abort(ExitReason.TimedOut, kTimeoutReason); - return; - } - - // Observe external signal if provided - if (signal) { - const onAbort = () => { - this.#abort(ExitReason.Aborted, signal.reason); - }; - this.#signal = signal; - this.#onAbort = onAbort; - this.#signal.addEventListener("abort", onAbort, { once: true }); - } - - // Set up timeout if provided - if (timeout) { - this.#timer = setTimeout(() => { - this.#abort(ExitReason.TimedOut, kTimeoutReason); - }, timeout); - } - } - - /** - * Register a one-time callback invoked on any exit (abort, timeout, or manual dispose). - * Runs immediately if already exited. - */ - finally(onfinally: () => void): void { - if (this.#exit !== undefined) { - onfinally(); - return; - } - this.#callbacks ??= []; - this.#callbacks.push(onfinally); - } - - /** - * Register a one-time callback invoked only if exited due to abort/timeout (not normal disposal). - */ - catch(oncatch: (reason: unknown) => void): void { - this.finally(() => { - if (this.aborted) { - oncatch(this.reason); - } - }); - } - - /** Internal: cause exit; only first call takes effect. */ - #abort(exit: ExitReason, reason?: unknown): void { - if (this.#exit !== undefined) return; - this.#reason = reason; - clearTimeout(this.#timer); - this.#signal?.removeEventListener("abort", this.#onAbort!); - - this.#exit = exit; - - const callbacks = this.#callbacks; - this.#callbacks = undefined; - callbacks?.forEach((fn) => void fn()); - } - - /** - * Dispose: marks as normally exited (not abort/timeout); disables further callback registration. - */ - [Symbol.dispose](): void { - this.#abort(ExitReason.None, kDisposedReason); - } -} +export { abortableSleep, once, untilAborted } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/src/core/voice-controller.ts b/packages/coding-agent/src/core/voice-controller.ts index 1873b4ec1..8a5cc5096 100644 --- a/packages/coding-agent/src/core/voice-controller.ts +++ b/packages/coding-agent/src/core/voice-controller.ts @@ -1,6 +1,6 @@ +import { logger } from "@oh-my-pi/pi-utils"; import { Agent, run, setDefaultOpenAIKey } from "@openai/agents"; import { z } from "zod"; -import { logger } from "./logger"; import type { ModelRegistry } from "./model-registry"; const DEFAULT_CONTROLLER_MODEL = process.env.OMP_VOICE_CONTROLLER_MODEL ?? "gpt-4o-mini"; diff --git a/packages/coding-agent/src/core/voice-supervisor.ts b/packages/coding-agent/src/core/voice-supervisor.ts index 012c605a0..e31e368f7 100644 --- a/packages/coding-agent/src/core/voice-supervisor.ts +++ b/packages/coding-agent/src/core/voice-supervisor.ts @@ -1,3 +1,4 @@ +import { logger, ptree } from "@oh-my-pi/pi-utils"; import { RealtimeAgent, RealtimeSession, @@ -6,10 +7,8 @@ import { type TransportLayerAudio, tool, } from "@openai/agents/realtime"; -import type { Subprocess } from "bun"; import type { ReadableStreamDefaultReader as WebReadableStreamDefaultReader } from "stream/web"; import { z } from "zod"; -import { logger } from "./logger"; import type { ModelRegistry } from "./model-registry"; const DEFAULT_REALTIME_MODEL = process.env.OMP_VOICE_REALTIME_MODEL ?? "gpt-realtime"; @@ -372,9 +371,9 @@ function rms16le(buffer: Uint8Array): number { export class VoiceSupervisor { private session: RealtimeSession | undefined = undefined; - private captureProcess: Subprocess | undefined = undefined; + private captureProcess: ptree.ChildProcess | undefined = undefined; private captureReader: WebReadableStreamDefaultReader | undefined = undefined; - private playbackProcess: Subprocess | undefined = undefined; + private playbackProcess: ptree.ChildProcess | undefined = undefined; private playbackWriter: | { write: (chunk: Uint8Array) => Promise; @@ -744,15 +743,11 @@ export class VoiceSupervisor { const { command, env: captureEnv } = captureResult; logger.debug("voice-supervisor: starting mic capture", { command, env: captureEnv }); - const proc = Bun.spawn(command, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + const proc = ptree.cspawn(command, { env: captureEnv ? { ...process.env, ...captureEnv } : undefined, }); this.captureProcess = proc; - - const reader = (proc.stdout as ReadableStream).getReader(); + const reader = proc.stdout.getReader(); this.captureReader = reader; (async () => { @@ -812,7 +807,7 @@ export class VoiceSupervisor { } if (this.captureProcess) { try { - this.captureProcess.kill(); + this.captureProcess.kill("SIGINT"); } catch { // ignore } @@ -829,14 +824,10 @@ export class VoiceSupervisor { } logger.debug("voice-supervisor: starting audio playback", { command }); - const proc = Bun.spawn(command, { + const proc = ptree.cspawn(command, { stdin: "pipe", - stdout: "ignore", - stderr: "pipe", }); const startedAt = Date.now(); - const stderrBuffer = { text: "" }; - this.readStderr(proc.stderr, stderrBuffer); this.playbackProcess = proc; const stdin = proc.stdin; @@ -876,17 +867,18 @@ export class VoiceSupervisor { } proc.exited - .then((code) => { + .then(() => { + const code = proc.exitCode; if (this.playbackProcess === proc) { this.playbackProcess = undefined; this.playbackWriter = undefined; } - const trimmed = stderrBuffer.text.trim(); + const trimmed = proc.peekStderr().trim(); if (trimmed) { logger.debug("voice-supervisor: playback stderr", { stderr: trimmed }); } const elapsed = Date.now() - startedAt; - if (code !== 0 && elapsed < 2000 && this.active) { + if (code !== 0 && elapsed < 2000 && this.active && code !== null) { this.maybeWarnPlaybackFailure(trimmed || `exit code ${code}`); } }) @@ -915,25 +907,6 @@ export class VoiceSupervisor { this.playbackWriter = undefined; } - private readStderr(stderr: Subprocess["stderr"], buffer: { text: string }): void { - if (!stderr || typeof stderr === "number") return; - const reader = (stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - (async () => { - while (true) { - const { value, done } = await reader.read(); - if (done || !value) break; - buffer.text += decoder.decode(value, { stream: true }); - if (buffer.text.length > 4000) { - buffer.text = buffer.text.slice(0, 4000); - break; - } - } - })().catch(() => { - // ignore - }); - } - private maybeWarnPlaybackFailure(message: string): void { if (!this.callbacks.onWarning) return; const now = Date.now(); diff --git a/packages/coding-agent/src/core/voice.ts b/packages/coding-agent/src/core/voice.ts index 72fc96e0b..895210cbc 100644 --- a/packages/coding-agent/src/core/voice.ts +++ b/packages/coding-agent/src/core/voice.ts @@ -2,9 +2,9 @@ import { unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { completeSimple, type Model } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; import { nanoid } from "nanoid"; import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" }; -import { logger } from "./logger"; import type { ModelRegistry } from "./model-registry"; import { findSmolModel } from "./model-resolver"; import { renderPromptTemplate } from "./prompt-templates"; @@ -143,13 +143,6 @@ function buildRecordingCommand(filePath: string, sampleRate: number, channels: n return null; } -/** - * @deprecated Use `new VoiceRecording(settings)` instead. - */ -export function startVoiceRecording(settings: VoiceSettings): VoiceRecordingHandle { - return new VoiceRecording(settings); -} - export async function transcribeAudio( filePath: string, apiKey: string, diff --git a/packages/coding-agent/src/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index 70e908cee..e9fe6fcca 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -8,6 +8,7 @@ */ import { join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { parse as parseToml } from "smol-toml"; import type { ContextFile } from "../capability/context-file"; import { contextFileCapability } from "../capability/context-file"; @@ -30,7 +31,6 @@ import type { CustomTool } from "../capability/tool"; import { toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult } from "../capability/types"; import { parseFrontmatter } from "../core/frontmatter"; -import { logger } from "../core/logger"; import { createSourceMeta, discoverExtensionModulePaths, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 902582588..f2376f687 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -5,6 +5,8 @@ export { StringEnum } from "@oh-my-pi/pi-ai"; // Re-export TUI components for custom tool rendering export { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +// Logging +export { logger } from "@oh-my-pi/pi-utils"; export { getAgentDir, VERSION } from "./config"; export { AgentSession, @@ -49,7 +51,6 @@ export type { CustomCommandsLoadResult, LoadedCustomCommand, } from "./core/custom-commands/types"; -// Custom tools export type { AgentToolUpdateCallback, CustomTool, @@ -63,8 +64,8 @@ export type { LoadedCustomTool, RenderResultOptions, } from "./core/custom-tools/index"; +// Custom tools export { CustomToolLoader, discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index"; -// Extension types and utilities export type { AppAction, Extension, @@ -95,6 +96,7 @@ export type { UserBashEvent, UserBashEventResult, } from "./core/extensions/index"; +// Extension types and utilities export { discoverAndLoadExtensions, ExtensionRunner, @@ -110,8 +112,6 @@ export { // Hook system types (legacy re-export) export type * from "./core/hooks/index"; export { formatKeyHint, formatKeyHints } from "./core/keybindings"; -// Logging -export { type Logger, logger } from "./core/logger"; export { convertToLlm } from "./core/messages"; export { ModelRegistry } from "./core/model-registry"; // Prompt templates diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 56bb70421..5f94eb2bc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -9,6 +9,7 @@ import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai"; +import { postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { type Args, parseArgs, printHelp } from "./cli/args"; import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli"; @@ -31,8 +32,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 { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; @@ -352,9 +352,9 @@ async function buildSessionOptions( // Auto-discover SYSTEM.md if no CLI system prompt provided const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile(); - const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt"); + const resolvedSystemPrompt = await resolvePromptInput(systemPromptSource, "system prompt"); const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile(); - const resolvedAppendPrompt = resolvePromptInput(appendPromptSource, "append system prompt"); + const resolvedAppendPrompt = await resolvePromptInput(appendPromptSource, "append system prompt"); if (sessionManager) { options.sessionManager = sessionManager; @@ -708,7 +708,6 @@ export async function main(args: string[]) { writeStdout(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); } - installTerminalCrashHandlers(); printTimings(); await runInteractiveMode( session, @@ -734,10 +733,6 @@ export async function main(args: string[]) { }); await session.dispose(); stopThemeWatcher(); - await runAsyncCleanup(); - if (process.stdout.writableLength > 0) { - await new Promise((resolve) => process.stdout.once("drain", resolve)); - } - process.exit(0); + await postmortem.quit(0); } } diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 591fe7839..9682af27a 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -4,11 +4,11 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { getAgentDbPath, getAgentDir, getBinDir } from "./config"; import { AgentStorage } from "./core/agent-storage"; import type { AuthCredential } from "./core/auth-storage"; -import { logger } from "./core/logger"; /** * Migrate legacy oauth.json and settings.json apiKeys to agent.db. diff --git a/packages/coding-agent/src/modes/cleanup.ts b/packages/coding-agent/src/modes/cleanup.ts deleted file mode 100644 index fa74eb5ca..000000000 --- a/packages/coding-agent/src/modes/cleanup.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Async cleanup registry for graceful shutdown on signals. - */ - -/** Registry of async cleanup callbacks to run on shutdown/signals */ -const asyncCleanupCallbacks: (() => Promise)[] = []; - -/** - * Register an async cleanup callback to be run on process signals (SIGINT, SIGTERM, SIGHUP). - * Returns an unsubscribe function. - */ -export function registerAsyncCleanup(callback: () => Promise): () => void { - asyncCleanupCallbacks.push(callback); - return () => { - const index = asyncCleanupCallbacks.indexOf(callback); - if (index >= 0) asyncCleanupCallbacks.splice(index, 1); - }; -} - -/** Run all registered async cleanup callbacks, settling all promises */ -export async function runAsyncCleanup(): Promise { - await Promise.allSettled(asyncCleanupCallbacks.map((cb) => cb())); -} diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 4e681fd11..b43f6194e 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -1,48 +1,15 @@ /** * Run modes for the coding agent. */ - -import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui"; -import { runAsyncCleanup } from "./cleanup"; - -/** - * Install handlers that restore terminal state on crash/signal. - * Must be called before entering interactive mode. - */ -export function installTerminalCrashHandlers(): void { - const cleanup = () => { - emergencyTerminalRestore(); - }; - - // Signals - run async cleanup before exit - process.on("SIGINT", () => { - cleanup(); - void runAsyncCleanup().finally(() => process.exit(128 + 2)); - }); - process.on("SIGTERM", () => { - cleanup(); - void runAsyncCleanup().finally(() => process.exit(128 + 15)); - }); - process.on("SIGHUP", () => { - cleanup(); - void runAsyncCleanup().finally(() => process.exit(128 + 1)); - }); - - // Crashes - exit immediately (async cleanup may not be safe in corrupted state) - process.on("uncaughtException", (err) => { - cleanup(); - console.error("Uncaught exception:", err); - process.exit(1); - }); - process.on("unhandledRejection", (reason) => { - cleanup(); - console.error("Unhandled rejection:", reason); - process.exit(1); - }); -} - export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode"; export { type PrintModeOptions, runPrintMode } from "./print-mode"; export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client"; export { runRpcMode } from "./rpc/rpc-mode"; export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types"; + +import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui"; +import { postmortem } from "@oh-my-pi/pi-utils"; + +postmortem.register("terminal-restore", () => { + emergencyTerminalRestore(); +}); diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts b/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts index ad24eb741..99ea1557f 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts @@ -3,6 +3,7 @@ * Handles data loading, tree building, filtering, and toggle persistence. */ +import { logger } from "@oh-my-pi/pi-utils"; import type { ContextFile } from "../../../../capability/context-file"; import type { ExtensionModule } from "../../../../capability/extension-module"; import type { Hook } from "../../../../capability/hook"; @@ -13,7 +14,6 @@ import type { Skill } from "../../../../capability/skill"; import type { SlashCommand } from "../../../../capability/slash-command"; import type { CustomTool } from "../../../../capability/tool"; import type { SourceMeta } from "../../../../capability/types"; -import { logger } from "../../../../core/logger"; import { disableProvider, enableProvider, diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts index 7de125687..21059ff11 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts @@ -3,7 +3,7 @@ * Supports Ctrl+G for external editor. */ -import * as fs from "node:fs"; +import { rm } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; @@ -75,7 +75,7 @@ export class HookEditorComponent extends Container { // Ctrl+G for external editor if (matchesKey(keyData, "ctrl+g")) { - this.openExternalEditor(); + void this.openExternalEditor(); return; } @@ -83,7 +83,7 @@ export class HookEditorComponent extends Container { this.editor.handleInput(keyData); } - private openExternalEditor(): void { + private async openExternalEditor(): Promise { const editorCmd = process.env.VISUAL || process.env.EDITOR; if (!editorCmd) { return; @@ -93,21 +93,24 @@ export class HookEditorComponent extends Container { const tmpFile = path.join(os.tmpdir(), `omp-hook-editor-${nanoid()}.md`); try { - fs.writeFileSync(tmpFile, currentText, "utf-8"); + await Bun.write(tmpFile, currentText); this.tui.stop(); const [editor, ...editorArgs] = editorCmd.split(" "); - const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], { - stdio: ["inherit", "inherit", "inherit"], + const child = Bun.spawn([editor, ...editorArgs, tmpFile], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", }); + const exitCode = await child.exited; - if (result.exitCode === 0) { - const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + if (exitCode === 0) { + const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, ""); this.editor.setText(newContent); } } finally { try { - fs.unlinkSync(tmpFile); + await rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 33ec703e3..08d0b0bde 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,5 +1,6 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai"; import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { $ } from "bun"; import { theme } from "../theme/theme"; import { DynamicBorder } from "./dynamic-border"; @@ -83,9 +84,21 @@ export class LoginDialogComponent extends Container { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - // Try to open browser using Bun.spawn - const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; - Bun.spawn([openCmd, url], { stdout: "ignore", stderr: "ignore" }); + // Try to open browser using $ + const openArgs = + process.platform === "darwin" + ? ["open", url] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + const [openCmd, ...openRest] = openArgs; + void (async () => { + try { + await $`${openCmd} ${openRest}`.quiet().nothrow(); + } catch { + // Best-effort: browser opening is non-critical + } + })(); this.tui.requestRender(); } @@ -100,10 +113,10 @@ export class LoginDialogComponent extends Container { this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); this.tui.requestRender(); - return new Promise((resolve, reject) => { - this.inputResolver = resolve; - this.inputRejecter = reject; - }); + const { promise, resolve, reject } = Promise.withResolvers(); + this.inputResolver = resolve; + this.inputRejecter = reject; + return promise; } /** @@ -122,10 +135,10 @@ export class LoginDialogComponent extends Container { this.input.setValue(""); this.tui.requestRender(); - return new Promise((resolve, reject) => { - this.inputResolver = resolve; - this.inputRejecter = reject; - }); + const { promise, resolve, reject } = Promise.withResolvers(); + this.inputResolver = resolve; + this.inputRejecter = reject; + return promise; } /** diff --git a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts index 856262bbc..92618f40a 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts @@ -321,6 +321,15 @@ export const SETTINGS_DEFS: SettingDef[] = [ get: (sm) => sm.getEditPatchMode(), set: (sm, v) => sm.setEditPatchMode(v), }, + { + id: "editStreamingAbort", + tab: "tools", + type: "boolean", + label: "Edit streaming abort", + description: "Abort streaming edit tool calls when patch preview fails", + get: (sm) => sm.getEditStreamingAbort(), + set: (sm, v) => sm.setEditStreamingAbort(v), + }, { id: "readLineNumbers", tab: "tools", diff --git a/packages/coding-agent/src/modes/interactive/components/status-line.ts b/packages/coding-agent/src/modes/interactive/components/status-line.ts index 6e07eee2d..f0671194d 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-line.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-line.ts @@ -1,5 +1,6 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { $ } from "bun"; import { type FSWatcher, watch } from "fs"; import { dirname, join } from "path"; import type { AgentSession } from "../../../core/agent-session"; @@ -158,51 +159,51 @@ export class StatusLineComponent implements Component { return this.cachedGitStatus; } - try { - const result = Bun.spawnSync(["git", "status", "--porcelain"], { - stdout: "pipe", - stderr: "pipe", - }); + // Fire async fetch, return cached value + (async () => { + try { + const result = await $`git status --porcelain`.quiet().nothrow(); - if (!result.success) { + if (result.exitCode !== 0) { + this.cachedGitStatus = null; + this.gitStatusLastFetch = now; + return; + } + + const output = result.stdout.toString(); + + let staged = 0; + let unstaged = 0; + let untracked = 0; + + for (const line of output.split("\n")) { + if (!line) continue; + const x = line[0]; + const y = line[1]; + + if (x === "?" && y === "?") { + untracked++; + continue; + } + + if (x && x !== " " && x !== "?") { + staged++; + } + + if (y && y !== " ") { + unstaged++; + } + } + + this.cachedGitStatus = { staged, unstaged, untracked }; + this.gitStatusLastFetch = now; + } catch { this.cachedGitStatus = null; this.gitStatusLastFetch = now; - return null; } + })(); - const output = result.stdout.toString("utf8"); - - let staged = 0; - let unstaged = 0; - let untracked = 0; - - for (const line of output.split("\n")) { - if (!line) continue; - const x = line[0]; - const y = line[1]; - - if (x === "?" && y === "?") { - untracked++; - continue; - } - - if (x && x !== " " && x !== "?") { - staged++; - } - - if (y && y !== " ") { - unstaged++; - } - } - - this.cachedGitStatus = { staged, unstaged, untracked }; - this.gitStatusLastFetch = now; - return this.cachedGitStatus; - } catch { - this.cachedGitStatus = null; - this.gitStatusLastFetch = now; - return null; - } + return this.cachedGitStatus; } private buildSegmentContext(width: number): SegmentContext { diff --git a/packages/coding-agent/src/modes/interactive/components/todo-display.ts b/packages/coding-agent/src/modes/interactive/components/todo-display.ts index d21260ecc..1669b77df 100644 --- a/packages/coding-agent/src/modes/interactive/components/todo-display.ts +++ b/packages/coding-agent/src/modes/interactive/components/todo-display.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; import { Text } from "@oh-my-pi/pi-tui"; -import { logger } from "../../../core/logger"; +import { logger } from "@oh-my-pi/pi-utils"; import { getArtifactsDir } from "../../../core/tools/task/artifacts"; import { theme } from "../theme/theme"; import type { TodoItem } from "../types"; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 99d713297..762e63946 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -10,7 +10,7 @@ import { Text, type TUI, } from "@oh-my-pi/pi-tui"; -import { sanitizeText } from "../../../core/streaming-output"; +import { sanitizeText } from "@oh-my-pi/pi-utils"; import { BASH_DEFAULT_PREVIEW_LINES } from "../../../core/tools/bash"; import { computeEditDiff, computePatchDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/patch"; import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../../core/tools/python"; 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 963261aac..8309fa1ce 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/command-controller.ts @@ -1,7 +1,8 @@ -import * as fs from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; +import { $ } from "bun"; import { nanoid } from "nanoid"; import { getDebugLogPath } from "../../../config"; import { loadCustomShare } from "../../../core/custom-share"; @@ -22,17 +23,20 @@ export class CommandController { constructor(private readonly ctx: InteractiveModeContext) {} openInBrowser(urlOrPath: string): void { - try { - const args = - process.platform === "darwin" - ? ["open", urlOrPath] - : process.platform === "win32" - ? ["cmd", "/c", "start", "", urlOrPath] - : ["xdg-open", urlOrPath]; - Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); - } catch { - // Best-effort: browser opening is non-critical - } + const args = + process.platform === "darwin" + ? ["open", urlOrPath] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", urlOrPath] + : ["xdg-open", urlOrPath]; + const [cmd, ...cmdArgs] = args; + void (async () => { + try { + await $`${cmd} ${cmdArgs}`.quiet().nothrow(); + } catch { + // Best-effort: browser opening is non-critical + } + })(); } async handleExportCommand(text: string): Promise { @@ -69,6 +73,13 @@ export class CommandController { async handleShareCommand(): Promise { const tmpFile = path.join(os.tmpdir(), `${nanoid()}.html`); + const cleanupTempFile = async () => { + try { + await rm(tmpFile, { force: true }); + } catch { + // Ignore cleanup errors + } + }; try { await this.ctx.session.exportToHtml(tmpFile); } catch (error: unknown) { @@ -85,21 +96,17 @@ export class CommandController { this.ctx.ui.setFocus(loader); this.ctx.ui.requestRender(); - const restoreEditor = () => { + const restoreEditor = async () => { loader.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } + await cleanupTempFile(); }; try { const result = await customShare.fn(tmpFile); - restoreEditor(); + await restoreEditor(); if (typeof result === "string") { this.ctx.showStatus(`Share URL: ${result}`); @@ -115,34 +122,26 @@ export class CommandController { } return; } catch (err) { - restoreEditor(); + await restoreEditor(); this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`); return; } } } catch (err) { - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } + await cleanupTempFile(); this.ctx.showError(err instanceof Error ? err.message : String(err)); return; } try { - const authResult = Bun.spawnSync(["gh", "auth", "status"]); + const authResult = await $`gh auth status`.quiet().nothrow(); if (authResult.exitCode !== 0) { - try { - fs.unlinkSync(tmpFile); - } catch {} + await cleanupTempFile(); this.ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; } } catch { - try { - fs.unlinkSync(tmpFile); - } catch {} + await cleanupTempFile(); this.ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); return; } @@ -153,71 +152,33 @@ export class CommandController { this.ctx.ui.setFocus(loader); this.ctx.ui.requestRender(); - const restoreEditor = () => { + const restoreEditor = async () => { loader.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } + await cleanupTempFile(); }; - let proc: ReturnType | null = null; - loader.onAbort = () => { - proc?.kill(); - restoreEditor(); + void restoreEditor(); this.ctx.showStatus("Share cancelled"); }; try { - proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], { - stdout: "pipe", - stderr: "pipe", - }); - - const readStream = async (stream: ReadableStream | null): Promise => { - 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 | null), - readStream(proc.stderr as ReadableStream | null), - proc.exited.catch(() => 1), - ]); - const result = { stdout, stderr, code }; - + const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow(); if (loader.signal.aborted) return; - restoreEditor(); + await restoreEditor(); - if (result.code !== 0) { - const errorMsg = result.stderr?.trim() || "Unknown error"; + if (result.exitCode !== 0) { + const errorMsg = result.stderr.toString("utf-8").trim() || "Unknown error"; this.ctx.showError(`Failed to create gist: ${errorMsg}`); return; } - const gistUrl = result.stdout?.trim(); - const gistId = gistUrl?.split("/").pop(); + const gistUrl = result.stdout.toString("utf-8").trim(); + const gistId = gistUrl.split("/").pop(); if (!gistId) { this.ctx.showError("Failed to parse gist ID from gh output"); return; @@ -228,7 +189,7 @@ export class CommandController { this.openInBrowser(previewUrl); } catch (error: unknown) { if (!loader.signal.aborted) { - restoreEditor(); + await restoreEditor(); this.ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); } } @@ -420,7 +381,7 @@ export class CommandController { this.ctx.ui.requestRender(); } - handleDebugCommand(): void { + async handleDebugCommand(): Promise { const width = this.ctx.ui.terminal.columns; const allLines = this.ctx.ui.render(width); @@ -442,8 +403,13 @@ export class CommandController { "", ].join("\n"); - fs.mkdirSync(path.dirname(debugLogPath), { recursive: true }); - fs.writeFileSync(debugLogPath, debugData); + try { + await mkdir(path.dirname(debugLogPath), { recursive: true }); + await Bun.write(debugLogPath, debugData); + } catch (error) { + this.ctx.showError(`Failed to write debug log: ${error instanceof Error ? error.message : String(error)}`); + return; + } this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild( @@ -519,7 +485,7 @@ export class CommandController { async handleSkillCommand(skillPath: string, args: string): Promise { try { - const content = fs.readFileSync(skillPath, "utf-8"); + const content = await Bun.file(skillPath).text(); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); const metaLines = [`Skill: ${skillPath}`]; if (args) { diff --git a/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts index 1132a1bab..c8369328c 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts @@ -1,5 +1,6 @@ import type { Component, TUI } from "@oh-my-pi/pi-tui"; import { Spacer, Text } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import type { ExtensionActions, ExtensionCommandContextActions, @@ -8,7 +9,6 @@ import type { ExtensionUIContext, } from "../../../core/extensions/index"; import { KeybindingsManager } from "../../../core/keybindings"; -import { logger } from "../../../core/logger"; import { setTerminalTitle } from "../../../core/title-generator"; import { HookEditorComponent } from "../components/hook-editor"; import { HookInputComponent } from "../components/hook-input"; @@ -483,26 +483,26 @@ export class ExtensionUiController { * Show a selector for hooks. */ showHookSelector(title: string, options: string[], initialIndex?: number): Promise { - return new Promise((resolve) => { - this.ctx.hookSelector = new HookSelectorComponent( - title, - options, - (option) => { - this.hideHookSelector(); - resolve(option); - }, - () => { - this.hideHookSelector(); - resolve(undefined); - }, - { initialIndex }, - ); + const { promise, resolve } = Promise.withResolvers(); + this.ctx.hookSelector = new HookSelectorComponent( + title, + options, + (option) => { + this.hideHookSelector(); + resolve(option); + }, + () => { + this.hideHookSelector(); + resolve(undefined); + }, + { initialIndex }, + ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookSelector); - this.ctx.ui.setFocus(this.ctx.hookSelector); - this.ctx.ui.requestRender(); - }); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookSelector); + this.ctx.ui.setFocus(this.ctx.hookSelector); + this.ctx.ui.requestRender(); + return promise; } /** @@ -528,25 +528,25 @@ export class ExtensionUiController { * Show a text input for hooks. */ showHookInput(title: string, placeholder?: string): Promise { - return new Promise((resolve) => { - this.ctx.hookInput = new HookInputComponent( - title, - placeholder, - (value) => { - this.hideHookInput(); - resolve(value); - }, - () => { - this.hideHookInput(); - resolve(undefined); - }, - ); + const { promise, resolve } = Promise.withResolvers(); + this.ctx.hookInput = new HookInputComponent( + title, + placeholder, + (value) => { + this.hideHookInput(); + resolve(value); + }, + () => { + this.hideHookInput(); + resolve(undefined); + }, + ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookInput); - this.ctx.ui.setFocus(this.ctx.hookInput); - this.ctx.ui.requestRender(); - }); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookInput); + this.ctx.ui.setFocus(this.ctx.hookInput); + this.ctx.ui.requestRender(); + return promise; } /** @@ -564,26 +564,26 @@ export class ExtensionUiController { * Show a multi-line editor for hooks (with Ctrl+G support). */ showHookEditor(title: string, prefill?: string): Promise { - return new Promise((resolve) => { - this.ctx.hookEditor = new HookEditorComponent( - this.ctx.ui, - title, - prefill, - (value) => { - this.hideHookEditor(); - resolve(value); - }, - () => { - this.hideHookEditor(); - resolve(undefined); - }, - ); + const { promise, resolve } = Promise.withResolvers(); + this.ctx.hookEditor = new HookEditorComponent( + this.ctx.ui, + title, + prefill, + (value) => { + this.hideHookEditor(); + resolve(value); + }, + () => { + this.hideHookEditor(); + resolve(undefined); + }, + ); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.hookEditor); - this.ctx.ui.setFocus(this.ctx.hookEditor); - this.ctx.ui.requestRender(); - }); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.hookEditor); + this.ctx.ui.setFocus(this.ctx.hookEditor); + this.ctx.ui.requestRender(); + return promise; } /** @@ -624,27 +624,27 @@ export class ExtensionUiController { const savedText = this.ctx.editor.getText(); const keybindings = KeybindingsManager.inMemory(); - return new Promise((resolve) => { - let component: Component & { dispose?(): void }; + const { promise, resolve } = Promise.withResolvers(); + let component: Component & { dispose?(): void }; - const close = (result: T) => { - component.dispose?.(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.editor.setText(savedText); - this.ctx.ui.setFocus(this.ctx.editor); - this.ctx.ui.requestRender(); - resolve(result); - }; + const close = (result: T) => { + component.dispose?.(); + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.editor.setText(savedText); + this.ctx.ui.setFocus(this.ctx.editor); + this.ctx.ui.requestRender(); + resolve(result); + }; - Promise.resolve(factory(this.ctx.ui, theme, keybindings, close)).then((c) => { - component = c; - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(component); - this.ctx.ui.setFocus(component); - this.ctx.ui.requestRender(); - }); + Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then((c) => { + component = c; + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(component); + this.ctx.ui.setFocus(component); + this.ctx.ui.requestRender(); }); + return promise; } /** diff --git a/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts index 870fe0181..7d125d8d5 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -1,4 +1,4 @@ -import * as fs from "node:fs"; +import { rm } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; @@ -56,11 +56,11 @@ export class InputController { this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true }); // Global debug handler on TUI (works regardless of focus) - this.ctx.ui.onDebug = () => this.ctx.handleDebugCommand(); + this.ctx.ui.onDebug = () => void this.ctx.handleDebugCommand(); this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector(); this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch(); this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion(); - this.ctx.editor.onCtrlG = () => this.openExternalEditor(); + this.ctx.editor.onCtrlG = () => void this.openExternalEditor(); this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand(); this.ctx.editor.onCtrlV = () => this.handleImagePaste(); @@ -246,7 +246,7 @@ export class InputController { return; } if (text === "/debug") { - this.ctx.handleDebugCommand(); + void this.ctx.handleDebugCommand(); this.ctx.editor.setText(""); return; } @@ -276,7 +276,7 @@ export class InputController { this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); try { - const content = fs.readFileSync(skillPath, "utf-8"); + const content = await Bun.file(skillPath).text(); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); const metaLines = [`Skill: ${skillPath}`]; if (args) { @@ -587,7 +587,7 @@ export class InputController { this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); } - openExternalEditor(): void { + async openExternalEditor(): Promise { // Determine editor (respect $VISUAL, then $EDITOR) const editorCmd = process.env.VISUAL || process.env.EDITOR; if (!editorCmd) { @@ -600,7 +600,7 @@ export class InputController { try { // Write current content to temp file - fs.writeFileSync(tmpFile, currentText, "utf-8"); + await Bun.write(tmpFile, currentText); // Stop TUI to release terminal this.ctx.ui.stop(); @@ -609,22 +609,23 @@ export class InputController { const [editor, ...editorArgs] = editorCmd.split(" "); // Spawn editor synchronously with inherited stdio for interactive editing - const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], { + const child = Bun.spawn([editor, ...editorArgs, tmpFile], { stdin: "inherit", stdout: "inherit", stderr: "inherit", }); + const exitCode = await child.exited; // On successful exit (exitCode 0), replace editor content - if (result.exitCode === 0) { - const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + if (exitCode === 0) { + const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, ""); this.ctx.editor.setText(newContent); } // On non-zero exit, keep original text (no action needed) } finally { // Clean up temp file try { - fs.unlinkSync(tmpFile); + await rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7f214fbba..ecbc8c4af 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -16,13 +16,13 @@ import { Text, TUI, } from "@oh-my-pi/pi-tui"; +import { logger, postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session"; import type { ExtensionUIContext } from "../../core/extensions/index"; import type { CompactOptions } from "../../core/extensions/types"; import { HistoryStorage } from "../../core/history-storage"; import { KeybindingsManager } from "../../core/keybindings"; -import { logger } from "../../core/logger"; import type { SessionContext, SessionManager } from "../../core/session-manager"; import { getRecentSessions } from "../../core/session-manager"; import type { SettingsManager } from "../../core/settings-manager"; @@ -30,7 +30,6 @@ import { loadSlashCommands } from "../../core/slash-commands"; import { setTerminalTitle } from "../../core/title-generator"; import { getArtifactsDir } from "../../core/tools/task/artifacts"; import { VoiceSupervisor } from "../../core/voice-supervisor"; -import { registerAsyncCleanup } from "../cleanup"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import { CustomEditor } from "./components/custom-editor"; @@ -268,7 +267,7 @@ export class InteractiveMode implements InteractiveModeContext { this.keybindings = await KeybindingsManager.create(); // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP) - this.cleanupUnsubscribe = registerAsyncCleanup(() => this.sessionManager.flush()); + this.cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush()); // Load and convert file commands to SlashCommand format (async) const fileCommands = await loadSlashCommands({ cwd: process.cwd() }); @@ -394,12 +393,12 @@ export class InteractiveMode implements InteractiveModeContext { } async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> { - return new Promise((resolve) => { - this.onInputCallback = (input) => { - this.onInputCallback = undefined; - resolve(input); - }; - }); + const { promise, resolve } = Promise.withResolvers<{ text: string; images?: ImageContent[] }>(); + this.onInputCallback = (input) => { + this.onInputCallback = undefined; + resolve(input); + }; + return promise; } updateEditorBorderColor(): void { @@ -664,8 +663,8 @@ export class InteractiveMode implements InteractiveModeContext { return this.commandController.handleClearCommand(); } - handleDebugCommand(): void { - this.commandController.handleDebugCommand(); + handleDebugCommand(): Promise { + return this.commandController.handleDebugCommand(); } handleArminSaysHi(): void { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index d848d7271..07bfcb5da 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1,12 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; import { highlight, supportsLanguage } from "cli-highlight"; import { getCustomThemesDir } from "../../../config"; -import { logger } from "../../../core/logger"; // Embed theme JSON files at build time import darkThemeJson from "./dark.json" with { type: "json" }; import { defaultThemes } from "./defaults"; diff --git a/packages/coding-agent/src/modes/interactive/types.ts b/packages/coding-agent/src/modes/interactive/types.ts index b0c17025a..133f1bebf 100644 --- a/packages/coding-agent/src/modes/interactive/types.ts +++ b/packages/coding-agent/src/modes/interactive/types.ts @@ -141,7 +141,7 @@ export interface InteractiveModeContext { handleHotkeysCommand(): void; handleDumpCommand(): Promise; handleClearCommand(): Promise; - handleDebugCommand(): void; + handleDebugCommand(): Promise; handleArminSaysHi(): void; handleBashCommand(command: string, excludeFromContext?: boolean): Promise; handleCompactCommand(customInstructions?: string): Promise; diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 9bea1e8fb..0be5e7344 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -6,16 +6,12 @@ import type { AgentEvent, AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; -import type { Subprocess } from "bun"; +import { createSanitizerStream, createSplitterStream, createTextDecoderStream, ptree } from "@oh-my-pi/pi-utils"; import type { SessionStats } from "../../core/agent-session"; import type { BashResult } from "../../core/bash-executor"; import type { CompactionResult } from "../../core/compaction/index"; import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types"; -// ============================================================================ -// Types -// ============================================================================ - /** Distributive Omit that works with union types */ type DistributiveOmit = T extends unknown ? Omit : never; @@ -51,13 +47,12 @@ export type RpcEventListener = (event: AgentEvent) => void; // ============================================================================ export class RpcClient { - private process: Subprocess | null = null; - private lineReader: ReadableStreamDefaultReader | null = null; + private process: ptree.ChildProcess | null = null; + private lineReader: ReadableStream | null = null; private eventListeners: RpcEventListener[] = []; private pendingRequests: Map void; reject: (error: Error) => void }> = new Map(); private requestId = 0; - private stderr = ""; constructor(private options: RpcClientOptions = {}) {} @@ -82,65 +77,39 @@ export class RpcClient { args.push(...this.options.args); } - this.process = Bun.spawn(["bun", cliPath, ...args], { + this.process = ptree.cspawn(["bun", cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdin: "pipe", - stdout: "pipe", - stderr: "pipe", }); - // Collect stderr for debugging - (async () => { - const reader = (this.process!.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - this.stderr += decoder.decode(value); - } - })(); - - // Set up line reader for stdout - const textStream = (this.process.stdout as ReadableStream).pipeThrough(new TextDecoderStream()); - this.lineReader = textStream - .pipeThrough( - new TransformStream({ - transform(chunk, controller) { - const lines = chunk.split("\n"); - for (const line of lines) { - if (line.trim()) { - controller.enqueue(line); - } - } - }, - }), - ) - .getReader() as ReadableStreamDefaultReader; - // Process lines in background - (async () => { + const lines = this.process.stdout + .pipeThrough(createTextDecoderStream()) + .pipeThrough(createSanitizerStream()) + .pipeThrough(createSplitterStream("\n")); + this.lineReader = lines; + void (async () => { try { - while (true) { - const { done, value } = await this.lineReader!.read(); - if (done) break; - this.handleLine(value); + for await (const line of lines) { + this.handleLine(line); } } catch { // Stream closed + } finally { + lines.cancel(); } })(); // Wait a moment for process to initialize - await new Promise((resolve) => setTimeout(resolve, 100)); + await Bun.sleep(100); try { - const exitCode = await Promise.race([ - this.process.exited, - new Promise((resolve) => setTimeout(() => resolve(null), 50)), - ]); + const exitCode = await Promise.race([this.process.exited, Bun.sleep(50).then(() => null)]); if (exitCode !== null) { - throw new Error(`Agent process exited immediately with code ${exitCode}. Stderr: ${this.stderr}`); + throw new Error( + `Agent process exited immediately with code ${exitCode}. Stderr: ${this.process.peekStderr()}`, + ); } } catch { // Process still running, which is what we want @@ -154,22 +123,7 @@ export class RpcClient { if (!this.process) return; this.lineReader?.cancel(); - this.process.kill(); - - // Wait for process to exit - await Promise.race([ - this.process.exited, - new Promise((resolve) => { - setTimeout(() => { - try { - this.process?.kill(9); - } catch { - // Already dead - } - resolve(); - }, 1000); - }), - ]); + await this.process.killAndWait(); this.process = null; this.lineReader = null; @@ -193,7 +147,7 @@ export class RpcClient { * Get collected stderr output (useful for debugging). */ getStderr(): string { - return this.stderr; + return this.process?.peekStderr() ?? ""; } // ========================================================================= @@ -416,42 +370,50 @@ export class RpcClient { * Resolves when agent_end event is received. */ waitForIdle(timeout = 60000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + const unsubscribe = this.onEvent((event) => { + if (event.type === "agent_end") { + settled = true; unsubscribe(); - reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); - }, timeout); - - const unsubscribe = this.onEvent((event) => { - if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); - resolve(); - } - }); + resolve(); + } }); + + void (async () => { + await Bun.sleep(timeout); + if (settled) return; + settled = true; + unsubscribe(); + reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.process?.peekStderr() ?? ""}`)); + })(); + return promise; } /** * Collect events until agent becomes idle. */ collectEvents(timeout = 60000): Promise { - return new Promise((resolve, reject) => { - const events: AgentEvent[] = []; - const timer = setTimeout(() => { + const { promise, resolve, reject } = Promise.withResolvers(); + const events: AgentEvent[] = []; + let settled = false; + const unsubscribe = this.onEvent((event) => { + events.push(event); + if (event.type === "agent_end") { + settled = true; unsubscribe(); - reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); - }, timeout); - - const unsubscribe = this.onEvent((event) => { - events.push(event); - if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); - resolve(events); - } - }); + resolve(events); + } }); + + void (async () => { + await Bun.sleep(timeout); + if (settled) return; + settled = true; + unsubscribe(); + reject(new Error(`Timeout collecting events. Stderr: ${this.process?.peekStderr() ?? ""}`)); + })(); + return promise; } /** @@ -495,37 +457,45 @@ export class RpcClient { const id = `req_${++this.requestId}`; const fullCommand = { ...command, id } as RpcCommand; + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + void (async () => { + await Bun.sleep(30000); + if (settled) return; + this.pendingRequests.delete(id); + settled = true; + reject( + new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.process?.peekStderr() ?? ""}`), + ); + })(); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); - }, 30000); - - this.pendingRequests.set(id, { - resolve: (response) => { - clearTimeout(timeout); - resolve(response); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - }, - }); - - // Write to stdin after registering the handler - const stdin = this.process!.stdin as import("bun").FileSink; - stdin.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`)); - // flush() returns number | Promise - handle both cases - const flushResult = stdin.flush(); - if (flushResult instanceof Promise) { - flushResult.catch((err: Error) => { - this.pendingRequests.delete(id); - clearTimeout(timeout); - reject(err); - }); - } + this.pendingRequests.set(id, { + resolve: (response) => { + if (settled) return; + settled = true; + resolve(response); + }, + reject: (error) => { + if (settled) return; + settled = true; + reject(error); + }, }); + + // Write to stdin after registering the handler + const stdin = this.process!.stdin as import("bun").FileSink; + stdin.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`)); + // flush() returns number | Promise - handle both cases + const flushResult = stdin.flush(); + if (flushResult instanceof Promise) { + flushResult.catch((err: Error) => { + this.pendingRequests.delete(id); + if (settled) return; + settled = true; + reject(err); + }); + } + return promise; } private getData(response: RpcResponse): T { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e043cb879..d10c87c44 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -11,6 +11,7 @@ * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response */ +import { readLines } from "@oh-my-pi/pi-utils"; import { nanoid } from "nanoid"; import type { AgentSession } from "../../core/agent-session"; import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index"; @@ -86,37 +87,37 @@ export async function runRpcMode(session: AgentSession): Promise { if (opts?.signal?.aborted) return Promise.resolve(defaultValue); const id = nanoid(); - return new Promise((resolve, reject) => { - let timeoutId: ReturnType | undefined; + const { promise, resolve, reject } = Promise.withResolvers(); + let timeoutId: ReturnType | undefined; - const cleanup = () => { - if (timeoutId) clearTimeout(timeoutId); - opts?.signal?.removeEventListener("abort", onAbort); - this.pendingRequests.delete(id); - }; + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + opts?.signal?.removeEventListener("abort", onAbort); + this.pendingRequests.delete(id); + }; - const onAbort = () => { + const onAbort = () => { + cleanup(); + resolve(defaultValue); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + + if (opts?.timeout !== undefined) { + timeoutId = setTimeout(() => { cleanup(); resolve(defaultValue); - }; - opts?.signal?.addEventListener("abort", onAbort, { once: true }); + }, opts.timeout); + } - if (opts?.timeout !== undefined) { - timeoutId = setTimeout(() => { - cleanup(); - resolve(defaultValue); - }, opts.timeout); - } - - this.pendingRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - cleanup(); - resolve(parseResponse(response)); - }, - reject, - }); - this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); + this.pendingRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { + cleanup(); + resolve(parseResponse(response)); + }, + reject, }); + this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); + return promise; } select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise { @@ -242,28 +243,28 @@ export async function runRpcMode(session: AgentSession): Promise { async editor(title: string, prefill?: string): Promise { const id = nanoid(); - return new Promise((resolve, reject) => { - this.pendingRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - this.pendingRequests.delete(id); - if ("cancelled" in response && response.cancelled) { - resolve(undefined); - } else if ("value" in response) { - resolve(response.value); - } else { - resolve(undefined); - } - }, - reject, - }); - this.output({ - type: "extension_ui_request", - id, - method: "editor", - title, - prefill, - } as RpcExtensionUIRequest); + const { promise, resolve, reject } = Promise.withResolvers(); + this.pendingRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { + this.pendingRequests.delete(id); + if ("cancelled" in response && response.cancelled) { + resolve(undefined); + } else if ("value" in response) { + resolve(response.value); + } else { + resolve(undefined); + } + }, + reject, }); + this.output({ + type: "extension_ui_request", + id, + method: "editor", + title, + prefill, + } as RpcExtensionUIRequest); + return promise; } get theme(): Theme { @@ -620,40 +621,31 @@ export async function runRpcMode(session: AgentSession): Promise { } // Listen for JSON input using Bun's stdin - const decoder = new TextDecoder(); - let buffer = ""; + for await (const line of readLines(Bun.stdin.stream())) { + if (!line.trim()) continue; - for await (const chunk of Bun.stdin.stream()) { - buffer += decoder.decode(chunk, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; + try { + const parsed = JSON.parse(line); - for (const line of lines) { - if (!line.trim()) continue; - - try { - const parsed = JSON.parse(line); - - // Handle extension UI responses - if (parsed.type === "extension_ui_response") { - const response = parsed as RpcExtensionUIResponse; - const pending = pendingExtensionRequests.get(response.id); - if (pending) { - pending.resolve(response); - } - continue; + // Handle extension UI responses + if (parsed.type === "extension_ui_response") { + const response = parsed as RpcExtensionUIResponse; + const pending = pendingExtensionRequests.get(response.id); + if (pending) { + pending.resolve(response); } - - // Handle regular commands - const command = parsed as RpcCommand; - const response = await handleCommand(command); - output(response); - - // Check for deferred shutdown request (idle between commands) - await checkShutdownRequested(); - } catch (e: any) { - output(error(undefined, "parse", `Failed to parse command: ${e.message}`)); + continue; } + + // Handle regular commands + const command = parsed as RpcCommand; + const response = await handleCommand(command); + output(response); + + // Check for deferred shutdown request (idle between commands) + await checkShutdownRequested(); + } catch (e: any) { + output(error(undefined, "parse", `Failed to parse command: ${e.message}`)); } } diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index a20aed2a8..164bbcde1 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,5 +1,6 @@ import { unlink } from "node:fs/promises"; import { platform } from "node:os"; +import { $ } from "bun"; import { nanoid } from "nanoid"; const PREFERRED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; @@ -30,92 +31,36 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null { return anyImage?.raw ?? null; } -async function spawnWithTimeout(cmd: string[], input: string, timeoutMs: number): Promise { - const proc = Bun.spawn(cmd, { stdin: "pipe" }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs); - }); - - try { - proc.stdin.write(input); - proc.stdin.end(); - await Promise.race([proc.exited, timeoutPromise]); - - if (proc.exitCode !== 0) { - throw new Error(`Command failed with exit code ${proc.exitCode}`); - } - } finally { - proc.kill(); - } -} - -async function spawnAndRead(cmd: string[], timeoutMs: number): Promise { - let proc: ReturnType | null = null; - - try { - proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs); - }); - - const stdoutStream = proc.stdout as ReadableStream; - const [exitCode, stdout] = await Promise.race([ - Promise.all([proc.exited, new Response(stdoutStream).arrayBuffer()]), - timeoutPromise, - ]); - - if (exitCode !== 0) { - return null; - } - - return Buffer.from(stdout); - } catch { - return null; - } finally { - proc?.kill(); - } -} - export async function copyToClipboard(text: string): Promise { - const p = platform(); - const timeout = 5000; + const timeout = Bun.sleep(3000).then(() => Promise.reject(new Error("Clipboard operation timed out"))); + let promise: Promise; try { - if (p === "darwin") { - await spawnWithTimeout(["pbcopy"], text, timeout); - } else if (p === "win32") { - await spawnWithTimeout(["clip"], text, timeout); - } else { - const wayland = isWaylandSession(); - if (wayland) { - const wlCopyPath = Bun.which("wl-copy"); - if (wlCopyPath) { - // Fire-and-forget: wl-copy may not exit promptly, so we unref to avoid blocking - const proc = Bun.spawn([wlCopyPath], { stdin: "pipe" }); - proc.stdin.write(text); - proc.stdin.end(); - proc.unref(); + switch (platform()) { + case "darwin": + promise = $`pbcopy ${text}`.quiet().then(() => void 0); + break; + case "win32": + promise = $`clip ${text}`.quiet().then(() => void 0); + break; + case "linux": + if (isWaylandSession()) { + $`wl-copy ${text}`.quiet(); // fire and forget return; + } else { + promise = $`xclip -selection clipboard -t text/plain -i ${text}`.quiet().then(() => void 0); } - } - - // Linux - try xclip first, fall back to xsel - try { - await spawnWithTimeout(["xclip", "-selection", "clipboard"], text, timeout); - } catch { - await spawnWithTimeout(["xsel", "--clipboard", "--input"], text, timeout); - } + break; + default: + throw new Error(`Unsupported platform: ${platform()}`); } } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (p === "linux") { - const tools = isWaylandSession() ? "wl-copy, xclip, or xsel" : "xclip or xsel"; - throw new Error(`Failed to copy to clipboard. Install ${tools}: ${msg}`); + if (error instanceof Error) { + throw new Error(`Failed to copy to clipboard: ${error.message}`); } - throw new Error(`Failed to copy to clipboard: ${msg}`); + throw new Error(`Failed to copy to clipboard: ${String(error)}`); } + await Promise.race([promise, timeout]); } export interface ClipboardImage { @@ -135,20 +80,21 @@ export interface ClipboardImage { export async function readImageFromClipboard(): Promise { const p = platform(); const timeout = 3000; - - try { - if (p === "linux") { - return await readImageLinux(timeout); - } else if (p === "darwin") { - return await readImageMacOS(timeout); - } else if (p === "win32") { - return await readImageWindows(timeout); - } - } catch { - // Clipboard access failed silently + let promise: Promise; + switch (p) { + case "linux": + promise = readImageLinux(); + break; + case "darwin": + promise = readImageMacOS(); + break; + case "win32": + promise = readImageWindows(); + break; + default: + return null; } - - return null; + return Promise.race([promise, Bun.sleep(timeout).then(() => null)]); } type ClipboardReadResult = @@ -156,27 +102,23 @@ type ClipboardReadResult = | { status: "empty" } // Tools ran successfully, no image in clipboard | { status: "unavailable" }; // Tools not found or failed to run -async function readImageLinux(timeout: number): Promise { +async function readImageLinux(): Promise { const wayland = isWaylandSession(); if (wayland) { - const result = await readImageWayland(timeout); + const result = await readImageWayland(); if (result.status === "found") return result.image; if (result.status === "empty") return null; // Don't fall back to X11 if Wayland worked } - const result = await readImageX11(timeout); + const result = await readImageX11(); return result.status === "found" ? result.image : null; } -async function readImageWayland(timeout: number): Promise { - const wlPastePath = Bun.which("wl-paste"); - if (!wlPastePath) return { status: "unavailable" }; - - const types = await spawnAndRead([wlPastePath, "--list-types"], timeout); +async function readImageWayland(): Promise { + const types = await $`wl-paste --list-types`.quiet().text(); if (!types) return { status: "unavailable" }; // Command failed const typeList = types - .toString("utf-8") .split(/\r?\n/) .map((t) => t.trim()) .filter(Boolean); @@ -184,27 +126,23 @@ async function readImageWayland(timeout: number): Promise { const selectedType = selectPreferredImageMimeType(typeList); if (!selectedType) return { status: "empty" }; // No image types available - const imageData = await spawnAndRead([wlPastePath, "--type", selectedType, "--no-newline"], timeout); - if (!imageData || imageData.length === 0) return { status: "empty" }; + const imageData = await $`wl-paste --type ${selectedType} --no-newline`.quiet().arrayBuffer(); + if (!imageData || imageData.byteLength === 0) return { status: "empty" }; return { status: "found", image: { - data: imageData.toString("base64"), + data: Buffer.from(imageData).toString("base64"), mimeType: baseMimeType(selectedType), }, }; } -async function readImageX11(timeout: number): Promise { - const xclipPath = Bun.which("xclip"); - if (!xclipPath) return { status: "unavailable" }; - - const targets = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout); +async function readImageX11(): Promise { + const targets = await $`xclip -selection clipboard -t TARGETS -o`.quiet().text(); if (!targets) return { status: "unavailable" }; // xclip failed (no X server?) const candidateTypes = targets - .toString("utf-8") .split(/\r?\n/) .map((t) => t.trim()) .filter(Boolean); @@ -212,19 +150,19 @@ async function readImageX11(timeout: number): Promise { const selectedType = selectPreferredImageMimeType(candidateTypes); if (!selectedType) return { status: "empty" }; // Clipboard has no image types - const imageData = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", selectedType, "-o"], timeout); - if (!imageData || imageData.length === 0) return { status: "empty" }; + const imageData = await $`xclip -selection clipboard -t ${selectedType} -o`.quiet().arrayBuffer(); + if (!imageData || imageData.byteLength === 0) return { status: "empty" }; return { status: "found", image: { - data: imageData.toString("base64"), + data: Buffer.from(imageData).toString("base64"), mimeType: baseMimeType(selectedType), }, }; } -async function readImageMacOS(timeout: number): Promise { +async function readImageMacOS(): Promise { // Use osascript to check clipboard class and read PNG data // First check if clipboard has image data const checkScript = ` @@ -241,15 +179,8 @@ async function readImageMacOS(timeout: number): Promise { end try `; - const checkProc = Bun.spawn(["osascript", "-e", checkScript], { stdout: "pipe", stderr: "pipe" }); - const checkResult = await Promise.race([ - new Response(checkProc.stdout).text(), - new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)), - ]).catch(() => "none"); - - await checkProc.exited; + const checkResult = await $`osascript -e ${checkScript}`.quiet().text(); const imageType = checkResult.trim(); - if (imageType === "none") return null; // Read the actual image data using a temp file approach @@ -265,20 +196,15 @@ async function readImageMacOS(timeout: number): Promise { close access fileRef `; - const writeProc = Bun.spawn(["osascript", "-e", readScript], { stdout: "pipe", stderr: "pipe" }); - await Promise.race([ - writeProc.exited, - new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)), - ]).catch(() => null); + await $`osascript -e ${readScript}`.quiet().text(); try { const file = Bun.file(tempFile); if (await file.exists()) { - const buffer = await file.arrayBuffer(); - await Bun.write(tempFile, ""); // Clear file + const buffer = await file.bytes(); await unlink(tempFile).catch(() => {}); - if (buffer.byteLength > 0) { + if (buffer.length > 0) { return { data: Buffer.from(buffer).toString("base64"), mimeType: imageType === "png" ? "image/png" : "image/jpeg", @@ -292,7 +218,7 @@ async function readImageMacOS(timeout: number): Promise { return null; } -async function readImageWindows(timeout: number): Promise { +async function readImageWindows(): Promise { // PowerShell script to read image from clipboard as base64 const script = ` Add-Type -AssemblyName System.Windows.Forms @@ -304,16 +230,6 @@ async function readImageWindows(timeout: number): Promise } `; - const result = await spawnAndRead(["powershell", "-NoProfile", "-Command", script], timeout); - if (result && result.length > 0) { - const base64 = result.toString("utf-8").trim(); - if (base64.length > 0) { - return { - data: base64, - mimeType: "image/png", - }; - } - } - - return null; + const result = await $`powershell -NoProfile -Command ${script}`.quiet().text(); + return result ? { data: result, mimeType: "image/png" } : null; } diff --git a/packages/coding-agent/src/utils/shell-snapshot.ts b/packages/coding-agent/src/utils/shell-snapshot.ts index 0a303b154..cd59eac0c 100644 --- a/packages/coding-agent/src/utils/shell-snapshot.ts +++ b/packages/coding-agent/src/utils/shell-snapshot.ts @@ -6,11 +6,14 @@ * shell experience. */ +import { unlinkSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { postmortem } from "@oh-my-pi/pi-utils"; +import { $ } from "bun"; let cachedSnapshotPath: string | null = null; -let cleanupRegistered = false; /** * Get the user's shell config file path. @@ -129,32 +132,19 @@ export async function getOrCreateSnapshot( // Create snapshot directory const snapshotDir = join(tmpdir(), "omp-shell-snapshots"); - const mkdirProc = Bun.spawnSync(["mkdir", "-p", snapshotDir]); - if (mkdirProc.exitCode !== 0) { - return null; - } + await mkdir(snapshotDir, { recursive: true }); // Generate unique snapshot path - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh"; - const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${timestamp}-${random}.sh`); + const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`); // Generate and execute snapshot script const script = await generateSnapshotScript(shell, snapshotPath, rcFile); try { - const result = Bun.spawnSync([shell, "-l", "-c", script], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env, - timeout: 10000, // 10 second timeout - }); - - if (result.exitCode === 0 && (await Bun.file(snapshotPath).exists())) { + await $`${shell} -l -c ${script}`.env(env).quiet().text(); + if (await Bun.file(snapshotPath).exists()) { cachedSnapshotPath = snapshotPath; - registerCleanup(); return snapshotPath; } } catch { @@ -175,46 +165,8 @@ export function getSnapshotSourceCommand(snapshotPath: string | null): string { return `source '${escaped}' 2>/dev/null && `; } -/** - * Register cleanup handler to delete snapshot on process exit. - */ -function registerCleanup(): void { - if (cleanupRegistered) return; - cleanupRegistered = true; - - const cleanup = async () => { - if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) { - try { - Bun.spawnSync(["rm", cachedSnapshotPath]); - } catch { - // Ignore cleanup errors - } - } - }; - - process.on("exit", () => { - cleanup(); - }); - process.on("SIGINT", () => { - cleanup(); - process.exit(130); - }); - process.on("SIGTERM", () => { - cleanup(); - process.exit(143); - }); -} - -/** - * Clear the cached snapshot (for testing or forced refresh). - */ -export async function clearSnapshotCache(): Promise { - if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) { - try { - Bun.spawnSync(["rm", cachedSnapshotPath]); - } catch { - // Ignore - } +postmortem.register("shell-snapshot", () => { + if (cachedSnapshotPath) { + unlinkSync(cachedSnapshotPath); } - cachedSnapshotPath = null; -} +}); diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index ed1f5e213..790e63a3e 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,4 +1,6 @@ -import { accessSync, constants, existsSync } from "node:fs"; +import { constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { $ } from "bun"; import { SettingsManager } from "../core/settings-manager"; export interface ShellConfig { @@ -13,9 +15,9 @@ let cachedShellConfig: ShellConfig | null = null; /** * Check if a shell binary is executable. */ -function isExecutable(path: string): boolean { +async function isExecutable(path: string): Promise { try { - accessSync(path, constants.X_OK); + await access(path, constants.X_OK); return true; } catch { return false; @@ -59,13 +61,7 @@ function getShellPrefix(): string | undefined { */ function findBashOnPath(): string | null { try { - const result = Bun.spawnSync(["where", "bash.exe"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); - if (result.exitCode === 0 && result.stdout) { - const firstMatch = result.stdout.toString().trim().split(/\r?\n/)[0]; - if (firstMatch && existsSync(firstMatch)) { - return firstMatch; - } - } + return Bun.which("bash.exe"); } catch { // Ignore errors } @@ -102,7 +98,7 @@ export async function getShellConfig(): Promise { // 1. Check user-specified shell path if (customShellPath) { - if (existsSync(customShellPath)) { + if (await Bun.file(customShellPath).exists()) { cachedShellConfig = buildConfig(customShellPath); return cachedShellConfig; } @@ -124,7 +120,7 @@ export async function getShellConfig(): Promise { } for (const path of paths) { - if (existsSync(path)) { + if (await Bun.file(path).exists()) { cachedShellConfig = buildConfig(path); return cachedShellConfig; } @@ -149,7 +145,7 @@ export async function getShellConfig(): Promise { // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable const userShell = process.env.SHELL; const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh")); - if (isValidShell && isExecutable(userShell)) { + if (isValidShell && (await isExecutable(userShell))) { cachedShellConfig = buildConfig(userShell); return cachedShellConfig; } @@ -162,7 +158,7 @@ export async function getShellConfig(): Promise { for (const shellName of shellOrder) { for (const dir of fallbackPaths) { const shellPath = `${dir}/${shellName}`; - if (isExecutable(shellPath)) { + if (await isExecutable(shellPath)) { cachedShellConfig = buildConfig(shellPath); return cachedShellConfig; } @@ -181,23 +177,17 @@ export async function getShellConfig(): Promise { return cachedShellConfig; } -let pgrepAvailable: boolean | null = null; +let pgrepAvailable: string | null | undefined; /** * Check if pgrep is available on this system (cached). */ -function hasPgrep(): boolean { - if (pgrepAvailable === null) { +function hasPgrep(): string | null { + if (pgrepAvailable === undefined) { try { - const result = Bun.spawnSync(["pgrep", "--version"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - // pgrep exists if it ran (exit 0 or 1 are both valid) - pgrepAvailable = result.exitCode !== null; + pgrepAvailable = Bun.which("pgrep") ?? null; } catch { - pgrepAvailable = false; + pgrepAvailable = null; } } return pgrepAvailable; @@ -206,17 +196,14 @@ function hasPgrep(): boolean { /** * Get direct children of a PID using pgrep. */ -function getChildrenViaPgrep(pid: number): number[] { - const result = Bun.spawnSync(["pgrep", "-P", String(pid)], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }); - - if (result.exitCode !== 0 || !result.stdout) return []; +async function getChildrenViaPgrep(pid: number): Promise { + const result = await $`pgrep -P ${pid}`.quiet().nothrow(); + if (result.exitCode !== 0) return []; + const output = result.stdout.toString().trim(); + if (!output) return []; const children: number[] = []; - for (const line of result.stdout.toString().trim().split("\n")) { + for (const line of output.split("\n")) { const childPid = parseInt(line, 10); if (!Number.isNaN(childPid)) children.push(childPid); } @@ -226,20 +213,16 @@ function getChildrenViaPgrep(pid: number): number[] { /** * Get direct children of a PID using /proc (Linux only). */ -function getChildrenViaProc(pid: number): number[] { +async function getChildrenViaProc(pid: number): Promise { try { - const result = Bun.spawnSync( - [ - "sh", - "-c", - `for p in /proc/[0-9]*/stat; do cat "$p" 2>/dev/null; done | awk -v ppid=${pid} '$4 == ppid { print $1 }'`, - ], - { stdin: "ignore", stdout: "pipe", stderr: "ignore" }, - ); - if (result.exitCode !== 0 || !result.stdout) return []; + const script = `for p in /proc/[0-9]*/stat; do cat "$p" 2>/dev/null; done | awk -v ppid=${pid} '$4 == ppid { print $1 }'`; + const result = await $`sh -c ${script}`.quiet().nothrow(); + if (result.exitCode !== 0) return []; + const output = result.stdout.toString().trim(); + if (!output) return []; const children: number[] = []; - for (const line of result.stdout.toString().trim().split("\n")) { + for (const line of output.split("\n")) { const childPid = parseInt(line, 10); if (!Number.isNaN(childPid)) children.push(childPid); } @@ -253,14 +236,14 @@ function getChildrenViaProc(pid: number): number[] { * Collect all descendant PIDs breadth-first. * Returns deepest descendants first (reverse BFS order) for proper kill ordering. */ -function getDescendantPids(pid: number): number[] { +async function getDescendantPids(pid: number): Promise { const getChildren = hasPgrep() ? getChildrenViaPgrep : getChildrenViaProc; const descendants: number[] = []; const queue = [pid]; while (queue.length > 0) { const current = queue.shift()!; - const children = getChildren(current); + const children = await getChildren(current); for (const child of children) { descendants.push(child); queue.push(child); @@ -284,13 +267,9 @@ function tryKill(pid: number, signal: NodeJS.Signals): boolean { * Kill a process and all its descendants. * @param gracePeriodMs - Time to wait after SIGTERM before SIGKILL (0 = immediate SIGKILL) */ -export function killProcessTree(pid: number, gracePeriodMs = 0): void { +export async function killProcessTree(pid: number, gracePeriodMs = 0): Promise { if (process.platform === "win32") { - Bun.spawnSync(["taskkill", "/F", "/T", "/PID", String(pid)], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); + await $`taskkill /F /T /PID ${pid}`.quiet().nothrow(); return; } @@ -300,7 +279,7 @@ export function killProcessTree(pid: number, gracePeriodMs = 0): void { try { process.kill(-pid, signal); if (gracePeriodMs > 0) { - Bun.sleepSync(gracePeriodMs); + await Bun.sleep(gracePeriodMs); try { process.kill(-pid, "SIGKILL"); } catch { @@ -313,11 +292,11 @@ export function killProcessTree(pid: number, gracePeriodMs = 0): void { } // Collect descendants BEFORE killing to minimize race window - const allPids = [...getDescendantPids(pid), pid]; + const allPids = [...(await getDescendantPids(pid)), pid]; if (gracePeriodMs > 0) { for (const p of allPids) tryKill(p, "SIGTERM"); - Bun.sleepSync(gracePeriodMs); + await Bun.sleep(gracePeriodMs); } for (const p of allPids) tryKill(p, "SIGKILL"); diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 37d965f73..4af4b0758 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,7 +1,8 @@ -import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; +import { chmod, mkdir, rename, rm } from "node:fs/promises"; import { arch, platform } from "node:os"; import { join } from "node:path"; -import chalk from "chalk"; +import { createTempDir, logger } from "@oh-my-pi/pi-utils"; +import { $ } from "bun"; import { APP_NAME, getBinDir } from "../config"; const TOOLS_DIR = getBinDir(); @@ -133,19 +134,14 @@ const PYTHON_TOOLS: Record = { }, }; -// Check if a command exists in PATH -function commandExists(cmd: string): string | null { - return Bun.which(cmd); -} - export type ToolName = "fd" | "rg" | "sd" | "sg" | "yt-dlp" | "markitdown" | "html2text"; // Get the path to a tool (system-wide or in our tools dir) -export function getToolPath(tool: ToolName): string | null { +export async function getToolPath(tool: ToolName): Promise { // Check Python tools first const pythonConfig = PYTHON_TOOLS[tool]; if (pythonConfig) { - return commandExists(pythonConfig.binaryName); + return Bun.which(pythonConfig.binaryName); } const config = TOOLS[tool]; @@ -153,12 +149,12 @@ export function getToolPath(tool: ToolName): string | null { // Check our tools directory first const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); - if (existsSync(localPath)) { + if (await Bun.file(localPath).exists()) { return localPath; } // Check system PATH - return commandExists(config.binaryName); + return Bun.which(config.binaryName); } // Fetch latest release version from GitHub @@ -178,27 +174,12 @@ async function getLatestVersion(repo: string): Promise { // Download a file from URL async function downloadFile(url: string, dest: string): Promise { const response = await fetch(url); - if (!response.ok) { throw new Error(`Failed to download: ${response.status}`); - } - - if (!response.body) { + } else if (!response.body) { throw new Error("No response body"); } - - const fileStream = createWriteStream(dest); - const reader = response.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - fileStream.write(Buffer.from(value)); - } - fileStream.end(); - await new Promise((resolve, reject) => { - fileStream.on("finish", resolve); - fileStream.on("error", reject); - }); + await Bun.write(dest, response); } // Download and install a tool @@ -219,7 +200,7 @@ async function downloadTool(tool: ToolName): Promise { } // Create tools directory - mkdirSync(TOOLS_DIR, { recursive: true }); + await mkdir(TOOLS_DIR, { recursive: true }); const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`; const binaryExt = plat === "win32" ? ".exe" : ""; @@ -229,7 +210,7 @@ async function downloadTool(tool: ToolName): Promise { if (config.isDirectBinary) { await downloadFile(downloadUrl, binaryPath); if (plat !== "win32") { - chmodSync(binaryPath, 0o755); + await chmod(binaryPath, 0o755); } return binaryPath; } @@ -239,74 +220,62 @@ async function downloadTool(tool: ToolName): Promise { await downloadFile(downloadUrl, archivePath); // Extract - const extractDir = join(TOOLS_DIR, "extract_tmp"); - mkdirSync(extractDir, { recursive: true }); + const tmp = await createTempDir("@omp-tools-extract-"); try { if (assetName.endsWith(".tar.gz")) { - Bun.spawnSync(["tar", "xzf", archivePath, "-C", extractDir], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const archive = new Bun.Archive(await Bun.file(archivePath).arrayBuffer()); + const files = await archive.files(); + for (const [path, file] of files) { + await Bun.write(join(tmp.path, path), file); + } } else if (assetName.endsWith(".zip")) { - Bun.spawnSync(["unzip", "-o", archivePath, "-d", extractDir], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + await mkdir(tmp.path, { recursive: true }); + await $`unzip -o ${archivePath} -d ${tmp.path}`.quiet().nothrow(); } // Find the binary in extracted files // ast-grep releases the binary directly in the zip, not in a subdirectory let extractedBinary: string; if (tool === "sg") { - extractedBinary = join(extractDir, config.binaryName + binaryExt); + extractedBinary = join(tmp.path, config.binaryName + binaryExt); } else { - const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); + const extractedDir = join(tmp.path, assetName.replace(/\.(tar\.gz|zip)$/, "")); extractedBinary = join(extractedDir, config.binaryName + binaryExt); } - if (existsSync(extractedBinary)) { - renameSync(extractedBinary, binaryPath); + if (await Bun.file(extractedBinary).exists()) { + await rename(extractedBinary, binaryPath); } else { throw new Error(`Binary not found in archive: ${extractedBinary}`); } // Make executable (Unix only) if (plat !== "win32") { - chmodSync(binaryPath, 0o755); + await chmod(binaryPath, 0o755); } } finally { // Cleanup - rmSync(archivePath, { force: true }); - rmSync(extractDir, { recursive: true, force: true }); + await tmp.remove(); + await rm(archivePath, { force: true }); } return binaryPath; } // Install a Python package via uv (preferred) or pip -function installPythonPackage(pkg: string): boolean { +async function installPythonPackage(pkg: string): Promise { // Try uv first (faster, better isolation) - const uv = commandExists("uv"); + const uv = Bun.which("uv"); if (uv) { - const result = Bun.spawnSync([uv, "tool", "install", pkg], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const result = await $`${uv} tool install ${pkg}`.quiet().nothrow(); if (result.exitCode === 0) return true; } // Fall back to pip - const pip = commandExists("pip3") || commandExists("pip"); + const pip = Bun.which("pip3") || Bun.which("pip"); if (pip) { - const result = Bun.spawnSync([pip, "install", "--user", pkg], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const result = await $`${pip} install --user ${pkg}`.quiet().nothrow(); return result.exitCode === 0; } @@ -316,7 +285,7 @@ function installPythonPackage(pkg: string): boolean { // Ensure a tool is available, downloading if necessary // Returns the path to the tool, or null if unavailable export async function ensureTool(tool: ToolName, silent: boolean = false): Promise { - const existingPath = getToolPath(tool); + const existingPath = await getToolPath(tool); if (existingPath) { return existingPath; } @@ -325,21 +294,21 @@ export async function ensureTool(tool: ToolName, silent: boolean = false): Promi const pythonConfig = PYTHON_TOOLS[tool]; if (pythonConfig) { if (!silent) { - console.log(chalk.dim(`${pythonConfig.name} not found. Installing via uv/pip...`)); + logger.debug(`${pythonConfig.name} not found. Installing via uv/pip...`); } - const success = installPythonPackage(pythonConfig.package); + const success = await installPythonPackage(pythonConfig.package); if (success) { // Re-check for the command after installation - const path = commandExists(pythonConfig.binaryName); + const path = Bun.which(pythonConfig.binaryName); if (path) { if (!silent) { - console.log(chalk.dim(`${pythonConfig.name} installed successfully`)); + logger.debug(`${pythonConfig.name} installed successfully`); } return path; } } if (!silent) { - console.log(chalk.yellow(`Failed to install ${pythonConfig.name}`)); + logger.warn(`Failed to install ${pythonConfig.name}`); } return undefined; } @@ -349,18 +318,20 @@ export async function ensureTool(tool: ToolName, silent: boolean = false): Promi // Tool not found - download it if (!silent) { - console.log(chalk.dim(`${config.name} not found. Downloading...`)); + logger.debug(`${config.name} not found. Downloading...`); } try { const path = await downloadTool(tool); if (!silent) { - console.log(chalk.dim(`${config.name} installed to ${path}`)); + logger.debug(`${config.name} installed to ${path}`); } return path; } catch (e) { if (!silent) { - console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); + logger.warn(`Failed to download ${config.name}`, { + error: e instanceof Error ? e.message : String(e), + }); } return undefined; } diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 5a04bbb1f..a21516b06 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -121,7 +121,7 @@ describe("AgentSession concurrent prompt guard", () => { const firstPrompt = session.prompt("First message"); // Wait a tick for isStreaming to be set - await new Promise((resolve) => setTimeout(resolve, 10)); + await Bun.sleep(10); // Verify we're streaming expect(session.isStreaming).toBe(true); @@ -141,7 +141,7 @@ describe("AgentSession concurrent prompt guard", () => { // Start first prompt const firstPrompt = session.prompt("First message"); - await new Promise((resolve) => setTimeout(resolve, 10)); + await Bun.sleep(10); // steer should work while streaming expect(() => session.steer("Steering message")).not.toThrow(); @@ -157,7 +157,7 @@ describe("AgentSession concurrent prompt guard", () => { // Start first prompt const firstPrompt = session.prompt("First message"); - await new Promise((resolve) => setTimeout(resolve, 10)); + await Bun.sleep(10); // followUp should work while streaming expect(() => session.followUp("Follow-up message")).not.toThrow(); diff --git a/packages/coding-agent/test/agent-session-tree-navigation.test.ts b/packages/coding-agent/test/agent-session-tree-navigation.test.ts index 89d4228ce..129b72506 100644 --- a/packages/coding-agent/test/agent-session-tree-navigation.test.ts +++ b/packages/coding-agent/test/agent-session-tree-navigation.test.ts @@ -192,7 +192,7 @@ describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => { const navigationPromise = session.navigateTree(rootNode.entry.id, { summarize: true }); // Abort after a short delay (let the LLM call start) - await new Promise((resolve) => setTimeout(resolve, 100)); + await Bun.sleep(100); session.abortBranchSummary(); const result = await navigationPromise; diff --git a/packages/coding-agent/test/core/python-executor-per-call.test.ts b/packages/coding-agent/test/core/python-executor-per-call.test.ts index 0576a6196..9687fa86b 100644 --- a/packages/coding-agent/test/core/python-executor-per-call.test.ts +++ b/packages/coding-agent/test/core/python-executor-per-call.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { executePython } from "../../src/core/python-executor"; import type { KernelExecuteOptions, KernelExecuteResult } from "../../src/core/python-kernel"; import { PythonKernel } from "../../src/core/python-kernel"; @@ -11,6 +12,7 @@ interface KernelStub { describe("executePython (per-call)", () => { it("shuts down kernel on timed-out cancellation", async () => { process.env.OMP_PYTHON_SKIP_CHECK = "1"; + using tempDir = createTempDirSync("@omp-python-executor-per-call-"); let shutdownCalls = 0; const kernel: KernelStub = { @@ -35,7 +37,7 @@ describe("executePython (per-call)", () => { const result = await executePython("sleep(10)", { kernelMode: "per-call", timeout: 2000, - cwd: "/tmp", + cwd: tempDir.path, }); expect(result.cancelled).toBe(true); diff --git a/packages/coding-agent/test/core/python-executor.test.ts b/packages/coding-agent/test/core/python-executor.test.ts index 46042aa95..6690d19ef 100644 --- a/packages/coding-agent/test/core/python-executor.test.ts +++ b/packages/coding-agent/test/core/python-executor.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { rmSync } from "node:fs"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { disposeAllKernelSessions, executePythonWithKernel, @@ -152,6 +153,7 @@ describe("warmPythonEnvironment", () => { it("caches prelude docs on warmup", async () => { const previousSkip = process.env.OMP_PYTHON_SKIP_CHECK; process.env.OMP_PYTHON_SKIP_CHECK = "1"; + using tempDir = createTempDirSync("@python-executor-"); const docs: PreludeHelper[] = [ { name: "read", @@ -168,7 +170,7 @@ describe("warmPythonEnvironment", () => { }; const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernel); - const result = await warmPythonEnvironment("/tmp/test", "session-1"); + const result = await warmPythonEnvironment(tempDir.path, "session-1"); expect(result.ok).toBe(true); expect(result.docs).toEqual(docs); diff --git a/packages/coding-agent/test/core/python-kernel-env.test.ts b/packages/coding-agent/test/core/python-kernel-env.test.ts index 7885ec243..73b9af06b 100644 --- a/packages/coding-agent/test/core/python-kernel-env.test.ts +++ b/packages/coding-agent/test/core/python-kernel-env.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { PythonKernel } from "../../src/core/python-kernel"; import { PYTHON_PRELUDE } from "../../src/core/python-prelude"; import * as shell from "../../src/utils/shell"; @@ -98,7 +99,8 @@ describe("PythonKernel.start (local gateway)", () => { .spyOn(PythonKernel.prototype, "execute") .mockResolvedValue({ status: "ok", cancelled: false, timedOut: false, stdinRequested: false }); - const kernel = await PythonKernel.start({ cwd: "/tmp/project", env: { CUSTOM_VAR: "ok" } }); + using tempDir = createTempDirSync("@python-kernel-env-"); + const kernel = await PythonKernel.start({ cwd: tempDir.path, env: { CUSTOM_VAR: "ok" } }); const createCall = fetchSpy.mock.calls.find(([input, init]) => { const url = typeof input === "string" ? input : input.toString(); @@ -117,7 +119,7 @@ describe("PythonKernel.start (local gateway)", () => { expect(spawnEnv?.CUSTOM_VAR).toBe("ok"); expect(spawnEnv?.OPENAI_API_KEY).toBeUndefined(); expect(spawnEnv?.UNSAFE_TOKEN).toBeUndefined(); - expect(spawnEnv?.PYTHONPATH).toBe("/tmp/project"); + expect(spawnEnv?.PYTHONPATH).toBe(tempDir.path); expect(executeSpy).toHaveBeenCalledWith( PYTHON_PRELUDE, diff --git a/packages/coding-agent/test/core/python-kernel-session.test.ts b/packages/coding-agent/test/core/python-kernel-session.test.ts index 95ff7fa38..f8d3e3d7f 100644 --- a/packages/coding-agent/test/core/python-kernel-session.test.ts +++ b/packages/coding-agent/test/core/python-kernel-session.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { disposeAllKernelSessions, executePython } from "../../src/core/python-executor"; import type { KernelExecuteOptions, KernelExecuteResult } from "../../src/core/python-kernel"; import { PythonKernel } from "../../src/core/python-kernel"; @@ -56,16 +57,18 @@ describe("executePython kernel reuse", () => { }); it("reuses kernels for session mode", async () => { - await executePython("print('one')", { cwd: "/tmp", sessionId: "session-a", kernelMode: "session" }); - await executePython("print('two')", { cwd: "/tmp", sessionId: "session-a", kernelMode: "session" }); + using tempDir = createTempDirSync("@python-kernel-session-"); + await executePython("print('one')", { cwd: tempDir.path, sessionId: "session-a", kernelMode: "session" }); + await executePython("print('two')", { cwd: tempDir.path, sessionId: "session-a", kernelMode: "session" }); expect(startCalls).toBe(1); expect(kernels[0]?.executeCalls).toBe(2); }); it("creates and disposes per-call kernels", async () => { - await executePython("print('one')", { cwd: "/tmp", kernelMode: "per-call" }); - await executePython("print('two')", { cwd: "/tmp", kernelMode: "per-call" }); + using tempDir = createTempDirSync("@python-kernel-session-"); + await executePython("print('one')", { cwd: tempDir.path, kernelMode: "per-call" }); + await executePython("print('two')", { cwd: tempDir.path, kernelMode: "per-call" }); expect(startCalls).toBe(2); expect(kernels[0]?.shutdownCalls).toBe(1); @@ -73,9 +76,10 @@ describe("executePython kernel reuse", () => { }); it("resets the session kernel when requested", async () => { - await executePython("print('one')", { cwd: "/tmp", sessionId: "session-b", kernelMode: "session" }); + using tempDir = createTempDirSync("@python-kernel-session-"); + await executePython("print('one')", { cwd: tempDir.path, sessionId: "session-b", kernelMode: "session" }); await executePython("print('two')", { - cwd: "/tmp", + cwd: tempDir.path, sessionId: "session-b", kernelMode: "session", reset: true, diff --git a/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts b/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts index 63a2dce64..be50ccd9a 100644 --- a/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts +++ b/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { rmSync } from "node:fs"; +import { createTempDirSync, type SyncTempDir } from "@oh-my-pi/pi-utils"; import type { Subprocess } from "bun"; import { PythonKernel } from "../../src/core/python-kernel"; @@ -69,8 +68,6 @@ const createResponse = (options: { ok: boolean; status?: number; json?: unknown; }; }; -const createTempDir = () => mkdtempSync(join(tmpdir(), "omp-python-kernel-")); - const createFakeProcess = (): Subprocess => { const exited = new Promise(() => undefined); return { pid: 999999, exited } as Subprocess; @@ -87,11 +84,11 @@ describe("PythonKernel gateway lifecycle", () => { const originalGatewayToken = process.env.OMP_PYTHON_GATEWAY_TOKEN; const originalBunEnv = process.env.BUN_ENV; - let tempDir: string; + let tempDir: SyncTempDir; let env: MockEnvironment; beforeEach(() => { - tempDir = createTempDir(); + tempDir = createTempDirSync("@omp-python-kernel-"); env = { fetchCalls: [], spawnCalls: [] }; process.env.BUN_ENV = "test"; @@ -124,7 +121,7 @@ describe("PythonKernel gateway lifecycle", () => { afterEach(() => { if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir.path, { recursive: true, force: true }); } if (originalBunEnv === undefined) { @@ -171,7 +168,7 @@ describe("PythonKernel gateway lifecycle", () => { return createResponse({ ok: true }) as unknown as Response; }) as typeof fetch; - const kernel = await PythonKernel.start({ cwd: tempDir, useSharedGateway: false }); + const kernel = await PythonKernel.start({ cwd: tempDir.path, useSharedGateway: false }); expect(env.spawnCalls).toHaveLength(1); expect(env.spawnCalls[0].cmd).toEqual( @@ -214,7 +211,7 @@ describe("PythonKernel gateway lifecycle", () => { return createResponse({ ok: true }) as unknown as Response; }) as typeof fetch; - await expect(PythonKernel.start({ cwd: tempDir, useSharedGateway: false })).rejects.toThrow( + await expect(PythonKernel.start({ cwd: tempDir.path, useSharedGateway: false })).rejects.toThrow( "Kernel gateway failed to start", ); expect(env.spawnCalls).toHaveLength(3); @@ -242,7 +239,7 @@ describe("PythonKernel gateway lifecycle", () => { return createResponse({ ok: true }) as unknown as Response; }) as typeof fetch; - const kernel = await PythonKernel.start({ cwd: tempDir }); + const kernel = await PythonKernel.start({ cwd: tempDir.path }); await expect(kernel.shutdown()).resolves.toBeUndefined(); }); diff --git a/packages/coding-agent/test/core/python-modules.test.ts b/packages/coding-agent/test/core/python-modules.test.ts index 18e256404..830c798ec 100644 --- a/packages/coding-agent/test/core/python-modules.test.ts +++ b/packages/coding-agent/test/core/python-modules.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { discoverPythonModules, loadPythonModules, type PythonModuleExecutor } from "../../src/core/python-modules"; const fixturesDir = resolve(import.meta.dir, "../../test/fixtures/python-modules"); @@ -14,22 +14,20 @@ const writeModule = (dir: string, name: string, tag: string) => { writeFileSync(join(dir, name), `${base}\n# ${tag}`); }; -const createTempRoot = () => mkdtempSync(join(tmpdir(), "omp-python-modules-")); - describe("python modules", () => { - let tempRoot: string | null = null; + let tempRoot: ReturnType | null = null; afterEach(() => { if (tempRoot) { - rmSync(tempRoot, { recursive: true, force: true }); + tempRoot.remove(); } tempRoot = null; }); it("discovers modules with project override and sorted order", async () => { - tempRoot = createTempRoot(); - const homeDir = join(tempRoot, "home"); - const cwd = join(tempRoot, "project"); + tempRoot = createTempDirSync("@omp-python-modules-"); + const homeDir = join(tempRoot.path, "home"); + const cwd = join(tempRoot.path, "project"); writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); writeModule(join(homeDir, ".pi", "agent", "modules"), "beta.py", "user-pi"); @@ -53,9 +51,9 @@ describe("python modules", () => { }); it("loads modules in sorted order with silent execution", async () => { - tempRoot = createTempRoot(); - const homeDir = join(tempRoot, "home"); - const cwd = join(tempRoot, "project"); + tempRoot = createTempDirSync("@omp-python-modules-"); + const homeDir = join(tempRoot.path, "home"); + const cwd = join(tempRoot.path, "project"); writeModule(join(homeDir, ".omp", "agent", "modules"), "beta.py", "user-omp"); writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); @@ -77,9 +75,9 @@ describe("python modules", () => { }); it("fails fast when a module fails to execute", async () => { - tempRoot = createTempRoot(); - const homeDir = join(tempRoot, "home"); - const cwd = join(tempRoot, "project"); + tempRoot = createTempDirSync("@omp-python-modules-"); + const homeDir = join(tempRoot.path, "home"); + const cwd = join(tempRoot.path, "project"); writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp"); diff --git a/packages/coding-agent/test/core/streaming-output.test.ts b/packages/coding-agent/test/core/streaming-output.test.ts deleted file mode 100644 index 8e5c80a9e..000000000 --- a/packages/coding-agent/test/core/streaming-output.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { OutputSink } from "../../src/core/streaming-output"; - -function makeLargeOutput(size: number): string { - return "x".repeat(size); -} - -describe("OutputSink", () => { - it("spills to disk and truncates large output", async () => { - const largeOutput = makeLargeOutput(60_000); - const sink = new OutputSink(10, 70_000); - const writer = sink.getWriter(); - - await writer.write(largeOutput); - await writer.close(); - - const result = sink.dump(); - - expect(result.truncated).toBe(true); - expect(result.fullOutputPath).toBeDefined(); - expect(result.output.length).toBeLessThan(largeOutput.length); - - const fullOutput = await Bun.file(result.fullOutputPath!).text(); - expect(fullOutput).toBe(largeOutput); - }); -}); diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 5d646cc0e..badb165e2 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -3,22 +3,23 @@ import * as fs from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe("extensions discovery", () => { - let tempDir: string; + let tempDir: ReturnType; let extensionsDir: string; beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(tmpdir(), "pi-ext-test-")); - extensionsDir = path.join(tempDir, ".omp", "extensions"); + tempDir = createTempDirSync(path.join(tmpdir(), "pi-ext-test-")); + extensionsDir = path.join(tempDir.path, ".omp", "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir.remove(); }); const extensionCode = ` @@ -44,7 +45,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode); fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); @@ -54,7 +55,7 @@ describe("extensions discovery", () => { it("discovers direct .js files in extensions/", async () => { fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -66,7 +67,7 @@ describe("extensions discovery", () => { fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -79,7 +80,7 @@ describe("extensions discovery", () => { fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -92,7 +93,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -115,7 +116,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -138,7 +139,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); @@ -159,7 +160,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -181,7 +182,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -194,7 +195,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); @@ -208,7 +209,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(nested, "index.ts"), extensionCode); // No index.ts or package.json in container/ - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); @@ -229,7 +230,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode); fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } })); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(3); @@ -248,7 +249,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -258,7 +259,7 @@ describe("extensions discovery", () => { it("loads extensions and registers commands", async () => { fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -268,7 +269,7 @@ describe("extensions discovery", () => { it("loads extensions and registers tools", async () => { fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool")); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -278,7 +279,7 @@ describe("extensions discovery", () => { it("reports errors for invalid extension code", async () => { fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export"); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(1); expect(result.errors[0].path).toContain("invalid.ts"); @@ -286,11 +287,11 @@ describe("extensions discovery", () => { }); it("handles explicitly configured paths", async () => { - const customPath = path.join(tempDir, "custom-location", "my-ext.ts"); + const customPath = path.join(tempDir.path, "custom-location", "my-ext.ts"); fs.mkdirSync(path.dirname(customPath), { recursive: true }); fs.writeFileSync(customPath, extensionCode); - const result = await discoverAndLoadExtensions([customPath], tempDir); + const result = await discoverAndLoadExtensions([customPath], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -301,7 +302,7 @@ describe("extensions discovery", () => { // Load the real chalk-logger extension from examples const chalkLoggerPath = path.resolve(__dirname, "../examples/extensions/chalk-logger.ts"); - const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir); + const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -314,7 +315,7 @@ describe("extensions discovery", () => { // Load extension that has its own package.json and node_modules with 'ms' package const extPath = path.resolve(__dirname, "../examples/extensions/with-deps"); - const result = await discoverAndLoadExtensions([extPath], tempDir); + const result = await discoverAndLoadExtensions([extPath], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -333,7 +334,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -348,7 +349,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(1); expect(result.errors[0].error).toContain("Initialization failed!"); @@ -363,7 +364,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(1); expect(result.errors[0].error).toContain("does not export a valid factory function"); @@ -374,7 +375,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a")); fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b")); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); @@ -399,7 +400,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -419,7 +420,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -437,7 +438,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); + const result = await discoverAndLoadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -449,11 +450,11 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCodeWithTool("discovered")); // Create explicit extension outside discovery path - const explicitPath = path.join(tempDir, "explicit.ts"); + const explicitPath = path.join(tempDir.path, "explicit.ts"); fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit")); // Use loadExtensions directly to skip discovery - const result = await loadExtensions([explicitPath], tempDir); + const result = await loadExtensions([explicitPath], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -466,7 +467,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode); // Use loadExtensions directly with empty paths - const result = await loadExtensions([], tempDir); + const result = await loadExtensions([], tempDir.path); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 1e92c9a07..128e41fbd 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -3,33 +3,32 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { createTempDirSync, logger } from "@oh-my-pi/pi-utils"; import { AuthStorage } from "../src/core/auth-storage"; import { discoverAndLoadExtensions } from "../src/core/extensions/loader"; import { ExtensionRunner } from "../src/core/extensions/runner"; -import { logger } from "../src/core/logger"; import { ModelRegistry } from "../src/core/model-registry"; import { SessionManager } from "../src/core/session-manager"; describe("ExtensionRunner", () => { - let tempDir: string; + let tempDir: ReturnType; let extensionsDir: string; let sessionManager: SessionManager; let modelRegistry: ModelRegistry; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "pi-runner-test-")); - extensionsDir = join(tempDir, ".omp", "extensions"); + tempDir = createTempDirSync("@pi-runner-test-"); + extensionsDir = join(tempDir.path, ".omp", "extensions"); mkdirSync(extensionsDir, { recursive: true }); sessionManager = SessionManager.inMemory(); - const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const authStorage = new AuthStorage(join(tempDir.path, "auth.json")); modelRegistry = new ModelRegistry(authStorage); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + tempDir.remove(); }); describe("shortcut conflicts", () => { @@ -46,8 +45,14 @@ describe("ExtensionRunner", () => { const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const shortcuts = runner.getShortcuts(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"), expect.any(Object)); @@ -79,8 +84,14 @@ describe("ExtensionRunner", () => { const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const shortcuts = runner.getShortcuts(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict"), expect.any(Object)); @@ -108,8 +119,14 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "tool-a.ts"), toolCode("tool_a")); writeFileSync(join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const tools = runner.getAllRegisteredTools(); expect(tools.length).toBe(2); @@ -130,8 +147,14 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); writeFileSync(join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const commands = runner.getRegisteredCommands(); expect(commands.length).toBe(2); @@ -149,8 +172,14 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "cmd.ts"), cmdCode); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const cmd = runner.getCommand("my-cmd"); expect(cmd).toBeDefined(); @@ -173,8 +202,14 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "throws.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const errors: Array<{ extensionPath: string; event: string; error: string }> = []; runner.onError((err) => { @@ -199,8 +234,14 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "renderer.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const renderer = runner.getMessageRenderer("my-type"); expect(renderer).toBeDefined(); @@ -222,8 +263,14 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "with-flag.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); const flags = runner.getFlags(); expect(flags.has("--my-flag")).toBe(true); @@ -240,8 +287,14 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "flag.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); // Setting a flag value should not throw runner.setFlagValue("--test-flag", true); @@ -260,8 +313,14 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "handler.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const result = await discoverAndLoadExtensions([], tempDir.path); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path, + sessionManager, + modelRegistry, + ); expect(runner.hasHandlers("tool_call")).toBe(true); expect(runner.hasHandlers("agent_end")).toBe(false); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 388ef455c..c02d9e57a 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -53,7 +53,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(messageEndEvents.length).toBeGreaterThanOrEqual(2); // user + assistant // Wait for file writes - await new Promise((resolve) => setTimeout(resolve, 200)); + await Bun.sleep(200); // Verify session file const sessionsPath = join(sessionDir, "sessions"); @@ -96,7 +96,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(result.tokensBefore).toBeGreaterThan(0); // Wait for file writes - await new Promise((resolve) => setTimeout(resolve, 200)); + await Bun.sleep(200); // Verify compaction in session file const sessionsPath = join(sessionDir, "sessions"); @@ -134,7 +134,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T await client.bash(`echo ${uniqueValue}`); // Wait for file writes - await new Promise((resolve) => setTimeout(resolve, 200)); + await Bun.sleep(200); // Verify bash message in session const sessionsPath = join(sessionDir, "sessions"); diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts new file mode 100644 index 000000000..70fc5ec93 --- /dev/null +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -0,0 +1,256 @@ +/** + * Streaming edit abort tests. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import { + type AssistantMessage, + type AssistantMessageEvent, + EventStream, + getModel, + type StopReason, + type ToolCall, +} from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { nanoid } from "nanoid"; +import { AgentSession } from "../src/core/agent-session"; +import { AuthStorage } from "../src/core/auth-storage"; +import { ModelRegistry } from "../src/core/model-registry"; +import { SessionManager } from "../src/core/session-manager"; +import { SettingsManager } from "../src/core/settings-manager"; + +class MockAssistantStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { + return { + role: "assistant", + content, + api: "anthropic-messages", + provider: "anthropic", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + timestamp: Date.now(), + }; +} + +function createToolCall(id: string, args: Record): ToolCall { + return { + type: "toolCall", + id, + name: "edit", + arguments: args, + }; +} + +function lastAssistantMessage(messages: Array<{ role: string }>): AssistantMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") return msg as AssistantMessage; + } + return undefined; +} + +function createRng(seed: number): () => number { + let state = seed % 2147483647; + if (state <= 0) state += 2147483646; + return () => { + state = (state * 48271) % 2147483647; + return state / 2147483647; + }; +} + +function chunkStringRandomly(text: string, seed: number): string[] { + const rand = createRng(seed); + const chunks: string[] = []; + let offset = 0; + while (offset < text.length) { + const remaining = text.length - offset; + const maxSize = Math.min(8, remaining); + const size = Math.max(1, Math.floor(rand() * maxSize) + 1); + chunks.push(text.slice(offset, offset + size)); + offset += size; + } + return chunks; +} + +async function createSession(tempDir: string, streamFn: Agent["streamFn"], tool: AgentTool): Promise { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: "Test", + tools: [tool], + }, + streamFn, + }); + + const sessionManager = SessionManager.inMemory(tempDir); + const settingsManager = SettingsManager.inMemory({ edit: { streamingAbort: true } }); + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, tempDir); + + return new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); +} + +function buildEditTool(): AgentTool { + const schema = Type.Object({ + path: Type.String(), + diff: Type.String(), + op: Type.Optional(Type.String()), + rename: Type.Optional(Type.String()), + }); + + return { + name: "edit", + label: "Edit", + description: "", + parameters: schema, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }; +} + +function createStreamForDiff( + path: string, + chunks: string[], + abortSignalRef: { current?: AbortSignal }, +): Agent["streamFn"] { + return (_model, _context, options) => { + abortSignalRef.current = options?.signal; + const stream = new MockAssistantStream(); + const toolCallId = "call_edit_1"; + let diffSoFar = ""; + let aborted = false; + + const notifyAbort = () => { + if (aborted) return; + aborted = true; + const partialCall = createToolCall(toolCallId, { path, diff: diffSoFar }); + stream.push({ + type: "toolcall_delta", + contentIndex: 0, + delta: "", + partial: createAssistantMessage([partialCall], "stop"), + }); + stream.push({ type: "error", reason: "aborted", error: createAssistantMessage([], "aborted") }); + }; + + options?.signal?.addEventListener("abort", notifyAbort, { once: true }); + + queueMicrotask(async () => { + const startMessage = createAssistantMessage([], "stop"); + stream.push({ type: "start", partial: startMessage }); + + const startCall = createToolCall(toolCallId, { path, diff: "" }); + stream.push({ type: "toolcall_start", contentIndex: 0, partial: createAssistantMessage([startCall], "stop") }); + + for (const chunk of chunks) { + if (aborted) return; + diffSoFar += chunk; + const partialCall = createToolCall(toolCallId, { path, diff: diffSoFar }); + stream.push({ + type: "toolcall_delta", + contentIndex: 0, + delta: chunk, + partial: createAssistantMessage([partialCall], "stop"), + }); + await Bun.sleep(0); + } + + if (aborted) return; + + const finalCall = createToolCall(toolCallId, { path, diff: diffSoFar }); + const finalMessage = createAssistantMessage([finalCall], "toolUse"); + stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: finalCall, partial: finalMessage }); + stream.push({ type: "done", reason: "toolUse", message: finalMessage }); + }); + + return stream; + }; +} + +describe("streaming edit abort", () => { + let tempDir: string; + const editTool = buildEditTool(); + const seeds = [7, 21, 42, 84, 128]; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-streaming-edit-${nanoid()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("does not abort for successful patches across random streams", async () => { + await Bun.write(join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); + const diff = "@@\n-beta\n+beta2\n"; + + for (const seed of seeds) { + const chunks = chunkStringRandomly(diff, seed); + const abortSignalRef: { current?: AbortSignal } = {}; + const streamFn = createStreamForDiff("sample.txt", chunks, abortSignalRef); + const session = await createSession(tempDir, streamFn, editTool); + + await session.prompt("apply patch"); + + const lastAssistant = lastAssistantMessage(session.state.messages); + expect(lastAssistant?.stopReason).not.toBe("aborted"); + expect(abortSignalRef.current?.aborted ?? false).toBe(false); + await session.dispose(); + } + }); + + it("aborts for failing patches across random streams", async () => { + await Bun.write(join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); + const diff = "@@\n-omega\n+beta2\n"; + + for (const seed of seeds) { + const chunks = chunkStringRandomly(diff, seed); + const abortSignalRef: { current?: AbortSignal } = {}; + const streamFn = createStreamForDiff("sample.txt", chunks, abortSignalRef); + const session = await createSession(tempDir, streamFn, editTool); + + await session.prompt("apply patch"); + + const lastAssistant = lastAssistantMessage(session.state.messages); + expect(lastAssistant?.stopReason).toBe("aborted"); + expect(abortSignalRef.current?.aborted ?? false).toBe(true); + await session.dispose(); + } + }); +}); diff --git a/packages/coding-agent/test/streaming-render-debug.ts b/packages/coding-agent/test/streaming-render-debug.ts index bdac62f18..7df364e26 100644 --- a/packages/coding-agent/test/streaming-render-debug.ts +++ b/packages/coding-agent/test/streaming-render-debug.ts @@ -8,6 +8,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui"; +import { sleep } from "bun"; import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message"; import { initTheme } from "../src/modes/interactive/theme/theme"; @@ -32,10 +33,6 @@ if (!thinkingContent || thinkingContent.type !== "thinking") { const fullThinkingText = thinkingContent.thinking; const fullTextContent = textContent && textContent.type === "text" ? textContent.text : ""; -async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - async function main() { const terminal = new ProcessTerminal(); const tui = new TUI(terminal); diff --git a/packages/coding-agent/test/tools/lsp-batching.test.ts b/packages/coding-agent/test/tools/lsp-batching.test.ts index a99dc8f3d..3749b1a6c 100644 --- a/packages/coding-agent/test/tools/lsp-batching.test.ts +++ b/packages/coding-agent/test/tools/lsp-batching.test.ts @@ -1,20 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import * as lspConfig from "../../src/core/tools/lsp/config"; import { createLspWritethrough } from "../../src/core/tools/lsp/index"; describe("createLspWritethrough batching", () => { - let tempDir: string; + let tempDir: ReturnType; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "omp-lsp-batch-")); + tempDir = createTempDirSync("@omp-lsp-batch-"); }); afterEach(() => { vi.restoreAllMocks(); - rmSync(tempDir, { recursive: true, force: true }); + tempDir.remove(); }); it("defers LSP work until the batch flush", async () => { @@ -22,10 +21,10 @@ describe("createLspWritethrough batching", () => { .spyOn(lspConfig, "loadConfig") .mockResolvedValue({ servers: {}, idleTimeoutMs: undefined }); const getServersSpy = vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]); - const writethrough = createLspWritethrough(tempDir, { enableFormat: true, enableDiagnostics: true }); + const writethrough = createLspWritethrough(tempDir.path, { enableFormat: true, enableDiagnostics: true }); - const fileA = join(tempDir, "a.ts"); - const fileB = join(tempDir, "b.ts"); + const fileA = join(tempDir.path, "a.ts"); + const fileB = join(tempDir.path, "b.ts"); const batchId = `batch-${Date.now()}`; const firstResult = await writethrough(fileA, "const a = 1;\n", undefined, undefined, { @@ -55,9 +54,9 @@ describe("createLspWritethrough batching", () => { .spyOn(lspConfig, "loadConfig") .mockResolvedValue({ servers: {}, idleTimeoutMs: undefined }); const getServersSpy = vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]); - const writethrough = createLspWritethrough(tempDir, { enableFormat: true, enableDiagnostics: true }); + const writethrough = createLspWritethrough(tempDir.path, { enableFormat: true, enableDiagnostics: true }); - const filePath = join(tempDir, "single.ts"); + const filePath = join(tempDir.path, "single.ts"); const result = await writethrough(filePath, "const single = true;\n"); expect(result).toBeUndefined(); diff --git a/packages/coding-agent/test/tools/python-execution.test.ts b/packages/coding-agent/test/tools/python-execution.test.ts index 735de5c55..12f7c4b63 100644 --- a/packages/coding-agent/test/tools/python-execution.test.ts +++ b/packages/coding-agent/test/tools/python-execution.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it, vi } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import * as pythonExecutor from "../../src/core/python-executor"; import type { ToolSession } from "../../src/core/tools/index"; import { PythonTool } from "../../src/core/tools/python"; @@ -30,7 +28,7 @@ function createSession(cwd: string): ToolSession { describe("python tool execution", () => { it("passes kernel options from settings and args", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "python-tool-")); + const tempDir = createTempDirSync("@python-tool-"); const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({ output: "ok", exitCode: 0, @@ -40,10 +38,10 @@ describe("python tool execution", () => { stdinRequested: false, }); - const tool = new PythonTool(createSession(tempDir)); + const tool = new PythonTool(createSession(tempDir.path)); const result = await tool.execute( "call-id", - { code: "print('hi')", timeout: 5, workdir: tempDir, reset: true }, + { code: "print('hi')", timeout: 5, workdir: tempDir.path, reset: true }, undefined, undefined, undefined, @@ -52,9 +50,9 @@ describe("python tool execution", () => { expect(executeSpy).toHaveBeenCalledWith( "print('hi')", expect.objectContaining({ - cwd: tempDir, + cwd: tempDir.path, timeout: 5000, - sessionId: `session:session-file:workdir:${tempDir}`, + sessionId: `session:session-file:workdir:${tempDir.path}`, kernelMode: "per-call", reset: true, }), @@ -63,6 +61,6 @@ describe("python tool execution", () => { expect(text).toBe("ok"); executeSpy.mockRestore(); - rmSync(tempDir, { recursive: true, force: true }); + tempDir.remove(); }); }); diff --git a/packages/coding-agent/test/tools/python.test.ts b/packages/coding-agent/test/tools/python.test.ts index 0b049e31b..d810e6ec0 100644 --- a/packages/coding-agent/test/tools/python.test.ts +++ b/packages/coding-agent/test/tools/python.test.ts @@ -1,11 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "bun:test"; +import { createTempDirSync, type SyncTempDir } from "@oh-my-pi/pi-utils"; import * as pythonExecutor from "../../src/core/python-executor"; import { createTools, type ToolSession } from "../../src/core/tools/index"; import { PythonTool } from "../../src/core/tools/python"; let previousSkipCheck: string | undefined; - +let tempDir: SyncTempDir; beforeAll(() => { + tempDir = createTempDirSync("@omp-python-test-"); previousSkipCheck = process.env.OMP_PYTHON_SKIP_CHECK; process.env.OMP_PYTHON_SKIP_CHECK = "1"; }); @@ -16,11 +18,12 @@ afterAll(() => { return; } process.env.OMP_PYTHON_SKIP_CHECK = previousSkipCheck; + tempDir.remove(); }); function createSession(overrides: Partial = {}): ToolSession { return { - cwd: "/tmp/test", + cwd: tempDir.path, hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", diff --git a/packages/coding-agent/test/worktree/collapse.test.ts b/packages/coding-agent/test/worktree/collapse.test.ts index 933a8f9a8..e698c3c02 100644 --- a/packages/coding-agent/test/worktree/collapse.test.ts +++ b/packages/coding-agent/test/worktree/collapse.test.ts @@ -1,37 +1,37 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import * as os from "node:os"; +import { rm } from "node:fs/promises"; import * as path from "node:path"; +import { type AsyncTempDir, createTempDir } from "@oh-my-pi/pi-utils"; import { collapse } from "../../src/lib/worktree/collapse"; import { WORKTREE_BASE } from "../../src/lib/worktree/constants"; import { getRepoRoot, git } from "../../src/lib/worktree/git"; import { create } from "../../src/lib/worktree/operations"; -let repoPath: string; +let repoPath: AsyncTempDir; let originalCwd: string; -async function createTestRepo(): Promise { - const dir = await mkdtemp(path.join(os.tmpdir(), "wt-test-")); - await git(["init", "-b", "main"], dir); - await git(["config", "user.email", "test@example.com"], dir); - await git(["config", "user.name", "Test User"], dir); - await Bun.write(path.join(dir, "README.md"), "init"); - await git(["add", "README.md"], dir); - await git(["commit", "-m", "init"], dir); - return dir; +async function createTestRepo(): Promise { + const tempDir = await createTempDir("@wt-test-"); + await git(["init", "-b", "main"], tempDir.path); + await git(["config", "user.email", "test@example.com"], tempDir.path); + await git(["config", "user.name", "Test User"], tempDir.path); + await Bun.write(path.join(tempDir.path, "README.md"), "init"); + await git(["add", "README.md"], tempDir.path); + await git(["commit", "-m", "init"], tempDir.path); + return tempDir; } -async function cleanupRepo(repoRoot: string): Promise { - const repoName = path.basename(repoRoot); +async function cleanupRepo(repoRoot: AsyncTempDir): Promise { + const repoName = path.basename(repoRoot.path); await rm(path.join(WORKTREE_BASE, repoName), { recursive: true, force: true }); - await rm(repoRoot, { recursive: true, force: true }); + await repoRoot.remove(); } describe("collapse strategies", () => { beforeEach(async () => { originalCwd = process.cwd(); repoPath = await createTestRepo(); - process.chdir(repoPath); + process.chdir(repoPath.path); }); afterEach(async () => { diff --git a/packages/coding-agent/test/worktree/operations.test.ts b/packages/coding-agent/test/worktree/operations.test.ts index eea56823d..d49768666 100644 --- a/packages/coding-agent/test/worktree/operations.test.ts +++ b/packages/coding-agent/test/worktree/operations.test.ts @@ -1,43 +1,35 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; -import { WORKTREE_BASE } from "../../src/lib/worktree/constants"; +import { type AsyncTempDir, createTempDir } from "@oh-my-pi/pi-utils"; import { WorktreeError } from "../../src/lib/worktree/errors"; import { git } from "../../src/lib/worktree/git"; import { create, find, list, remove } from "../../src/lib/worktree/operations"; -let repoPath: string; +let repoPath: AsyncTempDir; let originalCwd: string; -async function createTestRepo(): Promise { - const dir = await mkdtemp(path.join(os.tmpdir(), "wt-test-")); - await git(["init", "-b", "main"], dir); - await git(["config", "user.email", "test@example.com"], dir); - await git(["config", "user.name", "Test User"], dir); - await Bun.write(path.join(dir, "README.md"), "init"); - await git(["add", "README.md"], dir); - await git(["commit", "-m", "init"], dir); - return dir; -} - -async function cleanupRepo(repoRoot: string): Promise { - const repoName = path.basename(repoRoot); - await rm(path.join(WORKTREE_BASE, repoName), { recursive: true, force: true }); - await rm(repoRoot, { recursive: true, force: true }); +async function createTestRepo(): Promise { + const tempDir = await createTempDir("@omp-wt-test-"); + await git(["init", "-b", "main"], tempDir.path); + await git(["config", "user.email", "test@example.com"], tempDir.path); + await git(["config", "user.name", "Test User"], tempDir.path); + await Bun.write(path.join(tempDir.path, "README.md"), "init"); + await git(["add", "README.md"], tempDir.path); + await git(["commit", "-m", "init"], tempDir.path); + return tempDir; } describe("worktree operations", () => { beforeEach(async () => { originalCwd = process.cwd(); repoPath = await createTestRepo(); - process.chdir(repoPath); + process.chdir(repoPath.path); }); afterEach(async () => { process.chdir(originalCwd); - await cleanupRepo(repoPath); + await repoPath.remove(); }); test("create worktree with new branch", async () => { @@ -47,7 +39,7 @@ describe("worktree operations", () => { }); test("create worktree with existing branch", async () => { - await git(["branch", "existing-branch"], repoPath); + await git(["branch", "existing-branch"], repoPath.path); const wt = await create("existing-branch"); expect(wt.branch).toBe("existing-branch"); }); diff --git a/packages/git-tool/package.json b/packages/git-tool/package.json index 637402480..9f7985a0e 100644 --- a/packages/git-tool/package.json +++ b/packages/git-tool/package.json @@ -20,6 +20,9 @@ "build": "tsgo -p tsconfig.build.json", "test": "vitest --run" }, + "dependencies": { + "@oh-my-pi/pi-utils": "workspace:*" + }, "devDependencies": { "@types/node": "^24.3.0", "vitest": "^3.2.4" diff --git a/packages/git-tool/test/cache.test.ts b/packages/git-tool/test/cache.test.ts index fec6ac408..dabb5da35 100644 --- a/packages/git-tool/test/cache.test.ts +++ b/packages/git-tool/test/cache.test.ts @@ -1,30 +1,29 @@ -import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { gitTool } from "../src/git-tool"; import type { StatusResult, ToolResult } from "../src/types"; -import { cleanupRepo, createTestRepo, runGit, writeFile } from "./helpers"; +import { createTestRepo, type TestRepo } from "./helpers"; -let repoDir: string; +let repo: TestRepo; let previousCwd: string; beforeEach(() => { previousCwd = process.cwd(); - repoDir = createTestRepo(); - process.chdir(repoDir); + repo = createTestRepo(); + process.chdir(repo.path); }); afterEach(() => { process.chdir(previousCwd); - cleanupRepo(repoDir); + repo.remove(); }); describe("git-tool cache", () => { it("invalidates status cache on write operations", async () => { - writeFile(join(repoDir, "file.txt"), "hello"); - runGit(["add", "file.txt"], repoDir); - runGit(["commit", "-m", "initial"], repoDir); + repo.writeFile("file.txt", "hello"); + repo.run("add", "file.txt"); + repo.run("commit", "-m", "initial"); - writeFile(join(repoDir, "file.txt"), "hello world"); + repo.writeFile("file.txt", "hello world"); const status1 = (await gitTool({ operation: "status" })) as ToolResult; expect(status1.data.modified.map((file) => file.path)).toContain("file.txt"); diff --git a/packages/git-tool/test/helpers.ts b/packages/git-tool/test/helpers.ts index 6e72f6fab..27e6650ce 100644 --- a/packages/git-tool/test/helpers.ts +++ b/packages/git-tool/test/helpers.ts @@ -1,44 +1,46 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { type ExecResult, git } from "../src/utils"; -export interface GitRunResult { - stdout: string; - stderr: string; - exitCode: number; +export { git, type ExecResult }; + +export interface TestRepo { + readonly path: string; + run(...args: string[]): ExecResult; + remove(): void; + writeFile(path: string, content: string): void; } -export function runGit(args: string[], cwd: string): GitRunResult { - const result = Bun.spawnSync(["git", ...args], { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - return { - stdout: Buffer.from(result.stdout ?? []).toString(), - stderr: Buffer.from(result.stderr ?? []).toString(), - exitCode: result.exitCode ?? 0, +export function createTestRepo(): TestRepo { + const tempDir = createTempDirSync("@wt-test-"); + const repo = { + _tempDir: tempDir, + path: tempDir.path, + remove: () => tempDir.remove(), + run: (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { + cwd: tempDir.path, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + exitCode: result.exitCode, + }; + }, + writeFile(path: string, content: string) { + writeFileSync(join(tempDir.path, path), content); + }, }; + repo.run("init", "-b", "main"); + repo.run("config", "user.email", "test@example.com"); + repo.run("config", "user.name", "Test User"); + return repo; } -export function createTestRepo(): string { - const dir = mkdtempSync(join(tmpdir(), "git-tool-test-")); - let result = runGit(["init"], dir); - if (result.exitCode !== 0) { - throw new Error(result.stderr); - } - result = runGit(["config", "user.email", "test@example.com"], dir); - if (result.exitCode !== 0) throw new Error(result.stderr); - result = runGit(["config", "user.name", "Test User"], dir); - if (result.exitCode !== 0) throw new Error(result.stderr); - return dir; -} - -export function writeFile(path: string, content: string): void { - writeFileSync(path, content, "utf-8"); -} - -export function cleanupRepo(dir: string): void { - rmSync(dir, { recursive: true, force: true }); +export function writeFile(path: string, content: string) { + writeFileSync(path, content); } diff --git a/packages/git-tool/test/safety.test.ts b/packages/git-tool/test/safety.test.ts index 4fbc6dfb6..61ae5b18a 100644 --- a/packages/git-tool/test/safety.test.ts +++ b/packages/git-tool/test/safety.test.ts @@ -1,30 +1,28 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { checkSafety } from "../src/safety/guards"; -import { cleanupRepo, createTestRepo, runGit, writeFile } from "./helpers"; +import { createTestRepo, type TestRepo } from "./helpers"; -let repoDir: string; +let repo: TestRepo; let previousCwd: string; beforeEach(() => { previousCwd = process.cwd(); - repoDir = createTestRepo(); - process.chdir(repoDir); + repo = createTestRepo(); + process.chdir(repo.path); }); afterEach(() => { process.chdir(previousCwd); - cleanupRepo(repoDir); + repo.remove(); }); describe("git-tool safety", () => { it("blocks force push to protected branch", async () => { - writeFile(join(repoDir, "init.txt"), "init"); - runGit(["add", "."], repoDir); - runGit(["commit", "-m", "init"], repoDir); - runGit(["branch", "-M", "main"], repoDir); + repo.writeFile("init.txt", "init"); + repo.run("add", "."); + repo.run("commit", "-m", "init"); + repo.run("branch", "-M", "main"); const result = await checkSafety("push", { force: true }); expect(result.blocked).toBe(true); }); @@ -44,36 +42,32 @@ describe("git-tool safety", () => { }); it("blocks amend when HEAD is pushed", async () => { - runGit(["branch", "-M", "main"], repoDir); - writeFile(join(repoDir, "file.txt"), "hello"); - runGit(["add", "file.txt"], repoDir); - runGit(["commit", "-m", "initial"], repoDir); + repo.run("branch", "-M", "main"); + repo.writeFile("file.txt", "hello"); + repo.run("add", "file.txt"); + repo.run("commit", "-m", "initial"); - const remoteDir = mkdtempSync(join(tmpdir(), "git-tool-remote-")); - runGit(["init", "--bare"], remoteDir); - runGit(["remote", "add", "origin", remoteDir], repoDir); - runGit(["push", "-u", "origin", "main"], repoDir); + const remoteDir = createTempDirSync("@git-tool-remote-"); + Bun.spawnSync(["git", "init", "--bare"], { cwd: remoteDir.path }); + repo.run("remote", "add", "origin", remoteDir.path); + repo.run("push", "-u", "origin", "main"); const result = await checkSafety("commit", { amend: true }); expect(result.blocked).toBe(true); - - cleanupRepo(remoteDir); }); it("blocks rebase when HEAD is pushed", async () => { - runGit(["branch", "-M", "main"], repoDir); - writeFile(join(repoDir, "file.txt"), "hello"); - runGit(["add", "file.txt"], repoDir); - runGit(["commit", "-m", "initial"], repoDir); + repo.run("branch", "-M", "main"); + repo.writeFile("file.txt", "hello"); + repo.run("add", "file.txt"); + repo.run("commit", "-m", "initial"); - const remoteDir = mkdtempSync(join(tmpdir(), "git-tool-remote-")); - runGit(["init", "--bare"], remoteDir); - runGit(["remote", "add", "origin", remoteDir], repoDir); - runGit(["push", "-u", "origin", "main"], repoDir); + const remoteDir = createTempDirSync("@git-tool-remote-"); + Bun.spawnSync(["git", "init", "--bare"], { cwd: remoteDir.path }); + repo.run("remote", "add", "origin", remoteDir.path); + repo.run("push", "-u", "origin", "main"); const result = await checkSafety("rebase", {}); expect(result.blocked).toBe(true); - - cleanupRepo(remoteDir); }); }); diff --git a/packages/pi-utils/package.json b/packages/pi-utils/package.json new file mode 100644 index 000000000..0e7e50fd4 --- /dev/null +++ b/packages/pi-utils/package.json @@ -0,0 +1,41 @@ +{ + "name": "@oh-my-pi/pi-utils", + "version": "6.7.670", + "description": "Shared utilities for pi packages", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "files": [ + "src", + "README.md" + ], + "scripts": { + "check": "tsgo --noEmit", + "build": "tsgo -p tsconfig.build.json", + "test": "vitest --run" + }, + "author": "Can Bölük", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/pi-utils" + }, + "dependencies": { + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0", + "strip-ansi": "^7.1.2" + }, + "devDependencies": { + "@types/node": "^24.3.0" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/packages/pi-utils/src/abortable.ts b/packages/pi-utils/src/abortable.ts new file mode 100644 index 000000000..45e4f7124 --- /dev/null +++ b/packages/pi-utils/src/abortable.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; + +export class AbortError extends Error { + constructor(signal: AbortSignal) { + assert(signal.aborted, "Abort signal must be aborted"); + + const message = signal.reason instanceof Error ? signal.reason.message : "Cancelled"; + super(`Aborted: ${message}`, { cause: message }); + this.name = "AbortError"; + this.cause = signal.reason; + } +} + +/** + * Sleep for a given number of milliseconds, respecting abort signal. + */ +export async function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return untilAborted(signal, () => Bun.sleep(ms)); +} + +/** + * Creates a deferred { promise, resolve, reject } triple which automatically rejects + * with { name: "AbortError" } if the given abort signal fires before resolve/reject. + * + * @param signal - Optional AbortSignal to cancel the operation + * @returns A deferred { promise, resolve, reject } triple + */ +export function createAbortablePromise(signal?: AbortSignal): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + if (!signal) { + return Promise.withResolvers(); + } else if (signal.aborted) { + return { promise: Promise.reject(new AbortError(signal)), resolve: () => {}, reject: () => {} }; + } + + const { promise, resolve, reject } = Promise.withResolvers(); + + const abortHandler = () => { + reject(new AbortError(signal)); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + promise.finally(() => { + signal.removeEventListener("abort", abortHandler); + }); + return { promise, resolve, reject }; +} + +/** + * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during + * execution, the promise is rejected with a standard error. + * + * @param signal - Optional AbortSignal to cancel the operation + * @param pr - Function returning a promise to run + * @returns Promise resolving as `pr` would, or rejecting on abort + */ +export function untilAborted(signal: AbortSignal | undefined | null, pr: () => Promise): Promise { + if (!signal) { + return pr(); + } else if (signal.aborted) { + return Promise.reject(new AbortError(signal)); + } + const { promise, resolve, reject } = createAbortablePromise(signal); + pr().then(resolve, reject); + return promise; +} + +/** + * Memoizes a function with no arguments, calling it once and caching the result. + * + * @param fn - Function to be called once + * @returns A function that returns the cached result of `fn` + */ +export function once(fn: () => T): () => T { + let store = undefined as { value: T } | undefined; + return () => { + if (store) { + return store.value; + } + const value = fn(); + store = { value }; + return value; + }; +} diff --git a/packages/pi-utils/src/index.ts b/packages/pi-utils/src/index.ts new file mode 100644 index 000000000..bcaa102fc --- /dev/null +++ b/packages/pi-utils/src/index.ts @@ -0,0 +1,7 @@ +export * from "./abortable"; +export * as logger from "./logger"; +export * as postmortem from "./postmortem"; +export * as ptree from "./ptree"; +export { AbortError, ChildProcess, cspawn, Exception, NonZeroExitError } from "./ptree"; +export * from "./stream"; +export * from "./temp"; diff --git a/packages/coding-agent/src/core/logger.ts b/packages/pi-utils/src/logger.ts similarity index 80% rename from packages/coding-agent/src/core/logger.ts rename to packages/pi-utils/src/logger.ts index 3a3bd0432..d92c83e2c 100644 --- a/packages/coding-agent/src/core/logger.ts +++ b/packages/pi-utils/src/logger.ts @@ -79,33 +79,33 @@ export interface Logger { * * @example * ```typescript - * import { logger } from "../core/logger"; + * import { logger } from "@oh-my-pi/pi-utils"; * * logger.error("MCP request failed", { url, method }); * logger.warn("Theme file invalid, using fallback", { path }); * logger.debug("LSP fallback triggered", { reason }); * ``` */ -export const logger: Logger = { - error(message: string, context?: Record): void { - try { - winstonLogger.error(message, context); - } catch { - // Silently ignore logging failures - } - }, - warn(message: string, context?: Record): void { - try { - winstonLogger.warn(message, context); - } catch { - // Silently ignore logging failures - } - }, - debug(message: string, context?: Record): void { - try { - winstonLogger.debug(message, context); - } catch { - // Silently ignore logging failures - } - }, -}; +export function error(message: string, context?: Record): void { + try { + winstonLogger.error(message, context); + } catch { + // Silently ignore logging failures + } +} + +export function warn(message: string, context?: Record): void { + try { + winstonLogger.warn(message, context); + } catch { + // Silently ignore logging failures + } +} + +export function debug(message: string, context?: Record): void { + try { + winstonLogger.debug(message, context); + } catch { + // Silently ignore logging failures + } +} diff --git a/packages/pi-utils/src/postmortem.ts b/packages/pi-utils/src/postmortem.ts new file mode 100644 index 000000000..120153aaa --- /dev/null +++ b/packages/pi-utils/src/postmortem.ts @@ -0,0 +1,147 @@ +/** + * Cleanup and postmortem handler utilities. + * + * This module provides a system for registering and running cleanup callbacks + * in response to process exit, signals, or fatal exceptions. It is intended to + * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc. + */ + +import { logger } from "."; + +// Cleanup reasons, in order of priority/meaning. +export enum Reason { + PRE_EXIT = "pre_exit", // Pre-exit phase (not used by default) + EXIT = "exit", // Normal process exit + SIGINT = "sigint", // Ctrl-C or SIGINT + SIGTERM = "sigterm", // SIGTERM + SIGHUP = "sighup", // SIGHUP + UNCAUGHT_EXCEPTION = "uncaught_exception", // Fatal exception + UNHANDLED_REJECTION = "unhandled_rejection", // Unhandled promise rejection + MANUAL = "manual", // Manual cleanup (not triggered by process) +} + +// Internal list of active cleanup callbacks (in registration order) +const callbackList: ((reason: Reason) => Promise | void)[] = []; +// Tracks cleanup run state (to prevent recursion/reentry issues) +let cleanupStage: "idle" | "running" | "complete" = "idle"; + +/** + * Internal: runs all registered cleanup callbacks for the given reason. + * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy. + * + * Returns a Promise that settles after all cleanups complete or error out. + */ +function runCleanup(reason: Reason): Promise { + switch (cleanupStage) { + case "idle": + cleanupStage = "running"; + break; + case "running": + logger.error("Cleanup invoked recursively", { stack: new Error().stack }); + return Promise.resolve(); + case "complete": + return Promise.resolve(); + } + + // Call .cleanup() for each callback that is still "armed". + // Use Promise.try to handle sync/async, but only those armed. + const promises = callbackList.reverse().map((callback) => { + return Promise.try(() => callback(reason)); + }); + + return Promise.allSettled(promises).then((results) => { + for (const result of results) { + if (result.status === "rejected") { + const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason)); + logger.error("Cleanup callback failed", { err, stack: err.stack }); + } + } + cleanupStage = "complete"; + }); +} + +// Register signal and error event handlers to trigger cleanup before exit. +process + .on("SIGINT", async () => { + await runCleanup(Reason.SIGINT); + process.exit(130); // 128 + SIGINT (2) + }) + .on("uncaughtException", async (err) => { + logger.error("Uncaught exception", { err, stack: err.stack }); + await runCleanup(Reason.UNCAUGHT_EXCEPTION); + process.exit(1); + }) + .on("unhandledRejection", async (reason) => { + const err = reason instanceof Error ? reason : new Error(String(reason)); + logger.error("Unhandled rejection", { err, stack: err.stack }); + await runCleanup(Reason.UNHANDLED_REJECTION); + process.exit(1); + }) + .on("exit", async () => { + void runCleanup(Reason.EXIT); // fire and forget (exit imminent) + }) + .on("SIGTERM", async () => { + await runCleanup(Reason.SIGTERM); + process.exit(143); // 128 + SIGTERM (15) + }) + .on("SIGHUP", async () => { + await runCleanup(Reason.SIGHUP); + process.exit(129); // 128 + SIGHUP (1) + }); + +/** + * Register a process cleanup callback, to be run on shutdown, signal, or fatal error. + * + * Returns a Callback instance that can be used to cancel (unregister) or manually clean up. + * If register is called after cleanup already began, invokes callback on a microtask. + */ +export function register(id: string, callback: (reason: Reason) => void | Promise): () => void { + let done = false; + const exec = (reason: Reason) => { + if (done) return; + done = true; + try { + return callback(reason); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.error("Cleanup callback failed", { err, id, stack: err.stack }); + } + }; + + const cancel = () => { + const index = callbackList.indexOf(exec); + if (index >= 0) { + callbackList.splice(index, 1); + } + done = true; + }; + + if (cleanupStage !== "idle") { + // If cleanup is already running/completed, warn and run on microtask. + logger.warn("Cleanup invoked recursively", { id }); + try { + callback(Reason.MANUAL); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.error("Cleanup callback failed", { err, id, stack: err.stack }); + } + return () => {}; + } + + // Register callback as "armed" (active). + callbackList.push(exec); + return cancel; +} + +/** + * Runs all cleanup callbacks and exits the process. + */ +export async function quit(code: number = 0): Promise { + await runCleanup(Reason.MANUAL); + if (process.stdout.writableLength > 0) { + const { promise, resolve } = Promise.withResolvers(); + process.stdout.once("drain", resolve); + await Promise.race([promise, Bun.sleep(5000)]); + } + process.exit(code); +} diff --git a/packages/pi-utils/src/ptree.ts b/packages/pi-utils/src/ptree.ts new file mode 100644 index 000000000..c7f7035d0 --- /dev/null +++ b/packages/pi-utils/src/ptree.ts @@ -0,0 +1,365 @@ +/** + * Process tree management utilities for Bun subprocesses. + * + * Provides: + * - Managed tracking of child subprocesses for cleanup on exit/signals. + * - Windows and Unix support for proper tree killing. + * - ChildProcess wrapper for capturing output, errors, and kill/detach. + */ + +import { type FileSink, type Spawn, type Subprocess, spawn, spawnSync } from "bun"; +import { postmortem } from "."; + +// Platform detection: process tree kill behavior differs. +const isWindows = process.platform === "win32"; + +// Set of live children for managed termination/cleanup on shutdown. +const managedChildren = new Set(); + +/** + * Kill a child process and its descendents. + * - Windows: uses taskkill for tree and forceful kill (/T /F) + * - Unix: negative PID sends signal to process group (tree kill) + */ +function killChild(child: PipedSubprocess, signal: NodeJS.Signals = "SIGTERM"): void { + const pid = child.pid; + if (!pid) return; + + try { + if (isWindows) { + // /T (tree), /F (force): ensure entire tree is killed. + spawnSync(["taskkill", ...(signal === "SIGKILL" ? ["/F"] : []), "/T", "/PID", pid.toString()], { + stdout: "ignore", + stderr: "ignore", + timeout: 1000, + }); + } else { + // Send signal to process group (negative PID). + process.kill(-pid, signal); + } + + // If killed, remove from managed set and clean up. + if (child.killed) { + managedChildren.delete(child); + child.unref(); + } + } catch { + // Ignore: process may already be dead. + } +} + +postmortem.register("managed-children", () => { + for (const child of [...managedChildren]) { + killChild(child, "SIGKILL"); + managedChildren.delete(child); + } +}); + +/** + * Register a subprocess for managed cleanup. + * Will attach to exit Promise so removal happens even if child exits "naturally". + */ +function registerManaged(child: PipedSubprocess): void { + if (child.exitCode !== null) return; + if (managedChildren.has(child)) return; + child.ref(); + managedChildren.add(child); + + child.exited.then(() => { + managedChildren.delete(child); + child.unref(); + }); +} + +// A Bun subprocess with stdin=Writable, stdout/stderr=pipe (for tracking/cleanup). +type PipedSubprocess = Subprocess<"pipe" | null, "pipe", "pipe">; + +/** + * ChildProcess wraps a managed subprocess, capturing output, errors, and providing + * cross-platform kill/detach logic plus AbortSignal integration. + */ +export class ChildProcess { + #proc: PipedSubprocess; + #detached = false; + #nothrow = false; + #stderrTee: ReadableStream>; + #stderrBuffer = ""; + #exitReason?: Exception; + #exitReasonPending?: Exception; + #exited: Promise; + #resolveExited: (ex?: PromiseLike | Exception) => void; + + constructor(proc: PipedSubprocess) { + registerManaged(proc); + + const [left, right] = proc.stderr.tee(); + this.#stderrTee = right; + + // Capture stderr at all times, with a capped buffer for errors. + const decoder = new TextDecoder(); + void (async () => { + for await (const chunk of left) { + this.#stderrBuffer += decoder.decode(chunk, { stream: true }); + if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) { + this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE); + } + } + this.#stderrBuffer += decoder.decode(); + if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) { + this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE); + } + })().catch(() => {}); + + const { promise, resolve } = Promise.withResolvers(); + + this.#exited = promise.then((ex?: Exception) => { + if (!ex) return; // success, no exception + if (proc.killed && this.#exitReasonPending) { + ex = this.#exitReasonPending; // propagate reason if killed + } + this.#exitReason = ex; + return Promise.reject(ex); + }); + this.#resolveExited = resolve; + + // On exit, resolve with a ChildError if nonzero code. + proc.exited.then((exitCode) => { + if (exitCode !== 0) { + resolve(new NonZeroExitError(exitCode, this.#stderrBuffer)); + } else { + resolve(undefined); + } + }); + + this.#proc = proc; + } + + get pid(): number | undefined { + return this.#proc.pid; + } + get exited(): Promise { + return this.#exited; + } + get exitCode(): number | null { + return this.#proc.exitCode; + } + get exitReason(): Exception | undefined { + return this.#exitReason; + } + get killed(): boolean { + return this.#proc.killed; + } + get stdin(): FileSink | undefined { + return this.#proc.stdin; + } + get stdout(): ReadableStream> { + return this.#proc.stdout; + } + get stderr(): ReadableStream> { + return this.#stderrTee; + } + + /** + * Peek at the stderr buffer. + * @returns The stderr buffer. + */ + peekStderr(): string { + return this.#stderrBuffer; + } + + /** + * Detach this process from management (no cleanup on shutdown). + */ + detach(): void { + if (this.#detached || this.#proc.killed) return; + this.#detached = true; + if (managedChildren.delete(this.#proc)) { + this.#proc.unref(); + } + } + + /** + * Prevents thrown ChildError on nonzero exit code, for optional error handling. + */ + nothrow(): this { + this.#nothrow = true; + return this; + } + + /** + * Kill the process tree. + * Optionally set an exit reason (for better error propagation on cancellation). + */ + kill(signal: NodeJS.Signals = "SIGTERM", reason?: Exception) { + if (this.#proc.killed) return; + if (reason) { + this.#exitReasonPending = reason; + } + killChild(this.#proc, signal); + } + + async killAndWait(): Promise { + // Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second + this.kill("SIGTERM"); + await Promise.race([this.exited, Bun.sleep(1000).then(() => this.kill("SIGKILL"))]); + } + + // Output utilities (aliases for easy chaining) + async text(): Promise { + return (await this.blob()).text(); + } + async json(): Promise { + return (await this.blob()).json(); + } + async arrayBuffer(): Promise { + return (await this.blob()).arrayBuffer(); + } + async bytes() { + return (await this.blob()).bytes(); + } + async blob() { + const { promise, resolve, reject } = Promise.withResolvers(); + + const blob = this.#proc.stdout.blob(); + if (!this.#nothrow) { + this.#exited.catch((ex: Exception) => { + reject(ex); + }); + } + blob.then(resolve, reject); + return promise; + } + + /** + * Attach an AbortSignal to this process. Will kill tree with SIGKILL if aborted. + */ + attachSignal(signal: AbortSignal): void { + const onAbort = () => { + const cause = new AbortError(signal.reason, ""); + this.kill("SIGKILL", cause); + if (this.#proc.killed) { + queueMicrotask(() => { + try { + this.#resolveExited(cause); + } catch { + // Ignore + } + }); + } + }; + if (signal.aborted) { + return void onAbort(); + } + signal.addEventListener("abort", onAbort, { once: true }); + // Use .finally().catch() to avoid unhandled rejection when #exited rejects + this.#exited + .finally(() => { + signal.removeEventListener("abort", onAbort); + }) + .catch(() => {}); + } + + /** + * Attach a timeout to this process. Will kill the process with SIGKILL if the timeout is reached. + */ + attachTimeout(timeout: number): void { + if (timeout <= 0) return; + const timeoutId = setTimeout(() => { + this.kill("SIGKILL", new TimeoutError(timeout, this.#stderrBuffer)); + }, timeout); + // Use .finally().catch() to avoid unhandled rejection when #exited rejects + this.#exited + .finally(() => { + clearTimeout(timeoutId); + }) + .catch(() => {}); + } +} + +/** + * Base for all exceptions representing child process nonzero exit, killed, or cancellation. + */ +export abstract class Exception extends Error { + constructor( + message: string, + public readonly exitCode: number, + public readonly stderr: string, + ) { + super(message); + this.name = this.constructor.name; + } + abstract get aborted(): boolean; +} + +/** + * Exception for nonzero exit codes (not cancellation). + */ +export class NonZeroExitError extends Exception { + static readonly MAX_TRACE = 32 * 1024; + + constructor( + public readonly exitCode: number, + public readonly stderr: string, + ) { + super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr); + } + get aborted(): boolean { + return false; + } +} + +/** + * Exception for explicit process abortion (via signal). + */ +export class AbortError extends Exception { + constructor( + public readonly reason: unknown, + stderr: string, + ) { + const reasonString = reason instanceof Error ? reason.message : String(reason ?? "aborted"); + super(`Operation cancelled: ${reasonString}`, -1, stderr); + } + get aborted(): boolean { + return true; + } +} + +/** + * Exception for process timeout. + */ +export class TimeoutError extends AbortError { + constructor(timeout: number, stderr: string) { + super(new Error(`Process timed out after ${timeout}ms`), stderr); + } +} + +/** + * Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal. + */ +type ChildSpawnOptions = Omit, "stdout" | "stderr"> & { + signal?: AbortSignal; +}; + +/** + * Spawn a subprocess as a managed child process. + * - Always pipes stdout/stderr, launches in new session/process group (detached). + * - Optional AbortSignal integrates with kill-on-abort. + */ +export function cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess { + const { timeout, ...rest } = options ?? {}; + const child = spawn(cmd, { + ...rest, + stdout: "pipe", + stderr: "pipe", + // Windows: new console/pgroup; Unix: setsid for process group. + detached: true, + }); + const cproc = new ChildProcess(child); + if (options?.signal) { + cproc.attachSignal(options.signal); + } + if (timeout && timeout > 0) { + cproc.attachTimeout(timeout); + } + return cproc; +} diff --git a/packages/pi-utils/src/stream.ts b/packages/pi-utils/src/stream.ts new file mode 100644 index 000000000..1c4e10383 --- /dev/null +++ b/packages/pi-utils/src/stream.ts @@ -0,0 +1,241 @@ +import { TextDecoderStream } from "node:stream/web"; +import stripAnsi from "strip-ansi"; + +/** + * Sanitize binary output for display/storage. + * Removes characters that crash string-width or cause display issues: + * - Control characters (except tab, newline, carriage return) + * - Lone surrogates + * - Unicode Format characters (crash string-width due to a bug) + * - Characters with undefined code points + */ +export function sanitizeBinaryOutput(str: string): string { + // Use Array.from to properly iterate over code points (not code units) + // This handles surrogate pairs correctly and catches edge cases where + // codePointAt() might return undefined + return Array.from(str) + .filter((char) => { + // Filter out characters that cause string-width to crash + // This includes: + // - Unicode format characters + // - Lone surrogates (already filtered by Array.from) + // - Control chars except \t \n \r + // - Characters with undefined code points + + const code = char.codePointAt(0); + + // Skip if code point is undefined (edge case with invalid strings) + if (code === undefined) return false; + + // Allow tab, newline, carriage return + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + + // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) + if (code <= 0x1f) return false; + + // Filter out Unicode format characters + if (code >= 0xfff9 && code <= 0xfffb) return false; + + return true; + }) + .join(""); +} + +/** + * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings. + */ +export function sanitizeText(text: string): string { + return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, ""); +} + +/** + * Create a transform stream that splits lines. + */ +export function createSplitterStream(delimiter: string): TransformStream { + let buf = ""; + return new TransformStream({ + transform(chunk, controller) { + buf = buf ? `${buf}${chunk}` : chunk; + + while (true) { + const nl = buf.indexOf(delimiter); + if (nl === -1) break; + controller.enqueue(buf.slice(0, nl)); + buf = buf.slice(nl + delimiter.length); + } + }, + flush(controller) { + if (buf) { + controller.enqueue(buf); + } + }, + }); +} + +/** + * Create a transform stream that sanitizes text. + */ +export function createSanitizerStream(): TransformStream { + return new TransformStream({ + transform(chunk, controller) { + controller.enqueue(sanitizeText(chunk)); + }, + }); +} + +/** + * Create a transform stream that decodes text. + */ +export function createTextDecoderStream(): TransformStream { + return new TextDecoderStream("utf-8", { ignoreBOM: true }); +} + +/** + * Read stream line-by-line + * + * @param delimiter Line delimiter (default: "\n") + */ +export function readLines(stream: ReadableStream, delimiter = "\n"): AsyncIterable { + return stream.pipeThrough(createTextDecoderStream()).pipeThrough(createSplitterStream(delimiter)); +} + +// ============================================================================= +// SSE (Server-Sent Events) +// ============================================================================= + +/** + * Parsed SSE event. + */ +export interface SseEvent { + /** Event type (from `event:` field, default: "message") */ + event: string; + /** Event data (from `data:` field(s), joined with newlines) */ + data: string; + /** Event ID (from `id:` field) */ + id?: string; + /** Retry interval in ms (from `retry:` field) */ + retry?: number; +} + +/** + * Parse a single SSE event block (lines between blank lines). + * Returns null if the block contains no data. + */ +export function parseSseEvent(block: string): SseEvent | null { + const lines = block.split("\n"); + let event = "message"; + const dataLines: string[] = []; + let id: string | undefined; + let retry: number | undefined; + + for (const line of lines) { + // Comments start with ':' + if (line.startsWith(":")) continue; + + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + + const field = line.slice(0, colonIdx); + // Value starts after colon, with optional leading space trimmed + let value = line.slice(colonIdx + 1); + if (value.startsWith(" ")) value = value.slice(1); + + switch (field) { + case "event": + event = value; + break; + case "data": + dataLines.push(value); + break; + case "id": + id = value; + break; + case "retry": { + const n = parseInt(value, 10); + if (!Number.isNaN(n)) retry = n; + break; + } + } + } + + if (dataLines.length === 0) return null; + + return { + event, + data: dataLines.join("\n"), + id, + retry, + }; +} + +/** + * Read SSE events from a stream. + * + * Handles the SSE wire format: + * - Events separated by blank lines + * - Fields: event, data, id, retry + * - Comments (lines starting with :) are ignored + * - Multiple data: lines are joined with newlines + * + * @example + * ```ts + * for await (const event of readSseEvents(response.body)) { + * if (event.data === "[DONE]") break; + * const payload = JSON.parse(event.data); + * console.log(event.event, payload); + * } + * ``` + */ +export async function* readSseEvents(stream: ReadableStream): AsyncGenerator { + const blockLines: string[] = []; + + for await (const rawLine of readLines(stream)) { + const line = rawLine.replace(/\r$/, ""); + if (line === "") { + if (blockLines.length > 0) { + const event = parseSseEvent(blockLines.join("\n")); + if (event) yield event; + blockLines.length = 0; + } + continue; + } + + blockLines.push(line); + } + + if (blockLines.length > 0) { + const event = parseSseEvent(blockLines.join("\n")); + if (event) yield event; + } +} + +/** + * Read SSE data payloads from a stream, parsing JSON automatically. + * + * Convenience wrapper over readSseEvents that: + * - Skips [DONE] markers + * - Parses JSON data + * - Optionally filters by event type + * + * @example + * ```ts + * for await (const data of readSseData(response.body)) { + * console.log(data.choices[0].delta); + * } + * ``` + */ +export async function* readSseData( + stream: ReadableStream, + eventType?: string, +): AsyncGenerator { + for await (const event of readSseEvents(stream)) { + if (eventType && event.event !== eventType) continue; + if (event.data === "[DONE]") continue; + + try { + yield JSON.parse(event.data) as T; + } catch { + // Skip malformed JSON + } + } +} diff --git a/packages/pi-utils/src/temp.ts b/packages/pi-utils/src/temp.ts new file mode 100644 index 000000000..ce0b8d133 --- /dev/null +++ b/packages/pi-utils/src/temp.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; + +export interface AsyncTempDir { + path: string; + remove(): Promise; + toString(): string; + [Symbol.asyncDispose](): Promise; +} + +export interface SyncTempDir { + path: string; + remove(): void; + toString(): string; + [Symbol.dispose](): void; +} + +const kTempDir = tmpdir(); + +function normalizePrefix(prefix?: string): string { + if (!prefix) { + return `${kTempDir}${sep}pi-temp-`; + } else if (prefix.startsWith("@")) { + return join(kTempDir, prefix.slice(1)); + } + return prefix; +} + +export async function createTempDir(prefix?: string): Promise { + const path = await mkdtemp(normalizePrefix(prefix)); + + let promise: Promise | null = null; + const remove = () => { + if (promise) { + return promise; + } + promise = rm(path, { recursive: true, force: true }).catch(() => {}); + return promise; + }; + + return { + path: path!, + remove, + toString: () => path, + [Symbol.asyncDispose]: remove, + }; +} + +export function createTempDirSync(prefix?: string): SyncTempDir { + const path = mkdtempSync(normalizePrefix(prefix)); + + let done = false; + const remove = () => { + if (done) { + return; + } + done = true; + try { + rmSync(path, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }; + + return { + path, + toString: () => path, + remove, + [Symbol.dispose]: remove, + }; +} diff --git a/packages/pi-utils/tsconfig.build.json b/packages/pi-utils/tsconfig.build.json new file mode 100644 index 000000000..6089faa7f --- /dev/null +++ b/packages/pi-utils/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} \ No newline at end of file diff --git a/packages/react-edit-benchmark/generate.ts b/packages/react-edit-benchmark/generate.ts index 121b50392..a0fed79c5 100644 --- a/packages/react-edit-benchmark/generate.ts +++ b/packages/react-edit-benchmark/generate.ts @@ -19,6 +19,7 @@ * - nightmare: Long files where target line repeats, minimal info */ +import { createTempDirSync } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { existsSync, mkdirSync, readdirSync } from "node:fs"; import { basename, dirname, join, relative } from "node:path"; @@ -27,7 +28,7 @@ import { ALL_MUTATIONS, CATEGORY_MAP, type Mutation, type MutationInfo } from ". const SCRIPT_DIR = import.meta.dir; const SUPPORTED_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"]); -const DEFAULT_REACT_DIR = "/tmp/react-source"; +using DEFAULT_REACT_DIR = createTempDirSync("@react-source"); const DEFAULT_OUTPUT = join(SCRIPT_DIR, "fixtures.tar.gz"); const REACT_REPO_URL = "https://github.com/facebook/react.git"; @@ -103,7 +104,7 @@ function parseArguments(): Args { }; } -function ensureReactSource(reactDir: string): void { +async function ensureReactSource(reactDir: string): Promise { if (existsSync(reactDir)) { const packagesDir = join(reactDir, "packages"); if (existsSync(packagesDir)) return; @@ -112,9 +113,11 @@ function ensureReactSource(reactDir: string): void { console.log(`Cloning React repository to ${reactDir}...`); mkdirSync(dirname(reactDir), { recursive: true }); - const result = Bun.spawnSync(["git", "clone", "--depth", "1", REACT_REPO_URL, reactDir]); + const result = await $`git clone --depth 1 ${REACT_REPO_URL} ${reactDir}`.quiet().nothrow(); if (result.exitCode !== 0) { - throw new Error(`Failed to clone React: ${result.stderr.toString()}`); + const decoder = new TextDecoder(); + const stderr = result.stderr ? decoder.decode(result.stderr) : ""; + throw new Error(`Failed to clone React: ${stderr.trim()}`); } console.log("Clone complete."); } @@ -611,7 +614,7 @@ async function main(): Promise { const rng = createSeededRng(args.seed); const reactDir = args.reactDir; - ensureReactSource(reactDir); + await ensureReactSource(reactDir); const rawFiles = collectFiles(reactDir); const files = await filterFiles(rawFiles); diff --git a/packages/react-edit-benchmark/index.ts b/packages/react-edit-benchmark/index.ts index cf63ad284..cc01d1f49 100644 --- a/packages/react-edit-benchmark/index.ts +++ b/packages/react-edit-benchmark/index.ts @@ -10,9 +10,9 @@ */ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { createTempDir } from "@oh-my-pi/pi-utils"; import { readdirSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; import { join } from "node:path"; import { parseArgs } from "node:util"; import { generateJsonReport, generateReport } from "./report"; @@ -95,12 +95,23 @@ function resolveExtractedDir(tempDir: string): string { } async function extractTarGz(archivePath: string): Promise<{ dir: string; cleanupDir: string }> { - const tempDir = await mkdtemp(join(tmpdir(), "reach-benchmark-fixtures-")); - const result = Bun.spawnSync(["tar", "-xzf", archivePath, "-C", tempDir]); - if (!result.success) { - await rm(tempDir, { recursive: true, force: true }); - throw new Error(`Failed to extract archive: ${result.stderr.toString()}`); + const tempDirObj = await createTempDir("@reach-benchmark-fixtures-"); + const tempDir = tempDirObj.path; + try { + const bytes = await Bun.file(archivePath).arrayBuffer(); + const archive = new Bun.Archive(bytes); + const files = await archive.files(); + + for (const [path, file] of files) { + const destPath = join(tempDir, path); + await Bun.write(destPath, file); + } + } catch (error) { + await tempDirObj.remove(); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to extract archive: ${message}`, { cause: error }); } + return { dir: resolveExtractedDir(tempDir), cleanupDir: tempDir }; } diff --git a/packages/react-edit-benchmark/package.json b/packages/react-edit-benchmark/package.json index 6b8c40231..2a1fe1605 100644 --- a/packages/react-edit-benchmark/package.json +++ b/packages/react-edit-benchmark/package.json @@ -15,6 +15,7 @@ "dependencies": { "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-coding-agent": "workspace:*", + "@oh-my-pi/pi-utils": "workspace:*", "diff": "^8.0.2", "prettier": "^3.3.3" }, diff --git a/packages/react-edit-benchmark/runner.ts b/packages/react-edit-benchmark/runner.ts index 94eb5e152..a8521e72d 100644 --- a/packages/react-edit-benchmark/runner.ts +++ b/packages/react-edit-benchmark/runner.ts @@ -7,14 +7,15 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { RpcClient } from "@oh-my-pi/pi-coding-agent"; -import { appendFile, cp, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { createTempDir } from "@oh-my-pi/pi-utils"; +import { appendFile, cp, mkdir, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { formatDirectory } from "./formatter"; import { extractTaskFiles, type EditTask } from "./tasks"; import { verifyExpectedFileSubset, verifyExpectedFiles } from "./verify"; -const TMP = await mkdtemp(join(tmpdir(), "reach-benchmark-")); +const TMP_DIR = await createTempDir("@reach-benchmark-"); +const TMP = TMP_DIR.path; export interface BenchmarkConfig { provider: string; @@ -835,21 +836,21 @@ export async function runTask( config: BenchmarkConfig, onProgress?: (event: ProgressEvent) => void, ): Promise { - const tempDirs: string[] = []; + const tempDirs: Array>> = []; const { dir: expectedDir, cleanup: cleanupExpected } = await getExpectedDir(task); const cliPath = join(import.meta.dir, "../coding-agent/src/cli.ts"); try { for (let i = 0; i < config.runsPerTask; i++) { - const tempDir = await mkdtemp(join(TMP, `${task.id}-`)); + const tempDir = await createTempDir(join(TMP, `${task.id}-`)); tempDirs.push(tempDir); - await copyFixtures(task, tempDir); + await copyFixtures(task, tempDir.path); } - const runPromises = tempDirs.map(async (workDir, index) => { + const runPromises = tempDirs.map(async (tempDirObj, index) => { onProgress?.({ taskId: task.id, runIndex: index, status: "started" }); - const result = await runSingleTask(task, index, config, workDir, expectedDir, cliPath); + const result = await runSingleTask(task, index, config, tempDirObj.path, expectedDir, cliPath); onProgress?.({ taskId: task.id, runIndex: index, status: "completed", result }); return result; }); @@ -858,9 +859,9 @@ export async function runTask( return summarizeTaskRuns(task, runs); } finally { await cleanupExpected(); - for (const dir of tempDirs) { + for (const tempDirObj of tempDirs) { try { - await rm(dir, { recursive: true, force: true }); + await tempDirObj.remove(); } catch { // Ignore cleanup errors } diff --git a/packages/react-edit-benchmark/tasks.ts b/packages/react-edit-benchmark/tasks.ts index 932baa09d..11c1e58f4 100644 --- a/packages/react-edit-benchmark/tasks.ts +++ b/packages/react-edit-benchmark/tasks.ts @@ -83,8 +83,8 @@ export async function loadTasksFromDir(fixturesDir: string): Promise const expectedDir = join(challengeDir, "expected"); const metadataPath = join(challengeDir, "metadata.json"); - const promptExists = await Bun.file(promptPath).exists(); - if (!promptExists) { + const promptFile = Bun.file(promptPath); + if (!(await promptFile.exists())) { throw new Error(`Missing prompt.md for ${entry.name}`); } @@ -96,7 +96,7 @@ export async function loadTasksFromDir(fixturesDir: string): Promise throw new Error(`Missing expected directory for ${entry.name}`); } - const prompt = (await Bun.file(promptPath).text()).trim(); + const prompt = (await promptFile.text()).trim(); const files = listFiles(inputDir); const metadata = await loadMetadata(metadataPath); @@ -231,11 +231,12 @@ export async function validateFixturesFromDir(fixturesPath: string): Promise { - const exists = await Bun.file(metadataPath).exists(); + const metadataFile = Bun.file(metadataPath); + const exists = await metadataFile.exists(); if (!exists) { return undefined; } - const raw = JSON.parse(await Bun.file(metadataPath).text()) as Record; + const raw = (await metadataFile.json()) as Record; return parseTaskMetadata(raw); } diff --git a/packages/react-edit-benchmark/test/verify.test.ts b/packages/react-edit-benchmark/test/verify.test.ts index 9eba9aa42..d261806f0 100644 --- a/packages/react-edit-benchmark/test/verify.test.ts +++ b/packages/react-edit-benchmark/test/verify.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir } from "node:fs/promises"; import { join } from "node:path"; +import { createTempDir } from "@oh-my-pi/pi-utils"; import { verifyExpectedFiles } from "../verify"; async function createTempDirs(): Promise<{ @@ -10,7 +10,8 @@ async function createTempDirs(): Promise<{ actualDir: string; cleanup: () => Promise; }> { - const root = await mkdtemp(join(tmpdir(), "reach-benchmark-verify-")); + const tempDir = await createTempDir("@reach-benchmark-verify-"); + const root = tempDir.path; const expectedDir = join(root, "expected"); const actualDir = join(root, "actual"); await mkdir(expectedDir, { recursive: true }); @@ -20,7 +21,7 @@ async function createTempDirs(): Promise<{ expectedDir, actualDir, cleanup: async () => { - await rm(root, { recursive: true, force: true }); + await tempDir.remove(); }, }; } diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 2718bacf5..edcee9a05 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -27,11 +27,6 @@ describe("StdinBuffer", () => { buffer.process(data); } - // Helper to wait for async operations - async function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - describe("Regular Characters", () => { it("should pass through regular characters immediately", () => { processInput("a"); @@ -128,7 +123,7 @@ describe("StdinBuffer", () => { expect(emittedSequences).toEqual([]); // Wait for timeout - await wait(15); + await Bun.sleep(15); expect(emittedSequences).toEqual(["\x1b[<35"]); }); @@ -273,7 +268,7 @@ describe("StdinBuffer", () => { expect(emittedSequences).toEqual([]); // After timeout, should emit - await wait(15); + await Bun.sleep(15); expect(emittedSequences).toEqual(["\x1b"]); }); @@ -315,7 +310,7 @@ describe("StdinBuffer", () => { expect(emittedSequences).toEqual([]); // Wait for timeout to flush - await wait(15); + await Bun.sleep(15); expect(emittedSequences).toEqual(["\x1b[<35"]); }); @@ -412,7 +407,7 @@ describe("StdinBuffer", () => { buffer.destroy(); // Wait longer than timeout - await wait(15); + await Bun.sleep(15); // Should not have emitted anything expect(emittedSequences).toEqual([]); diff --git a/tsconfig.json b/tsconfig.json index b189f77fd..1335948a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,8 @@ "*": ["./*"], "@oh-my-pi/pi-agent-core": ["./packages/agent/src/index.ts"], "@oh-my-pi/pi-agent-core/*": ["./packages/agent/src/*"], + "@oh-my-pi/pi-utils": ["./packages/pi-utils/src/index.ts"], + "@oh-my-pi/pi-utils/*": ["./packages/pi-utils/src/*"], "@oh-my-pi/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@oh-my-pi/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@oh-my-pi/pi-coding-agent/*": ["./packages/coding-agent/src/*"],