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:
@@ -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 });
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 +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
Reference in New Issue
Block a user