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.
This commit is contained in:
can1357
2026-01-20 04:34:29 +01:00
parent 030b812624
commit b2c94bef1a
166 changed files with 3821 additions and 3215 deletions
+92 -21
View File
@@ -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<Uint8Array>).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 });
+25 -2
View File
@@ -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=="],
+3
View File
@@ -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
+3 -1
View File
@@ -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"
}
+8 -23
View File
@@ -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<AssistantMessageEvent, AssistantMessage> {
@@ -148,33 +149,17 @@ export function streamProxy(model: Model<any>, context: Context, options: ProxyS
throw new Error(errorMessage);
}
reader = response.body!.getReader() as ReadableStreamDefaultReader<Uint8Array>;
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);
}
}
+6
View File
@@ -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
+4 -2
View File
@@ -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<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
const { promise, resolve } = Promise.withResolvers<string>();
rl.question(question, resolve);
return promise;
}
async function login(provider: OAuthProvider): Promise<void> {
+4 -20
View File
@@ -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<void> {
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");
@@ -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;
}
@@ -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<R
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let index = buffer.indexOf("\n\n");
while (index !== -1) {
const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const event = parseSseChunk(chunk);
if (event) yield event;
index = buffer.indexOf("\n\n");
}
}
if (buffer.trim()) {
const event = parseSseChunk(buffer);
if (event) yield event;
}
}
function parseSseChunk(chunk: string): Record<string, unknown> | 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<string, unknown>;
} catch {
return null;
for await (const data of readSseData<Record<string, unknown>>(response.body)) {
yield data;
}
}
+3 -3
View File
@@ -12,9 +12,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
private isComplete: (event: T) => boolean,
private extractResult: (event: T) => R,
) {
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
const { promise, resolve } = Promise.withResolvers<R>();
this.finalResultPromise = promise;
this.resolveFinalResult = resolve;
}
push(event: T): void {
+1 -5
View File
@@ -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<void> {
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}`);
+1 -23
View File
@@ -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<DeviceCodeResponse> {
};
}
/**
* Sleep that can be interrupted by an AbortSignal
*/
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
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,
@@ -47,10 +47,6 @@ interface GoogleRpcErrorResponse {
};
}
function wait(ms: number): Promise<void> {
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}`, {
+11 -13
View File
@@ -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",
+19
View File
@@ -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
+1 -2
View File
@@ -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": {
+27 -28
View File
@@ -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<string | null> {
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<string | null>();
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;
}
+7 -16
View File
@@ -4,6 +4,7 @@
* Handles `omp setup <component>` 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<PythonCheckResult> {
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<PythonCheckResult> {
/**
* 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<boolean> {
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`));
+1 -1
View File
@@ -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)
+94 -28
View File
@@ -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<string, number>();
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<void> {
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<string, unknown> | 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<string, unknown>): Promise<void> {
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<void>();
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<void> {
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.
*/
@@ -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 */
@@ -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 = {
+90 -132
View File
@@ -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<BashResult> {
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<Uint8Array>, writer),
pumpStream(child.stderr as ReadableStream<Uint8Array>, 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<BashResult> {
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<Uint8Array>;
const dataStream = new ReadableStream<Uint8Array>({
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<BashResult> {
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(),
};
}
@@ -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 {
@@ -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";
@@ -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 */
+12 -99
View File
@@ -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<ExecResult> {
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<Uint8Array>).getReader();
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).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 });
}
})();
});
}
@@ -110,10 +110,4 @@ export {
isReadToolResult,
isWriteToolResult,
} from "./types";
export {
ExtensionToolWrapper,
RegisteredToolAdapter,
wrapRegisteredTool,
wrapRegisteredTools,
wrapToolWithExtensions,
} from "./wrapper";
export { ExtensionToolWrapper, RegisteredToolAdapter, wrapRegisteredTool, wrapRegisteredTools } from "./wrapper";
@@ -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,
@@ -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 {
@@ -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");
@@ -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<T> implements AgentTool<any, T> {
export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetails = unknown>
implements AgentTool<TParameters, TDetails>
{
name: string;
label: string;
description: string;
parameters: unknown;
renderCall?: AgentTool["renderCall"];
renderResult?: AgentTool["renderResult"];
parameters: TParameters;
renderCall?: AgentTool<TParameters, TDetails>["renderCall"];
renderResult?: AgentTool<TParameters, TDetails>["renderResult"];
constructor(
private tool: AgentTool<any, T>,
private tool: AgentTool<TParameters, TDetails>,
private runner: ExtensionRunner,
) {
this.name = tool.name;
@@ -92,9 +95,9 @@ export class ExtensionToolWrapper<T> implements AgentTool<any, T> {
async execute(
toolCallId: string,
params: Record<string, unknown>,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<T>,
onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
context?: AgentToolContext,
) {
// Emit tool_call event - extensions can block execution
@@ -104,7 +107,7 @@ export class ExtensionToolWrapper<T> implements AgentTool<any, T> {
type: "tool_call",
toolName: this.tool.name,
toolCallId,
input: params,
input: params as Record<string, unknown>,
})) as ToolCallEventResult | undefined;
if (callResult?.block) {
@@ -120,7 +123,7 @@ export class ExtensionToolWrapper<T> implements AgentTool<any, T> {
}
// 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<T> implements AgentTool<any, T> {
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<T> implements AgentTool<any, T> {
type: "tool_result",
toolName: this.tool.name,
toolCallId,
input: params,
input: params as Record<string, unknown>,
content: result.content,
details: result.details,
isError: !!executionError,
@@ -147,7 +150,7 @@ export class ExtensionToolWrapper<T> implements AgentTool<any, T> {
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<T> implements AgentTool<any, T> {
return result;
}
}
/**
* Wrap a tool with extension callbacks for interception.
* @deprecated Use `new ExtensionToolWrapper()` directly
*/
export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: ExtensionRunner): AgentTool<any, T> {
return new ExtensionToolWrapper(tool, runner);
}
@@ -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(/<!--[\s\S]*?-->/g, "");
@@ -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 {
@@ -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";
@@ -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";
@@ -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<T> implements AgentTool<any, T> {
export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = unknown>
implements AgentTool<TParameters, TDetails>
{
name: string;
label: string;
description: string;
parameters: unknown;
renderCall?: AgentTool["renderCall"];
renderResult?: AgentTool["renderResult"];
parameters: TParameters;
renderCall?: AgentTool<TParameters, TDetails>["renderCall"];
renderResult?: AgentTool<TParameters, TDetails>["renderResult"];
constructor(
private tool: AgentTool<any, T>,
private tool: AgentTool<TParameters, TDetails>,
private hookRunner: HookRunner,
) {
this.name = tool.name;
@@ -36,9 +39,9 @@ export class HookToolWrapper<T> implements AgentTool<any, T> {
async execute(
toolCallId: string,
params: Record<string, unknown>,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<T>,
onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
context?: AgentToolContext,
) {
// Emit tool_call event - hooks can block execution
@@ -49,7 +52,7 @@ export class HookToolWrapper<T> implements AgentTool<any, T> {
type: "tool_call",
toolName: this.tool.name,
toolCallId,
input: params,
input: params as Record<string, unknown>,
})) as ToolCallEventResult | undefined;
if (callResult?.block) {
@@ -75,7 +78,7 @@ export class HookToolWrapper<T> implements AgentTool<any, T> {
type: "tool_result",
toolName: this.tool.name,
toolCallId,
input: params,
input: params as Record<string, unknown>,
content: result.content,
details: result.details,
isError: false,
@@ -85,7 +88,7 @@ export class HookToolWrapper<T> implements AgentTool<any, T> {
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<T> implements AgentTool<any, T> {
type: "tool_result",
toolName: this.tool.name,
toolCallId,
input: params,
input: params as Record<string, unknown>,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
details: undefined,
isError: true,
@@ -108,18 +111,3 @@ export class HookToolWrapper<T> implements AgentTool<any, T> {
}
}
}
/**
* Wrap all tools with hook callbacks.
*/
export function wrapToolsWithHooks<T>(tools: AgentTool<any, T>[], hookRunner: HookRunner): AgentTool<any, T>[] {
return tools.map((tool) => new HookToolWrapper(tool, hookRunner));
}
/**
* Backward compatibility alias - use HookToolWrapper directly.
* @deprecated Use HookToolWrapper class instead
*/
export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRunner): AgentTool<any, T> {
return new HookToolWrapper(tool, hookRunner);
}
@@ -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<ExecResult>;
/** 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 */
@@ -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).
+13 -13
View File
@@ -36,19 +36,19 @@ const CLIENT_INFO = {
/** Wrap a promise with a timeout */
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
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<T>();
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
return wrapped;
}
/**
@@ -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 {
+1 -1
View File
@@ -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";
@@ -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<T>(promise: Promise<T>): TrackedPromise<T> {
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return Bun.sleep(ms);
}
/** Result of loading MCP tools */
@@ -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;
@@ -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
}
}
@@ -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;
@@ -38,8 +38,9 @@ export async function installPlugin(packageName: string): Promise<InstalledPlugi
// Initialize package.json if it doesn't exist
const pkgJsonPath = join(PLUGINS_DIR, "package.json");
if (!(await Bun.file(pkgJsonPath).exists())) {
await Bun.write(pkgJsonPath, JSON.stringify({ name: "omp-plugins", private: true, dependencies: {} }, null, 2));
const pkgJson = Bun.file(pkgJsonPath);
if (!(await pkgJson.exists())) {
await pkgJson.write(JSON.stringify({ name: "omp-plugins", private: true, dependencies: {} }, null, 2));
}
// Run npm install in plugins directory
@@ -98,24 +99,25 @@ export async function uninstallPlugin(name: string): Promise<void> {
}
export async function listPlugins(): Promise<InstalledPlugin[]> {
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,
});
@@ -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<PromptTemplate[]> {
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) });
}
@@ -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<PythonResult> {
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();
}
}
@@ -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<string, string
}
async function allocatePort(): Promise<number> {
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<number>();
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
server.close((err: Error | null | undefined) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
} else {
server.close();
reject(new Error("Failed to allocate port"));
}
});
return promise;
}
function getGatewayDir(): string {
@@ -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<void> {
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<AcquireResult |
logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid });
if (isPidRunning(existingInfo.pid)) {
try {
killProcessTree(existingInfo.pid);
await killProcessTree(existingInfo.pid);
} catch (err) {
logger.warn("Failed to kill stale shared gateway process", {
error: err instanceof Error ? err.message : String(err),
@@ -826,7 +827,7 @@ export async function shutdownSharedGateway(): Promise<void> {
error: err instanceof Error ? err.message : String(err),
});
} finally {
shutdownLocalGateway();
await shutdownLocalGateway();
isCoordinatorInitialized = false;
}
}
+235 -216
View File
@@ -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<Python
const { env } = await getShellConfig();
const baseEnv = filterEnv(env);
const runtime = await resolvePythonRuntime(cwd, baseEnv);
const result = Bun.spawnSync(
[
runtime.pythonPath,
"-c",
"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)",
],
{ cwd, env: runtime.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" },
);
const checkScript =
"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)";
const result = await $`${runtime.pythonPath} -c ${checkScript}`.quiet().nothrow().cwd(cwd).env(runtime.env);
if (result.exitCode === 0) {
return { ok: true, pythonPath: runtime.pythonPath };
}
@@ -354,27 +348,28 @@ async function checkExternalGatewayAvailability(config: ExternalGatewayConfig):
}
async function allocatePort(): Promise<number> {
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<number>();
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
server.close((err: Error | null | undefined) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
} else {
server.close();
reject(new Error("Failed to allocate port"));
}
});
return promise;
}
function 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<void>();
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<KernelExecuteResult>();
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<string, unknown>,
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<string, unknown>,
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<PreludeHelper[]> {
@@ -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),
+10 -13
View File
@@ -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<void> {
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") {
@@ -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,
@@ -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<SettingsManager> {
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<string, unknown>);
}
private static migrateLegacySettingsFile(storage: AgentStorage, agentDir: string): void {
private static async migrateLegacySettingsFile(storage: AgentStorage, agentDir: string): Promise<void> {
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<string, unknown>);
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<void> {
if (!this.globalSettings.edit) {
this.globalSettings.edit = {};
}
this.globalSettings.edit.streamingAbort = enabled;
await this.save();
}
getNormativeRewrite(): boolean {
return this.settings.normativeRewrite ?? false;
}
+1 -1
View File
@@ -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
@@ -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<SSHHostInfo> {
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<SSHHostInfo> {
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<void>
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<void>
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<void>
}
}
function closeConnectionInternal(host: SSHConnectionTarget): void {
async function closeConnectionInternal(host: SSHConnectionTarget): Promise<void> {
const target = buildSshTarget(host);
runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
}
export async function closeConnection(hostName: string): Promise<void> {
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<void> {
for (const [name, host] of Array.from(activeHosts.entries())) {
closeConnectionInternal(host);
await closeConnectionInternal(host);
activeHosts.delete(name);
}
}
@@ -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<Uint8Array>, writer),
pumpStream(child.stderr as ReadableStream<Uint8Array>, 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(),
};
}
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<boolean>
export async function unmountAll(): Promise<void> {
for (const mountPath of Array.from(mountedPaths)) {
unmountPath(mountPath);
await unmountPath(mountPath);
}
mountedPaths.clear();
}
@@ -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. */
+184 -130
View File
@@ -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<number>;
end(): void;
}
export function createSanitizer(): TransformStream<Uint8Array, string> {
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<Uint8Array>, writer: WritableStreamDefaultWriter<string>) {
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<string>;
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<string>({
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<Uint8Array> {
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<Uint8Array>({
write: (chunk) => {
buf += sanitizeText(decoder.decode(chunk, { stream: true }));
flushLines();
},
close: finalize,
abort: finalize,
});
}
getWriter(): WritableStreamDefaultWriter<string> {
return this.stream.getWriter();
createStringWritable(): WritableStream<string> {
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<string>({
write: (chunk) => {
buf += sanitizeText(chunk);
flushLines();
},
close: finalize,
abort: finalize,
});
}
dump(annotation?: string): OutputSinkDump {
async close(): Promise<void> {
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,
};
}
}
+119 -79
View File
@@ -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<GitContext | null> {
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<ToolName, string> = {
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 | undefined | null>): 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<string> {
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<string | null> {
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<string | null> {
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<string | null> {
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<SystemInfoCache | null> {
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<void> {
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<SystemInfoCache> {
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<string | null> {
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<Array<{ label: string; value: string }>> {
// 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<string | undefined> {
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,
@@ -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";
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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;
@@ -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,
@@ -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,
@@ -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,
+37 -69
View File
@@ -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<FdResult> {
const child = ptree.cspawn([fdPath, ...args], { signal });
const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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<typeof findSchema, FindToolDetails> {
@@ -263,24 +247,17 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
"--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<typeof findSchema, FindToolDetails> {
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) {
@@ -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";
+51 -106
View File
@@ -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<typeof grepSchema, GrepToolDetails> {
// 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<typeof grepSchema, GrepToolDetails> {
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<string>();
@@ -308,49 +298,18 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
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<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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<typeof grepSchema, GrepToolDetails> {
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<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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<typeof grepSchema, GrepToolDetails> {
}
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) {
@@ -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";
+1 -1
View File
@@ -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 {
@@ -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<typeof setTimeout> | 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<unknown>();
let timeout: ReturnType<typeof setTimeout> | 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;
}
/**
@@ -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) ?? [];
@@ -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";
@@ -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) ?? [];
}
@@ -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.
@@ -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<Diagno
let stableIterations = 0;
for (let i = 0; i < maxPollIterations; i++) {
await sleep(pollIntervalMs);
await Bun.sleep(pollIntervalMs);
const currentDiagnosticsVersion = client.diagnosticsVersion;
const currentDiagnosticsCount = countDiagnostics(client.diagnostics);
@@ -507,20 +507,6 @@ export function extractHoverText(
// General Utilities
// =============================================================================
/**
* Sleep for the specified number of milliseconds.
*/
export function sleep(ms: number): Promise<void> {
return Bun.sleep(ms);
}
/**
* Check if a command exists in PATH.
*/
export async function commandExists(command: string): Promise<boolean> {
return Bun.which(command) !== null;
}
/**
* Truncate a string to a maximum length with ellipsis.
*/
@@ -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,
@@ -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);
@@ -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;
+16 -66
View File
@@ -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<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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({
@@ -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;
+3 -3
View File
@@ -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[] = [];
@@ -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<string[]> {
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;
@@ -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<PythonToolCallResponse["result"]> {
return new Promise((resolve, reject) => {
const callId = generatePythonCallId();
if (signal?.aborted) {
reject(new Error("Aborted"));
return;
const { promise, resolve, reject } = Promise.withResolvers<PythonToolCallResponse["result"]>();
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<LspToolCallResponse["result"]> {
return new Promise((resolve, reject) => {
const callId = generateLspCallId();
if (signal?.aborted) {
reject(new Error("Aborted"));
return;
const { promise, resolve, reject } = Promise.withResolvers<LspToolCallResponse["result"]>();
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 {
@@ -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";
@@ -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 };
@@ -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<Parameters<typeof Bun.spawnSync>[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 {}
}
}
@@ -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;
}
@@ -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<Parameters<typeof Bun.spawnSync>[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<ExecResult> {
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 {}
}
}
@@ -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<Uint8Array>).text(),
(proc.stderr as ReadableStream<Uint8Array>).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,
};
}
@@ -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";
@@ -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,
+1 -1
View File
@@ -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 {
+1 -187
View File
@@ -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<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
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<T>(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";
@@ -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";
@@ -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<Uint8Array> | undefined = undefined;
private playbackProcess: Subprocess | undefined = undefined;
private playbackProcess: ptree.ChildProcess | undefined = undefined;
private playbackWriter:
| {
write: (chunk: Uint8Array) => Promise<void>;
@@ -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<Uint8Array>).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<Uint8Array>).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();

Some files were not shown because too many files have changed in this diff Show More