diff --git a/AGENTS.md b/AGENTS.md index bd9a028b0..1e749efba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,14 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr ### Package Structure -| Package | Description | -|---------|-------------| -| `packages/ai` | Multi-provider LLM client with streaming support | -| `packages/agent` | Agent runtime with tool calling and state management | -| `packages/coding-agent` | Main CLI application (primary focus) | -| `packages/tui` | Terminal UI library with differential rendering | -| `packages/stats` | Local observability dashboard (`omp stats`) | -| `packages/pi-utils` | Shared utilities (logger, streams, temp files) | +| Package | Description | +| ----------------------- | ---------------------------------------------------- | +| `packages/ai` | Multi-provider LLM client with streaming support | +| `packages/agent` | Agent runtime with tool calling and state management | +| `packages/coding-agent` | Main CLI application (primary focus) | +| `packages/tui` | Terminal UI library with differential rendering | +| `packages/stats` | Local observability dashboard (`omp stats`) | +| `packages/pi-utils` | Shared utilities (logger, streams, temp files) | ## Code Quality @@ -37,25 +37,28 @@ This project uses Bun. Use Bun APIs where they provide a cleaner alternative; us ### Process Execution **Prefer Bun Shell** (`$` template literals) for simple commands: + ```typescript import { $ } from "bun"; // Capture output const result = await $`git status`.cwd(dir).quiet().nothrow(); if (result.exitCode === 0) { - const text = result.text(); + const text = result.text(); } // Fire and forget -$`rm ${tmpFile}`.quiet().nothrow(); +$`do-stuff ${tmpFile}`.quiet().nothrow(); ``` **Use `Bun.spawn`/`Bun.spawnSync`** only when: + - Long-running processes (LSP servers, Python kernels) - Streaming stdin/stdout/stderr required (SSE, JSON-RPC) - Process control needed (signals, kill, complex lifecycle) **Bun Shell methods:** + - `.quiet()` - suppress output (stdout/stderr to null) - `.nothrow()` - don't throw on non-zero exit - `.text()` - get stdout as string @@ -66,9 +69,43 @@ $`rm ${tmpFile}`.quiet().nothrow(); **Prefer** `await Bun.sleep(ms)` **Avoid** `new Promise((resolve) => setTimeout(resolve, ms))` +### Node Module Imports + +**NEVER use named imports from `node:fs` or `node:path`** — always use namespace imports: + +```typescript +// BAD: Named imports +import { readdir, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +// GOOD: Namespace imports +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +// Then use: fs.readdir(), path.join(), etc. +``` + +**Choosing between `node:fs` and `node:fs/promises`:** + +- **Async-only file** → `import * as fs from "node:fs/promises"` +- **Needs both sync and async** → `import * as fs from "node:fs"`, use `fs.promises.xxx` for async + +```typescript +// File with only async operations +import * as fs from "node:fs/promises"; +await fs.readdir(dir); +await fs.stat(path); + +// File mixing sync and async (e.g., sync in constructor, async in methods) +import * as fs from "node:fs"; +fs.existsSync(path); // sync +await fs.promises.readdir(dir); // async +``` + ### File I/O **Prefer Bun file APIs:** + ```typescript // Read const text = await Bun.file(path).text(); @@ -79,22 +116,129 @@ const exists = await Bun.file(path).exists(); 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"; +**`Bun.write()` is smart** — it auto-creates parent directories and uses optimal syscalls: -await mkdir(path, { recursive: true }); -await rm(path, { recursive: true, force: true }); -const entries = await readdir(path); +```typescript +// BAD: Redundant mkdir before write +await mkdir(dirname(path), { recursive: true }); +await Bun.write(path, data); + +// GOOD: Bun.write handles it +await Bun.write(path, data); // Creates parent dirs automatically +``` + +**Use `node:fs/promises`** for directories (Bun has no native directory APIs): + +```typescript +import * as fs from "node:fs/promises"; + +await fs.mkdir(path, { recursive: true }); +await fs.rm(path, { recursive: true, force: true }); +const entries = await fs.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 +### File I/O Anti-Patterns + +**NEVER check `.exists()` before reading** — use try-catch with error code: + +```typescript +// BAD: Two syscalls, race condition +if (await Bun.file(path).exists()) { + return await Bun.file(path).json(); +} + +// BAD: Even with handle reuse, still two syscalls +const file = Bun.file(path); +if (await file.exists()) { + return await file.json(); +} + +// GOOD: One syscall, atomic, type-safe error handling +import { isEnoent } from "@anthropic/pi-utils"; + +try { + return await Bun.file(path).json(); +} catch (err) { + if (isEnoent(err)) return null; + throw err; +} +``` + +**NEVER use `Bun.file().exists()` for directories** — it doesn't distinguish files from dirs: + +```typescript +// BAD: Bun.file is for files, not directories +if (await Bun.file(dirPath).exists()) { ... } + +// GOOD: Use fs.stat for directories +import * as fs from "node:fs/promises"; + +try { + const s = await fs.stat(dirPath); + if (s.isDirectory()) { ... } +} catch (err) { + if (isEnoent(err)) { /* doesn't exist */ } +} +``` + +**NEVER create multiple handles to the same path**: + +```typescript +// BAD: Creates two file handles +if (await Bun.file(path).exists()) { + const content = await Bun.file(path).text(); +} + +// BAD: Still wasteful even in separate functions +async function checkConfig() { + return await Bun.file(configPath).exists(); +} +async function loadConfig() { + return await Bun.file(configPath).json(); // second handle +} +``` + +**NEVER use `Buffer.from(await Bun.file(x).arrayBuffer())`** — just use `readFile`: + +```typescript +// BAD: Unnecessary conversion +const buffer = Buffer.from(await Bun.file(path).arrayBuffer()); + +// GOOD: Direct buffer read +import * as fs from "node:fs/promises"; +const buffer = await fs.readFile(path); +``` + +**NEVER mix redundant existence checks with try-catch**: + +```typescript +// BAD: Existence check is pointless when you have try-catch +if (await file.exists()) { + try { + return await file.json(); + } catch { + return null; + } +} + +// GOOD: Let try-catch handle missing files +try { + return await Bun.file(path).json(); +} catch (err) { + if (isEnoent(err)) return null; + throw err; +} +``` + ### Streams **Prefer centralized helpers:** + ```typescript import { readStream, readLines } from "./utils/stream"; @@ -103,7 +247,7 @@ const text = await readStream(child.stdout); // Line-by-line iteration for await (const line of readLines(stream)) { - // process line + // process line } ``` @@ -114,7 +258,6 @@ for await (const line of readLines(stream)) { | 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"])` | diff --git a/bun.lock b/bun.lock index ecbbc0f42..84344bfb9 100644 --- a/bun.lock +++ b/bun.lock @@ -9,15 +9,16 @@ "get-east-asian-width": "^1.4.0", }, "devDependencies": { - "@biomejs/biome": "2.3.11", + "@biomejs/biome": "^2.3.12", "@bufbuild/protoc-gen-es": "^2.10.2", "@types/bun": "^1.2.17", - "@types/node": "^22.10.5", - "@typescript/native-preview": "^7.0.0-dev.20260102.1", + "@types/node": "^25.0.10", + "@typescript/native-preview": "^7.0.0-dev.20260123.3", "bun-types": "^1.3.5", "concurrently": "^9.2.1", "lint-staged": "^16.2.7", "prettier": "^3.8.0", + "ts-morph": "^27.0.2", }, }, "packages/agent": { @@ -30,7 +31,7 @@ }, "devDependencies": { "@sinclair/typebox": "^0.34.46", - "@types/node": "^24.3.0", + "@types/node": "^25.0.10", }, }, "packages/ai": { @@ -40,32 +41,32 @@ "pi-ai": "./src/cli.ts", }, "dependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@aws-sdk/client-bedrock-runtime": "^3.968.0", + "@anthropic-ai/sdk": "^0.71.2", + "@aws-sdk/client-bedrock-runtime": "^3.975.0", "@bufbuild/protobuf": "^2.10.2", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-node": "^2.1.1", - "@google/genai": "1.34.0", - "@mistralai/mistralai": "1.10.0", + "@google/genai": "^1.38.0", + "@mistralai/mistralai": "^1.13.0", "@oh-my-pi/pi-utils": "workspace:*", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "json5": "^2.2.3", - "openai": "6.10.0", + "openai": "^6.16.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6", }, "devDependencies": { - "@types/node": "^24.3.0", + "@types/node": "^25.0.10", }, }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", "version": "8.1.0", "bin": { - "omp": "dist/cli.js", + "omp": "src/cli.ts", }, "dependencies": { "@oh-my-pi/omp-stats": "workspace:*", @@ -73,29 +74,29 @@ "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", - "@openai/agents": "^0.3.7", + "@openai/agents": "^0.4.3", "@sinclair/typebox": "^0.34.46", "ajv": "^8.17.1", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "file-type": "^21.1.1", - "glob": "^11.0.3", + "glob": "^13.0.0", "handlebars": "^4.7.8", "highlight.js": "^11.11.1", - "marked": "^15.0.12", + "marked": "^17.0.1", "nanoid": "^5.1.6", "ndjson": "^2.0.0", - "node-html-parser": "^6.1.13", + "node-html-parser": "^7.0.2", "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", "zod": "^4.3.5", }, "devDependencies": { - "@types/diff": "^7.0.2", + "@types/diff": "^8.0.0", "@types/ms": "^2.1.0", "@types/ndjson": "^2.0.4", - "@types/node": "^24.3.0", + "@types/node": "^25.0.10", "ms": "^2.1.3", }, }, @@ -108,7 +109,7 @@ "winston-daily-rotate-file": "^5.0.0", }, "devDependencies": { - "@types/node": "^24.3.0", + "@types/node": "^25.0.10", }, }, "packages/react-edit-benchmark": { @@ -126,18 +127,19 @@ }, "devDependencies": { "@types/diff": "^7.0.2", + "bun-types": "^1.2.19", }, }, "packages/stats": { "name": "@oh-my-pi/omp-stats", "version": "8.1.0", "bin": { - "omp-stats": "./dist/index.js", + "omp-stats": "./src/index.ts", }, "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", "date-fns": "^4.1.0", - "lucide-react": "^0.562.0", + "lucide-react": "^0.563.0", "react": "^19.2.3", "react-dom": "^19.2.3", "recharts": "^3.6.0", @@ -152,15 +154,15 @@ "name": "@oh-my-pi/pi-tui", "version": "8.1.0", "dependencies": { - "@types/mime-types": "^2.1.4", + "@types/mime-types": "^3.0.1", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", + "marked": "^17.0.1", "mime-types": "^3.0.1", }, "devDependencies": { - "@xterm/headless": "^5.5.0", - "@xterm/xterm": "^5.5.0", + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0", }, }, }, @@ -177,11 +179,11 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/credential-provider-node": "^3.972.1", "@aws-sdk/eventstream-handler-node": "^3.972.1", "@aws-sdk/middleware-eventstream": "^3.972.1", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/middleware-websocket": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/token-providers": "3.974.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/eventstream-serde-config-resolver": "^4.3.8", "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-x/xKuWcQ6tJaaJedkAtoLB5IqAn4Zblq3j5OS1PCNUjhKDVfiSV1ZOuD3/cAWRUDPROiZvNK1POGLkBtin3cxg=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.975.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.1", "@aws-sdk/credential-provider-node": "^3.972.1", "@aws-sdk/eventstream-handler-node": "^3.972.1", "@aws-sdk/middleware-eventstream": "^3.972.1", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.2", "@aws-sdk/middleware-websocket": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/token-providers": "3.975.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.1", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/eventstream-serde-config-resolver": "^4.3.8", "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.11", "@smithy/middleware-retry": "^4.4.27", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.12", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.26", "@smithy/util-defaults-mode-node": "^4.2.29", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-stream": "^4.5.10", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZptHL8Z8y2m6sq1ksl+MIGoXxzRkWuOzqbGOd+P5htwIX0kEvzmxPwAqyCoiULn1OjS+kB+TCxfvBUVyglq3MQ=="], "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + "@aws-sdk/core": ["@aws-sdk/core@3.973.1", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.12", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ocubx42QsMyVs9ANSmFpRm0S+hubWljpPLjOi9UFrtcnVJjrVJTzQ51sN0e5g4e8i8QZ7uY73zosLmgYL7kZTQ=="], "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw=="], @@ -209,15 +211,15 @@ "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.1", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.2", "", { "dependencies": { "@aws-sdk/core": "^3.973.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-d+Exq074wy0X6wvShg/kmZVtkah+28vMuqCtuY3cydg8LUZOJBtbAolCpEJizSyb8mJJZF9BjWaTANXL4OYnkg=="], "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.1", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-format-url": "^3.972.1", "@smithy/eventstream-codec": "^4.2.8", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zej/2+u6KCEHUipHW/4KXwj+4PTJkrORwR4KHNHE8ATdzLf7hwu7HsPK+TyQXZSftH+VqYkFmTbyF9OPxOpwmw=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.975.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.1", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.2", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.11", "@smithy/middleware-retry": "^4.4.27", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.12", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.26", "@smithy/util-defaults-mode-node": "^4.2.29", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-OkeFHPlQj2c/Y5bQGkX14pxhDWUGUFt3LRHhjcDKsSCw6lrxKcxN3WFZN0qbJwKNydP+knL5nxvfgKiCLpTLRA=="], "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.1", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@smithy/config-resolver": "^4.4.6", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.974.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/nested-clients": "3.974.0", "@aws-sdk/types": "^3.973.0", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.975.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.1", "@aws-sdk/nested-clients": "3.975.0", "@aws-sdk/types": "^3.973.0", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-AWQt64hkVbDQ+CmM09wnvSk2mVyH4iRROkmYkr3/lmUtFNbE2L/fnw26sckZnUcFCsHPqbkQrcsZAnTcBLbH4w=="], "@aws-sdk/types": ["@aws-sdk/types@3.973.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ=="], @@ -237,23 +239,23 @@ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], + "@biomejs/biome": ["@biomejs/biome@2.3.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.12", "@biomejs/cli-darwin-x64": "2.3.12", "@biomejs/cli-linux-arm64": "2.3.12", "@biomejs/cli-linux-arm64-musl": "2.3.12", "@biomejs/cli-linux-x64": "2.3.12", "@biomejs/cli-linux-x64-musl": "2.3.12", "@biomejs/cli-win32-arm64": "2.3.12", "@biomejs/cli-win32-x64": "2.3.12" }, "bin": { "biome": "bin/biome" } }, "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], @@ -271,7 +273,7 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], - "@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], + "@google/genai": ["@google/genai@1.38.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-V/4CQVQGovvGHuS73lwJwHKR9x33kCij3zz/ReEQ4A7RJaV0U7m4k1mvYhFk55cGZdF5JLKu2S9BTaFuEs5xTA=="], "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -281,7 +283,7 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@mistralai/mistralai": ["@mistralai/mistralai@1.10.0", "", { "dependencies": { "zod": "^3.20.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg=="], + "@mistralai/mistralai": ["@mistralai/mistralai@1.13.0", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-eg7/8hQp91SshYDJ5XdWDsJENdIaSu52npJfKLCPuhla8Vm6GlESv2RROQyxESglCl7b1OULWiAv1ERbsrZO7w=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], @@ -299,16 +301,36 @@ "@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=="], + "@openai/agents": ["@openai/agents@0.4.3", "", { "dependencies": { "@openai/agents-core": "0.4.3", "@openai/agents-openai": "0.4.3", "@openai/agents-realtime": "0.4.3", "debug": "^4.4.0", "openai": "^6" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vj1vRkmhex4r+aQ3EjI9s9u4veFx/kT6NsZmj5qEvBTn8tk7HxPjZJR601dIVBISspH+dqh276I9e2thydPc3w=="], - "@openai/agents-core": ["@openai/agents-core@0.3.9", "", { "dependencies": { "debug": "^4.4.0", "openai": "^6" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "peerDependencies": { "zod": "^3.25.40 || ^4.0" }, "optionalPeers": ["zod"] }, "sha512-6Fr/VkA3lMaTT9EV2+OsmkMX9Yx+/PeWtlmaWNKDRG8D15IWuK13NOC9eFklTsa7otbuwbw/Xmjes+h4Z+CwSQ=="], + "@openai/agents-core": ["@openai/agents-core@0.4.3", "", { "dependencies": { "debug": "^4.4.0", "openai": "^6" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-2cz7ptgdHp2NzoN9uImBypRmR+e8ExvBXhxdDnNE6Cw0R8LQxd/KfdDupg7BB1inleOKLgDoasBXD7jRZ0iSQA=="], - "@openai/agents-openai": ["@openai/agents-openai@0.3.9", "", { "dependencies": { "@openai/agents-core": "0.3.9", "debug": "^4.4.0", "openai": "^6" }, "peerDependencies": { "zod": "^3.25.40 || ^4.0" } }, "sha512-duXUt0xU6K/+c7ae4m8BrJIUzZal6Pzln8V0frnJfNyfYO4SvHMV4qwPRzVDvv/ANj4DQXWI2L1JdPxKJeSHkw=="], + "@openai/agents-openai": ["@openai/agents-openai@0.4.3", "", { "dependencies": { "@openai/agents-core": "0.4.3", "debug": "^4.4.0", "openai": "^6" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-dBQDr6IJR9+h/7+0Xn0CQhBbVWvn3PE3a56WuwbKr3cYHU9QxUbVrsRprx3ERboww2qy2D9Hk6Cik2q9MHDnZg=="], - "@openai/agents-realtime": ["@openai/agents-realtime@0.3.9", "", { "dependencies": { "@openai/agents-core": "0.3.9", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" }, "peerDependencies": { "zod": "^3.25.40 || ^4.0" } }, "sha512-51zHO/zao/LHv70gseU1otTvXyS81tuVaewHlUBiNMXvqSZNkYViiO69hpXMoTYn5c3gCjUrXPxxI+NlHUtaHg=="], + "@openai/agents-realtime": ["@openai/agents-realtime@0.4.3", "", { "dependencies": { "@openai/agents-core": "0.4.3", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-hfe69MfEM0KRN/auLzca7UlMl8mcNMhnIQfOb01R0SbFpU1yl332kQEZw3a2o+n9YRCVAmP6QKuJjejsZwqbvg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], @@ -413,6 +435,8 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@ts-morph/common": ["@ts-morph/common@0.28.1", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -433,15 +457,15 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - "@types/diff": ["@types/diff@7.0.2", "", {}, "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], - "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ndjson": ["@types/ndjson@2.0.4", "", { "dependencies": { "@types/node": "*", "@types/through": "*" } }, "sha512-ajAl7AjhFstF6waORYNSS49GL5iBKisqJlgvXuprXFKCX9fto4ordlNU3+XMgkMddgeR0WoQQBmKUk0v0dJ4pw=="], - "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], @@ -455,27 +479,27 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260122.4", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260122.4", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260122.4", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260122.4", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260122.4", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260122.4", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260122.4", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260122.4" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-lboRukXxL3jeIyMKc4EjNHI4QThjOrrT5jjCyaNOUFkrk3JiObrR4z7Z6Z+m+WM/gbSW2tqaRBRFBRMsZGsxKQ=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260123.3", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260123.3", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260123.3", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260123.3", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260123.3", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260123.3", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260123.3", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260123.3" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-VE8ebP25U8B3XNIe1azQka7ty+lgYse7j8UtKuEbbC2G8JL/QY2iGIFaeR936h6ezPIjSDsHVNlsV5C5VQY5qA=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260122.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8hIIPe6LoY+FmUBRvhX7IbsZCr4Puwps0Ok+ez+rzg7d+sJCVuJ37ZxFh1pcNJEM39eII2o5TZ9dTCIT7/aAWA=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260123.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sIVxtOi5KXcbxyA7zMkB0zw4BQX5muehXciQlGNBuAqupQJgxbUPNuypXcmJKFDDUdEkEARRS/whtG0SA3HTrg=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260122.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-kJNpVzPW9jcFuRco+QwSHToEFHmfovNmwLo9PY97DLMqGnrvxkSy+k5sClHscEThIdzSRQbi7uZJUVBwgohNmA=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260123.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1yF90TxZvMa7MmlP7ExifqLOvPl4wNsTB58rE1hK9b6BAB/7/nx+CeYuWIoID4CAG2r1TY1EPqpJkbFyd6w2Rw=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260122.4", "", { "os": "linux", "cpu": "arm" }, "sha512-ImSMhsrB9QMK/cT2s4yCWkWOBUGZ+1L6vBaMMdyw3eNrU4tQo85Z3AqTn12M8WRbefLL89OmP6zJppP2TH3PAQ=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260123.3", "", { "os": "linux", "cpu": "arm" }, "sha512-H8y6qC4mJhzJvtq2bXfNcoD86KOIB7bCa571Mv7r5cATzN4BVG7pym6m49N+m5Tnoe1BkazWbMUZlH6yxrqiUQ=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260122.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-QsSNK3PbRyNviv0xru21jGV8fgzTVJ26oMnQwoK0IcyGabXtcLHJe5I5y4lwvLNjZPCbKDNThawso54Fo72FJQ=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260123.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-GiiukbZlzxrigq+52NemzgAleQ2iM9k88KGZnQgy8Y/7Rf73B8YgBrkGipgUZCcAWbvlhDooXMLAuQLXc6wdwQ=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260122.4", "", { "os": "linux", "cpu": "x64" }, "sha512-0OyzmbZ2Zq039RPLCyEjcrBhDkCXDfIrwbMJfoOrCyEO9XrK9Icwqzogm7H4bLyhcfuZIgugYSP2mQflaDvOVg=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260123.3", "", { "os": "linux", "cpu": "x64" }, "sha512-CsP6Aw7b3XNd293VYBfl6K1PYVLMjxYcyBRUpu9JX8l9X4RbTdJl6O1/Ab5/X6l+C8ukv2w0lR3CavX2K1kxnQ=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260122.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-FSRZ0iylGKbrYiimfkqKBXRlhR+PwOz5Jcb57dAU7wzr/7xSGON9bfywb0BhoF31GjYAnZ5Xv+1fuQOiB2HBYw=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260123.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Za3ra9JhTRUakBuxCIa5qJnwlkHUgMjBZ9p/BH3lQp3Wwx0dhav3aA+9is514iSZw8fR/q4hhesFXCrJW8XQzg=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260122.4", "", { "os": "win32", "cpu": "x64" }, "sha512-vAzRnS4nMBAd2XduzdmrhsCAAVSbCzZxVGMIKVvRcL9ljO5+fooggiYq7sk798TIZ1ov7A0rZk5k+o0Wyx2nXA=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260123.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7/PE9vznvc4mQz6Hiuw4tTQq9J0b5QWebYoGXoxOqFF/JI3m920yv8+nRI144cVoBYdonOwanexFfNq4KdwCQ=="], "@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="], - "@xterm/headless": ["@xterm/headless@5.5.0", "", {}, "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g=="], + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], - "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -533,6 +557,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -657,6 +683,8 @@ "fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], @@ -693,7 +721,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], @@ -743,7 +771,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], @@ -773,11 +801,13 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], - "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -819,7 +849,7 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="], + "node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -837,7 +867,7 @@ "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "openai": ["openai@6.10.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A=="], + "openai": ["openai@6.16.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -849,6 +879,8 @@ "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], @@ -863,6 +895,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], @@ -973,6 +1007,8 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -985,6 +1021,8 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-morph": ["ts-morph@27.0.2", "", { "dependencies": { "@ts-morph/common": "~0.28.1", "code-block-writer": "^13.0.3" } }, "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -995,7 +1033,7 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -1043,23 +1081,43 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-sdk/client-sso/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/client-sso/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.974.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/nested-clients": "3.974.0", "@aws-sdk/types": "^3.973.0", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q=="], + "@aws-sdk/util-endpoints/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="], + "@aws-sdk/util-user-agent-node/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@mistralai/mistralai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@oh-my-pi/omp-stats/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], - "@oh-my-pi/pi-agent-core/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], - - "@oh-my-pi/pi-ai/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], - - "@oh-my-pi/pi-coding-agent/@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=="], + "@oh-my-pi/react-edit-benchmark/@types/diff": ["@types/diff@7.0.2", "", {}, "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q=="], "@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="], @@ -1103,6 +1161,8 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1113,28 +1173,20 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.974.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.0", "@aws-sdk/middleware-host-header": "^3.972.1", "@aws-sdk/middleware-logger": "^3.972.1", "@aws-sdk/middleware-recursion-detection": "^3.972.1", "@aws-sdk/middleware-user-agent": "^3.972.1", "@aws-sdk/region-config-resolver": "^3.972.1", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@aws-sdk/util-user-agent-browser": "^3.972.1", "@aws-sdk/util-user-agent-node": "^3.972.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.21.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.10", "@smithy/middleware-retry": "^4.4.26", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.25", "@smithy/util-defaults-mode-node": "^4.2.28", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + + "@aws-sdk/util-user-agent-node/@aws-sdk/middleware-user-agent/@aws-sdk/core": ["@aws-sdk/core@3.973.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.0", "@aws-sdk/xml-builder": "^3.972.1", "@smithy/core": "^3.21.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.10.11", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@oh-my-pi/omp-stats/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@oh-my-pi/pi-agent-core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@oh-my-pi/pi-ai/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@oh-my-pi/pi-coding-agent/@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=="], - - "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "cli-highlight/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], @@ -1147,8 +1199,6 @@ "concurrently/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -1165,6 +1215,8 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.1", "", { "dependencies": { "@aws-sdk/core": "^3.973.0", "@aws-sdk/types": "^3.973.0", "@aws-sdk/util-endpoints": "3.972.0", "@smithy/core": "^3.21.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA=="], + "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], diff --git a/package.json b/package.json index 86946e2a7..c4b73354a 100644 --- a/package.json +++ b/package.json @@ -24,15 +24,16 @@ "release": "bun scripts/release.ts" }, "devDependencies": { - "@biomejs/biome": "2.3.11", + "@biomejs/biome": "^2.3.12", "@bufbuild/protoc-gen-es": "^2.10.2", "@types/bun": "^1.2.17", - "@types/node": "^22.10.5", - "@typescript/native-preview": "^7.0.0-dev.20260102.1", + "@types/node": "^25.0.10", + "@typescript/native-preview": "^7.0.0-dev.20260123.3", "bun-types": "^1.3.5", "concurrently": "^9.2.1", "lint-staged": "^16.2.7", - "prettier": "^3.8.0" + "prettier": "^3.8.0", + "ts-morph": "^27.0.2" }, "lint-staged": { "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" diff --git a/packages/agent/package.json b/packages/agent/package.json index 70005394c..dcfff1906 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -47,6 +47,6 @@ }, "devDependencies": { "@sinclair/typebox": "^0.34.46", - "@types/node": "^24.3.0" + "@types/node": "^25.0.10" } } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 082a380e3..d56d3f32a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,9 +1,6 @@ # Changelog ## [Unreleased] - -### Fixed -- Fixed orphaned tool result handling by tracking skipped tool call IDs in message conversion to prevent dangling references ## [8.0.0] - 2026-01-23 ### Fixed @@ -119,7 +116,7 @@ - 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 +- Replaced temp directory creation with TempDir API - Changed credential storage from auth.json to ~/.omp/agent/agent.db - Changed CLI command examples from npx to bunx - Refactored OAuth flows to use common callback server base class diff --git a/packages/ai/README.md b/packages/ai/README.md index a6632c7ed..2efb51750 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -551,10 +551,9 @@ The abort signal allows you to cancel in-progress requests. Aborted requests hav import { getModel, stream } from "@oh-my-pi/pi-ai"; const model = getModel("openai", "gpt-4o-mini"); -const controller = new AbortController(); // Abort after 2 seconds -setTimeout(() => controller.abort(), 2000); +const signal = AbortSignal.timeout(2000); const s = stream( model, @@ -562,7 +561,7 @@ const s = stream( messages: [{ role: "user", content: "Write a long story" }], }, { - signal: controller.signal, + signal, }, ); diff --git a/packages/ai/package.json b/packages/ai/package.json index 45e674169..3c929d9fa 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -57,19 +57,19 @@ }, "dependencies": { "@oh-my-pi/pi-utils": "workspace:*", - "@anthropic-ai/sdk": "0.71.2", - "@aws-sdk/client-bedrock-runtime": "^3.968.0", + "@anthropic-ai/sdk": "^0.71.2", + "@aws-sdk/client-bedrock-runtime": "^3.975.0", "@bufbuild/protobuf": "^2.10.2", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-node": "^2.1.1", - "@google/genai": "1.34.0", - "@mistralai/mistralai": "1.10.0", + "@google/genai": "^1.38.0", + "@mistralai/mistralai": "^1.13.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "json5": "^2.2.3", - "openai": "6.10.0", + "openai": "^6.16.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" }, @@ -93,6 +93,6 @@ "bun": ">=1.0.0" }, "devDependencies": { - "@types/node": "^24.3.0" + "@types/node": "^25.0.10" } } diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 4d94b7dad..5194dfb06 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -23,7 +23,7 @@ async function login(provider: OAuthProvider): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); const promptFn = (msg: string) => prompt(rl, `${msg} `); - const storage = new CliAuthStorage(); + const storage = await CliAuthStorage.create(); try { let credentials: OAuthCredentials; @@ -145,7 +145,7 @@ Examples: } if (command === "status") { - const storage = new CliAuthStorage(); + const storage = await CliAuthStorage.create(); try { const providers = storage.listProviders(); if (providers.length === 0) { @@ -179,7 +179,7 @@ Examples: if (command === "logout") { let provider = args[1] as OAuthProvider | undefined; - const storage = new CliAuthStorage(); + const storage = await CliAuthStorage.create(); try { if (!provider) { diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 22a8d63ea..17ea1e76e 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { appendFile } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import http2 from "node:http2"; import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf"; import { ValueSchema } from "@bufbuild/protobuf/wkt"; @@ -142,7 +142,7 @@ async function appendCursorDebugLog(entry: CursorLogEntry): Promise { const logPath = process.env.DEBUG_CURSOR_LOG; if (!logPath) return; try { - await appendFile(logPath, `${JSON.stringify(entry, debugReplacer)}\n`); + await fs.appendFile(logPath, `${JSON.stringify(entry, debugReplacer)}\n`); } catch { // Ignore debug log failures } diff --git a/packages/ai/src/storage.ts b/packages/ai/src/storage.ts index 9b7daa266..b705f0538 100644 --- a/packages/ai/src/storage.ts +++ b/packages/ai/src/storage.ts @@ -4,9 +4,9 @@ */ import { Database } from "bun:sqlite"; -import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import * as fs from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import * as path from "node:path"; import type { OAuthCredentials } from "./utils/oauth/types"; type AuthCredential = { type: "api_key"; key: string } | ({ type: "oauth" } & OAuthCredentials); @@ -24,7 +24,7 @@ type AuthRow = { * Get the agent config directory (e.g., ~/.omp/agent/) */ function getAgentDir(): string { - const configDir = process.env.OMP_CODING_AGENT_DIR || join(homedir(), ".omp", "agent"); + const configDir = process.env.OMP_CODING_AGENT_DIR || path.join(homedir(), ".omp", "agent"); return configDir; } @@ -32,7 +32,7 @@ function getAgentDir(): string { * Get path to agent.db */ function getAgentDbPath(): string { - return join(getAgentDir(), "agent.db"); + return path.join(getAgentDir(), "agent.db"); } function serializeCredential(credential: AuthCredential): { credentialType: string; data: string } | null { @@ -79,6 +79,8 @@ function deserializeCredential(row: AuthRow): AuthCredential | null { /** * Simple storage class for CLI auth credentials. + * + * Use `CliAuthStorage.create()` to instantiate (async initialization). */ export class CliAuthStorage { private db: Database; @@ -87,20 +89,8 @@ export class CliAuthStorage { private listAllStmt: ReturnType; private deleteByProviderStmt: ReturnType; - constructor(dbPath: string = getAgentDbPath()) { - // Ensure directory exists with secure permissions - const dir = dirname(dbPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - this.db = new Database(dbPath); - // Harden database file permissions to prevent credential leakage - try { - chmodSync(dbPath, 0o600); - } catch { - // Ignore chmod failures (e.g., Windows) - } + private constructor(db: Database) { + this.db = db; this.initializeSchema(); this.insertStmt = this.db.prepare( @@ -111,6 +101,26 @@ export class CliAuthStorage { this.deleteByProviderStmt = this.db.prepare("DELETE FROM auth_credentials WHERE provider = ?"); } + static async create(dbPath: string = getAgentDbPath()): Promise { + const dir = path.dirname(dbPath); + const dirExists = await fs + .stat(dir) + .then((s) => s.isDirectory()) + .catch(() => false); + if (!dirExists) { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + } + + const db = new Database(dbPath); + try { + await fs.chmod(dbPath, 0o600); + } catch { + // Ignore chmod failures (e.g., Windows) + } + + return new CliAuthStorage(db); + } + private initializeSchema(): void { this.db.exec(` PRAGMA journal_mode=WAL; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 0c5422306..275ce7b53 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,6 +1,6 @@ -import { existsSync } from "node:fs"; +import * as fs from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { supportsXhigh } from "./models"; import { type BedrockOptions, streamBedrock } from "./providers/amazon-bedrock"; import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic"; @@ -34,10 +34,10 @@ function hasVertexAdcCredentials(): boolean { if (cachedVertexAdcCredentialsExists === null) { const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; if (gacPath) { - cachedVertexAdcCredentialsExists = existsSync(gacPath); + cachedVertexAdcCredentialsExists = fs.existsSync(gacPath); } else { - cachedVertexAdcCredentialsExists = existsSync( - join(homedir(), ".config", "gcloud", "application_default_credentials.json"), + cachedVertexAdcCredentialsExists = fs.existsSync( + path.join(homedir(), ".config", "gcloud", "application_default_credentials.json"), ); } } diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index e07aee12d..f7ba27170 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -69,30 +69,30 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { execSync } from "node:child_process"; -import { mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { getModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey } from "./oauth"; -const TEMP_DIR = join(import.meta.dir, ".temp-images"); +const TEMP_DIR = path.join(import.meta.dir, ".temp-images"); /** * Generate a valid PNG image of specified dimensions using ImageMagick */ -function generateImage(width: number, height: number, filename: string): string { - const filepath = join(TEMP_DIR, filename); +async function generateImage(width: number, height: number, filename: string): Promise { + const filepath = path.join(TEMP_DIR, filename); execSync(`magick -size ${width}x${height} xc:red "${filepath}"`, { stdio: "ignore" }); - const buffer = require("fs").readFileSync(filepath); + const buffer = await fs.promises.readFile(filepath); return buffer.toString("base64"); } /** * Generate a valid PNG image of approximately the specified size in bytes */ -function generateImageWithSize(targetBytes: number, filename: string): string { - const filepath = join(TEMP_DIR, filename); +async function generateImageWithSize(targetBytes: number, filename: string): Promise { + const filepath = path.join(TEMP_DIR, filename); // Use uncompressed PNG to get predictable sizes // Each pixel is 3 bytes (RGB), plus PNG overhead (~100 bytes) // For a square image: side = sqrt(targetBytes / 3) @@ -101,16 +101,16 @@ function generateImageWithSize(targetBytes: number, filename: string): string { execSync(`magick -size ${side}x${side} xc: +noise Random -depth 8 PNG24:"${filepath}"`, { stdio: "ignore" }); // Check actual size and adjust if needed - const stats = require("fs").statSync(filepath); - if (stats.size < targetBytes * 0.8) { + const { size } = await Bun.file(filepath).stat(); + if (size < targetBytes * 0.8) { // If too small, increase dimensions - const newSide = Math.ceil(side * Math.sqrt(targetBytes / stats.size)); + const newSide = Math.ceil(side * Math.sqrt(targetBytes / size)); execSync(`magick -size ${newSide}x${newSide} xc: +noise Random -depth 8 PNG24:"${filepath}"`, { stdio: "ignore", }); } - const buffer = require("fs").readFileSync(filepath); + const buffer = await fs.promises.readFile(filepath); return buffer.toString("base64"); } @@ -260,17 +260,17 @@ async function findLimit( describe("Image Limits E2E Tests", () => { let smallImage: string; // 100x100 for count tests - beforeAll(() => { + beforeAll(async () => { // Create temp directory - mkdirSync(TEMP_DIR, { recursive: true }); + fs.mkdirSync(TEMP_DIR, { recursive: true }); // Generate small test image for count tests - smallImage = generateImage(100, 100, "small.png"); + smallImage = await generateImage(100, 100, "small.png"); }); afterAll(() => { // Clean up temp directory - rmSync(TEMP_DIR, { recursive: true, force: true }); + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); }); // ------------------------------------------------------------------------- @@ -314,7 +314,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -343,7 +343,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -404,7 +404,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -432,7 +432,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -491,7 +491,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -519,7 +519,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -572,7 +572,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -600,7 +600,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -658,7 +658,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -686,7 +686,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -742,7 +742,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -770,7 +770,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -821,7 +821,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -849,7 +849,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -905,7 +905,7 @@ describe("Image Limits E2E Tests", () => { for (const sizeMB of sizes) { console.log(` Testing size: ${sizeMB}MB...`); - const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); + const imageBase64 = await generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`); const result = await testImageSize(model, imageBase64); if (result.success) { lastSuccess = sizeMB; @@ -933,7 +933,7 @@ describe("Image Limits E2E Tests", () => { for (const dim of dimensions) { console.log(` Testing dimension: ${dim}x${dim}...`); - const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`); + const imageBase64 = await generateImage(dim, dim, `dim-${dim}.png`); const result = await testImageDimensions(model, imageBase64); if (result.success) { lastSuccess = dim; @@ -966,12 +966,12 @@ describe("Image Limits E2E Tests", () => { // Generate images at specific sizes for each provider's limit const imageCache: Map = new Map(); - function getImageAtSize(targetMB: number): string { + async function getImageAtSize(targetMB: number): Promise { if (imageCache.has(targetMB)) { return imageCache.get(targetMB)!; } console.log(` Generating ~${targetMB}MB noise image...`); - const imageBase64 = generateImageWithSize(targetMB * 1024 * 1024, `stress-${targetMB}mb.png`); + const imageBase64 = await generateImageWithSize(targetMB * 1024 * 1024, `stress-${targetMB}mb.png`); const actualSize = Buffer.from(imageBase64, "base64").length; console.log(` Actual size: ${(actualSize / 1024 / 1024).toFixed(2)}MB`); imageCache.set(targetMB, imageBase64); @@ -984,7 +984,7 @@ describe("Image Limits E2E Tests", () => { "Anthropic: max ~3MB images before rejection", async () => { const model = getModel("anthropic", "claude-3-5-haiku-20241022"); - const image3mb = getImageAtSize(3); + const image3mb = await getImageAtSize(3); // 32MB total limit / ~4MB actual = ~8 images const counts = [1, 2, 4, 6, 8, 10, 12]; @@ -1016,7 +1016,7 @@ describe("Image Limits E2E Tests", () => { "OpenAI: max ~15MB images before rejection", async () => { const model = getModel("openai", "gpt-4o-mini"); - const image15mb = getImageAtSize(15); + const image15mb = await getImageAtSize(15); // Test progressively const counts = [1, 2, 5, 10, 20]; @@ -1048,7 +1048,7 @@ describe("Image Limits E2E Tests", () => { "Gemini: max ~20MB images before rejection", async () => { const model = getModel("google", "gemini-2.5-flash"); - const image20mb = getImageAtSize(20); + const image20mb = await getImageAtSize(20); // Test progressively const counts = [1, 2, 5, 10, 20, 50]; @@ -1080,7 +1080,7 @@ describe("Image Limits E2E Tests", () => { "Mistral: max ~10MB images before rejection", async () => { const model = getModel("mistral", "pixtral-12b"); - const image10mb = getImageAtSize(10); + const image10mb = await getImageAtSize(10); // Known limit is 8 images const counts = [1, 2, 4, 6, 8, 9]; @@ -1112,7 +1112,7 @@ describe("Image Limits E2E Tests", () => { "xAI: max ~20MB images before rejection", async () => { const model = getModel("xai", "grok-2-vision"); - const image20mb = getImageAtSize(20); + const image20mb = await getImageAtSize(20); // Test progressively const counts = [1, 2, 5, 10, 20]; @@ -1146,7 +1146,7 @@ describe("Image Limits E2E Tests", () => { const model = getModel("groq", "meta-llama/llama-4-scout-17b-16e-instruct"); // Generate 5760x5760 image (33177600 pixels = Groq's limit) console.log(" Generating 5760x5760 test image for Groq..."); - const image5760 = generateImage(5760, 5760, "stress-5760.png"); + const image5760 = await generateImage(5760, 5760, "stress-5760.png"); // Known limit is 5 images const counts = [1, 2, 3, 4, 5, 6]; @@ -1179,7 +1179,7 @@ describe("Image Limits E2E Tests", () => { "zAI: max ~15MB images before rejection", async () => { const model = getModel("zai", "glm-4.5v"); - const image15mb = getImageAtSize(15); + const image15mb = await getImageAtSize(15); // Context-limited, test progressively const counts = [1, 2, 5, 10, 20]; @@ -1211,7 +1211,7 @@ describe("Image Limits E2E Tests", () => { "OpenRouter: max ~5MB images before rejection", async () => { const model = getModel("openrouter", "z-ai/glm-4.5v"); - const image5mb = getImageAtSize(5); + const image5mb = await getImageAtSize(5); // Context-limited, test progressively const counts = [1, 2, 5, 10, 20]; diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index a85dc4a82..17ec08760 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import type { Api, Context, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { complete, getModel } from "@oh-my-pi/pi-ai"; import type { OptionsForApi } from "@oh-my-pi/pi-ai/types"; @@ -32,8 +32,8 @@ async function handleToolWithImageResult(model: Model, o } // Read the test image - const imagePath = join(import.meta.dir, "data", "red-circle.png"); - const imageBuffer = readFileSync(imagePath); + const imagePath = path.join(import.meta.dir, "data", "red-circle.png"); + const imageBuffer = await fs.readFile(imagePath); const base64Image = imageBuffer.toString("base64"); // Define a tool that returns only an image (no text) @@ -120,8 +120,8 @@ async function handleToolWithTextAndImageResult(model: Model; -function loadAuthStorage(): AuthStorage { - if (!existsSync(AUTH_PATH)) { - return {}; - } +async function loadAuthStorage(): Promise { try { - const content = readFileSync(AUTH_PATH, "utf-8"); + const content = await Bun.file(AUTH_PATH).text(); return JSON.parse(content); - } catch { - return {}; + } catch (err) { + if (isEnoent(err)) return {}; + throw err; } } -function saveAuthStorage(storage: AuthStorage): void { +async function saveAuthStorage(storage: AuthStorage): Promise { const configDir = dirname(AUTH_PATH); - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - } - writeFileSync(AUTH_PATH, JSON.stringify(storage, null, 2), "utf-8"); - chmodSync(AUTH_PATH, 0o600); + await fs.mkdir(configDir, { recursive: true, mode: 0o700 }); + await Bun.write(AUTH_PATH, JSON.stringify(storage, null, 2)); + await fs.chmod(AUTH_PATH, 0o600); } /** @@ -76,7 +73,7 @@ function saveAuthStorage(storage: AuthStorage): void { export async function resolveApiKey(provider: string): Promise { if (!E2E_ENABLED) return undefined; - const storage = loadAuthStorage(); + const storage = await loadAuthStorage(); const entry = storage[provider]; if (!entry) return undefined; @@ -100,7 +97,7 @@ export async function resolveApiKey(provider: string): Promise { describe("openai-codex streaming", () => { it("streams SSE responses into AssistantMessageEventStream", async () => { - const tempDir = createTempDirSync("@pi-codex-stream-"); - process.env.OMP_CODING_AGENT_DIR = tempDir.path; + const tempDir = TempDir.createSync("@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" } }), @@ -129,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 = createTempDirSync("@pi-codex-stream-"); - process.env.OMP_CODING_AGENT_DIR = tempDir.path; + const tempDir = TempDir.createSync("@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" } }), @@ -230,8 +230,8 @@ describe("openai-codex streaming", () => { }); it("does not set conversation_id/session_id headers when sessionId is not provided", async () => { - const tempDir = createTempDirSync("@pi-codex-stream-"); - process.env.OMP_CODING_AGENT_DIR = tempDir.path; + const tempDir = TempDir.createSync("@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" } }), diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 4fe172387..4da9cf9c4 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { type ChildProcess, execSync, spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; +import * as fs from "node:fs/promises"; import { getModel } from "@oh-my-pi/pi-ai/models"; import { complete, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; @@ -219,7 +219,7 @@ async function handleImage(model: Model, options?: Optio // Read the test image const imagePath = join(import.meta.dir, "data", "red-circle.png"); - const imageBuffer = readFileSync(imagePath); + const imageBuffer = await fs.readFile(imagePath); const base64Image = imageBuffer.toString("base64"); const imageContent: ImageContent = { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 885f0dfb9..0fc54f7b5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -46,6 +46,9 @@ - Added automatic idle kernel session cleanup timer (5-minute timeout, 30-second interval) - Added types/assets/index.d.ts for global TypeScript module declarations supporting `.md`, `.py`, and `.wasm?raw` imports - Added bunfig.toml loader configuration for importing markdown, Python, and WASM files as text modules +- Added color manipulation utilities (hexToHsv, hsvToHex, shiftHue) to pi-utils for accessible theme adjustments +- Added color-blind mode setting for improved accessibility +- Added filesystem error type guards (isEnoent, isEacces, isPerm, isEnotempty, isFsError, hasFsCode) to pi-utils for safe error handling ### Changed - Changed changelog diff truncation limit to be configurable via settings @@ -102,6 +105,11 @@ - Updated TypeScript and Bun configuration for monorepo-wide build consistency and reduced boilerplate - Removed WASM base64 encoding build script; imports now use Bun loader with `wasm?raw` query parameter - Unified TypeScript checking pipeline with tsgo-based configuration instead of per-package tsconfig.publish.json boilerplate +- Migrated file system operations from synchronous to asynchronous APIs in skills loading, agent discovery, and file processing for improved performance +- Refactored scanDirectoryForSkills to use async/await with concurrent directory scanning via Promise.all +- Migrated file system operations from synchronous to asynchronous APIs across plugins, settings, extensions, and config modules +- Updated import organization in model-registry.ts and other modules for consistency +- Changed FilesystemError handling to use new pi-utils type guards for safer error checking ### Fixed - Fixed database busy errors during concurrent access by adding retry logic with exponential backoff when opening storage diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts index 1623533c4..d226a00e0 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/hooks/file-trigger.ts @@ -15,9 +15,9 @@ export default function (pi: HookAPI) { pi.on("session_start", async (_event, ctx) => { const triggerFile = "/tmp/agent-trigger.txt"; - fs.watch(triggerFile, () => { + fs.watch(triggerFile, async () => { try { - const content = fs.readFileSync(triggerFile, "utf-8").trim(); + const content = (await Bun.file(triggerFile).text()).trim(); if (content) { pi.sendMessage( { @@ -27,7 +27,7 @@ export default function (pi: HookAPI) { }, true, // triggerTurn - get LLM to respond ); - fs.writeFileSync(triggerFile, ""); // Clear after reading + await Bun.write(triggerFile, ""); // Clear after reading } } catch { // File might not exist yet diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c02b9da3b..3135864de 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -80,29 +80,29 @@ "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", - "@openai/agents": "^0.3.7", + "@openai/agents": "^0.4.3", "@sinclair/typebox": "^0.34.46", "ajv": "^8.17.1", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "file-type": "^21.1.1", - "glob": "^11.0.3", + "glob": "^13.0.0", "handlebars": "^4.7.8", "highlight.js": "^11.11.1", - "marked": "^15.0.12", + "marked": "^17.0.1", "nanoid": "^5.1.6", "ndjson": "^2.0.0", - "node-html-parser": "^6.1.13", + "node-html-parser": "^7.0.2", "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", "zod": "^4.3.5" }, "devDependencies": { - "@types/diff": "^7.0.2", + "@types/diff": "^8.0.0", "@types/ms": "^2.1.0", "@types/ndjson": "^2.0.4", - "@types/node": "^24.3.0", + "@types/node": "^25.0.10", "ms": "^2.1.3" }, "keywords": [ diff --git a/packages/coding-agent/src/capability/fs.ts b/packages/coding-agent/src/capability/fs.ts index 8448b5609..b43993c0b 100644 --- a/packages/coding-agent/src/capability/fs.ts +++ b/packages/coding-agent/src/capability/fs.ts @@ -1,16 +1,15 @@ -import type { Dirent } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; const contentCache = new Map(); -const dirCache = new Map(); +const dirCache = new Map(); -function resolvePath(path: string): string { - return resolve(path); +function resolvePath(filePath: string): string { + return path.resolve(filePath); } -export async function readFile(path: string): Promise { - const abs = resolvePath(path); +export async function readFile(filePath: string): Promise { + const abs = resolvePath(filePath); if (contentCache.has(abs)) { return contentCache.get(abs) ?? null; } @@ -25,14 +24,14 @@ export async function readFile(path: string): Promise { } } -export async function readDirEntries(path: string): Promise { - const abs = resolvePath(path); +export async function readDirEntries(dirPath: string): Promise { + const abs = resolvePath(dirPath); if (dirCache.has(abs)) { return dirCache.get(abs) ?? []; } try { - const entries = await readdir(abs, { withFileTypes: true }); + const entries = await fs.promises.readdir(abs, { withFileTypes: true }); dirCache.set(abs, entries); return entries; } catch { @@ -41,8 +40,8 @@ export async function readDirEntries(path: string): Promise { } } -export async function readDir(path: string): Promise { - const entries = await readDirEntries(path); +export async function readDir(dirPath: string): Promise { + const entries = await readDirEntries(dirPath); return entries.map((entry) => entry.name); } @@ -58,10 +57,10 @@ export async function walkUp( const entries = await readDirEntries(current); const entry = entries.find((e) => e.name === name); if (entry) { - if (file && entry.isFile()) return join(current, name); - if (dir && entry.isDirectory()) return join(current, name); + if (file && entry.isFile()) return path.join(current, name); + if (dir && entry.isDirectory()) return path.join(current, name); } - const parent = dirname(current); + const parent = path.dirname(current); if (parent === current) return null; current = parent; } @@ -79,11 +78,11 @@ export function clearCache(): void { dirCache.clear(); } -export function invalidate(path: string): void { - const abs = resolvePath(path); +export function invalidate(filePath: string): void { + const abs = resolvePath(filePath); contentCache.delete(abs); dirCache.delete(abs); - const parent = dirname(abs); + const parent = path.dirname(abs); if (parent !== abs) { dirCache.delete(parent); } diff --git a/packages/coding-agent/src/capability/index.ts b/packages/coding-agent/src/capability/index.ts index b328cb2be..85e233317 100644 --- a/packages/coding-agent/src/capability/index.ts +++ b/packages/coding-agent/src/capability/index.ts @@ -8,7 +8,7 @@ */ import { homedir } from "node:os"; -import { resolve } from "node:path"; +import * as path from "node:path"; import { clearCache as clearFsCache, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs"; import type { Capability, @@ -399,10 +399,10 @@ export function reset(): void { /** * Invalidate cache for a specific path. - * @param path - Absolute or relative path to invalidate + * @param filePath - Absolute or relative path to invalidate */ -export function invalidate(path: string, cwd?: string): void { - const resolved = cwd ? resolve(cwd, path) : path; +export function invalidate(filePath: string, cwd?: string): void { + const resolved = cwd ? path.resolve(cwd, filePath) : filePath; invalidateFs(resolved); } diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 8fdc80761..46791ef76 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -2,9 +2,10 @@ * Process @file CLI arguments into text content and image attachments */ -import { existsSync, readFileSync, statSync } from "node:fs"; -import { resolve } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import type { ImageContent } from "@oh-my-pi/pi-ai"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { resolveReadPath } from "../tools/path-utils"; import { formatDimensionNote, resizeImage } from "../utils/image-resize"; @@ -28,16 +29,20 @@ export async function processFileArguments(fileArgs: string[], options?: Process for (const fileArg of fileArgs) { // Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces) - const absolutePath = resolve(resolveReadPath(fileArg, process.cwd())); + const absolutePath = path.resolve(resolveReadPath(fileArg, process.cwd())); - // Check if file exists and is not empty - if (!existsSync(absolutePath)) { - console.error(chalk.red(`Error: File not found: ${absolutePath}`)); - process.exit(1); + // Read file, handling not-found gracefully + let buffer: Buffer; + try { + buffer = await fs.readFile(absolutePath); + } catch (err) { + if (isEnoent(err)) { + console.error(chalk.red(`Error: File not found: ${absolutePath}`)); + process.exit(1); + } + throw err; } - const stats = statSync(absolutePath); - if (stats.size === 0) { - // Skip empty files + if (buffer.length === 0) { continue; } @@ -45,9 +50,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process if (mimeType) { // Handle image file - const buffer = readFileSync(absolutePath); const base64Content = buffer.toString("base64"); - let attachment: ImageContent; let dimensionNote: string | undefined; @@ -87,7 +90,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process } else { // Handle text file try { - const content = readFileSync(absolutePath, "utf-8"); + const content = buffer.toString("utf-8"); text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/cli/jupyter-cli.ts b/packages/coding-agent/src/cli/jupyter-cli.ts new file mode 100644 index 000000000..7e8b57382 --- /dev/null +++ b/packages/coding-agent/src/cli/jupyter-cli.ts @@ -0,0 +1,106 @@ +/** + * Jupyter CLI command handlers. + * + * Handles `omp jupyter` subcommand for managing the shared Python gateway. + */ + +import chalk from "chalk"; +import { APP_NAME } from "../config"; +import { getGatewayStatus, shutdownSharedGateway } from "../ipy/gateway-coordinator"; + +export type JupyterAction = "kill" | "status"; + +export interface JupyterCommandArgs { + action: JupyterAction; +} + +export function parseJupyterArgs(args: string[]): JupyterCommandArgs | undefined { + if (args.length === 0 || args[0] !== "jupyter") { + return undefined; + } + + const action = args[1] as JupyterAction | undefined; + if (!action || !["kill", "status"].includes(action)) { + return { action: "status" }; + } + + return { action }; +} + +export async function runJupyterCommand(cmd: JupyterCommandArgs): Promise { + switch (cmd.action) { + case "kill": + await runKill(); + break; + case "status": + await runStatus(); + break; + } +} + +async function runKill(): Promise { + const status = await getGatewayStatus(); + + if (!status.active) { + console.log(chalk.dim("No Jupyter gateway is running")); + return; + } + + console.log(`Killing Jupyter gateway (PID ${status.pid})...`); + await shutdownSharedGateway(); + console.log(chalk.green("Jupyter gateway stopped")); +} + +async function runStatus(): Promise { + const status = await getGatewayStatus(); + + if (!status.active) { + console.log(chalk.dim("No Jupyter gateway is running")); + return; + } + + console.log(chalk.bold("Jupyter Gateway Status\n")); + console.log(` ${chalk.green("●")} Running`); + console.log(` PID: ${status.pid}`); + console.log(` URL: ${status.url}`); + if (status.uptime !== null) { + console.log(` Uptime: ${formatUptime(status.uptime)}`); + } + if (status.pythonPath) { + console.log(` Python: ${status.pythonPath}`); + } + if (status.venvPath) { + console.log(` Venv: ${status.venvPath}`); + } +} + +function formatUptime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +export function printJupyterHelp(): void { + console.log(`${chalk.bold(`${APP_NAME} jupyter`)} - Manage the shared Jupyter gateway + +${chalk.bold("Usage:")} + ${APP_NAME} jupyter + +${chalk.bold("Commands:")} + status Show gateway status (default) + kill Stop the running gateway + +${chalk.bold("Examples:")} + ${APP_NAME} jupyter # Show status + ${APP_NAME} jupyter status # Show status + ${APP_NAME} jupyter kill # Stop the gateway +`); +} diff --git a/packages/coding-agent/src/cli/plugin-cli.ts b/packages/coding-agent/src/cli/plugin-cli.ts index 3de86d2e7..c5ae95c7b 100644 --- a/packages/coding-agent/src/cli/plugin-cli.ts +++ b/packages/coding-agent/src/cli/plugin-cli.ts @@ -337,7 +337,7 @@ async function handleFeatures( // Handle modifications if (flags.enable || flags.disable || flags.set) { - let currentFeatures = new Set(manager.getEnabledFeatures(pluginName) ?? []); + let currentFeatures = new Set((await manager.getEnabledFeatures(pluginName)) ?? []); if (flags.set) { // --set replaces all features @@ -371,7 +371,7 @@ async function handleFeatures( } // Display current state - const updatedFeatures = manager.getEnabledFeatures(pluginName); + const updatedFeatures = await manager.getEnabledFeatures(pluginName); if (flags.json) { console.log( @@ -442,7 +442,7 @@ async function handleConfig( switch (subcommand) { case "list": { - const settings = manager.getPluginSettings(pluginName); + const settings = await manager.getPluginSettings(pluginName); const schema = plugin.manifest.settings || {}; if (flags.json) { @@ -477,7 +477,7 @@ async function handleConfig( process.exit(1); } - const settings = manager.getPluginSettings(pluginName); + const settings = await manager.getPluginSettings(pluginName); const schema = plugin.manifest.settings?.[key]; const value = settings[key] ?? schema?.default; @@ -512,7 +512,7 @@ async function handleConfig( } } - manager.setPluginSetting(pluginName, key, value); + await manager.setPluginSetting(pluginName, key, value); console.log(chalk.green(`${theme.status.success} Set ${key}`)); break; } @@ -523,7 +523,7 @@ async function handleConfig( process.exit(1); } - manager.deletePluginSetting(pluginName, key); + await manager.deletePluginSetting(pluginName, key); console.log(chalk.green(`${theme.status.success} Deleted ${key}`)); break; } @@ -540,7 +540,7 @@ async function handleConfigValidate(manager: PluginManager, flags: { json?: bool const results: Array<{ plugin: string; key: string; error: string }> = []; for (const plugin of plugins) { - const settings = manager.getPluginSettings(plugin.name); + const settings = await manager.getPluginSettings(plugin.name); const schema = plugin.manifest.settings || {}; for (const [key, s] of Object.entries(schema)) { diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 46bdbbbfe..aa431b804 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -6,10 +6,11 @@ */ import { execSync, spawnSync } from "node:child_process"; -import { createWriteStream, existsSync, renameSync, unlinkSync } from "node:fs"; -import { dirname } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { APP_NAME, VERSION } from "../config"; import { theme } from "../modes/theme/theme"; @@ -161,7 +162,7 @@ async function updateViaBinary(release: ReleaseInfo): Promise { } const execPath = process.execPath; - const _execDir = dirname(execPath); + const _execDir = path.dirname(execPath); const tempPath = `${execPath}.new`; const backupPath = `${execPath}.bak`; @@ -173,7 +174,7 @@ async function updateViaBinary(release: ReleaseInfo): Promise { throw new Error(`Download failed: ${response.statusText}`); } - const fileStream = createWriteStream(tempPath, { mode: 0o755 }); + const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 }); const nodeStream = Readable.fromWeb(response.body as import("stream/web").ReadableStream); await pipeline(nodeStream, fileStream); @@ -181,27 +182,30 @@ async function updateViaBinary(release: ReleaseInfo): Promise { console.log(chalk.dim("Installing update...")); try { - // Backup current binary - if (existsSync(backupPath)) { - unlinkSync(backupPath); + try { + await fs.promises.unlink(backupPath); + } catch (err) { + if (!isEnoent(err)) throw err; } - renameSync(execPath, backupPath); + await fs.promises.rename(execPath, backupPath); - // Move new binary into place - renameSync(tempPath, execPath); + await fs.promises.rename(tempPath, execPath); - // Clean up backup - unlinkSync(backupPath); + await fs.promises.unlink(backupPath); console.log(chalk.green(`\n${theme.status.success} Updated to ${release.version}`)); console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`)); } catch (err) { - // Restore from backup if possible - if (existsSync(backupPath) && !existsSync(execPath)) { - renameSync(backupPath, execPath); + const [backupExists, execExists, tempExists] = await Promise.all([ + Bun.file(backupPath).exists(), + Bun.file(execPath).exists(), + Bun.file(tempPath).exists(), + ]); + if (backupExists && !execExists) { + await fs.promises.rename(backupPath, execPath); } - if (existsSync(tempPath)) { - unlinkSync(tempPath); + if (tempExists) { + await fs.promises.unlink(tempPath); } throw err; } diff --git a/packages/coding-agent/src/commit/agentic/fallback.ts b/packages/coding-agent/src/commit/agentic/fallback.ts index c1023c8b2..728ad65ab 100644 --- a/packages/coding-agent/src/commit/agentic/fallback.ts +++ b/packages/coding-agent/src/commit/agentic/fallback.ts @@ -1,4 +1,4 @@ -import { basename } from "node:path"; +import * as path from "node:path"; import type { CommitType, ConventionalAnalysis, NumstatEntry } from "../../commit/types"; import type { CommitProposal } from "./state"; @@ -40,8 +40,8 @@ function inferTypeFromFiles(numstat: NumstatEntry[]): CommitType { return "refactor"; } -function getExtension(path: string): string { - const name = basename(path); +function getExtension(filePath: string): string { + const name = path.basename(filePath); const dotIndex = name.lastIndexOf("."); return dotIndex >= 0 ? name.slice(dotIndex).toLowerCase() : ""; } @@ -49,7 +49,7 @@ function getExtension(path: string): string { export function generateFallbackAnalysis(numstat: NumstatEntry[]): ConventionalAnalysis { const type = inferTypeFromFiles(numstat); const details = numstat.slice(0, 3).map((e) => ({ - text: `Updated ${basename(e.path)}`, + text: `Updated ${path.basename(e.path)}`, userVisible: false, })); @@ -76,7 +76,7 @@ export function generateFallbackSummary(type: CommitType, numstat: NumstatEntry[ revert: "reverted changes in", }; const verb = verbMap[type] ?? "updated"; - const file = basename(numstat[0]?.path ?? "files"); + const file = path.basename(numstat[0]?.path ?? "files"); if (numstat.length === 1) { return `${verb} ${file}`; diff --git a/packages/coding-agent/src/commit/agentic/index.ts b/packages/coding-agent/src/commit/agentic/index.ts index 6a7f5e686..ae9ce3c82 100644 --- a/packages/coding-agent/src/commit/agentic/index.ts +++ b/packages/coding-agent/src/commit/agentic/index.ts @@ -1,4 +1,4 @@ -import { relative } from "node:path"; +import * as path from "node:path"; import { createInterface } from "node:readline/promises"; import { applyChangelogProposals } from "../../commit/changelog"; import { detectChangelogBoundaries } from "../../commit/changelog/detect"; @@ -165,10 +165,10 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { writeStdout(` ├─ ${message}`); }, }); - updatedChangelogFiles = updated.map((path) => relative(cwd, path)); + updatedChangelogFiles = updated.map((filePath) => path.relative(cwd, filePath)); if (updated.length > 0) { - for (const path of updated) { - writeStdout(` └─ ${path}`); + for (const filePath of updated) { + writeStdout(` └─ ${filePath}`); } } else { writeStdout(" └─ (no changes)"); diff --git a/packages/coding-agent/src/commit/changelog/detect.ts b/packages/coding-agent/src/commit/changelog/detect.ts index e7153e7d7..8d96e9095 100644 --- a/packages/coding-agent/src/commit/changelog/detect.ts +++ b/packages/coding-agent/src/commit/changelog/detect.ts @@ -1,4 +1,4 @@ -import { dirname, resolve } from "node:path"; +import * as path from "node:path"; import type { ChangelogBoundary } from "../../commit/types"; const CHANGELOG_NAME = "CHANGELOG.md"; @@ -21,15 +21,15 @@ export async function detectChangelogBoundaries(cwd: string, stagedFiles: string } async function findNearestChangelog(cwd: string, filePath: string): Promise { - let current = resolve(cwd, dirname(filePath)); - const root = resolve(cwd); + let current = path.resolve(cwd, path.dirname(filePath)); + const root = path.resolve(cwd); while (true) { - const candidate = resolve(current, CHANGELOG_NAME); + const candidate = path.resolve(current, CHANGELOG_NAME); if (await Bun.file(candidate).exists()) { return candidate; } if (current === root) return null; - const parent = dirname(current); + const parent = path.dirname(current); if (parent === current) return null; current = parent; } diff --git a/packages/coding-agent/src/commit/changelog/index.ts b/packages/coding-agent/src/commit/changelog/index.ts index f414db787..c1ff312e9 100644 --- a/packages/coding-agent/src/commit/changelog/index.ts +++ b/packages/coding-agent/src/commit/changelog/index.ts @@ -1,4 +1,4 @@ -import { relative, resolve } from "node:path"; +import * as path from "node:path"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import type { ControlledGit } from "../../commit/git"; @@ -67,7 +67,7 @@ export async function runChangelogFlow({ continue; } const existingEntries = formatExistingEntries(unreleased.entries); - const isPackageChangelog = resolve(boundary.changelogPath) !== resolve(cwd, "CHANGELOG.md"); + const isPackageChangelog = path.resolve(boundary.changelogPath) !== path.resolve(cwd, "CHANGELOG.md"); const generated = await generateChangelogEntries({ model, apiKey, @@ -82,7 +82,7 @@ export async function runChangelogFlow({ const updatedContent = applyChangelogEntries(changelogContent, unreleased, generated.entries); if (!dryRun) { await Bun.write(boundary.changelogPath, updatedContent); - await git.stageFiles([relative(cwd, boundary.changelogPath)]); + await git.stageFiles([path.relative(cwd, boundary.changelogPath)]); } updated.push(boundary.changelogPath); } @@ -127,7 +127,7 @@ export async function applyChangelogProposals({ const updatedContent = applyChangelogEntries(changelogContent, unreleased, normalized, normalizedDeletions); if (!dryRun) { await Bun.write(proposal.path, updatedContent); - await git.stageFiles([relative(cwd, proposal.path)]); + await git.stageFiles([path.relative(cwd, proposal.path)]); } updated.push(proposal.path); } diff --git a/packages/coding-agent/src/commit/git/index.ts b/packages/coding-agent/src/commit/git/index.ts index f8c9cd9b5..31bae9b51 100644 --- a/packages/coding-agent/src/commit/git/index.ts +++ b/packages/coding-agent/src/commit/git/index.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import type { FileDiff, FileHunks, NumstatEntry } from "../../commit/types"; import { parseDiffHunks, parseFileDiffs, parseFileHunks, parseNumstat } from "./diff"; @@ -134,13 +134,13 @@ export class ControlledGit { const patch = joinPatch(patchParts); if (!patch.trim()) return; - const tempPath = join(tmpdir(), `omp-hunks-${randomUUID()}.patch`); + const tempPath = path.join(tmpdir(), `omp-hunks-${randomUUID()}.patch`); try { await Bun.write(tempPath, patch); const result = await runGitCommand(this.cwd, ["apply", "--cached", "--binary", tempPath]); this.ensureSuccess(result, "git apply --cached"); } finally { - await rm(tempPath, { force: true }); + await fs.rm(tempPath, { force: true }); } } diff --git a/packages/coding-agent/src/commit/map-reduce/map-phase.ts b/packages/coding-agent/src/commit/map-reduce/map-phase.ts index e13500e65..3ab4377f0 100644 --- a/packages/coding-agent/src/commit/map-reduce/map-phase.ts +++ b/packages/coding-agent/src/commit/map-reduce/map-phase.ts @@ -1,4 +1,4 @@ -import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; +import type { Api, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai"; import { completeSimple } from "@oh-my-pi/pi-ai"; import fileObserverSystemPrompt from "../../commit/prompts/file-observer-system.md" with { type: "text" }; import fileObserverUserPrompt from "../../commit/prompts/file-observer-user.md" with { type: "text" }; @@ -52,24 +52,13 @@ export async function runMapPhase({ model, apiKey, files, config }: MapPhaseInpu diff: truncated, context_header: contextHeader, }); + const request = { + systemPrompt, + messages: [{ role: "user", content: prompt, timestamp: Date.now() }] as Message[], + }; const response = await withRetry( - async () => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await completeSimple( - model, - { - systemPrompt: systemPrompt, - messages: [{ role: "user", content: prompt, timestamp: Date.now() }], - }, - { apiKey, maxTokens: 400, signal: controller.signal }, - ); - } finally { - clearTimeout(timeout); - } - }, + () => completeSimple(model, request, { apiKey, maxTokens: 400, signal: AbortSignal.timeout(timeoutMs) }), maxRetries, retryBackoffMs, ); diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index 7ac1b5486..c9a198508 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -1,4 +1,4 @@ -import { relative } from "node:path"; +import * as path from "node:path"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import { renderPromptTemplate } from "../config/prompt-templates"; @@ -86,7 +86,7 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { const recentCommits = await git.getRecentCommits(RECENT_COMMITS_COUNT); const contextFiles = await loadProjectContextFiles({ cwd }); const formattedContextFiles = contextFiles.map((file) => ({ - path: relative(cwd, file.path), + path: path.relative(cwd, file.path), content: file.content, })); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 7bd5ea739..5ee89e028 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync, statSync } from "node:fs"; +import * as fs from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import * as path from "node:path"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; // Embed package.json at build time for config import packageJson from "../package.json" with { type: "json" }; @@ -35,11 +35,11 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`; */ export function getPackageDir(): string { let dir = import.meta.dir; - while (dir !== dirname(dir)) { - if (existsSync(join(dir, "package.json"))) { + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, "package.json"))) { return dir; } - dir = dirname(dir); + dir = path.dirname(dir); } // Fallback to cwd (docs/examples won't be found, but that's fine) return process.cwd(); @@ -47,7 +47,7 @@ export function getPackageDir(): string { /** Get path to CHANGELOG.md (optional, may not exist in binary) */ export function getChangelogPath(): string { - return resolve(join(getPackageDir(), "CHANGELOG.md")); + return path.resolve(path.join(getPackageDir(), "CHANGELOG.md")); } // ============================================================================= @@ -56,27 +56,27 @@ export function getChangelogPath(): string { /** Get the agent config directory (e.g., ~/.omp/agent/) */ export function getAgentDir(): string { - return process.env[ENV_AGENT_DIR] || join(homedir(), CONFIG_DIR_NAME, "agent"); + return process.env[ENV_AGENT_DIR] || path.join(homedir(), CONFIG_DIR_NAME, "agent"); } /** Get path to user's custom themes directory */ export function getCustomThemesDir(): string { - return join(getAgentDir(), "themes"); + return path.join(getAgentDir(), "themes"); } /** Get path to models.json */ export function getModelsPath(): string { - return join(getAgentDir(), "models.json"); + return path.join(getAgentDir(), "models.json"); } /** Get path to models.yml (preferred over models.json) */ export function getModelsYamlPath(): string { - return join(getAgentDir(), "models.yml"); + return path.join(getAgentDir(), "models.yml"); } /** Get path to auth.json */ export function getAuthPath(): string { - return join(getAgentDir(), "auth.json"); + return path.join(getAgentDir(), "auth.json"); } /** @@ -85,37 +85,37 @@ export function getAuthPath(): string { * @returns Absolute path to the agent.db file */ export function getAgentDbPath(agentDir: string = getAgentDir()): string { - return join(agentDir, "agent.db"); + return path.join(agentDir, "agent.db"); } /** Get path to tools directory */ export function getToolsDir(): string { - return join(getAgentDir(), "tools"); + return path.join(getAgentDir(), "tools"); } /** Get path to managed binaries directory (fd, rg) */ export function getBinDir(): string { - return join(getAgentDir(), "bin"); + return path.join(getAgentDir(), "bin"); } /** Get path to slash commands directory */ export function getCommandsDir(): string { - return join(getAgentDir(), "commands"); + return path.join(getAgentDir(), "commands"); } /** Get path to prompts directory */ export function getPromptsDir(): string { - return join(getAgentDir(), "prompts"); + return path.join(getAgentDir(), "prompts"); } /** Get path to sessions directory */ export function getSessionsDir(): string { - return join(getAgentDir(), "sessions"); + return path.join(getAgentDir(), "sessions"); } /** Get path to debug log file */ export function getDebugLogPath(): string { - return join(getAgentDir(), `${APP_NAME}-debug.log`); + return path.join(getAgentDir(), `${APP_NAME}-debug.log`); } // ============================================================================= @@ -128,7 +128,7 @@ export function getDebugLogPath(): string { * Project-level: .omp, .pi, .claude, .codex, .gemini */ const USER_CONFIG_BASES = priorityList.map(({ dir, globalAgentDir }) => ({ - base: () => join(homedir(), globalAgentDir ?? dir), + base: () => path.join(homedir(), globalAgentDir ?? dir), name: dir, })); @@ -177,9 +177,9 @@ export function getConfigDirs(subpath: string, options: GetConfigDirsOptions = { // User-level directories (highest priority) if (user) { for (const { base, name } of USER_CONFIG_BASES) { - const path = join(base(), subpath); - if (!existingOnly || existsSync(path)) { - results.push({ path, source: name, level: "user" }); + const resolvedPath = path.resolve(base(), subpath); + if (!existingOnly || fs.existsSync(resolvedPath)) { + results.push({ path: resolvedPath, source: name, level: "user" }); } } } @@ -187,9 +187,9 @@ export function getConfigDirs(subpath: string, options: GetConfigDirsOptions = { // Project-level directories if (project) { for (const { base, name } of PROJECT_CONFIG_BASES) { - const path = resolve(cwd, base, subpath); - if (!existingOnly || existsSync(path)) { - results.push({ path, source: name, level: "project" }); + const resolvedPath = path.resolve(cwd, base, subpath); + if (!existingOnly || fs.existsSync(resolvedPath)) { + results.push({ path: resolvedPath, source: name, level: "project" }); } } } @@ -226,25 +226,24 @@ export interface ConfigFileResult { * console.log(result.content); * } */ -export function readConfigFile( +export async function readConfigFile( subpath: string, options: GetConfigDirsOptions = {}, -): ConfigFileResult | undefined { +): Promise | undefined> { const dirs = getConfigDirs("", { ...options, existingOnly: false }); for (const { path: base, source, level } of dirs) { - const filePath = join(base, subpath); + const filePath = path.join(base, subpath); try { - if (existsSync(filePath)) { - const content = readFileSync(filePath, "utf-8"); - return { - path: filePath, - source, - level, - content: JSON.parse(content) as T, - }; - } + const content = await Bun.file(filePath).text(); + return { + path: filePath, + source, + level, + content: JSON.parse(content) as T, + }; } catch (error) { + if (isEnoent(error)) continue; logger.warn("Failed to parse config file", { path: filePath, error: String(error) }); } } @@ -256,26 +255,25 @@ export function readConfigFile( * Get all existing config files for a subpath (for merging scenarios). * Returns in priority order (highest first). */ -export function readAllConfigFiles( +export async function readAllConfigFiles( subpath: string, options: GetConfigDirsOptions = {}, -): ConfigFileResult[] { +): Promise[]> { const dirs = getConfigDirs("", { ...options, existingOnly: false }); const results: ConfigFileResult[] = []; for (const { path: base, source, level } of dirs) { - const filePath = join(base, subpath); + const filePath = path.join(base, subpath); try { - if (existsSync(filePath)) { - const content = readFileSync(filePath, "utf-8"); - results.push({ - path: filePath, - source, - level, - content: JSON.parse(content) as T, - }); - } + const content = await Bun.file(filePath).text(); + results.push({ + path: filePath, + source, + level, + content: JSON.parse(content) as T, + }); } catch (error) { + if (isEnoent(error)) continue; logger.warn("Failed to parse config file", { path: filePath, error: String(error) }); } } @@ -291,8 +289,8 @@ export function findConfigFile(subpath: string, options: GetConfigDirsOptions = const dirs = getConfigDirs("", { ...options, existingOnly: false }); for (const { path: base } of dirs) { - const filePath = join(base, subpath); - if (existsSync(filePath)) { + const filePath = path.join(base, subpath); + if (fs.existsSync(filePath)) { return filePath; } } @@ -310,8 +308,8 @@ export function findConfigFileWithMeta( const dirs = getConfigDirs("", { ...options, existingOnly: false }); for (const { path: base, source, level } of dirs) { - const filePath = join(base, subpath); - if (existsSync(filePath)) { + const filePath = path.join(base, subpath); + if (fs.existsSync(filePath)) { return { path: filePath, source, level }; } } @@ -325,7 +323,7 @@ export function findConfigFileWithMeta( async function isDirectory(p: string): Promise { try { - return existsSync(p) && statSync(p).isDirectory(); + return (await fs.promises.stat(p)).isDirectory(); } catch { return false; } @@ -348,14 +346,14 @@ export async function findNearestProjectConfigDir( while (true) { // Check all config bases at this level, in priority order for (const { base, name } of PROJECT_CONFIG_BASES) { - const candidate = join(currentDir, base, subpath); + const candidate = path.join(currentDir, base, subpath); if (await isDirectory(candidate)) { return { path: candidate, source: name, level: "project" }; } } // Move up one directory - const parentDir = dirname(currentDir); + const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; // Reached root currentDir = parentDir; } @@ -381,14 +379,14 @@ export async function findAllNearestProjectConfigDirs( for (const { base, name } of PROJECT_CONFIG_BASES) { if (foundBases.has(name)) continue; - const candidate = join(currentDir, base, subpath); + const candidate = path.join(currentDir, base, subpath); if (await isDirectory(candidate)) { results.push({ path: candidate, source: name, level: "project" }); foundBases.add(name); } } - const parentDir = dirname(currentDir); + const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir; } diff --git a/packages/coding-agent/src/config/file-lock.ts b/packages/coding-agent/src/config/file-lock.ts index 35bbd1dd2..28e03f58c 100644 --- a/packages/coding-agent/src/config/file-lock.ts +++ b/packages/coding-agent/src/config/file-lock.ts @@ -1,5 +1,5 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; +import { isEnoent } from "@oh-my-pi/pi-utils"; export interface FileLockOptions { staleMs?: number; @@ -29,7 +29,7 @@ async function writeLockInfo(lockPath: string): Promise { async function readLockInfo(lockPath: string): Promise { try { - const content = await readFile(`${lockPath}/info`, "utf-8"); + const content = await fs.readFile(`${lockPath}/info`, "utf-8"); return JSON.parse(content) as LockInfo; } catch { return null; @@ -58,7 +58,7 @@ async function isLockStale(lockPath: string, staleMs: number): Promise async function tryAcquireLock(lockPath: string): Promise { try { - await mkdir(lockPath); + await fs.mkdir(lockPath); await writeLockInfo(lockPath); return true; } catch (error) { @@ -71,12 +71,22 @@ async function tryAcquireLock(lockPath: string): Promise { async function releaseLock(lockPath: string): Promise { try { - await rm(lockPath, { recursive: true }); + await fs.rm(lockPath, { recursive: true }); } catch { // Ignore errors on release } } +async function lockExists(lockPath: string): Promise { + try { + await fs.stat(lockPath); + return true; + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } +} + async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise> { const opts = { ...DEFAULT_OPTIONS, ...options }; const lockPath = getLockPath(filePath); @@ -86,7 +96,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro return () => releaseLock(lockPath); } - if (existsSync(lockPath) && (await isLockStale(lockPath, opts.staleMs))) { + if ((await lockExists(lockPath)) && (await isLockStale(lockPath, opts.staleMs))) { await releaseLock(lockPath); continue; } diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 99c5de4ea..131c3e018 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import * as path from "node:path"; import { DEFAULT_EDITOR_KEYBINDINGS, type EditorAction, @@ -162,7 +161,7 @@ export class KeybindingsManager { * Create from config file and set up editor keybindings. */ static async create(agentDir: string = getAgentDir()): Promise { - const configPath = join(agentDir, "keybindings.json"); + const configPath = path.join(agentDir, "keybindings.json"); const config = await KeybindingsManager.loadFromFile(configPath); const manager = new KeybindingsManager(config); @@ -186,9 +185,8 @@ export class KeybindingsManager { } private static async loadFromFile(path: string): Promise { - if (!existsSync(path)) return {}; try { - return JSON.parse(readFileSync(path, "utf-8")); + return await Bun.file(path).json(); } catch (error) { logger.warn("Failed to parse keybindings config", { path, error: String(error) }); return {}; diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index b4b19ba77..fa62990e8 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -2,8 +2,8 @@ * Model registry - manages built-in and custom models, provides API key resolution. */ -import { existsSync, readFileSync } from "node:fs"; -import { extname } from "node:path"; +import * as path from "node:path"; + import { type Api, getGitHubCopilotBaseUrl, @@ -12,7 +12,8 @@ import { type Model, normalizeDomain, } from "@oh-my-pi/pi-ai"; -import { logger } from "@oh-my-pi/pi-utils"; + +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import AjvModule from "ajv"; import { YAML } from "bun"; @@ -104,10 +105,12 @@ interface CustomModelsResult { /** Providers with only baseUrl/headers override (no custom models) */ overrides: Map; error: string | undefined; + /** Whether the file was found (true) or didn't exist (false) */ + found: boolean; } function emptyCustomModelsResult(error?: string): CustomModelsResult { - return { models: [], replacedProviders: new Set(), overrides: new Map(), error }; + return { models: [], replacedProviders: new Set(), overrides: new Map(), error, found: false }; } /** @@ -146,8 +149,6 @@ export class ModelRegistry { } return undefined; }); - // Load models synchronously in constructor - this.loadModels(); } /** @@ -190,10 +191,10 @@ export class ModelRegistry { /** * Reload models from disk (built-in + custom from models.json). */ - refresh(): void { + async refresh(): Promise { this.customProviderApiKeys.clear(); this.loadError = undefined; - this.loadModels(); + await this.loadModels(); } /** @@ -203,7 +204,7 @@ export class ModelRegistry { return this.loadError; } - private loadModels(): void { + private async loadModels(): Promise { // Load custom models from models.json first (to know which providers to skip/override) let customModels: Model[] = []; let replacedProviders: Set = new Set(); @@ -215,19 +216,20 @@ export class ModelRegistry { } for (const modelsPath of pathsToCheck) { - if (existsSync(modelsPath)) { - logger.debug("ModelRegistry.loadModels loading", { path: modelsPath }); - const result = this.loadCustomModels(modelsPath); - if (result.error) { - this.loadError = result.error; - // Keep built-in models even if custom models failed to load - } else { - customModels = result.models; - replacedProviders = result.replacedProviders; - overrides = result.overrides; - } - break; // Use first existing file + const result = await this.loadCustomModels(modelsPath); + if (!result.found) { + continue; // File doesn't exist, try next path } + logger.debug("ModelRegistry.loadModels loading", { path: modelsPath }); + if (result.error) { + this.loadError = result.error; + // Keep built-in models even if custom models failed to load + } else { + customModels = result.models; + replacedProviders = result.replacedProviders; + overrides = result.overrides; + } + break; // Use first existing file } const builtInModels = this.loadBuiltInModels(replacedProviders, overrides); @@ -264,14 +266,24 @@ export class ModelRegistry { }); } - private loadCustomModels(modelsPath: string): CustomModelsResult { - if (!existsSync(modelsPath)) { - return emptyCustomModelsResult(); + private async loadCustomModels(modelsPath: string): Promise { + let content: string; + try { + content = await Bun.file(modelsPath).text(); + } catch (error) { + if (isEnoent(error)) { + return emptyCustomModelsResult(); + } + return { + ...emptyCustomModelsResult( + `Failed to load models config: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsPath}`, + ), + found: true, + }; } try { - const content = readFileSync(modelsPath, "utf-8"); - const ext = extname(modelsPath).toLowerCase(); + const ext = path.extname(modelsPath).toLowerCase(); let config: ModelsConfig; if (ext === ".yaml" || ext === ".yml") { @@ -315,14 +327,20 @@ export class ModelRegistry { } } - return { models: this.parseModels(config), replacedProviders, overrides, error: undefined }; + return { models: this.parseModels(config), replacedProviders, overrides, error: undefined, found: true }; } catch (error) { if (error instanceof SyntaxError) { - return emptyCustomModelsResult(`Failed to parse models config: ${error.message}\n\nFile: ${modelsPath}`); + return { + ...emptyCustomModelsResult(`Failed to parse models config: ${error.message}\n\nFile: ${modelsPath}`), + found: true, + }; } - return emptyCustomModelsResult( - `Failed to load models config: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsPath}`, - ); + return { + ...emptyCustomModelsResult( + `Failed to load models config: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsPath}`, + ), + found: true, + }; } } diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 1e448acf0..e777c634d 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -1,4 +1,4 @@ -import { join, resolve } from "node:path"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import Handlebars from "handlebars"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config"; @@ -382,7 +382,7 @@ async function loadTemplatesFromDir( entries.sort((a, b) => a.split("/").length - b.split("/").length); for (const entry of entries) { - const fullPath = join(dir, entry); + const fullPath = path.join(dir, entry); const file = Bun.file(fullPath); try { @@ -461,11 +461,11 @@ export async function loadPromptTemplates(options: LoadPromptTemplatesOptions = // 1. Load global templates from agentDir/prompts/ // Note: if agentDir is provided, it should be the agent dir, not the prompts dir - const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + const globalPromptsDir = options.agentDir ? path.join(options.agentDir, "prompts") : resolvedAgentDir; templates.push(...(await loadTemplatesFromDir(globalPromptsDir, "user"))); // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ - const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + const projectPromptsDir = path.resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); templates.push(...(await loadTemplatesFromDir(projectPromptsDir, "project"))); return templates; diff --git a/packages/coding-agent/src/config/settings-manager.ts b/packages/coding-agent/src/config/settings-manager.ts index edb796d12..c3cc10b08 100644 --- a/packages/coding-agent/src/config/settings-manager.ts +++ b/packages/coding-agent/src/config/settings-manager.ts @@ -1,7 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { rename } from "node:fs/promises"; -import { join } from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { type Settings as SettingsItem, settingsCapability } from "../capability/settings"; import { getAgentDbPath, getAgentDir } from "../config"; @@ -203,6 +202,7 @@ export interface Settings { interruptMode?: "immediate" | "wait"; theme?: string; symbolPreset?: SymbolPreset; // default: uses theme's preset or "unicode" + colorBlindMode?: boolean; // default: false (use blue instead of green for diff additions) compaction?: CompactionSettings; branchSummary?: BranchSummarySettings; retry?: RetrySettings; @@ -503,7 +503,7 @@ export class SettingsManager { * @returns Configured SettingsManager with merged global and user settings */ static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise { - const configPath = join(agentDir, "config.yml"); + const configPath = path.join(agentDir, "config.yml"); const storage = await AgentStorage.open(getAgentDbPath(agentDir)); // Migrate from legacy storage if config.yml doesn't exist @@ -521,7 +521,7 @@ export class SettingsManager { } // Load persisted settings from config.yml - const storedSettings = SettingsManager.loadFromYaml(configPath); + const storedSettings = await SettingsManager.loadFromYaml(configPath); globalSettings = deepMergeSettings(globalSettings, storedSettings); // Load project settings before construction (constructor is sync) @@ -559,18 +559,19 @@ export class SettingsManager { * @param configPath - Path to config.yml, or null for in-memory mode * @returns Parsed and migrated settings, or empty object if file doesn't exist */ - private static loadFromYaml(configPath: string | null): Settings { - if (!configPath || !existsSync(configPath)) { + private static async loadFromYaml(configPath: string | null): Promise { + if (!configPath) { return {}; } try { - const content = readFileSync(configPath, "utf-8"); + const content = await Bun.file(configPath).text(); const parsed = YAML.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } return SettingsManager.migrateSettings(parsed as Record); } catch (error) { + if (isEnoent(error)) return {}; logger.warn("SettingsManager failed to load config.yml", { path: configPath, error: String(error) }); return {}; } @@ -582,31 +583,34 @@ export class SettingsManager { * Only migrates if config.yml doesn't exist. */ private static async migrateToYaml(storage: AgentStorage, agentDir: string, configPath: string): Promise { - // Skip if config.yml already exists - if (existsSync(configPath)) return; + try { + await Bun.file(configPath).text(); + return; + } catch (err) { + if (!isEnoent(err)) throw err; + } let settings: Settings = {}; let migrated = false; // 1. Try to migrate from settings.json (oldest legacy format) - const settingsJsonPath = join(agentDir, "settings.json"); + const settingsJsonPath = path.join(agentDir, "settings.json"); try { - const settingsFile = Bun.file(settingsJsonPath); - if (await settingsFile.exists()) { - const parsed = JSON.parse(await settingsFile.text()); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - settings = deepMergeSettings(settings, SettingsManager.migrateSettings(parsed)); - migrated = true; - // Backup settings.json - try { - await rename(settingsJsonPath, `${settingsJsonPath}.bak`); - } catch (error) { - logger.warn("SettingsManager failed to backup settings.json", { error: String(error) }); - } + const parsed = JSON.parse(await Bun.file(settingsJsonPath).text()); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + settings = deepMergeSettings(settings, SettingsManager.migrateSettings(parsed)); + migrated = true; + // Backup settings.json + try { + await fs.rename(settingsJsonPath, `${settingsJsonPath}.bak`); + } catch (error) { + logger.warn("SettingsManager failed to backup settings.json", { error: String(error) }); } } } catch (error) { - logger.warn("SettingsManager failed to read settings.json", { error: String(error) }); + if (!isEnoent(error)) { + logger.warn("SettingsManager failed to read settings.json", { error: String(error) }); + } } // 2. Migrate from agent.db settings table @@ -690,7 +694,7 @@ export class SettingsManager { const configPath = this.configPath; try { await withFileLock(configPath, async () => { - const currentSettings = SettingsManager.loadFromYaml(configPath); + const currentSettings = await SettingsManager.loadFromYaml(configPath); const mergedSettings = deepMergeSettings(currentSettings, this.globalSettings); this.globalSettings = mergedSettings; await Bun.write(configPath, YAML.stringify(this.globalSettings, null, 2)); @@ -788,6 +792,15 @@ export class SettingsManager { await this.save(); } + getColorBlindMode(): boolean { + return this.settings.colorBlindMode ?? false; + } + + async setColorBlindMode(enabled: boolean): Promise { + this.globalSettings.colorBlindMode = enabled; + await this.save(); + } + getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { return this.settings.defaultThinkingLevel; } diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index 79261a1b8..208c0b46d 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { rmSync, statSync } from "node:fs"; +import * as fs from "node:fs/promises"; import type { AgentEvent, AgentTool, @@ -98,17 +98,19 @@ async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, let result: AgentToolResult; try { - const stat = statSync(absolutePath, { throwIfNoEntry: false }); - if (!stat) { + let fileStat: Awaited> | undefined; + try { + fileStat = await fs.stat(absolutePath); + } catch { throw new Error(`File not found: ${pathArg}`); } - if (!stat.isFile()) { + if (!fileStat.isFile()) { throw new Error(`Path is not a file: ${pathArg}`); } - rmSync(absolutePath); + await fs.rm(absolutePath); - const sizeText = stat.size ? ` (${stat.size} bytes)` : ""; + const sizeText = fileStat.size ? ` (${fileStat.size} bytes)` : ""; const message = `Deleted ${pathArg}${sizeText}`; result = { content: [{ type: "text", text: message }], details: {} }; } catch (error) { diff --git a/packages/coding-agent/src/discovery/agents-md.ts b/packages/coding-agent/src/discovery/agents-md.ts index 982533211..0fa16d81e 100644 --- a/packages/coding-agent/src/discovery/agents-md.ts +++ b/packages/coding-agent/src/discovery/agents-md.ts @@ -6,7 +6,7 @@ * like .codex/ or .gemini/, which are handled by their respective providers). */ -import { dirname, join, sep } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { readFile } from "../capability/fs"; @@ -29,16 +29,16 @@ async function loadAgentsMd(ctx: LoadContext): Promise> let depth = 0; while (depth < MAX_DEPTH) { - const candidate = join(current, "AGENTS.md"); + const candidate = path.join(current, "AGENTS.md"); const content = await readFile(candidate); if (content !== null) { - const parent = dirname(candidate); - const baseName = parent.split(sep).pop() ?? ""; + const parent = path.dirname(candidate); + const baseName = parent.split(path.sep).pop() ?? ""; if (!baseName.startsWith(".")) { - const fileDir = dirname(candidate); - const calculatedDepth = calculateDepth(ctx.cwd, fileDir, sep); + const fileDir = path.dirname(candidate); + const calculatedDepth = calculateDepth(ctx.cwd, fileDir, path.sep); items.push({ path: candidate, @@ -51,7 +51,7 @@ async function loadAgentsMd(ctx: LoadContext): Promise> } // Move to parent directory - const parent = dirname(current); + const parent = path.dirname(current); if (parent === current) break; // Reached filesystem root current = parent; depth++; diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index 08b07f73d..73e11e071 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -5,7 +5,7 @@ * Priority: 80 (tool-specific, below builtin but above shared standards) */ -import { join, sep } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; @@ -38,14 +38,14 @@ const CONFIG_DIR = ".claude"; * Get user-level .claude path. */ function getUserClaude(ctx: LoadContext): string { - return join(ctx.home, CONFIG_DIR); + return path.join(ctx.home, CONFIG_DIR); } /** * Get project-level .claude path (cwd only). */ function getProjectClaude(ctx: LoadContext): string { - return join(ctx.cwd, CONFIG_DIR); + return path.join(ctx.cwd, CONFIG_DIR); } // ============================================================================= @@ -57,12 +57,12 @@ async function loadMCPServers(ctx: LoadContext): Promise> const warnings: string[] = []; const userBase = getUserClaude(ctx); - const userClaudeJson = join(ctx.home, ".claude.json"); - const userMcpJson = join(userBase, "mcp.json"); + const userClaudeJson = path.join(ctx.home, ".claude.json"); + const userMcpJson = path.join(userBase, "mcp.json"); - const projectBase = join(ctx.cwd, CONFIG_DIR); - const projectMcpJson = join(projectBase, ".mcp.json"); - const projectMcpJsonAlt = join(projectBase, "mcp.json"); + const projectBase = path.join(ctx.cwd, CONFIG_DIR); + const projectMcpJson = path.join(projectBase, ".mcp.json"); + const projectMcpJsonAlt = path.join(projectBase, "mcp.json"); const userPaths = [ { path: userClaudeJson, level: "user" as const }, @@ -126,7 +126,7 @@ async function loadContextFiles(ctx: LoadContext): Promise> { - const userSkillsDir = join(getUserClaude(ctx), "skills"); - const projectSkillsDir = join(getProjectClaude(ctx), "skills"); + const userSkillsDir = path.join(getUserClaude(ctx), "skills"); + const projectSkillsDir = path.join(getProjectClaude(ctx), "skills"); const results = await Promise.all([ loadSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }), @@ -183,8 +183,8 @@ async function loadExtensionModules(ctx: LoadContext): Promise(ctx, userCommandsDir, PROVIDER_ID, "user", { extensions: ["md"], @@ -240,7 +240,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise(ctx, projectCommandsDir, PROVIDER_ID, "project", { extensions: ["md"], @@ -271,18 +271,18 @@ async function loadHooks(ctx: LoadContext): Promise> { const warnings: string[] = []; const userBase = getUserClaude(ctx); - const userHooksDir = join(userBase, "hooks"); + const userHooksDir = path.join(userBase, "hooks"); const projectBase = getProjectClaude(ctx); - const projectHooksDir = join(projectBase, "hooks"); + const projectHooksDir = path.join(projectBase, "hooks"); const hookTypes = ["pre", "post"] as const; const loadTasks: { dir: string; hookType: "pre" | "post"; level: "user" | "project" }[] = []; for (const hookType of hookTypes) { - loadTasks.push({ dir: join(userHooksDir, hookType), hookType, level: "user" }); + loadTasks.push({ dir: path.join(userHooksDir, hookType), hookType, level: "user" }); } for (const hookType of hookTypes) { - loadTasks.push({ dir: join(projectHooksDir, hookType), hookType, level: "project" }); + loadTasks.push({ dir: path.join(projectHooksDir, hookType), hookType, level: "project" }); } const results = await Promise.all( @@ -320,7 +320,7 @@ async function loadTools(ctx: LoadContext): Promise> { const warnings: string[] = []; const userBase = getUserClaude(ctx); - const userToolsDir = join(userBase, "tools"); + const userToolsDir = path.join(userBase, "tools"); const userResult = await loadFilesFromDir(ctx, userToolsDir, PROVIDER_ID, "user", { transform: (name, _content, path, source) => { @@ -339,7 +339,7 @@ async function loadTools(ctx: LoadContext): Promise> { if (userResult.warnings) warnings.push(...userResult.warnings); const projectBase = getProjectClaude(ctx); - const projectToolsDir = join(projectBase, "tools"); + const projectToolsDir = path.join(projectBase, "tools"); const projectResult = await loadFilesFromDir(ctx, projectToolsDir, PROVIDER_ID, "project", { transform: (name, _content, path, source) => { @@ -369,7 +369,7 @@ async function loadSystemPrompts(ctx: LoadContext): Promise> { const warnings: string[] = []; const userBase = getUserClaude(ctx); - const userSettingsJson = join(userBase, "settings.json"); + const userSettingsJson = path.join(userBase, "settings.json"); const userContent = await readFile(userSettingsJson); if (userContent) { @@ -411,7 +411,7 @@ async function loadSettings(ctx: LoadContext): Promise> { } const projectBase = getProjectClaude(ctx); - const projectSettingsJson = join(projectBase, "settings.json"); + const projectSettingsJson = path.join(projectBase, "settings.json"); const projectContent = await readFile(projectSettingsJson); if (projectContent) { const data = parseJSON>(projectContent); diff --git a/packages/coding-agent/src/discovery/cline.ts b/packages/coding-agent/src/discovery/cline.ts index 057128d25..40710cba8 100644 --- a/packages/coding-agent/src/discovery/cline.ts +++ b/packages/coding-agent/src/discovery/cline.ts @@ -5,7 +5,7 @@ * Project-only (no user-level config). */ -import { dirname, resolve } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { readDirEntries, readFile } from "../capability/fs"; import type { Rule } from "../capability/rule"; @@ -19,18 +19,18 @@ const DISPLAY_NAME = "Cline"; const PRIORITY = 40; async function findClinerules(startDir: string): Promise<{ path: string; isDir: boolean } | null> { - let current = resolve(startDir); + let current = path.resolve(startDir); while (true) { const entries = await readDirEntries(current); const entry = entries.find((e) => e.name === ".clinerules"); if (entry) { return { - path: resolve(current, ".clinerules"), + path: path.resolve(current, ".clinerules"), isDir: entry.isDirectory(), }; } - const parent = dirname(current); + const parent = path.dirname(current); if (parent === current) return null; current = parent; } diff --git a/packages/coding-agent/src/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index 5649179da..105d66e24 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -7,7 +7,7 @@ * User directory: ~/.codex */ -import { join } from "node:path"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { parse as parseToml } from "smol-toml"; import { registerProvider } from "../capability"; @@ -45,7 +45,7 @@ const DISPLAY_NAME = "OpenAI Codex"; const PRIORITY = 70; function getProjectCodexDir(ctx: LoadContext): string { - return join(ctx.cwd, ".codex"); + return path.join(ctx.cwd, ".codex"); } // ============================================================================= @@ -57,7 +57,7 @@ async function loadContextFiles(ctx: LoadContext): Promise> { const warnings: string[] = []; - const userConfigPath = join(ctx.home, SOURCE_PATHS.codex.userBase, "config.toml"); + const userConfigPath = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "config.toml"); const codexDir = getProjectCodexDir(ctx); - const projectConfigPath = join(codexDir, "config.toml"); + const projectConfigPath = path.join(codexDir, "config.toml"); const [userConfig, projectConfig] = await Promise.all([ loadTomlConfig(ctx, userConfigPath), @@ -210,9 +210,9 @@ function extractMCPServersFromToml(toml: Record): Record> { - const userSkillsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "skills"); + const userSkillsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "skills"); const codexDir = getProjectCodexDir(ctx); - const projectSkillsDir = join(codexDir, "skills"); + const projectSkillsDir = path.join(codexDir, "skills"); const results = await Promise.all([ loadSkillsFromDir(ctx, { @@ -240,9 +240,9 @@ async function loadSkills(ctx: LoadContext): Promise> { async function loadExtensionModules(ctx: LoadContext): Promise> { const warnings: string[] = []; - const userExtensionsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "extensions"); + const userExtensionsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "extensions"); const codexDir = getProjectCodexDir(ctx); - const projectExtensionsDir = join(codexDir, "extensions"); + const projectExtensionsDir = path.join(codexDir, "extensions"); const [userPaths, projectPaths] = await Promise.all([ discoverExtensionModulePaths(ctx, userExtensionsDir), @@ -272,9 +272,9 @@ async function loadExtensionModules(ctx: LoadContext): Promise> { - const userCommandsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "commands"); + const userCommandsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "commands"); const codexDir = getProjectCodexDir(ctx); - const projectCommandsDir = join(codexDir, "commands"); + const projectCommandsDir = path.join(codexDir, "commands"); const transformCommand = (level: "user" | "project") => @@ -312,9 +312,9 @@ async function loadSlashCommands(ctx: LoadContext): Promise> { - const userPromptsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "prompts"); + const userPromptsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "prompts"); const codexDir = getProjectCodexDir(ctx); - const projectPromptsDir = join(codexDir, "prompts"); + const projectPromptsDir = path.join(codexDir, "prompts"); const transformPrompt = ( name: string, @@ -355,9 +355,9 @@ async function loadPrompts(ctx: LoadContext): Promise> { // ============================================================================= async function loadHooks(ctx: LoadContext): Promise> { - const userHooksDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "hooks"); + const userHooksDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "hooks"); const codexDir = getProjectCodexDir(ctx); - const projectHooksDir = join(codexDir, "hooks"); + const projectHooksDir = path.join(codexDir, "hooks"); const transformHook = (level: "user" | "project") => @@ -398,9 +398,9 @@ async function loadHooks(ctx: LoadContext): Promise> { // ============================================================================= async function loadTools(ctx: LoadContext): Promise> { - const userToolsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "tools"); + const userToolsDir = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "tools"); const codexDir = getProjectCodexDir(ctx); - const projectToolsDir = join(codexDir, "tools"); + const projectToolsDir = path.join(codexDir, "tools"); const transformTool = (level: "user" | "project") => @@ -438,9 +438,9 @@ async function loadTools(ctx: LoadContext): Promise> { async function loadSettings(ctx: LoadContext): Promise> { const warnings: string[] = []; - const userConfigPath = join(ctx.home, SOURCE_PATHS.codex.userBase, "config.toml"); + const userConfigPath = path.join(ctx.home, SOURCE_PATHS.codex.userBase, "config.toml"); const codexDir = getProjectCodexDir(ctx); - const projectConfigPath = join(codexDir, "config.toml"); + const projectConfigPath = path.join(codexDir, "config.toml"); const [userConfig, projectConfig] = await Promise.all([ loadTomlConfig(ctx, userConfigPath), diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index c1f564616..dc704fe33 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -16,7 +16,7 @@ * - settings: From settings.json */ -import { join, sep } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension"; @@ -146,7 +146,7 @@ async function loadContextFiles(ctx: LoadContext): Promise { - const extPath = join(extensionsDir, entry.name); - const manifestPath = join(extPath, "gemini-extension.json"); + const extPath = path.join(extensionsDir, entry.name); + const manifestPath = path.join(extPath, "gemini-extension.json"); const content = await readFile(manifestPath); return { entry, extPath, manifestPath, content }; }), diff --git a/packages/coding-agent/src/discovery/github.ts b/packages/coding-agent/src/discovery/github.ts index d7003e1ce..1e01ddb3d 100644 --- a/packages/coding-agent/src/discovery/github.ts +++ b/packages/coding-agent/src/discovery/github.ts @@ -12,7 +12,7 @@ * - instructions: *.instructions.md in .github/instructions/ with applyTo frontmatter */ -import { basename, dirname, sep } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { readFile } from "../capability/fs"; @@ -37,8 +37,8 @@ async function loadContextFiles(ctx: LoadContext): Promise { - const skillFile = join(dir, entry.name, "SKILL.md"); + const skillFile = path.join(dir, entry.name, "SKILL.md"); const content = await readFile(skillFile); if (!content) { return { item: null as Skill | null, warning: null as string | null }; @@ -326,12 +326,14 @@ export async function loadFilesFromDir( }); const [subResults, fileResults] = await Promise.all([ - Promise.all(directories.map((entry) => loadFilesFromDir(_ctx, join(dir, entry.name), provider, level, options))), + Promise.all( + directories.map((entry) => loadFilesFromDir(_ctx, path.join(dir, entry.name), provider, level, options)), + ), Promise.all( files.map(async (entry) => { - const path = join(dir, entry.name); - const content = await readFile(path); - return { entry, path, content }; + const filePath = path.join(dir, entry.name); + const content = await readFile(filePath); + return { entry, path: filePath, content }; }), ), ]); @@ -427,7 +429,7 @@ export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const entryPath = join(dir, entry.name); + const entryPath = path.join(dir, entry.name); // 1. Direct files: *.ts or *.js if (entry.isFile() && isExtensionModuleFile(entry.name)) { @@ -442,11 +444,11 @@ export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string // Check for package.json with "omp"/"pi" field first if (subFileNames.has("package.json")) { - const packageJsonPath = join(entryPath, "package.json"); + const packageJsonPath = path.join(entryPath, "package.json"); const manifest = await readExtensionModuleManifest(ctx, packageJsonPath); if (manifest?.extensions && Array.isArray(manifest.extensions)) { for (const extPath of manifest.extensions) { - const resolvedExtPath = resolve(entryPath, extPath); + const resolvedExtPath = path.resolve(entryPath, extPath); const content = await readFile(resolvedExtPath); if (content !== null) { discovered.push(resolvedExtPath); @@ -458,9 +460,9 @@ export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string // Check for index.ts or index.js if (subFileNames.has("index.ts")) { - discovered.push(join(entryPath, "index.ts")); + discovered.push(path.join(entryPath, "index.ts")); } else if (subFileNames.has("index.js")) { - discovered.push(join(entryPath, "index.js")); + discovered.push(path.join(entryPath, "index.js")); } } } diff --git a/packages/coding-agent/src/discovery/mcp-json.ts b/packages/coding-agent/src/discovery/mcp-json.ts index 99b38dcea..b9d6d0ea6 100644 --- a/packages/coding-agent/src/discovery/mcp-json.ts +++ b/packages/coding-agent/src/discovery/mcp-json.ts @@ -7,7 +7,7 @@ * Priority: 5 (low, as this is a fallback after tool-specific providers) */ -import { join } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { readFile } from "../capability/fs"; import { type MCPServer, mcpCapability } from "../capability/mcp"; @@ -102,7 +102,7 @@ async function loadMCPJsonFile( async function load(ctx: LoadContext): Promise> { const filenames = ["mcp.json", ".mcp.json"]; const results = await Promise.all( - filenames.map((filename) => loadMCPJsonFile(ctx, join(ctx.cwd, filename), "project")), + filenames.map((filename) => loadMCPJsonFile(ctx, path.join(ctx.cwd, filename), "project")), ); const allItems = results.flatMap((r) => r.items); diff --git a/packages/coding-agent/src/discovery/ssh.ts b/packages/coding-agent/src/discovery/ssh.ts index d09497411..ded5d3816 100644 --- a/packages/coding-agent/src/discovery/ssh.ts +++ b/packages/coding-agent/src/discovery/ssh.ts @@ -5,7 +5,7 @@ * Priority: 5 (low, project-level only) */ -import { join } from "node:path"; +import * as path from "node:path"; import { registerProvider } from "../capability"; import { readFile } from "../capability/fs"; import { type SSHHost, sshCapability } from "../capability/ssh"; @@ -134,7 +134,7 @@ async function loadSshJsonFile(_ctx: LoadContext, path: string): Promise> { const filenames = ["ssh.json", ".ssh.json"]; - const results = await Promise.all(filenames.map((filename) => loadSshJsonFile(ctx, join(ctx.cwd, filename)))); + const results = await Promise.all(filenames.map((filename) => loadSshJsonFile(ctx, path.join(ctx.cwd, filename)))); const allItems = results.flatMap((r) => r.items); const allWarnings = results.flatMap((r) => r.warnings ?? []); diff --git a/packages/coding-agent/src/exa/mcp-client.ts b/packages/coding-agent/src/exa/mcp-client.ts index 07173ebc0..671f033aa 100644 --- a/packages/coding-agent/src/exa/mcp-client.ts +++ b/packages/coding-agent/src/exa/mcp-client.ts @@ -4,10 +4,10 @@ * Client for interacting with Exa MCP servers. */ -import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { logger } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; + import type { CustomTool, CustomToolResult } from "../extensibility/custom-tools/types"; import { callMCP } from "../mcp/json-rpc"; import type { @@ -34,15 +34,15 @@ export async function findApiKey(): Promise { for (const dir of [cwd, home]) { const envPath = `${dir}/.env`; try { - if (existsSync(envPath)) { - const content = readFileSync(envPath, "utf-8"); - const match = content.match(/^EXA_API_KEY=(.+)$/m); - if (match?.[1]) { - return match[1].trim().replace(/^["']|["']$/g, ""); - } + const content = await Bun.file(envPath).text(); + const match = content.match(/^EXA_API_KEY=(.+)$/m); + if (match?.[1]) { + return match[1].trim().replace(/^["']|["']$/g, ""); + } + } catch (err) { + if (!isEnoent(err)) { + logger.debug("Error reading .env file", { path: envPath, error: String(err) }); } - } catch { - // Ignore read errors } } diff --git a/packages/coding-agent/src/export/custom-share.ts b/packages/coding-agent/src/export/custom-share.ts index db53d4e78..688ce3116 100644 --- a/packages/coding-agent/src/export/custom-share.ts +++ b/packages/coding-agent/src/export/custom-share.ts @@ -5,8 +5,8 @@ * that will be used instead of the default GitHub Gist sharing. */ -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { getAgentDir } from "../config"; export interface CustomShareResult { @@ -32,8 +32,8 @@ export function getCustomSharePath(): string | null { const agentDir = getAgentDir(); for (const candidate of SHARE_SCRIPT_CANDIDATES) { - const scriptPath = join(agentDir, candidate); - if (existsSync(scriptPath)) { + const scriptPath = path.join(agentDir, candidate); + if (fs.existsSync(scriptPath)) { return scriptPath; } } diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts index 3f1cb9399..7262e8669 100644 --- a/packages/coding-agent/src/export/html/index.ts +++ b/packages/coding-agent/src/export/html/index.ts @@ -1,6 +1,6 @@ -import { existsSync, writeFileSync } from "node:fs"; -import { basename } from "node:path"; +import * as path from "node:path"; import type { AgentState } from "@oh-my-pi/pi-agent-core"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { APP_NAME } from "../../config"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme"; import { SessionManager } from "../../session/session-manager"; @@ -74,14 +74,14 @@ function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string } /** Generate CSS custom properties for theme. */ -function generateThemeVars(themeName?: string): string { - const colors = getResolvedThemeColors(themeName); +async function generateThemeVars(themeName?: string): Promise { + const colors = await getResolvedThemeColors(themeName); const lines: string[] = []; for (const [key, value] of Object.entries(colors)) { lines.push(`--${key}: ${value};`); } - const themeExport = getThemeExportColors(themeName); + const themeExport = await getThemeExportColors(themeName); const userMessageBg = colors.userMessageBg || "#343541"; const derived = deriveExportColors(userMessageBg); @@ -101,8 +101,8 @@ interface SessionData { } /** Generate HTML from bundled template with runtime substitutions. */ -function generateHtml(sessionData: SessionData, themeName?: string): string { - const themeVars = generateThemeVars(themeName); +async function generateHtml(sessionData: SessionData, themeName?: string): Promise { + const themeVars = await generateThemeVars(themeName); const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64"); return TEMPLATE.replace("", ``).replace( @@ -121,7 +121,6 @@ export async function exportSessionToHtml( const sessionFile = sm.getSessionFile(); if (!sessionFile) throw new Error("Cannot export in-memory session to HTML"); - if (!existsSync(sessionFile)) throw new Error("Nothing to export yet - start a conversation first"); const sessionData: SessionData = { header: sm.getHeader(), @@ -131,10 +130,10 @@ export async function exportSessionToHtml( tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })), }; - const html = generateHtml(sessionData, opts.themeName); - const outputPath = opts.outputPath || `${APP_NAME}-session-${basename(sessionFile, ".jsonl")}.html`; + const html = await generateHtml(sessionData, opts.themeName); + const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`; - writeFileSync(outputPath, html, "utf8"); + await Bun.write(outputPath, html); return outputPath; } @@ -142,18 +141,23 @@ export async function exportSessionToHtml( export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; - if (!existsSync(inputPath)) throw new Error(`File not found: ${inputPath}`); + let sm: SessionManager; + try { + sm = await SessionManager.open(inputPath); + } catch (err) { + if (isEnoent(err)) throw new Error(`File not found: ${inputPath}`); + throw err; + } - const sm = await SessionManager.open(inputPath); const sessionData: SessionData = { header: sm.getHeader(), entries: sm.getEntries(), leafId: sm.getLeafId(), }; - const html = generateHtml(sessionData, opts.themeName); - const outputPath = opts.outputPath || `${APP_NAME}-session-${basename(inputPath, ".jsonl")}.html`; + const html = await generateHtml(sessionData, opts.themeName); + const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`; - writeFileSync(outputPath, html, "utf8"); + await Bun.write(outputPath, html); return outputPath; } diff --git a/packages/coding-agent/src/extensibility/custom-commands/loader.ts b/packages/coding-agent/src/extensibility/custom-commands/loader.ts index e61cbc9ff..3cd0dd99c 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/loader.ts @@ -5,10 +5,10 @@ * to avoid import resolution issues with custom commands loaded from user directories. */ -import { type Dirent, existsSync, readdirSync } from "node:fs"; +import * as fs from "node:fs"; import * as path from "node:path"; import * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; -import { logger } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import * as typebox from "@sinclair/typebox"; import { getAgentDir, getConfigDirs } from "../../config"; import { execCommand } from "../../exec/exec"; @@ -77,7 +77,9 @@ export interface DiscoverCustomCommandsResult { * Discover custom command modules (TypeScript slash commands). * Markdown slash commands are handled by core/slash-commands.ts. */ -export function discoverCustomCommands(options: DiscoverCustomCommandsOptions = {}): DiscoverCustomCommandsResult { +export async function discoverCustomCommands( + options: DiscoverCustomCommandsOptions = {}, +): Promise { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getAgentDir(); const paths: Array<{ path: string; source: CustomCommandSource }> = []; @@ -93,7 +95,7 @@ export function discoverCustomCommands(options: DiscoverCustomCommandsOptions = const commandDirs: Array<{ path: string; source: CustomCommandSource }> = []; if (agentDir) { const userCommandsDir = path.join(agentDir, "commands"); - if (existsSync(userCommandsDir)) { + if (fs.existsSync(userCommandsDir)) { commandDirs.push({ path: userCommandsDir, source: "user" }); } } @@ -107,11 +109,13 @@ export function discoverCustomCommands(options: DiscoverCustomCommandsOptions = const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"]; for (const { path: commandsDir, source } of commandDirs) { - let entries: Dirent[]; + let entries: fs.Dirent[]; try { - entries = readdirSync(commandsDir, { withFileTypes: true }); + entries = await fs.promises.readdir(commandsDir, { withFileTypes: true }); } catch (error) { - logger.warn("Failed to read custom commands directory", { path: commandsDir, error: String(error) }); + if (!isEnoent(error)) { + logger.warn("Failed to read custom commands directory", { path: commandsDir, error: String(error) }); + } continue; } for (const entry of entries) { @@ -120,7 +124,7 @@ export function discoverCustomCommands(options: DiscoverCustomCommandsOptions = for (const filename of indexCandidates) { const candidate = path.join(commandDir, filename); - if (existsSync(candidate)) { + if (fs.existsSync(candidate)) { addPath(candidate, source); break; } @@ -162,7 +166,7 @@ export async function loadCustomCommands(options: LoadCustomCommandsOptions = {} const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getAgentDir(); - const { paths } = discoverCustomCommands({ cwd, agentDir }); + const { paths } = await discoverCustomCommands({ cwd, agentDir }); const commands: LoadedCustomCommand[] = []; const errors: Array<{ path: string; error: string }> = []; diff --git a/packages/coding-agent/src/extensibility/custom-tools/loader.ts b/packages/coding-agent/src/extensibility/custom-tools/loader.ts index 0430231b0..8fd6887c0 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/loader.ts @@ -223,7 +223,7 @@ export async function discoverAndLoadCustomTools(configuredPaths: string[], cwd: } // 2. Plugin tools: ~/.omp/plugins/node_modules/*/ - for (const pluginPath of getAllPluginToolPaths(cwd)) { + for (const pluginPath of await getAllPluginToolPaths(cwd)) { addPath(pluginPath, { provider: "plugin", providerName: "Plugin", level: "user" }); } diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 670d92bad..658053772 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -2,13 +2,14 @@ * Extension loader - loads TypeScript extension modules using native Bun import. */ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import type * as fs1 from "node:fs"; +import * as fs from "node:fs/promises"; 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 * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import type { KeyId } from "@oh-my-pi/pi-tui"; -import { logger } from "@oh-my-pi/pi-utils"; +import { isEnoent, 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"; @@ -316,10 +317,9 @@ interface ExtensionManifest { skills?: string[]; } -function readExtensionManifest(packageJsonPath: string): ExtensionManifest | null { +async function readExtensionManifest(packageJsonPath: string): Promise { try { - const content = readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content) as { omp?: ExtensionManifest; pi?: ExtensionManifest }; + const pkg = (await Bun.file(packageJsonPath).json()) as { omp?: ExtensionManifest; pi?: ExtensionManifest }; const manifest = pkg.omp ?? pkg.pi; if (manifest && typeof manifest === "object") { return manifest; @@ -338,31 +338,38 @@ function isExtensionFile(name: string): boolean { /** * Resolve extension entry points from a directory. */ -function resolveExtensionEntries(dir: string): string[] | null { +async function resolveExtensionEntries(dir: string): Promise { const packageJsonPath = path.join(dir, "package.json"); - if (existsSync(packageJsonPath)) { - const manifest = readExtensionManifest(packageJsonPath); - if (manifest?.extensions?.length) { - const entries: string[] = []; - for (const extPath of manifest.extensions) { - const resolvedExtPath = path.resolve(dir, extPath); - if (existsSync(resolvedExtPath)) { - entries.push(resolvedExtPath); - } - } - if (entries.length > 0) { - return entries; + const manifest = await readExtensionManifest(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = path.resolve(dir, extPath); + try { + await fs.stat(resolvedExtPath); + entries.push(resolvedExtPath); + } catch (err) { + if (!isEnoent(err)) throw err; } } + if (entries.length > 0) { + return entries; + } } const indexTs = path.join(dir, "index.ts"); const indexJs = path.join(dir, "index.js"); - if (existsSync(indexTs)) { + try { + await fs.stat(indexTs); return [indexTs]; + } catch (err) { + if (!isEnoent(err)) throw err; } - if (existsSync(indexJs)) { + try { + await fs.stat(indexJs); return [indexJs]; + } catch (err) { + if (!isEnoent(err)) throw err; } return null; @@ -378,36 +385,32 @@ function resolveExtensionEntries(dir: string): string[] | null { * * No recursion beyond one level. Complex packages must use package.json manifest. */ -function discoverExtensionsInDir(dir: string): string[] { - if (!existsSync(dir)) { +async function discoverExtensionsInDir(dir: string): Promise { + const discovered: string[] = []; + + let entries: fs1.Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + if (isEnoent(err)) return []; + logger.warn("Failed to discover extensions in directory", { path: dir, error: String(err) }); return []; } - const discovered: string[] = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); - try { - const entries = readdirSync(dir, { withFileTypes: true }); + if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { + discovered.push(entryPath); + continue; + } - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - - // 1. Direct files: *.ts or *.js - if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { - discovered.push(entryPath); - continue; - } - - // 2 & 3. Subdirectories - if (entry.isDirectory() || entry.isSymbolicLink()) { - const entries = resolveExtensionEntries(entryPath); - if (entries) { - discovered.push(...entries); - } + if (entry.isDirectory() || entry.isSymbolicLink()) { + const resolved = await resolveExtensionEntries(entryPath); + if (resolved) { + discovered.push(...resolved); } } - } catch (error) { - logger.warn("Failed to discover extensions in directory", { path: dir, error: String(error) }); - return []; } return discovered; @@ -454,14 +457,22 @@ export async function discoverAndLoadExtensions( // 2. Explicitly configured paths for (const configuredPath of configuredPaths) { const resolved = resolvePath(configuredPath, cwd); - if (existsSync(resolved) && statSync(resolved).isDirectory()) { - const entries = resolveExtensionEntries(resolved); + + let stat: fs1.Stats | null = null; + try { + stat = await fs.stat(resolved); + } catch (err) { + if (!isEnoent(err)) throw err; + } + + if (stat?.isDirectory()) { + const entries = await resolveExtensionEntries(resolved); if (entries) { addPaths(entries); continue; } - const discovered = discoverExtensionsInDir(resolved); + const discovered = await discoverExtensionsInDir(resolved); if (discovered.length > 0) { addPaths(discovered); } diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 16c123840..f5f1064f3 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -98,9 +98,9 @@ const noOpUIContext: ExtensionUIContext = { get theme() { return theme; }, - getAllThemes: () => [], - getTheme: () => undefined, - setTheme: (_theme: string | Theme) => ({ success: false, error: "UI not available" }), + getAllThemes: () => Promise.resolve([]), + getTheme: () => Promise.resolve(undefined), + setTheme: (_theme: string | Theme) => Promise.resolve({ success: false, error: "UI not available" }), }; export class ExtensionRunner { diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 9259219f7..7d261762d 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -112,13 +112,13 @@ export interface ExtensionUIContext { readonly theme: Theme; /** Get all available themes with names and paths. */ - getAllThemes(): { name: string; path: string | undefined }[]; + getAllThemes(): Promise<{ name: string; path: string | undefined }[]>; /** Load a theme by name without switching to it. */ - getTheme(name: string): Theme | undefined; + getTheme(name: string): Promise; /** Set the current theme by name or Theme object. */ - setTheme(theme: string | Theme): { success: boolean; error?: string }; + setTheme(theme: string | Theme): Promise<{ success: boolean; error?: string }>; } // ============================================================================ diff --git a/packages/coding-agent/src/extensibility/plugins/installer.ts b/packages/coding-agent/src/extensibility/plugins/installer.ts index 13e85f462..12ea10fc4 100644 --- a/packages/coding-agent/src/extensibility/plugins/installer.ts +++ b/packages/coding-agent/src/extensibility/plugins/installer.ts @@ -1,10 +1,10 @@ -import { lstatSync, symlinkSync, unlinkSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { getAgentDir } from "../../config"; import type { InstalledPlugin } from "./types"; -const PLUGINS_DIR = join(getAgentDir(), "plugins"); +const PLUGINS_DIR = path.join(getAgentDir(), "plugins"); // Valid npm package name pattern (scoped and unscoped) const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i; @@ -26,8 +26,8 @@ function validatePackageName(name: string): void { * Ensure the plugins directory exists */ async function ensurePluginsDir(): Promise { - await mkdir(PLUGINS_DIR, { recursive: true }); - await mkdir(join(PLUGINS_DIR, "node_modules"), { recursive: true }); + await fs.mkdir(PLUGINS_DIR, { recursive: true }); + await fs.mkdir(path.join(PLUGINS_DIR, "node_modules"), { recursive: true }); } export async function installPlugin(packageName: string): Promise { @@ -38,7 +38,7 @@ export async function installPlugin(packageName: string): Promise { } export async function listPlugins(): Promise { - const pkgJsonPath = Bun.file(join(PLUGINS_DIR, "package.json")); + const pkgJsonPath = Bun.file(path.join(PLUGINS_DIR, "package.json")); if (!(await pkgJsonPath.exists())) { return []; } @@ -110,14 +110,14 @@ export async function listPlugins(): Promise { const plugins: InstalledPlugin[] = []; for (const [name, _version] of Object.entries(deps)) { - const path = join(PLUGINS_DIR, "node_modules", name); - const fpkg = Bun.file(join(path, "package.json")); + const pluginPath = path.join(PLUGINS_DIR, "node_modules", name); + const fpkg = Bun.file(path.join(pluginPath, "package.json")); if (await fpkg.exists()) { const pkg = await fpkg.json(); plugins.push({ name, version: pkg.version, - path, + path: pluginPath, manifest: pkg.omp || pkg.pi || { version: pkg.version }, enabledFeatures: null, enabled: true, @@ -130,17 +130,17 @@ export async function listPlugins(): Promise { export async function linkPlugin(localPath: string): Promise { const cwd = process.cwd(); - const absolutePath = resolve(cwd, localPath); + const absolutePath = path.resolve(cwd, localPath); // Validate that resolved path is within cwd to prevent path traversal - const normalizedCwd = resolve(cwd); - const normalizedPath = resolve(absolutePath); + const normalizedCwd = path.resolve(cwd); + const normalizedPath = path.resolve(absolutePath); if (!normalizedPath.startsWith(`${normalizedCwd}/`) && normalizedPath !== normalizedCwd) { throw new Error(`Invalid path: ${localPath} resolves outside working directory`); } // Validate package.json exists - const pkgFile = Bun.file(join(absolutePath, "package.json")); + const pkgFile = Bun.file(path.join(absolutePath, "package.json")); if (!(await pkgFile.exists())) { throw new Error(`package.json not found at ${absolutePath}`); } @@ -167,24 +167,24 @@ export async function linkPlugin(localPath: string): Promise { await ensurePluginsDir(); // Create symlink in plugins/node_modules - const linkPath = join(PLUGINS_DIR, "node_modules", pkg.name); + const linkPath = path.join(PLUGINS_DIR, "node_modules", pkg.name); // For scoped packages, ensure the scope directory exists if (pkg.name.startsWith("@")) { - const scopeDir = join(PLUGINS_DIR, "node_modules", pkg.name.split("/")[0]); - await mkdir(scopeDir, { recursive: true }); + const scopeDir = path.join(PLUGINS_DIR, "node_modules", pkg.name.split("/")[0]); + await fs.mkdir(scopeDir, { recursive: true }); } // Remove existing if present try { - const stat = lstatSync(linkPath); - if (stat.isSymbolicLink() || stat.isDirectory()) { - unlinkSync(linkPath); + const stats = await fs.lstat(linkPath); + if (stats.isSymbolicLink() || stats.isDirectory()) { + await fs.unlink(linkPath); } - } catch { - // Doesn't exist, that's fine + } catch (err) { + if (!isEnoent(err)) throw err; } // Create symlink using fs instead of shell command - symlinkSync(absolutePath, linkPath); + await fs.symlink(absolutePath, linkPath); } diff --git a/packages/coding-agent/src/extensibility/plugins/loader.ts b/packages/coding-agent/src/extensibility/plugins/loader.ts index 9af5d9965..2b02b23dd 100644 --- a/packages/coding-agent/src/extensibility/plugins/loader.ts +++ b/packages/coding-agent/src/extensibility/plugins/loader.ts @@ -5,8 +5,9 @@ * based on manifest entries and enabled features. */ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { getAllProjectPluginOverridePaths, getPluginsLockfile, @@ -22,29 +23,26 @@ import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPlugi /** * Load plugin runtime config from lock file. */ -function loadRuntimeConfig(): PluginRuntimeConfig { +async function loadRuntimeConfig(): Promise { const lockPath = getPluginsLockfile(); - if (!existsSync(lockPath)) { - return { plugins: {}, settings: {} }; - } try { - return JSON.parse(readFileSync(lockPath, "utf-8")); - } catch { - return { plugins: {}, settings: {} }; + return await Bun.file(lockPath).json(); + } catch (err) { + if (isEnoent(err)) return { plugins: {}, settings: {} }; + throw err; } } /** * Load project-local plugin overrides (checks .omp and .pi directories). */ -function loadProjectOverrides(cwd: string): ProjectPluginOverrides { +async function loadProjectOverrides(cwd: string): Promise { for (const overridesPath of getAllProjectPluginOverridePaths(cwd)) { - if (existsSync(overridesPath)) { - try { - return JSON.parse(readFileSync(overridesPath, "utf-8")); - } catch { - // Continue to next path - } + try { + return await Bun.file(overridesPath).json(); + } catch (err) { + if (isEnoent(err)) continue; + // JSON parse error - continue to next path } } return {}; @@ -58,30 +56,36 @@ function loadProjectOverrides(cwd: string): ProjectPluginOverrides { * Get list of enabled plugins with their resolved configurations. * Respects both global runtime config and project overrides. */ -export function getEnabledPlugins(cwd: string): InstalledPlugin[] { +export async function getEnabledPlugins(cwd: string): Promise { const pkgJsonPath = getPluginsPackageJson(); - if (!existsSync(pkgJsonPath)) { - return []; + let pkg: { dependencies?: Record }; + try { + pkg = await Bun.file(pkgJsonPath).json(); + } catch (err) { + if (isEnoent(err)) return []; + throw err; } const nodeModulesPath = getPluginsNodeModules(); - if (!existsSync(nodeModulesPath)) { + if (!fs.existsSync(nodeModulesPath)) { return []; } - const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); const deps = pkg.dependencies || {}; - const runtimeConfig = loadRuntimeConfig(); - const projectOverrides = loadProjectOverrides(cwd); + const runtimeConfig = await loadRuntimeConfig(); + const projectOverrides = await loadProjectOverrides(cwd); const plugins: InstalledPlugin[] = []; for (const [name] of Object.entries(deps)) { - const pluginPkgPath = join(nodeModulesPath, name, "package.json"); - if (!existsSync(pluginPkgPath)) { - continue; + const pluginPkgPath = path.join(nodeModulesPath, name, "package.json"); + let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest }; + try { + pluginPkg = await Bun.file(pluginPkgPath).json(); + } catch (err) { + if (isEnoent(err)) continue; + throw err; } - const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8")); const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi; if (!manifest) { @@ -109,7 +113,7 @@ export function getEnabledPlugins(cwd: string): InstalledPlugin[] { plugins.push({ name, version: pluginPkg.version, - path: join(nodeModulesPath, name), + path: path.join(nodeModulesPath, name), manifest, enabledFeatures, enabled: true, @@ -133,8 +137,8 @@ export function resolvePluginToolPaths(plugin: InstalledPlugin): string[] { // Base tools entry (always included if exists) if (manifest.tools) { - const toolPath = join(plugin.path, manifest.tools); - if (existsSync(toolPath)) { + const toolPath = path.join(plugin.path, manifest.tools); + if (fs.existsSync(toolPath)) { paths.push(toolPath); } } @@ -148,8 +152,8 @@ export function resolvePluginToolPaths(plugin: InstalledPlugin): string[] { if (feat.tools) { for (const toolEntry of feat.tools) { - const toolPath = join(plugin.path, toolEntry); - if (existsSync(toolPath)) { + const toolPath = path.join(plugin.path, toolEntry); + if (fs.existsSync(toolPath)) { paths.push(toolPath); } } @@ -162,8 +166,8 @@ export function resolvePluginToolPaths(plugin: InstalledPlugin): string[] { if (feat.tools) { for (const toolEntry of feat.tools) { - const toolPath = join(plugin.path, toolEntry); - if (existsSync(toolPath)) { + const toolPath = path.join(plugin.path, toolEntry); + if (fs.existsSync(toolPath)) { paths.push(toolPath); } } @@ -184,8 +188,8 @@ export function resolvePluginHookPaths(plugin: InstalledPlugin): string[] { // Base hooks entry (always included if exists) if (manifest.hooks) { - const hookPath = join(plugin.path, manifest.hooks); - if (existsSync(hookPath)) { + const hookPath = path.join(plugin.path, manifest.hooks); + if (fs.existsSync(hookPath)) { paths.push(hookPath); } } @@ -199,8 +203,8 @@ export function resolvePluginHookPaths(plugin: InstalledPlugin): string[] { if (feat.hooks) { for (const hookEntry of feat.hooks) { - const hookPath = join(plugin.path, hookEntry); - if (existsSync(hookPath)) { + const hookPath = path.join(plugin.path, hookEntry); + if (fs.existsSync(hookPath)) { paths.push(hookPath); } } @@ -213,8 +217,8 @@ export function resolvePluginHookPaths(plugin: InstalledPlugin): string[] { if (feat.hooks) { for (const hookEntry of feat.hooks) { - const hookPath = join(plugin.path, hookEntry); - if (existsSync(hookPath)) { + const hookPath = path.join(plugin.path, hookEntry); + if (fs.existsSync(hookPath)) { paths.push(hookPath); } } @@ -236,8 +240,8 @@ export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] { // Base commands (always included if exists) if (manifest.commands) { for (const cmdEntry of manifest.commands) { - const cmdPath = join(plugin.path, cmdEntry); - if (existsSync(cmdPath)) { + const cmdPath = path.join(plugin.path, cmdEntry); + if (fs.existsSync(cmdPath)) { paths.push(cmdPath); } } @@ -252,8 +256,8 @@ export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] { if (feat.commands) { for (const cmdEntry of feat.commands) { - const cmdPath = join(plugin.path, cmdEntry); - if (existsSync(cmdPath)) { + const cmdPath = path.join(plugin.path, cmdEntry); + if (fs.existsSync(cmdPath)) { paths.push(cmdPath); } } @@ -266,8 +270,8 @@ export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] { if (feat.commands) { for (const cmdEntry of feat.commands) { - const cmdPath = join(plugin.path, cmdEntry); - if (existsSync(cmdPath)) { + const cmdPath = path.join(plugin.path, cmdEntry); + if (fs.existsSync(cmdPath)) { paths.push(cmdPath); } } @@ -285,8 +289,8 @@ export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] { /** * Get all tool paths from all enabled plugins. */ -export function getAllPluginToolPaths(cwd: string): string[] { - const plugins = getEnabledPlugins(cwd); +export async function getAllPluginToolPaths(cwd: string): Promise { + const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { @@ -299,8 +303,8 @@ export function getAllPluginToolPaths(cwd: string): string[] { /** * Get all hook paths from all enabled plugins. */ -export function getAllPluginHookPaths(cwd: string): string[] { - const plugins = getEnabledPlugins(cwd); +export async function getAllPluginHookPaths(cwd: string): Promise { + const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { @@ -313,8 +317,8 @@ export function getAllPluginHookPaths(cwd: string): string[] { /** * Get all command paths from all enabled plugins. */ -export function getAllPluginCommandPaths(cwd: string): string[] { - const plugins = getEnabledPlugins(cwd); +export async function getAllPluginCommandPaths(cwd: string): Promise { + const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { @@ -328,9 +332,9 @@ export function getAllPluginCommandPaths(cwd: string): string[] { * Get plugin settings for use in tool/hook contexts. * Merges global settings with project overrides. */ -export function getPluginSettings(pluginName: string, cwd: string): Record { - const runtimeConfig = loadRuntimeConfig(); - const projectOverrides = loadProjectOverrides(cwd); +export async function getPluginSettings(pluginName: string, cwd: string): Promise> { + const runtimeConfig = await loadRuntimeConfig(); + const projectOverrides = await loadProjectOverrides(cwd); const global = runtimeConfig.settings[pluginName] || {}; const project = projectOverrides.settings?.[pluginName] || {}; diff --git a/packages/coding-agent/src/extensibility/plugins/manager.ts b/packages/coding-agent/src/extensibility/plugins/manager.ts index b9c149909..994b41419 100644 --- a/packages/coding-agent/src/extensibility/plugins/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/manager.ts @@ -1,5 +1,6 @@ -import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { extractPackageName, parsePluginSpec } from "./parser"; import { getPluginsDir, @@ -46,44 +47,47 @@ function validatePackageName(name: string): void { // ============================================================================= export class PluginManager { - private runtimeConfig: PluginRuntimeConfig; + private runtimeConfig: PluginRuntimeConfig | null = null; private cwd: string; constructor(cwd: string = process.cwd()) { this.cwd = cwd; - this.runtimeConfig = this.loadRuntimeConfig(); } // ========================================================================== // Runtime Config Management // ========================================================================== - private loadRuntimeConfig(): PluginRuntimeConfig { + private async loadRuntimeConfig(): Promise { const lockPath = getPluginsLockfile(); - if (!existsSync(lockPath)) { - return { plugins: {}, settings: {} }; - } try { - return JSON.parse(readFileSync(lockPath, "utf-8")); - } catch { - return { plugins: {}, settings: {} }; + return await Bun.file(lockPath).json(); + } catch (err) { + if (isEnoent(err)) return { plugins: {}, settings: {} }; + throw err; } } - private saveRuntimeConfig(): void { - this.ensurePluginsDir(); - writeFileSync(getPluginsLockfile(), JSON.stringify(this.runtimeConfig, null, 2)); + private async ensureConfigLoaded(): Promise { + if (!this.runtimeConfig) { + this.runtimeConfig = await this.loadRuntimeConfig(); + } + return this.runtimeConfig; } - private loadProjectOverrides(): ProjectPluginOverrides { + private async saveRuntimeConfig(): Promise { + await this.ensureConfigLoaded(); + await this.ensurePluginsDir(); + await Bun.write(getPluginsLockfile(), JSON.stringify(this.runtimeConfig, null, 2)); + } + + private async loadProjectOverrides(): Promise { const overridesPath = getProjectPluginOverrides(this.cwd); - if (!existsSync(overridesPath)) { - return {}; - } try { - return JSON.parse(readFileSync(overridesPath, "utf-8")); - } catch { - return {}; + return await Bun.file(overridesPath).json(); + } catch (err) { + if (isEnoent(err)) return {}; + throw err; } } @@ -91,33 +95,33 @@ export class PluginManager { // Directory Management // ========================================================================== - private ensurePluginsDir(): void { - const dir = getPluginsDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - const nodeModules = getPluginsNodeModules(); - if (!existsSync(nodeModules)) { - mkdirSync(nodeModules, { recursive: true }); - } + private async ensurePluginsDir(): Promise { + await fs.promises.mkdir(getPluginsDir(), { recursive: true }); + await fs.promises.mkdir(getPluginsNodeModules(), { recursive: true }); } - private ensurePackageJson(): void { - this.ensurePluginsDir(); + private async ensurePackageJson(): Promise { + await this.ensurePluginsDir(); const pkgJsonPath = getPluginsPackageJson(); - if (!existsSync(pkgJsonPath)) { - writeFileSync( - pkgJsonPath, - JSON.stringify( - { - name: "omp-plugins", - private: true, - dependencies: {}, - }, - null, - 2, - ), - ); + try { + await Bun.file(pkgJsonPath).json(); + } catch (err) { + if (isEnoent(err)) { + await Bun.write( + pkgJsonPath, + JSON.stringify( + { + name: "omp-plugins", + private: true, + dependencies: {}, + }, + null, + 2, + ), + ); + return; + } + throw err; } } @@ -136,7 +140,7 @@ export class PluginManager { const spec = parsePluginSpec(specString); validatePackageName(spec.packageName); - this.ensurePackageJson(); + await this.ensurePackageJson(); if (options.dryRun) { return { @@ -165,13 +169,17 @@ export class PluginManager { // Resolve actual package name (strip version specifier) const actualName = extractPackageName(spec.packageName); - const pkgPath = join(getPluginsNodeModules(), actualName, "package.json"); + const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json"); - if (!existsSync(pkgPath)) { - throw new Error(`Package installed but package.json not found at ${pkgPath}`); + let pkg: { name: string; version: string; omp?: PluginManifest; pi?: PluginManifest }; + try { + pkg = await Bun.file(pkgPath).json(); + } catch (err) { + if (isEnoent(err)) { + throw new Error(`Package installed but package.json not found at ${pkgPath}`); + } + throw err; } - - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); const manifest: PluginManifest = pkg.omp || pkg.pi || { version: pkg.version }; manifest.version = pkg.version; @@ -201,17 +209,18 @@ export class PluginManager { // null = use defaults // Update runtime config - this.runtimeConfig.plugins[pkg.name] = { + const config = await this.ensureConfigLoaded(); + config.plugins[pkg.name] = { version: pkg.version, enabledFeatures, enabled: true, }; - this.saveRuntimeConfig(); + await this.saveRuntimeConfig(); return { name: pkg.name, version: pkg.version, - path: join(getPluginsNodeModules(), actualName), + path: path.join(getPluginsNodeModules(), actualName), manifest, enabledFeatures, enabled: true, @@ -223,7 +232,7 @@ export class PluginManager { */ async uninstall(name: string): Promise { validatePackageName(name); - this.ensurePackageJson(); + await this.ensurePackageJson(); const proc = Bun.spawn(["npm", "uninstall", name], { cwd: getPluginsDir(), @@ -238,9 +247,10 @@ export class PluginManager { } // Remove from runtime config - delete this.runtimeConfig.plugins[name]; - delete this.runtimeConfig.settings[name]; - this.saveRuntimeConfig(); + const config = await this.ensureConfigLoaded(); + delete config.plugins[name]; + delete config.settings[name]; + await this.saveRuntimeConfig(); } /** @@ -248,41 +258,49 @@ export class PluginManager { */ async list(): Promise { const pkgJsonPath = getPluginsPackageJson(); - if (!existsSync(pkgJsonPath)) { - return []; + let pkg: { dependencies?: Record }; + try { + pkg = await Bun.file(pkgJsonPath).json(); + } catch (err) { + if (isEnoent(err)) return []; + throw err; } - const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); const deps = pkg.dependencies || {}; - const projectOverrides = this.loadProjectOverrides(); + const projectOverrides = await this.loadProjectOverrides(); + const config = await this.ensureConfigLoaded(); const plugins: InstalledPlugin[] = []; for (const [name] of Object.entries(deps)) { - const pluginPkgPath = join(getPluginsNodeModules(), name, "package.json"); - if (existsSync(pluginPkgPath)) { - const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8")); - const manifest: PluginManifest = pluginPkg.omp || pluginPkg.pi || { version: pluginPkg.version }; - manifest.version = pluginPkg.version; - - const runtimeState = this.runtimeConfig.plugins[name] || { - version: pluginPkg.version, - enabledFeatures: null, - enabled: true, - }; - - // Apply project overrides - const isDisabledInProject = projectOverrides.disabled?.includes(name) ?? false; - const projectFeatures = projectOverrides.features?.[name]; - - plugins.push({ - name, - version: pluginPkg.version, - path: join(getPluginsNodeModules(), name), - manifest, - enabledFeatures: projectFeatures ?? runtimeState.enabledFeatures, - enabled: runtimeState.enabled && !isDisabledInProject, - }); + const pluginPkgPath = path.join(getPluginsNodeModules(), name, "package.json"); + let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest }; + try { + pluginPkg = await Bun.file(pluginPkgPath).json(); + } catch (err) { + if (isEnoent(err)) continue; + throw err; } + const manifest: PluginManifest = pluginPkg.omp || pluginPkg.pi || { version: pluginPkg.version }; + manifest.version = pluginPkg.version; + + const runtimeState = config.plugins[name] || { + version: pluginPkg.version, + enabledFeatures: null, + enabled: true, + }; + + // Apply project overrides + const isDisabledInProject = projectOverrides.disabled?.includes(name) ?? false; + const projectFeatures = projectOverrides.features?.[name]; + + plugins.push({ + name, + version: pluginPkg.version, + path: path.join(getPluginsNodeModules(), name), + manifest, + enabledFeatures: projectFeatures ?? runtimeState.enabledFeatures, + enabled: runtimeState.enabled && !isDisabledInProject, + }); } return plugins; @@ -292,52 +310,53 @@ export class PluginManager { * Link a local plugin for development. */ async link(localPath: string): Promise { - const absolutePath = resolve(this.cwd, localPath); + const absolutePath = path.resolve(this.cwd, localPath); - const pkgFile = join(absolutePath, "package.json"); - if (!existsSync(pkgFile)) { - throw new Error(`package.json not found at ${absolutePath}`); + const pkgFilePath = path.join(absolutePath, "package.json"); + let pkg: { name?: string; version: string; omp?: PluginManifest; pi?: PluginManifest }; + try { + pkg = await Bun.file(pkgFilePath).json(); + } catch (err) { + if (isEnoent(err)) throw new Error(`package.json not found at ${absolutePath}`); + throw err; } - - const pkg = JSON.parse(readFileSync(pkgFile, "utf-8")); if (!pkg.name) { throw new Error("package.json must have a name field"); } - this.ensurePluginsDir(); + await this.ensurePluginsDir(); - const linkPath = join(getPluginsNodeModules(), pkg.name); + const linkPath = path.join(getPluginsNodeModules(), pkg.name); // Handle scoped packages if (pkg.name.startsWith("@")) { - const scopeDir = join(getPluginsNodeModules(), pkg.name.split("/")[0]); - if (!existsSync(scopeDir)) { - mkdirSync(scopeDir, { recursive: true }); - } + const scopeDir = path.join(getPluginsNodeModules(), pkg.name.split("/")[0]); + await fs.promises.mkdir(scopeDir, { recursive: true }); } // Remove existing try { - const stat = lstatSync(linkPath); - if (stat.isSymbolicLink() || stat.isDirectory()) { - unlinkSync(linkPath); + const stats = await fs.promises.lstat(linkPath); + if (stats.isSymbolicLink() || stats.isDirectory()) { + await fs.promises.unlink(linkPath); } - } catch { - // Doesn't exist + } catch (err) { + if (!isEnoent(err)) throw err; } - symlinkSync(absolutePath, linkPath); + await fs.promises.symlink(absolutePath, linkPath); const manifest: PluginManifest = pkg.omp || pkg.pi || { version: pkg.version }; manifest.version = pkg.version; // Add to runtime config - this.runtimeConfig.plugins[pkg.name] = { + const config = await this.ensureConfigLoaded(); + config.plugins[pkg.name] = { version: pkg.version, enabledFeatures: null, enabled: true, }; - this.saveRuntimeConfig(); + await this.saveRuntimeConfig(); return { name: pkg.name, @@ -357,11 +376,12 @@ export class PluginManager { * Enable or disable a plugin globally. */ async setEnabled(name: string, enabled: boolean): Promise { - if (!this.runtimeConfig.plugins[name]) { + const config = await this.ensureConfigLoaded(); + if (!config.plugins[name]) { throw new Error(`Plugin ${name} not found in runtime config`); } - this.runtimeConfig.plugins[name].enabled = enabled; - this.saveRuntimeConfig(); + config.plugins[name].enabled = enabled; + await this.saveRuntimeConfig(); } // ========================================================================== @@ -371,15 +391,17 @@ export class PluginManager { /** * Get enabled features for a plugin. */ - getEnabledFeatures(name: string): string[] | null { - return this.runtimeConfig.plugins[name]?.enabledFeatures ?? null; + async getEnabledFeatures(name: string): Promise { + const config = await this.ensureConfigLoaded(); + return config.plugins[name]?.enabledFeatures ?? null; } /** * Set enabled features for a plugin. */ async setEnabledFeatures(name: string, features: string[] | null): Promise { - if (!this.runtimeConfig.plugins[name]) { + const config = await this.ensureConfigLoaded(); + if (!config.plugins[name]) { throw new Error(`Plugin ${name} not found in runtime config`); } @@ -398,8 +420,8 @@ export class PluginManager { } } - this.runtimeConfig.plugins[name].enabledFeatures = features; - this.saveRuntimeConfig(); + config.plugins[name].enabledFeatures = features; + await this.saveRuntimeConfig(); } // ========================================================================== @@ -409,9 +431,10 @@ export class PluginManager { /** * Get all settings for a plugin. */ - getPluginSettings(name: string): Record { - const global = this.runtimeConfig.settings[name] || {}; - const projectOverrides = this.loadProjectOverrides(); + async getPluginSettings(name: string): Promise> { + const config = await this.ensureConfigLoaded(); + const global = config.settings[name] || {}; + const projectOverrides = await this.loadProjectOverrides(); const project = projectOverrides.settings?.[name] || {}; // Project settings override global @@ -421,21 +444,23 @@ export class PluginManager { /** * Set a plugin setting value. */ - setPluginSetting(name: string, key: string, value: unknown): void { - if (!this.runtimeConfig.settings[name]) { - this.runtimeConfig.settings[name] = {}; + async setPluginSetting(name: string, key: string, value: unknown): Promise { + const config = await this.ensureConfigLoaded(); + if (!config.settings[name]) { + config.settings[name] = {}; } - this.runtimeConfig.settings[name][key] = value; - this.saveRuntimeConfig(); + config.settings[name][key] = value; + await this.saveRuntimeConfig(); } /** * Delete a plugin setting. */ - deletePluginSetting(name: string, key: string): void { - if (this.runtimeConfig.settings[name]) { - delete this.runtimeConfig.settings[name][key]; - this.saveRuntimeConfig(); + async deletePluginSetting(name: string, key: string): Promise { + const config = await this.ensureConfigLoaded(); + if (config.settings[name]) { + delete config.settings[name][key]; + await this.saveRuntimeConfig(); } } @@ -451,15 +476,27 @@ export class PluginManager { // Check 1: Plugins directory exists const pluginsDir = getPluginsDir(); + const pluginsDirExists = fs.existsSync(pluginsDir); checks.push({ name: "plugins_directory", - status: existsSync(pluginsDir) ? "ok" : "warning", - message: existsSync(pluginsDir) ? `Found at ${pluginsDir}` : "Not created yet", + status: pluginsDirExists ? "ok" : "warning", + message: pluginsDirExists ? `Found at ${pluginsDir}` : "Not created yet", }); // Check 2: package.json exists const pkgJsonPath = getPluginsPackageJson(); - const hasPkgJson = existsSync(pkgJsonPath); + let pkg: { dependencies?: Record }; + let hasPkgJson = true; + try { + pkg = await Bun.file(pkgJsonPath).json(); + } catch (err) { + if (isEnoent(err)) { + hasPkgJson = false; + pkg = {}; + } else { + throw err; + } + } checks.push({ name: "package_manifest", status: hasPkgJson ? "ok" : "warning", @@ -468,7 +505,7 @@ export class PluginManager { // Check 3: node_modules exists const nodeModulesPath = getPluginsNodeModules(); - const hasNodeModules = existsSync(nodeModulesPath); + const hasNodeModules = fs.existsSync(nodeModulesPath); checks.push({ name: "node_modules", status: hasNodeModules ? "ok" : hasPkgJson ? "error" : "warning", @@ -478,36 +515,37 @@ export class PluginManager { if (!hasPkgJson) { return checks; } - - // Check each installed plugin - const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); const deps = pkg.dependencies || {}; + const config = await this.ensureConfigLoaded(); for (const [name] of Object.entries(deps)) { - const pluginPath = join(nodeModulesPath, name); - const pluginPkgPath = join(pluginPath, "package.json"); + const pluginPath = path.join(nodeModulesPath, name); + const pluginPkgPath = path.join(pluginPath, "package.json"); - if (!existsSync(pluginPath)) { - const fixed = options.fix ? await this.fixMissingPlugin() : false; - checks.push({ - name: `plugin:${name}`, - status: "error", - message: "Missing from node_modules", - fixed, - }); - continue; + let pluginPkg: { version: string; description?: string; omp?: PluginManifest; pi?: PluginManifest }; + try { + pluginPkg = await Bun.file(pluginPkgPath).json(); + } catch (err) { + if (isEnoent(err)) { + if (!fs.existsSync(pluginPath)) { + const fixed = options.fix ? await this.fixMissingPlugin() : false; + checks.push({ + name: `plugin:${name}`, + status: "error", + message: "Missing from node_modules", + fixed, + }); + } else { + checks.push({ + name: `plugin:${name}`, + status: "error", + message: "Missing package.json", + }); + } + continue; + } + throw err; } - - if (!existsSync(pluginPkgPath)) { - checks.push({ - name: `plugin:${name}`, - status: "error", - message: "Missing package.json", - }); - continue; - } - - const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8")); const hasManifest = !!(pluginPkg.omp || pluginPkg.pi); const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi; @@ -521,8 +559,8 @@ export class PluginManager { // Check tools path exists if specified if (manifest?.tools) { - const toolsPath = join(pluginPath, manifest.tools); - if (!existsSync(toolsPath)) { + const toolsPath = path.join(pluginPath, manifest.tools); + if (!fs.existsSync(toolsPath)) { checks.push({ name: `plugin:${name}:tools`, status: "error", @@ -533,8 +571,8 @@ export class PluginManager { // Check hooks path exists if specified if (manifest?.hooks) { - const hooksPath = join(pluginPath, manifest.hooks); - if (!existsSync(hooksPath)) { + const hooksPath = path.join(pluginPath, manifest.hooks); + if (!fs.existsSync(hooksPath)) { checks.push({ name: `plugin:${name}:hooks`, status: "error", @@ -544,11 +582,11 @@ export class PluginManager { } // Check enabled features exist in manifest - const runtimeState = this.runtimeConfig.plugins[name]; + const runtimeState = config.plugins[name]; if (runtimeState?.enabledFeatures && manifest?.features) { for (const feat of runtimeState.enabledFeatures) { if (!(feat in manifest.features)) { - const fixed = options.fix ? this.removeInvalidFeature(name, feat) : false; + const fixed = options.fix ? await this.removeInvalidFeature(name, feat) : false; checks.push({ name: `plugin:${name}:feature:${feat}`, status: "warning", @@ -561,9 +599,9 @@ export class PluginManager { } // Check for orphaned runtime config entries - for (const name of Object.keys(this.runtimeConfig.plugins)) { + for (const name of Object.keys(config.plugins)) { if (!(name in deps)) { - const fixed = options.fix ? this.removeOrphanedConfig(name) : false; + const fixed = options.fix ? await this.removeOrphanedConfig(name) : false; checks.push({ name: `orphan:${name}`, status: "warning", @@ -590,20 +628,22 @@ export class PluginManager { } } - private removeInvalidFeature(name: string, feat: string): boolean { - const state = this.runtimeConfig.plugins[name]; + private async removeInvalidFeature(name: string, feat: string): Promise { + const config = await this.ensureConfigLoaded(); + const state = config.plugins[name]; if (state?.enabledFeatures) { state.enabledFeatures = state.enabledFeatures.filter((f) => f !== feat); - this.saveRuntimeConfig(); + await this.saveRuntimeConfig(); return true; } return false; } - private removeOrphanedConfig(name: string): boolean { - delete this.runtimeConfig.plugins[name]; - delete this.runtimeConfig.settings[name]; - this.saveRuntimeConfig(); + private async removeOrphanedConfig(name: string): Promise { + const config = await this.ensureConfigLoaded(); + delete config.plugins[name]; + delete config.settings[name]; + await this.saveRuntimeConfig(); return true; } } diff --git a/packages/coding-agent/src/extensibility/plugins/paths.ts b/packages/coding-agent/src/extensibility/plugins/paths.ts index 7af1d984c..075418637 100644 --- a/packages/coding-agent/src/extensibility/plugins/paths.ts +++ b/packages/coding-agent/src/extensibility/plugins/paths.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { CONFIG_DIR_NAME, getConfigDirPaths } from "../../config"; // ============================================================================= @@ -8,27 +8,27 @@ import { CONFIG_DIR_NAME, getConfigDirPaths } from "../../config"; /** Root plugin directory: ~/.omp/plugins (not under agent/) */ export function getPluginsDir(): string { - return join(homedir(), CONFIG_DIR_NAME, "plugins"); + return path.join(homedir(), CONFIG_DIR_NAME, "plugins"); } /** Where npm installs packages: ~/.omp/plugins/node_modules */ export function getPluginsNodeModules(): string { - return join(getPluginsDir(), "node_modules"); + return path.join(getPluginsDir(), "node_modules"); } /** Plugin manifest: ~/.omp/plugins/package.json */ export function getPluginsPackageJson(): string { - return join(getPluginsDir(), "package.json"); + return path.join(getPluginsDir(), "package.json"); } /** Plugin lock file: ~/.omp/plugins/omp-plugins.lock.json */ export function getPluginsLockfile(): string { - return join(getPluginsDir(), "omp-plugins.lock.json"); + return path.join(getPluginsDir(), "omp-plugins.lock.json"); } /** Project-local plugin overrides: .omp/plugin-overrides.json (primary) */ export function getProjectPluginOverrides(cwd: string): string { - return join(cwd, CONFIG_DIR_NAME, "plugin-overrides.json"); + return path.join(cwd, CONFIG_DIR_NAME, "plugin-overrides.json"); } /** All possible project plugin override paths (primary + legacy) */ diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index 79d113656..ce41b08ff 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -1,6 +1,5 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { realpath } from "node:fs/promises"; -import { basename, join } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { skillCapability } from "../capability/skill"; import type { SourceMeta } from "../capability/types"; @@ -44,15 +43,15 @@ export interface LoadSkillsFromDirOptions { * Skills are directories containing a SKILL.md file with frontmatter including a description. * @deprecated Use loadSync("skills") from discovery API instead */ -export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult { +export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise { const skills: Skill[] = []; const warnings: SkillWarning[] = []; const seenPaths = new Set(); - function addSkill(skillFile: string, skillDir: string, dirName: string) { + async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise { if (seenPaths.has(skillFile)) return; try { - const content = readFileSync(skillFile, "utf-8"); + const content = await fs.readFile(skillFile, "utf-8"); const { frontmatter } = parseFrontmatter(content, { source: skillFile }); const name = (frontmatter.name as string) || dirName; const description = frontmatter.description as string; @@ -72,34 +71,42 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills } } - function scanDir(dir: string) { + async function scanDir(dir: string): Promise { try { - const entries = readdirSync(dir, { withFileTypes: true }); + const entries = await fs.readdir(dir, { withFileTypes: true }); + const tasks: Promise[] = []; + for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const fullPath = join(dir, entry.name); + const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - const skillFile = join(fullPath, "SKILL.md"); - try { - const stat = statSync(skillFile); - if (stat.isFile()) { - addSkill(skillFile, fullPath, entry.name); - } - } catch { - // No SKILL.md in this directory - } - scanDir(fullPath); + const skillFile = path.join(fullPath, "SKILL.md"); + tasks.push( + fs + .stat(skillFile) + .then((s) => { + if (s.isFile()) { + return addSkill(skillFile, fullPath, entry.name); + } + }) + .catch(() => { + // No SKILL.md in this directory + }), + ); + tasks.push(scanDir(fullPath)); } else if (entry.isFile() && entry.name === "SKILL.md") { - addSkill(fullPath, dir, basename(dir)); + tasks.push(addSkill(fullPath, dir, path.basename(dir))); } } + + await Promise.all(tasks); } catch (err) { warnings.push({ skillPath: dir, message: `Failed to read directory: ${err}` }); } } - scanDir(options.dir); + await scanDir(options.dir); return { skills, warnings }; } @@ -108,15 +115,15 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills * Scan a directory for SKILL.md files recursively. * Used internally by loadSkills for custom directories. */ -function scanDirectoryForSkills(dir: string): LoadSkillsResult { +async function scanDirectoryForSkills(dir: string): Promise { const skills: Skill[] = []; const warnings: SkillWarning[] = []; const seenPaths = new Set(); - function addSkill(skillFile: string, skillDir: string, dirName: string) { + async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise { if (seenPaths.has(skillFile)) return; try { - const content = readFileSync(skillFile, "utf-8"); + const content = await fs.readFile(skillFile, "utf-8"); const { frontmatter } = parseFrontmatter(content, { source: skillFile }); const name = (frontmatter.name as string) || dirName; const description = frontmatter.description as string; @@ -136,34 +143,42 @@ function scanDirectoryForSkills(dir: string): LoadSkillsResult { } } - function scanDir(currentDir: string) { + async function scanDir(currentDir: string): Promise { try { - const entries = readdirSync(currentDir, { withFileTypes: true }); + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + const tasks: Promise[] = []; + for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const fullPath = join(currentDir, entry.name); + const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { - const skillFile = join(fullPath, "SKILL.md"); - try { - const stat = statSync(skillFile); - if (stat.isFile()) { - addSkill(skillFile, fullPath, entry.name); - } - } catch { - // No SKILL.md in this directory - } - scanDir(fullPath); + const skillFile = path.join(fullPath, "SKILL.md"); + tasks.push( + fs + .stat(skillFile) + .then((s) => { + if (s.isFile()) { + return addSkill(skillFile, fullPath, entry.name); + } + }) + .catch(() => { + // No SKILL.md in this directory + }), + ); + tasks.push(scanDir(fullPath)); } else if (entry.isFile() && entry.name === "SKILL.md") { - addSkill(fullPath, currentDir, basename(currentDir)); + tasks.push(addSkill(fullPath, currentDir, path.basename(currentDir))); } } + + await Promise.all(tasks); } catch (err) { warnings.push({ skillPath: currentDir, message: `Failed to read directory: ${err}` }); } } - scanDir(dir); + await scanDir(dir); return { skills, warnings }; } @@ -239,7 +254,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise { try { - return await realpath(capSkill.path); + return await fs.realpath(capSkill.path); } catch { return capSkill.path; } @@ -279,8 +294,8 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise = []; - for (const dir of customDirectories) { - const customSkills = scanDirectoryForSkills(dir); + const customScanResults = await Promise.all(customDirectories.map((dir) => scanDirectoryForSkills(dir))); + for (const customSkills of customScanResults) { for (const s of customSkills.skills) { if (matchesIgnorePatterns(s.name)) continue; if (!matchesIncludePatterns(s.name)) continue; @@ -303,7 +318,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise { try { - return await realpath(path); + return await fs.realpath(path); } catch { return path; } diff --git a/packages/coding-agent/src/internal-urls/agent-protocol.ts b/packages/coding-agent/src/internal-urls/agent-protocol.ts index 6a187a32d..9490e655b 100644 --- a/packages/coding-agent/src/internal-urls/agent-protocol.ts +++ b/packages/coding-agent/src/internal-urls/agent-protocol.ts @@ -9,8 +9,9 @@ * - agent://?q= - JSON extraction via query form */ -import * as fs from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { applyQuery, pathToQuery } from "./json-query"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; @@ -25,9 +26,9 @@ export interface AgentProtocolOptions { /** * List available output IDs in artifacts directory. */ -function listAvailableOutputs(artifactsDir: string): string[] { +async function listAvailableOutputs(artifactsDir: string): Promise { try { - const files = fs.readdirSync(artifactsDir); + const files = await fs.readdir(artifactsDir); return files.filter((f) => f.endsWith(".md")).map((f) => f.replace(".md", "")); } catch { return []; @@ -51,8 +52,13 @@ export class AgentProtocolHandler implements ProtocolHandler { throw new Error("No session - agent outputs unavailable"); } - if (!fs.existsSync(artifactsDir)) { - throw new Error("No artifacts directory found"); + try { + await fs.stat(artifactsDir); + } catch (err) { + if (isEnoent(err)) { + throw new Error("No artifacts directory found"); + } + throw err; } // Extract output ID from host @@ -73,10 +79,15 @@ export class AgentProtocolHandler implements ProtocolHandler { // Load the output file const outputPath = path.join(artifactsDir, `${outputId}.md`); - if (!fs.existsSync(outputPath)) { - const available = listAvailableOutputs(artifactsDir); - const availableStr = available.length > 0 ? available.join(", ") : "none"; - throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`); + try { + await fs.stat(outputPath); + } catch (err) { + if (isEnoent(err)) { + const available = await listAvailableOutputs(artifactsDir); + const availableStr = available.length > 0 ? available.join(", ") : "none"; + throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`); + } + throw err; } const rawContent = await Bun.file(outputPath).text(); diff --git a/packages/coding-agent/src/internal-urls/artifact-protocol.ts b/packages/coding-agent/src/internal-urls/artifact-protocol.ts index 8c8093ba6..ec8cbfaa2 100644 --- a/packages/coding-agent/src/internal-urls/artifact-protocol.ts +++ b/packages/coding-agent/src/internal-urls/artifact-protocol.ts @@ -10,8 +10,9 @@ * Pagination is handled by the read tool via offset/limit parameters. */ -import * as fs from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; export interface ArtifactProtocolOptions { @@ -24,9 +25,9 @@ export interface ArtifactProtocolOptions { /** * List available artifact IDs in the directory. */ -function listAvailableArtifacts(artifactsDir: string): string[] { +async function listAvailableArtifacts(artifactsDir: string): Promise { try { - const files = fs.readdirSync(artifactsDir); + const files = await fs.readdir(artifactsDir); return files .filter((f) => /^\d+\./.test(f)) .map((f) => f.split(".")[0]) @@ -64,17 +65,21 @@ export class ArtifactProtocolHandler implements ProtocolHandler { throw new Error(`artifact:// ID must be numeric, got: ${id}`); } - // Check directory exists - if (!fs.existsSync(artifactsDir)) { - throw new Error("No artifacts directory found"); + // Check directory exists and find file matching ID prefix + let files: string[]; + try { + files = await fs.readdir(artifactsDir); + } catch (err) { + if (isEnoent(err)) { + throw new Error("No artifacts directory found"); + } + throw err; } - // Find file matching ID prefix - const files = fs.readdirSync(artifactsDir); const match = files.find((f) => f.startsWith(`${id}.`)); if (!match) { - const available = listAvailableArtifacts(artifactsDir); + const available = await listAvailableArtifacts(artifactsDir); const availableStr = available.length > 0 ? available.join(", ") : "none"; throw new Error(`Artifact ${id} not found. Available: ${availableStr}`); } diff --git a/packages/coding-agent/src/ipy/gateway-coordinator.ts b/packages/coding-agent/src/ipy/gateway-coordinator.ts index 86139637d..b1b04fa53 100644 --- a/packages/coding-agent/src/ipy/gateway-coordinator.ts +++ b/packages/coding-agent/src/ipy/gateway-coordinator.ts @@ -1,19 +1,9 @@ -import { - closeSync, - existsSync, - mkdirSync, - openSync, - readFileSync, - renameSync, - statSync, - unlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; +import * as fs from "node:fs"; import { createServer } from "node:net"; -import { delimiter, join } from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import * as path from "node:path"; +import { isEnoent, 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"; @@ -175,7 +165,7 @@ function filterEnv(env: Record): Record { if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV; - const candidates = [join(cwd, ".venv"), join(cwd, "venv")]; + const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")]; for (const candidate of candidates) { if (await Bun.file(candidate).exists()) { return candidate; @@ -189,12 +179,12 @@ async function resolvePythonRuntime(cwd: string, baseEnv: Record { } function getGatewayDir(): string { - return join(getAgentDir(), GATEWAY_DIR_NAME); + return path.join(getAgentDir(), GATEWAY_DIR_NAME); } function getGatewayInfoPath(): string { - return join(getGatewayDir(), GATEWAY_INFO_FILE); + return path.join(getGatewayDir(), GATEWAY_INFO_FILE); } function getGatewayLockPath(): string { - return join(getGatewayDir(), GATEWAY_LOCK_FILE); + return path.join(getGatewayDir(), GATEWAY_LOCK_FILE); } -function writeLockInfo(lockPath: string, fd: number): void { +async function writeLockInfo(lockPath: string): Promise { const payload: GatewayLockInfo = { pid: process.pid, startedAt: Date.now() }; try { - writeFileSync(fd, JSON.stringify(payload)); + await Bun.write(lockPath, JSON.stringify(payload)); } catch { - try { - writeFileSync(lockPath, JSON.stringify(payload)); - } catch { - // Ignore lock write failures - } + // Ignore lock write failures } } -function readLockInfo(lockPath: string): GatewayLockInfo | null { +async function readLockInfo(lockPath: string): Promise { try { - const raw = readFileSync(lockPath, "utf-8"); + const raw = await Bun.file(lockPath).text(); const parsed = JSON.parse(raw) as Partial; if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid)) { return { pid: parsed.pid, startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0 }; @@ -269,36 +255,41 @@ function readLockInfo(lockPath: string): GatewayLockInfo | null { return null; } -function ensureGatewayDir(): void { +async function ensureGatewayDir(): Promise { const dir = getGatewayDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } + await fs.promises.mkdir(dir, { recursive: true }); } async function withGatewayLock(handler: () => Promise): Promise { - ensureGatewayDir(); + await ensureGatewayDir(); const lockPath = getGatewayLockPath(); const start = Date.now(); while (true) { + let fd: fs.promises.FileHandle | undefined; try { - const fd = openSync(lockPath, "wx"); - const heartbeat = setInterval(() => { - try { - const now = new Date(); - utimesSync(lockPath, now, now); - } catch { - // Ignore heartbeat errors + fd = await fs.promises.open(lockPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL); + let heartbeatRunning = true; + const heartbeat = (async () => { + while (heartbeatRunning) { + await Bun.sleep(GATEWAY_LOCK_HEARTBEAT_MS); + if (!heartbeatRunning) break; + try { + const now = new Date(); + await fs.promises.utimes(lockPath, now, now); + } catch { + // Ignore heartbeat errors + } } - }, GATEWAY_LOCK_HEARTBEAT_MS); + })(); try { - writeLockInfo(lockPath, fd); + await writeLockInfo(lockPath); return await handler(); } finally { - clearInterval(heartbeat); + heartbeatRunning = false; + void heartbeat.catch(() => {}); // Don't await - let it die naturally try { - closeSync(fd); - unlinkSync(lockPath); + await fd.close(); + await fs.promises.unlink(lockPath); } catch { // Ignore lock cleanup errors } @@ -308,15 +299,15 @@ async function withGatewayLock(handler: () => Promise): Promise { if (error.code === "EEXIST") { let removedStale = false; try { - const stat = statSync(lockPath); - const lockInfo = readLockInfo(lockPath); + const lockStat = await fs.promises.stat(lockPath); + const lockInfo = await readLockInfo(lockPath); const lockPid = lockInfo?.pid; - const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - stat.mtimeMs; + const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - lockStat.mtimeMs; const staleByTime = lockAgeMs > GATEWAY_LOCK_STALE_MS; const staleByPid = lockPid !== undefined && !isPidRunning(lockPid); const staleByMissingPid = lockPid === undefined && staleByTime; if (staleByPid || staleByMissingPid) { - unlinkSync(lockPath); + await fs.promises.unlink(lockPath); removedStale = true; logger.warn("Removed stale shared gateway lock", { path: lockPath, pid: lockPid }); } @@ -336,14 +327,12 @@ async function withGatewayLock(handler: () => Promise): Promise { } } -function readGatewayInfo(): GatewayInfo | null { +async function readGatewayInfo(): Promise { const infoPath = getGatewayInfoPath(); - if (!existsSync(infoPath)) return null; - try { - const content = readFileSync(infoPath, "utf-8"); + const content = await Bun.file(infoPath).text(); const parsed = JSON.parse(content) as Partial; - if (!parsed || typeof parsed !== "object") return null; + if (typeof parsed.url !== "string" || typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") { return null; } @@ -354,26 +343,25 @@ function readGatewayInfo(): GatewayInfo | null { pythonPath: typeof parsed.pythonPath === "string" ? parsed.pythonPath : undefined, venvPath: typeof parsed.venvPath === "string" || parsed.venvPath === null ? parsed.venvPath : undefined, }; - } catch { + } catch (err) { + if (isEnoent(err)) return null; return null; } } -function writeGatewayInfo(info: GatewayInfo): void { +async function writeGatewayInfo(info: GatewayInfo): Promise { const infoPath = getGatewayInfoPath(); const tempPath = `${infoPath}.tmp`; - writeFileSync(tempPath, JSON.stringify(info, null, 2)); - renameSync(tempPath, infoPath); + await Bun.write(tempPath, JSON.stringify(info, null, 2)); + await fs.promises.rename(tempPath, infoPath); } -function clearGatewayInfo(): void { +async function clearGatewayInfo(): Promise { const infoPath = getGatewayInfoPath(); - if (existsSync(infoPath)) { - try { - unlinkSync(infoPath); - } catch { - // Ignore errors on cleanup - } + try { + await fs.promises.unlink(infoPath); + } catch { + // Ignore errors on cleanup (file may not exist) } } @@ -388,12 +376,9 @@ function isPidRunning(pid: number): boolean { async function isGatewayHealthy(url: string): Promise { try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); const response = await fetch(`${url}/api/kernelspecs`, { - signal: controller.signal, + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), }); - clearTimeout(timeout); return response.ok; } catch { return false; @@ -497,7 +482,7 @@ export async function acquireSharedGateway(cwd: string): Promise { - const existingInfo = readGatewayInfo(); + const existingInfo = await readGatewayInfo(); if (existingInfo) { if (await isGatewayAlive(existingInfo)) { localGatewayUrl = existingInfo.url; @@ -510,7 +495,7 @@ export async function acquireSharedGateway(cwd: string): Promise { if (!isCoordinatorInitialized) return; } -export function getSharedGatewayUrl(): string | null { +export async function getSharedGatewayUrl(): Promise { if (localGatewayUrl) return localGatewayUrl; - return readGatewayInfo()?.url ?? null; + return (await readGatewayInfo())?.url ?? null; } -export function isSharedGatewayActive(): boolean { - return getGatewayStatus().active; +export async function isSharedGatewayActive(): Promise { + return (await getGatewayStatus()).active; } export interface GatewayStatus { @@ -556,8 +541,8 @@ export interface GatewayStatus { venvPath: string | null; } -export function getGatewayStatus(): GatewayStatus { - const info = readGatewayInfo(); +export async function getGatewayStatus(): Promise { + const info = await readGatewayInfo(); if (!info) { return { active: false, @@ -582,12 +567,12 @@ export function getGatewayStatus(): GatewayStatus { export async function shutdownSharedGateway(): Promise { try { await withGatewayLock(async () => { - const info = readGatewayInfo(); + const info = await readGatewayInfo(); if (!info) return; if (isPidRunning(info.pid)) { await killGateway(info.pid, "shutdown"); } - clearGatewayInfo(); + await clearGatewayInfo(); }); } catch (err) { logger.warn("Failed to shutdown shared gateway", { diff --git a/packages/coding-agent/src/ipy/kernel.ts b/packages/coding-agent/src/ipy/kernel.ts index 96a5e83c9..57f43c081 100644 --- a/packages/coding-agent/src/ipy/kernel.ts +++ b/packages/coding-agent/src/ipy/kernel.ts @@ -1,5 +1,5 @@ import { createServer } from "node:net"; -import { delimiter, join } from "node:path"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { $, type Subprocess } from "bun"; import { nanoid } from "nanoid"; @@ -239,7 +239,7 @@ function filterEnv(env: Record): Record { if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV; - const candidates = [join(cwd, ".venv"), join(cwd, "venv")]; + const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")]; for (const candidate of candidates) { if (await Bun.file(candidate).exists()) { return candidate; @@ -253,12 +253,12 @@ async function resolvePythonRuntime(cwd: string, baseEnv: Record controller.abort(), 5000); const response = await fetch(`${config.url}/api/kernelspecs`, { headers, - signal: controller.signal, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]), }); - clearTimeout(timeout); if (response.ok) { return { ok: true }; @@ -627,7 +625,7 @@ export class PythonKernel { OMP_SHELL_SNAPSHOT: snapshotPath ?? undefined, }; - const pythonPathParts = [options.cwd, kernelEnv.PYTHONPATH].filter(Boolean).join(delimiter); + const pythonPathParts = [options.cwd, kernelEnv.PYTHONPATH].filter(Boolean).join(path.delimiter); if (pythonPathParts) { kernelEnv.PYTHONPATH = pythonPathParts; } @@ -1064,14 +1062,11 @@ export class PythonKernel { async interrupt(): Promise { try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2000); await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, { method: "POST", headers: this.#authHeaders(), - signal: controller.signal, + signal: AbortSignal.timeout(2000), }); - clearTimeout(timeout); } catch (err: unknown) { logger.warn("Failed to interrupt kernel via API", { error: err instanceof Error ? err.message : String(err) }); } @@ -1138,13 +1133,10 @@ export class PythonKernel { async ping(timeoutMs: number = HEARTBEAT_TIMEOUT_MS): Promise { if (!this.isAlive()) return false; try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}`, { - signal: controller.signal, + signal: AbortSignal.timeout(timeoutMs), headers: this.#authHeaders(), }); - clearTimeout(timeout); if (response.ok) { this.#heartbeatFailures = 0; return true; diff --git a/packages/coding-agent/src/ipy/modules.ts b/packages/coding-agent/src/ipy/modules.ts index bd9b61a60..9887ae88b 100644 --- a/packages/coding-agent/src/ipy/modules.ts +++ b/packages/coding-agent/src/ipy/modules.ts @@ -1,6 +1,6 @@ -import { readdir } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import * as path from "node:path"; export type PythonModuleSource = "user" | "project"; @@ -38,12 +38,12 @@ interface ModuleCandidate { async function listModuleCandidates(dir: string, source: PythonModuleSource): Promise { try { - const entries = await readdir(dir, { withFileTypes: true }); + const entries = await fs.readdir(dir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".py")) .map((entry) => ({ name: entry.name, - path: resolve(dir, entry.name), + path: path.resolve(dir, entry.name), source, })); } catch { @@ -68,8 +68,8 @@ export async function discoverPythonModules(options: DiscoverPythonModulesOption const cwd = options.cwd ?? process.cwd(); const homeDir = options.homeDir ?? homedir(); - const userDirs = [join(homeDir, ".omp", "agent", "modules"), join(homeDir, ".pi", "agent", "modules")]; - const projectDirs = [resolve(cwd, ".omp", "modules"), resolve(cwd, ".pi", "modules")]; + const userDirs = [path.join(homeDir, ".omp", "agent", "modules"), path.join(homeDir, ".pi", "agent", "modules")]; + const projectDirs = [path.resolve(cwd, ".omp", "modules"), path.resolve(cwd, ".pi", "modules")]; const userCandidates = (await Promise.all(userDirs.map((dir) => listModuleCandidates(dir, "user")))).flat(); const projectCandidates = (await Promise.all(projectDirs.map((dir) => listModuleCandidates(dir, "project")))).flat(); diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index 78d4c958d..7ececeff0 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -1,5 +1,4 @@ -import * as fs from "node:fs"; -import { logger } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import { ToolAbortError, throwIfAborted } from "../tools/tool-errors"; import { applyWorkspaceEdit } from "./edits"; import { getLspmuxCommand, isLspmuxSupported } from "./lspmux"; @@ -512,7 +511,13 @@ export async function ensureFileOpen(client: LspClient, filePath: string): Promi return; } - const content = fs.readFileSync(filePath, "utf-8"); + let content: string; + try { + content = await Bun.file(filePath).text(); + } catch (err) { + if (isEnoent(err)) return; + throw err; + } const languageId = detectLanguageId(filePath); await sendNotification(client, "textDocument/didOpen", { @@ -634,7 +639,13 @@ export async function refreshFile(client: LspClient, filePath: string): Promise< return; } - const content = fs.readFileSync(filePath, "utf-8"); + let content: string; + try { + content = await Bun.file(filePath).text(); + } catch (err) { + if (isEnoent(err)) return; + throw err; + } const version = ++info.version; await sendNotification(client, "textDocument/didChange", { diff --git a/packages/coding-agent/src/lsp/config.ts b/packages/coding-agent/src/lsp/config.ts index 2a9f957f5..c31cef315 100644 --- a/packages/coding-agent/src/lsp/config.ts +++ b/packages/coding-agent/src/lsp/config.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { basename, extname, join } from "node:path"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { globSync } from "glob"; @@ -30,7 +30,7 @@ function isRecord(value: unknown): value is Record { } function parseConfigContent(content: string, filePath: string): unknown { - const extension = extname(filePath).toLowerCase(); + const extension = path.extname(filePath).toLowerCase(); if (extension === ".yaml" || extension === ".yml") { return YAML.parse(content) as unknown; } @@ -160,7 +160,7 @@ export async function hasRootMarkers(cwd: string, markers: string[]): Promise 0) { return true; } @@ -169,7 +169,7 @@ export async function hasRootMarkers(cwd: string, markers: string[]): Promise { * Returns servers sorted with primary (non-linter) servers first. */ export function getServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> { - const ext = extname(filePath).toLowerCase(); - const fileName = basename(filePath).toLowerCase(); + const ext = path.extname(filePath).toLowerCase(); + const fileName = path.basename(filePath).toLowerCase(); const matches: Array<[string, ServerConfig]> = []; for (const [name, serverConfig] of Object.entries(config.servers)) { diff --git a/packages/coding-agent/src/lsp/edits.ts b/packages/coding-agent/src/lsp/edits.ts index ca0630318..b07c15649 100644 --- a/packages/coding-agent/src/lsp/edits.ts +++ b/packages/coding-agent/src/lsp/edits.ts @@ -1,4 +1,4 @@ -import { mkdir, rename, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import path from "node:path"; import type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types"; import { uriToFile } from "./utils"; @@ -86,20 +86,20 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom if (change.kind === "create") { const createOp = change as CreateFile; const filePath = uriToFile(createOp.uri); - await mkdir(path.dirname(filePath), { recursive: true }); + await fs.mkdir(path.dirname(filePath), { recursive: true }); await Bun.write(filePath, ""); applied.push(`Created ${path.relative(cwd, filePath)}`); } else if (change.kind === "rename") { const renameOp = change as RenameFile; const oldPath = uriToFile(renameOp.oldUri); const newPath = uriToFile(renameOp.newUri); - await mkdir(path.dirname(newPath), { recursive: true }); - await rename(oldPath, newPath); + await fs.mkdir(path.dirname(newPath), { recursive: true }); + await fs.rename(oldPath, newPath); applied.push(`Renamed ${path.relative(cwd, oldPath)} → ${path.relative(cwd, newPath)}`); } else if (change.kind === "delete") { const deleteOp = change as DeleteFile; const filePath = uriToFile(deleteOp.uri); - await rm(filePath, { recursive: true }); + await fs.rm(filePath, { recursive: true }); applied.push(`Deleted ${path.relative(cwd, filePath)}`); } } diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 379249e19..609932d6f 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -1,8 +1,7 @@ -import type { Dirent } from "node:fs"; -import { existsSync, statSync } from "node:fs"; +import * as fs 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 { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils"; import type { BunFile } from "bun"; import { renderPromptTemplate } from "../config/prompt-templates"; import { type Theme, theme } from "../modes/theme/theme"; @@ -257,18 +256,18 @@ function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: n const normalized = extensions.map((ext) => ext.toLowerCase()); const search = (dir: string, depth: number): string | null => { if (depth > maxDepth) return null; - const entries: Dirent[] = []; + const entries: fs.Dirent[] = []; try { const names = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false })); for (const name of names) { const fullPath = path.join(dir, name); let isDir = false; try { - isDir = statSync(fullPath).isDirectory(); + isDir = fs.statSync(fullPath).isDirectory(); } catch { continue; } - entries.push({ name, isFile: () => !isDir, isDirectory: () => isDir } as Dirent); + entries.push({ name, isFile: () => !isDir, isDirectory: () => isDir } as fs.Dirent); } } catch { return null; @@ -359,25 +358,38 @@ interface ProjectType { description: string; } +async function fileExists(filePath: string): Promise { + try { + await fs.promises.stat(filePath); + return true; + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } +} + /** Detect project type from root markers */ -function detectProjectType(cwd: string): ProjectType { +async function detectProjectType(cwd: string): Promise { // Check for Rust (Cargo.toml) - if (existsSync(path.join(cwd, "Cargo.toml"))) { + if (await fileExists(path.join(cwd, "Cargo.toml"))) { return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" }; } // Check for TypeScript (tsconfig.json) - if (existsSync(path.join(cwd, "tsconfig.json"))) { + if (await fileExists(path.join(cwd, "tsconfig.json"))) { return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" }; } // Check for Go (go.mod) - if (existsSync(path.join(cwd, "go.mod"))) { + if (await fileExists(path.join(cwd, "go.mod"))) { return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" }; } // Check for Python (pyproject.toml or pyrightconfig.json) - if (existsSync(path.join(cwd, "pyproject.toml")) || existsSync(path.join(cwd, "pyrightconfig.json"))) { + if ( + (await fileExists(path.join(cwd, "pyproject.toml"))) || + (await fileExists(path.join(cwd, "pyrightconfig.json"))) + ) { return { type: "python", command: ["pyright"], description: "Python (pyright)" }; } @@ -389,7 +401,7 @@ async function runWorkspaceDiagnostics( cwd: string, config: LspConfig, ): Promise<{ output: string; projectType: ProjectType }> { - const projectType = detectProjectType(cwd); + const projectType = await detectProjectType(cwd); // For Rust, use flycheck via rust-analyzer if available if (projectType.type === "rust") { diff --git a/packages/coding-agent/src/lsp/lspmux.ts b/packages/coding-agent/src/lsp/lspmux.ts index 5675facd3..ca52fe5e7 100644 --- a/packages/coding-agent/src/lsp/lspmux.ts +++ b/packages/coding-agent/src/lsp/lspmux.ts @@ -1,5 +1,5 @@ import { homedir, platform } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { TOML } from "bun"; @@ -66,11 +66,11 @@ function getConfigPath(): string { const home = homedir(); switch (platform()) { case "win32": - return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "lspmux", "config.toml"); + return path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "lspmux", "config.toml"); case "darwin": - return join(home, "Library", "Application Support", "lspmux", "config.toml"); + return path.join(home, "Library", "Application Support", "lspmux", "config.toml"); default: - return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "lspmux", "config.toml"); + return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), "lspmux", "config.toml"); } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9d0572167..f6cc9570e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,8 +5,9 @@ * createAgentSession() options. The SDK does the heavy lifting. */ +import * as fs from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import * as path from "node:path"; import { createInterface } from "node:readline/promises"; import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai"; import { postmortem } from "@oh-my-pi/pi-utils"; @@ -14,6 +15,7 @@ import chalk from "chalk"; import { type Args, parseArgs, printHelp } from "./cli/args"; import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli"; import { processFileArguments } from "./cli/file-processor"; +import { parseJupyterArgs, printJupyterHelp, runJupyterCommand } from "./cli/jupyter-cli"; import { listModels } from "./cli/list-models"; import { parsePluginArgs, printPluginHelp, runPluginCommand } from "./cli/plugin-cli"; import { selectSession } from "./cli/session-picker"; @@ -176,12 +178,16 @@ async function prepareInitialMessage( /** * Resolve a session argument to a local or global session match. */ -function resolveSessionMatch(sessionArg: string, cwd: string, sessionDir?: string): SessionInfo | undefined { - const sessions = SessionManager.list(cwd, sessionDir); +async function resolveSessionMatch( + sessionArg: string, + cwd: string, + sessionDir?: string, +): Promise { + const sessions = await SessionManager.list(cwd, sessionDir); let matches = sessions.filter((session) => session.id.startsWith(sessionArg)); if (matches.length === 0 && !sessionDir) { - const globalSessions = SessionManager.listAll(); + const globalSessions = await SessionManager.listAll(); matches = globalSessions.filter((session) => session.id.startsWith(sessionArg)); } @@ -202,14 +208,14 @@ async function promptForkSession(session: SessionInfo): Promise { } } -function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined { +async function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): Promise { if (parsed.continue || parsed.resume) { return undefined; } const lastVersion = settingsManager.getLastChangelogVersion(); const changelogPath = getChangelogPath(); - const entries = parseChangelog(changelogPath); + const entries = await parseChangelog(changelogPath); if (!lastVersion) { if (entries.length > 0) { @@ -236,12 +242,12 @@ async function createSessionManager(parsed: Args, cwd: string): Promise { } const normalizePath = (value: string) => { - const resolved = resolve(value); + const resolved = path.resolve(value); return process.platform === "win32" ? resolved.toLowerCase() : resolved; }; @@ -284,16 +290,16 @@ async function maybeAutoChdir(parsed: Args): Promise { return; } - const isDirectory = async (path: string) => { + const isDirectory = async (p: string) => { try { - const stat = await Bun.file(path).stat(); - return stat.isDirectory(); + const s = await fs.stat(p); + return s.isDirectory(); } catch { return false; } }; - const candidates = [join(home, "tmp"), "/tmp", "/var/tmp"]; + const candidates = [path.join(home, "tmp"), "/tmp", "/var/tmp"]; for (const candidate of candidates) { try { if (!(await isDirectory(candidate))) { @@ -477,7 +483,7 @@ export async function main(args: string[]) { // Initialize theme early with defaults (CLI commands need symbols) // Will be re-initialized with user preferences later - initTheme(); + await initTheme(); // Handle plugin subcommand before regular parsing const pluginCmd = parsePluginArgs(args); @@ -523,6 +529,17 @@ export async function main(args: string[]) { return; } + // Handle jupyter subcommand + const jupyterCmd = parseJupyterArgs(args); + if (jupyterCmd) { + if (args.includes("--help") || args.includes("-h")) { + printJupyterHelp(); + return; + } + await runJupyterCommand(jupyterCmd); + return; + } + // Handle stats subcommand const statsCmd = parseStatsArgs(args); if (statsCmd) { @@ -619,7 +636,12 @@ export async function main(args: string[]) { settingsManager.applyOverrides({ modelRoles: roleOverrides }); } - initTheme(settingsManager.getTheme(), isInteractive, settingsManager.getSymbolPreset()); + await initTheme( + settingsManager.getTheme(), + isInteractive, + settingsManager.getSymbolPreset(), + settingsManager.getColorBlindMode(), + ); time("initTheme"); // Show deprecation warnings in interactive mode @@ -640,7 +662,7 @@ export async function main(args: string[]) { // Handle --resume: show session picker if (parsed.resume) { - const sessions = SessionManager.list(cwd, parsed.sessionDir); + const sessions = await SessionManager.list(cwd, parsed.sessionDir); time("SessionManager.list"); if (sessions.length === 0) { writeStdout(chalk.dim("No sessions found")); @@ -722,7 +744,7 @@ export async function main(args: string[]) { await runRpcMode(session); } else if (isInteractive) { const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined); - const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); + const changelogMarkdown = await getChangelogForDisplay(parsed, settingsManager); const scopedModelsForDisplay = sessionOptions.scopedModels ?? scopedModels; if (scopedModelsForDisplay.length > 0) { diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index e63bb6137..442847a2d 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -2,10 +2,12 @@ * One-time migrations that run on startup. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; + import { getAgentDbPath, getAgentDir, getBinDir } from "./config"; import { AgentStorage } from "./session/agent-storage"; import type { AuthCredential } from "./session/auth-storage"; @@ -17,16 +19,16 @@ import type { AuthCredential } from "./session/auth-storage"; */ export async function migrateAuthToAgentDb(): Promise { const agentDir = getAgentDir(); - const oauthPath = join(agentDir, "oauth.json"); - const settingsPath = join(agentDir, "settings.json"); + const oauthPath = path.join(agentDir, "oauth.json"); + const settingsPath = path.join(agentDir, "settings.json"); const storage = await AgentStorage.open(getAgentDbPath(agentDir)); const migrated: Record = {}; const providers: string[] = []; - if (existsSync(oauthPath)) { + try { + const oauth = await Bun.file(oauthPath).json(); try { - const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); for (const [provider, cred] of Object.entries(oauth)) { if (storage.listAuthCredentials(provider).length > 0) { continue; @@ -34,16 +36,19 @@ export async function migrateAuthToAgentDb(): Promise { migrated[provider] = [{ type: "oauth", ...(cred as object) } as AuthCredential]; providers.push(provider); } - renameSync(oauthPath, `${oauthPath}.migrated`); + await fs.promises.rename(oauthPath, `${oauthPath}.migrated`); } catch (error) { logger.warn("Failed to migrate oauth.json", { path: oauthPath, error: String(error) }); } + } catch (err) { + if (!isEnoent(err)) { + logger.warn("Failed to read oauth.json", { path: oauthPath, error: String(err) }); + } } - if (existsSync(settingsPath)) { + try { + const settings = await Bun.file(settingsPath).json(); try { - const content = readFileSync(settingsPath, "utf-8"); - const settings = JSON.parse(content); if (settings.apiKeys && typeof settings.apiKeys === "object") { for (const [provider, key] of Object.entries(settings.apiKeys)) { if (typeof key !== "string") continue; @@ -53,11 +58,15 @@ export async function migrateAuthToAgentDb(): Promise { providers.push(provider); } delete settings.apiKeys; - writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); } } catch (error) { logger.warn("Failed to migrate settings.json apiKeys", { path: settingsPath, error: String(error) }); } + } catch (err) { + if (!isEnoent(err)) { + logger.warn("Failed to read settings.json", { path: settingsPath, error: String(err) }); + } } for (const [provider, credentials] of Object.entries(migrated)) { @@ -76,15 +85,14 @@ export async function migrateAuthToAgentDb(): Promise { * * See: https://github.com/badlogic/pi-mono/issues/320 */ -export function migrateSessionsFromAgentRoot(): void { +export async function migrateSessionsFromAgentRoot(): Promise { const agentDir = getAgentDir(); // Find all .jsonl files directly in agentDir (not in subdirectories) let files: string[]; try { - files = readdirSync(agentDir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(agentDir, f)); + const entries = await fs.promises.readdir(agentDir); + files = entries.filter((f) => f.endsWith(".jsonl")).map((f) => path.join(agentDir, f)); } catch (error) { logger.warn("Failed to read agent directory for session migration", { path: agentDir, error: String(error) }); return; @@ -95,7 +103,13 @@ export function migrateSessionsFromAgentRoot(): void { for (const file of files) { try { // Read first line to get session header - const content = readFileSync(file, "utf8"); + let content: string; + try { + content = await Bun.file(file).text(); + } catch (err) { + if (isEnoent(err)) continue; + throw err; + } const firstLine = content.split("\n")[0]; if (!firstLine?.trim()) continue; @@ -106,18 +120,18 @@ export function migrateSessionsFromAgentRoot(): void { // Compute the correct session directory (same encoding as session-manager.ts) const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const correctDir = join(agentDir, "sessions", safePath); + const correctDir = path.join(agentDir, "sessions", safePath); // Create directory if needed - mkdirSync(correctDir, { recursive: true }); + await fs.promises.mkdir(correctDir, { recursive: true }); // Move the file const fileName = file.split("/").pop() || file.split("\\").pop(); - const newPath = join(correctDir, fileName!); + const newPath = path.join(correctDir, fileName!); - if (existsSync(newPath)) continue; // Skip if target exists + if (fs.existsSync(newPath)) continue; // Skip if target exists - renameSync(file, newPath); + await fs.promises.rename(file, newPath); } catch (error) { logger.warn("Failed to migrate session file", { path: file, error: String(error) }); } @@ -127,38 +141,38 @@ export function migrateSessionsFromAgentRoot(): void { /** * Move fd/rg binaries from tools/ to bin/ if they exist. */ -function migrateToolsToBin(): void { +async function migrateToolsToBin(): Promise { const agentDir = getAgentDir(); - const toolsDir = join(agentDir, "tools"); + const toolsDir = path.join(agentDir, "tools"); const binDir = getBinDir(); - if (!existsSync(toolsDir)) return; + if (!fs.existsSync(toolsDir)) return; const binaries = ["fd", "rg", "fd.exe", "rg.exe"]; let movedAny = false; for (const bin of binaries) { - const oldPath = join(toolsDir, bin); - const newPath = join(binDir, bin); + const oldPath = path.join(toolsDir, bin); + const newPath = path.join(binDir, bin); + if (!fs.existsSync(oldPath)) continue; - if (existsSync(oldPath)) { - if (!existsSync(binDir)) { - mkdirSync(binDir, { recursive: true }); + if (!fs.existsSync(binDir)) { + await fs.promises.mkdir(binDir, { recursive: true }); + } + + if (!fs.existsSync(newPath)) { + try { + await fs.promises.rename(oldPath, newPath); + movedAny = true; + } catch (error) { + logger.warn("Failed to migrate binary", { from: oldPath, to: newPath, error: String(error) }); } - if (!existsSync(newPath)) { - try { - renameSync(oldPath, newPath); - movedAny = true; - } catch (error) { - logger.warn("Failed to migrate binary", { from: oldPath, to: newPath, error: String(error) }); - } - } else { - // Target exists, just delete the old one - try { - rmSync(oldPath, { force: true }); - } catch { - // Ignore - } + } else { + // Target exists, just delete the old one + try { + await fs.promises.rm(oldPath, { force: true }); + } catch { + // Ignore } } } @@ -180,8 +194,8 @@ export async function runMigrations(_cwd: string): Promise<{ }> { // Then: run data migrations const migratedAuthProviders = await migrateAuthToAgentDb(); - migrateSessionsFromAgentRoot(); - migrateToolsToBin(); + await migrateSessionsFromAgentRoot(); + await migrateToolsToBin(); return { migratedAuthProviders, deprecationWarnings: [] }; } diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 81801cb27..196bf5fd4 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -1,8 +1,10 @@ -import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs"; +import * as fs from "node:fs"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { isEnoent } from "@oh-my-pi/pi-utils"; import { dirname, join } from "path"; import { theme } from "../../modes/theme/theme"; + import type { AgentSession } from "../../session/agent-session"; import { shortenPath } from "../../tools/render-utils"; @@ -18,20 +20,19 @@ function sanitizeStatusText(text: string): string { .trim(); } -/** - * Find the git root directory by walking up from cwd. - * Returns the path to .git/HEAD if found, null otherwise. - */ -function findGitHeadPath(): string | null { +/** Find the git root by walking up from cwd. Returns path and content of .git/HEAD if found. */ +async function findGitHeadPath(): Promise<{ path: string; content: string } | null> { let dir = process.cwd(); while (true) { const gitHeadPath = join(dir, ".git", "HEAD"); - if (existsSync(gitHeadPath)) { - return gitHeadPath; + try { + const content = await Bun.file(gitHeadPath).text(); + return { path: gitHeadPath, content }; + } catch (err) { + if (!isEnoent(err)) throw err; } const parent = dirname(dir); if (parent === dir) { - // Reached filesystem root return null; } dir = parent; @@ -44,7 +45,7 @@ function findGitHeadPath(): string | null { export class FooterComponent implements Component { private session: AgentSession; private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name - private gitWatcher: FSWatcher | null = null; + private gitWatcher: fs.FSWatcher | null = null; private onBranchChange: (() => void) | null = null; private autoCompactEnabled: boolean = true; private extensionStatuses: Map = new Map(); @@ -88,21 +89,22 @@ export class FooterComponent implements Component { this.gitWatcher = null; } - const gitHeadPath = findGitHeadPath(); - if (!gitHeadPath) { - return; - } + findGitHeadPath().then((result) => { + if (!result) { + return; + } - try { - this.gitWatcher = watch(gitHeadPath, () => { - this.cachedBranch = undefined; // Invalidate cache - if (this.onBranchChange) { - this.onBranchChange(); - } - }); - } catch { - // Silently fail if we can't watch - } + try { + this.gitWatcher = fs.watch(result.path, () => { + this.cachedBranch = undefined; // Invalidate cache + if (this.onBranchChange) { + this.onBranchChange(); + } + }); + } catch { + // Silently fail if we can't watch + } + }); } /** @@ -130,27 +132,24 @@ export class FooterComponent implements Component { return this.cachedBranch; } - try { - const gitHeadPath = findGitHeadPath(); - if (!gitHeadPath) { + // Note: fire-and-forget async call - will return undefined on first call + // This is acceptable since it's a cached value that will update on next render + findGitHeadPath().then((result) => { + if (!result) { this.cachedBranch = null; - return null; + return; } - const content = readFileSync(gitHeadPath, "utf8").trim(); + const content = result.content.trim(); if (content.startsWith("ref: refs/heads/")) { - // Normal branch: extract branch name this.cachedBranch = content.slice(16); } else { - // Detached HEAD state this.cachedBranch = "detached"; } - } catch { - // Not in a git repo or error reading file - this.cachedBranch = null; - } + }); - return this.cachedBranch; + // Return undefined while loading (will show on next render once loaded) + return null; } render(width: number): string[] { diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index 0f0c9d100..692a7f41b 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -3,7 +3,7 @@ * Supports Ctrl+G for external editor. */ -import { rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; @@ -110,7 +110,7 @@ export class HookEditorComponent extends Container { } } finally { try { - await rm(tmpFile, { force: true }); + await fs.rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } diff --git a/packages/coding-agent/src/modes/components/plugin-settings.ts b/packages/coding-agent/src/modes/components/plugin-settings.ts index f0c8597fe..137fa2370 100644 --- a/packages/coding-agent/src/modes/components/plugin-settings.ts +++ b/packages/coding-agent/src/modes/components/plugin-settings.ts @@ -132,10 +132,10 @@ export class PluginDetailComponent extends Container { this.manager = manager; this.callbacks = callbacks; - this.rebuild(); + void this.rebuild(); } - private rebuild(): void { + private async rebuild(): Promise { this.clear(); const plugin = this.plugin; @@ -184,7 +184,7 @@ export class PluginDetailComponent extends Container { // Config settings if (manifest.settings && Object.keys(manifest.settings).length > 0) { - const settings = this.manager.getPluginSettings(plugin.name); + const settings = await this.manager.getPluginSettings(plugin.name); for (const [key, schema] of Object.entries(manifest.settings)) { const currentValue = settings[key] ?? schema.default; @@ -456,7 +456,7 @@ export class PluginSettingsComponent extends Container { this.callbacks.onPluginChanged(); }, onFeatureChange: async (feature, enabled) => { - const current = new Set(this.manager.getEnabledFeatures(plugin.name) ?? []); + const current = new Set((await this.manager.getEnabledFeatures(plugin.name)) ?? []); if (enabled) { current.add(feature); } else { @@ -465,8 +465,8 @@ export class PluginSettingsComponent extends Container { await this.manager.setEnabledFeatures(plugin.name, [...current]); this.callbacks.onPluginChanged(); }, - onConfigChange: (key, value) => { - this.manager.setPluginSetting(plugin.name, key, value); + onConfigChange: async (key, value) => { + await this.manager.setPluginSetting(plugin.name, key, value); this.callbacks.onPluginChanged(); }, onBack: () => this.showPluginList(), diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 9b5bece22..3b66e073e 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -422,6 +422,15 @@ export const SETTINGS_DEFS: SettingDef[] = [ { value: "ascii", label: "ASCII", description: "ASCII-only characters (maximum compatibility)" }, ], }, + { + id: "colorBlindMode", + tab: "display", + type: "boolean", + label: "Color blind mode", + description: "Use blue instead of green for diff additions (red-green color blindness)", + get: (sm) => sm.getColorBlindMode(), + set: (sm, v) => sm.setColorBlindMode(v), + }, { id: "thinkingLevel", tab: "display", diff --git a/packages/coding-agent/src/modes/components/theme-selector.ts b/packages/coding-agent/src/modes/components/theme-selector.ts index ab0231532..00547de00 100644 --- a/packages/coding-agent/src/modes/components/theme-selector.ts +++ b/packages/coding-agent/src/modes/components/theme-selector.ts @@ -1,9 +1,10 @@ import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui"; -import { getAvailableThemes, getSelectListTheme } from "../../modes/theme/theme"; +import { getSelectListTheme } from "../../modes/theme/theme"; import { DynamicBorder } from "./dynamic-border"; /** - * Component that renders a theme selector + * Component that renders a theme selector. + * Themes must be pre-loaded and passed to the constructor. */ export class ThemeSelectorComponent extends Container { private selectList: SelectList; @@ -11,6 +12,7 @@ export class ThemeSelectorComponent extends Container { constructor( currentTheme: string, + themes: string[], onSelect: (themeName: string) => void, onCancel: () => void, onPreview: (themeName: string) => void, @@ -18,8 +20,7 @@ export class ThemeSelectorComponent extends Container { super(); this.onPreview = onPreview; - // Get available themes and create select items - const themes = getAvailableThemes(); + // Create select items from provided themes const themeItems: SelectItem[] = themes.map((name) => ({ value: name, label: name, diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index e0df14e74..ad5e8ea43 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1,4 +1,4 @@ -import { mkdir, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai"; @@ -77,7 +77,7 @@ export class CommandController { const tmpFile = path.join(os.tmpdir(), `${nanoid()}.html`); const cleanupTempFile = async () => { try { - await rm(tmpFile, { force: true }); + await fs.rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } @@ -212,7 +212,7 @@ export class CommandController { } } - handleSessionCommand(): void { + async handleSessionCommand(): Promise { const stats = this.ctx.session.getSessionStats(); let info = `${theme.bold("Session Info")}\n\n`; @@ -240,7 +240,7 @@ export class CommandController { info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`; } - const gateway = getGatewayStatus(); + const gateway = await getGatewayStatus(); info += `\n${theme.bold("Python Gateway")}\n`; if (gateway.active) { info += `${theme.fg("dim", "Status:")} ${theme.fg("success", "Active (Global)")}\n`; @@ -316,9 +316,9 @@ export class CommandController { this.ctx.ui.requestRender(); } - handleChangelogCommand(): void { + async handleChangelogCommand(): Promise { const changelogPath = getChangelogPath(); - const allEntries = parseChangelog(changelogPath); + const allEntries = await parseChangelog(changelogPath); const changelogMarkdown = allEntries.length > 0 @@ -439,7 +439,7 @@ export class CommandController { ].join("\n"); try { - await mkdir(path.dirname(debugLogPath), { recursive: true }); + await fs.mkdir(path.dirname(debugLogPath), { recursive: true }); await Bun.write(debugLogPath, debugData); } catch (error) { this.ctx.showError(`Failed to write debug log: ${error instanceof Error ? error.message : String(error)}`); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 6e3fa394a..a3e2d00d2 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -39,14 +39,14 @@ export class ExtensionUiController { get theme() { return theme; }, - getAllThemes: () => getAvailableThemesWithPaths().map((t) => ({ name: t.name, path: t.path })), + getAllThemes: async () => (await getAvailableThemesWithPaths()).map((t) => ({ name: t.name, path: t.path })), getTheme: (name) => getThemeByName(name), - setTheme: (themeArg) => { + setTheme: async (themeArg) => { if (typeof themeArg === "string") { - return setTheme(themeArg, true); + return await setTheme(themeArg, true); } // Theme object passed directly - not supported in current implementation - return { success: false, error: "Direct theme object not supported" }; + return Promise.resolve({ success: false, error: "Direct theme object not supported" }); }, setFooter: () => {}, setHeader: () => {}, @@ -398,9 +398,9 @@ export class ExtensionUiController { get theme() { return theme; }, - getAllThemes: () => [], - getTheme: () => undefined, - setTheme: () => ({ success: false, error: "Background mode" }), + getAllThemes: () => Promise.resolve([]), + getTheme: () => Promise.resolve(undefined), + setTheme: () => Promise.resolve({ success: false, error: "Background mode" }), setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 379f4902e..c8da7ac7e 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1,6 +1,5 @@ import { spawn } from "node:child_process"; -import type { FileHandle } from "node:fs/promises"; -import { open, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; @@ -207,7 +206,7 @@ export class InputController { return; } if (text === "/session") { - this.ctx.handleSessionCommand(); + await this.ctx.handleSessionCommand(); this.ctx.editor.setText(""); return; } @@ -217,7 +216,7 @@ export class InputController { return; } if (text === "/changelog") { - this.ctx.handleChangelogCommand(); + await this.ctx.handleChangelogCommand(); this.ctx.editor.setText(""); return; } @@ -638,13 +637,13 @@ export class InputController { return "/dev/tty"; } - private async openEditorTerminalHandle(): Promise { + private async openEditorTerminalHandle(): Promise { const terminalPath = this.getEditorTerminalPath(); if (!terminalPath) { return null; } try { - return await open(terminalPath, "r+"); + return await fs.open(terminalPath, "r+"); } catch { return null; } @@ -661,7 +660,7 @@ export class InputController { const currentText = this.ctx.editor.getText(); const tmpFile = path.join(os.tmpdir(), `omp-editor-${nanoid()}.omp.md`); - let ttyHandle: FileHandle | null = null; + let ttyHandle: fs.FileHandle | null = null; try { // Write current content to temp file await Bun.write(tmpFile, currentText); @@ -696,7 +695,7 @@ export class InputController { } finally { // Clean up temp file try { - await rm(tmpFile, { force: true }); + await fs.rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ed3851311..ed6be23c6 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -14,7 +14,14 @@ import { SettingsSelectorComponent } from "../../modes/components/settings-selec import { ToolExecutionComponent } from "../../modes/components/tool-execution"; import { TreeSelectorComponent } from "../../modes/components/tree-selector"; import { UserMessageSelectorComponent } from "../../modes/components/user-message-selector"; -import { getAvailableThemes, getSymbolTheme, setSymbolPreset, setTheme, theme } from "../../modes/theme/theme"; +import { + getAvailableThemes, + getSymbolTheme, + setColorBlindMode, + setSymbolPreset, + setTheme, + theme, +} from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import { SessionManager } from "../../session/session-manager"; import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../tools"; @@ -40,49 +47,52 @@ export class SelectorController { } showSettingsSelector(): void { - this.showSelector((done) => { - const selector = new SettingsSelectorComponent( - this.ctx.settingsManager, - { - availableThinkingLevels: this.ctx.session.getAvailableThinkingLevels(), - thinkingLevel: this.ctx.session.thinkingLevel, - availableThemes: getAvailableThemes(), - cwd: process.cwd(), - }, - { - onChange: (id, value) => this.handleSettingChange(id, value), - onThemePreview: (themeName) => { - const result = setTheme(themeName, true); - if (result.success) { - this.ctx.ui.invalidate(); + getAvailableThemes().then((availableThemes) => { + this.showSelector((done) => { + const selector = new SettingsSelectorComponent( + this.ctx.settingsManager, + { + availableThinkingLevels: this.ctx.session.getAvailableThinkingLevels(), + thinkingLevel: this.ctx.session.thinkingLevel, + availableThemes, + cwd: process.cwd(), + }, + { + onChange: (id, value) => this.handleSettingChange(id, value), + onThemePreview: (themeName) => { + setTheme(themeName, true).then((result) => { + if (result.success) { + this.ctx.ui.invalidate(); + this.ctx.ui.requestRender(); + } + }); + }, + onStatusLinePreview: (settings) => { + // Update status line with preview settings + const currentSettings = this.ctx.settingsManager.getStatusLineSettings(); + this.ctx.statusLine.updateSettings({ ...currentSettings, ...settings }); + this.ctx.updateEditorTopBorder(); this.ctx.ui.requestRender(); - } + }, + getStatusLinePreview: () => { + // Return the rendered status line for inline preview + const width = this.ctx.ui.getWidth(); + return this.ctx.statusLine.getTopBorder(width).content; + }, + onPluginsChanged: () => { + this.ctx.ui.requestRender(); + }, + onCancel: () => { + done(); + // Restore status line to saved settings + this.ctx.statusLine.updateSettings(this.ctx.settingsManager.getStatusLineSettings()); + this.ctx.updateEditorTopBorder(); + this.ctx.ui.requestRender(); + }, }, - onStatusLinePreview: (settings) => { - // Update status line with preview settings - const currentSettings = this.ctx.settingsManager.getStatusLineSettings(); - this.ctx.statusLine.updateSettings({ ...currentSettings, ...settings }); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.requestRender(); - }, - getStatusLinePreview: () => { - // Return the rendered status line for inline preview - const width = this.ctx.ui.getWidth(); - return this.ctx.statusLine.getTopBorder(width).content; - }, - onPluginsChanged: () => { - this.ctx.ui.requestRender(); - }, - onCancel: () => { - done(); - // Restore status line to saved settings - this.ctx.statusLine.updateSettings(this.ctx.settingsManager.getStatusLineSettings()); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.requestRender(); - }, - }, - ); - return { component: selector, focus: selector }; + ); + return { component: selector, focus: selector }; + }); }); } @@ -183,20 +193,28 @@ export class SelectorController { this.ctx.rebuildChatFromMessages(); break; case "theme": { - const result = setTheme(value as string, true); - this.ctx.statusLine.invalidate(); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.invalidate(); - if (!result.success) { - this.ctx.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`); - } + setTheme(value as string, true).then((result) => { + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorTopBorder(); + this.ctx.ui.invalidate(); + if (!result.success) { + this.ctx.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`); + } + }); break; } case "symbolPreset": { - setSymbolPreset(value as "unicode" | "nerd" | "ascii"); - this.ctx.statusLine.invalidate(); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.invalidate(); + setSymbolPreset(value as "unicode" | "nerd" | "ascii").then(() => { + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorTopBorder(); + this.ctx.ui.invalidate(); + }); + break; + } + case "colorBlindMode": { + setColorBlindMode(value === "true" || value === true).then(() => { + this.ctx.ui.invalidate(); + }); break; } case "statusLinePreset": @@ -447,12 +465,12 @@ export class SelectorController { }); } - showSessionSelector(): void { + async showSessionSelector(): Promise { + const sessions = await SessionManager.list( + this.ctx.sessionManager.getCwd(), + this.ctx.sessionManager.getSessionDir(), + ); this.showSelector((done) => { - const sessions = SessionManager.list( - this.ctx.sessionManager.getCwd(), - this.ctx.sessionManager.getSessionDir(), - ); const selector = new SessionSelectorComponent( sessions, async (sessionPath) => { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e20dbbefc..c487a56f4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -16,7 +16,7 @@ import { Text, TUI, } from "@oh-my-pi/pi-tui"; -import { logger, postmortem } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { KeybindingsManager } from "../config/keybindings"; import type { SettingsManager } from "../config/settings-manager"; @@ -268,7 +268,7 @@ export class InteractiveMode implements InteractiveModeContext { const providerName = this.session.model?.provider ?? "Unknown"; // Get recent sessions - const recentSessions = getRecentSessions(this.sessionManager.getSessionDir()).map((s) => ({ + const recentSessions = (await getRecentSessions(this.sessionManager.getSessionDir())).map((s) => ({ name: s.name, timeAgo: s.timeAgo, })); @@ -452,17 +452,16 @@ export class InteractiveMode implements InteractiveModeContext { } const artifactsDir = sessionFile.slice(0, -6); const todoPath = path.join(artifactsDir, TODO_FILE_NAME); - const file = Bun.file(todoPath); - if (!(await file.exists())) { - this.renderTodoList(); - return; - } try { - const data = (await file.json()) as { todos?: TodoItem[] }; + const data = (await Bun.file(todoPath).json()) as { todos?: TodoItem[] }; if (data?.todos && Array.isArray(data.todos)) { this.todoItems = data.todos; } } catch (error) { + if (isEnoent(error)) { + this.renderTodoList(); + return; + } logger.warn("Failed to load todos", { path: todoPath, error: String(error) }); } this.renderTodoList(); @@ -612,16 +611,16 @@ export class InteractiveMode implements InteractiveModeContext { return this.commandController.handleCopyCommand(); } - handleSessionCommand(): void { - this.commandController.handleSessionCommand(); + handleSessionCommand(): Promise { + return this.commandController.handleSessionCommand(); } handleUsageCommand(reports?: UsageReport[] | null): Promise { return this.commandController.handleUsageCommand(reports); } - handleChangelogCommand(): void { - this.commandController.handleChangelogCommand(); + async handleChangelogCommand(): Promise { + await this.commandController.handleChangelogCommand(); } handleHotkeysCommand(): void { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index de988d20e..6293aba58 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -271,17 +271,17 @@ export async function runRpcMode(session: AgentSession): Promise { return theme; } - getAllThemes(): { name: string; path: string | undefined }[] { - return []; + getAllThemes(): Promise<{ name: string; path: string | undefined }[]> { + return Promise.resolve([]); } - getTheme(_name: string): Theme | undefined { - return undefined; + getTheme(_name: string): Promise { + return Promise.resolve(undefined); } - setTheme(_theme: string | Theme): { success: boolean; error?: string } { + setTheme(_theme: string | Theme): Promise<{ success: boolean; error?: string }> { // Theme switching not supported in RPC mode - return { success: false, error: "Theme switching not supported in RPC mode" }; + return Promise.resolve({ success: false, error: "Theme switching not supported in RPC mode" }); } setEditorComponent(): void { diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index a0ef1e0b0..69ba359ef 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; -import { logger } from "@oh-my-pi/pi-utils"; +import { adjustHsv, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; @@ -1600,16 +1600,18 @@ function getBuiltinThemes(): Record { return BUILTIN_THEMES; } -export function getAvailableThemes(): string[] { +export async function getAvailableThemes(): Promise { const themes = new Set(Object.keys(getBuiltinThemes())); const customThemesDir = getCustomThemesDir(); - if (fs.existsSync(customThemesDir)) { - const files = fs.readdirSync(customThemesDir); + try { + const files = await fs.promises.readdir(customThemesDir); for (const file of files) { if (file.endsWith(".json")) { themes.add(file.slice(0, -5)); } } + } catch { + // Directory doesn't exist or isn't readable } return Array.from(themes).sort(); } @@ -1619,7 +1621,7 @@ export interface ThemeInfo { path: string | undefined; } -export function getAvailableThemesWithPaths(): ThemeInfo[] { +export async function getAvailableThemesWithPaths(): Promise { const result: ThemeInfo[] = []; // Built-in themes (embedded, no file path) @@ -1629,8 +1631,9 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] { // Custom themes const customThemesDir = getCustomThemesDir(); - if (fs.existsSync(customThemesDir)) { - for (const file of fs.readdirSync(customThemesDir)) { + try { + const files = await fs.promises.readdir(customThemesDir); + for (const file of files) { if (file.endsWith(".json")) { const name = file.slice(0, -5); if (!result.some((themeInfo) => themeInfo.name === name)) { @@ -1638,22 +1641,27 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] { } } } + } catch { + // Directory doesn't exist or isn't readable } return result.sort((a, b) => a.name.localeCompare(b.name)); } -function loadThemeJson(name: string): ThemeJson { +async function loadThemeJson(name: string): Promise { const builtinThemes = getBuiltinThemes(); if (name in builtinThemes) { return builtinThemes[name]; } const customThemesDir = getCustomThemesDir(); const themePath = path.join(customThemesDir, `${name}.json`); - if (!fs.existsSync(themePath)) { - throw new Error(`Theme not found: ${name}`); + let content: string; + try { + content = await Bun.file(themePath).text(); + } catch (err) { + if (isEnoent(err)) throw new Error(`Theme not found: ${name}`); + throw err; } - const content = fs.readFileSync(themePath, "utf-8"); let json: unknown; try { json = JSON.parse(content); @@ -1691,9 +1699,27 @@ function loadThemeJson(name: string): ThemeJson { return json as ThemeJson; } -function createTheme(themeJson: ThemeJson, mode?: ColorMode, symbolPresetOverride?: SymbolPreset): Theme { +interface CreateThemeOptions { + mode?: ColorMode; + symbolPresetOverride?: SymbolPreset; + colorBlindMode?: boolean; +} + +/** HSV adjustment to shift green toward blue for colorblind mode (red-green colorblindness) */ +const COLORBLIND_ADJUSTMENT = { h: 60, s: 0.71 }; + +function createTheme(themeJson: ThemeJson, options: CreateThemeOptions = {}): Theme { + const { mode, symbolPresetOverride, colorBlindMode } = options; const colorMode = mode ?? detectColorMode(); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); + + if (colorBlindMode) { + const added = resolvedColors.toolDiffAdded; + if (typeof added === "string" && added.startsWith("#")) { + resolvedColors.toolDiffAdded = adjustHsv(added, COLORBLIND_ADJUSTMENT); + } + } + const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; const bgColorKeys: Set = new Set([ @@ -1718,14 +1744,14 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, symbolPresetOverrid return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides); } -function loadTheme(name: string, mode?: ColorMode, symbolPresetOverride?: SymbolPreset): Theme { - const themeJson = loadThemeJson(name); - return createTheme(themeJson, mode, symbolPresetOverride); +async function loadTheme(name: string, options: CreateThemeOptions = {}): Promise { + const themeJson = await loadThemeJson(name); + return createTheme(themeJson, options); } -export function getThemeByName(name: string): Theme | undefined { +export async function getThemeByName(name: string): Promise { try { - return loadTheme(name); + return await loadTheme(name); } catch { return undefined; } @@ -1757,32 +1783,49 @@ function getDefaultTheme(): string { export let theme: Theme; let currentThemeName: string | undefined; let currentSymbolPresetOverride: SymbolPreset | undefined; +let currentColorBlindMode: boolean = false; let themeWatcher: fs.FSWatcher | undefined; let onThemeChangeCallback: (() => void) | undefined; -export function initTheme(themeName?: string, enableWatcher: boolean = false, symbolPreset?: SymbolPreset): void { +function getCurrentThemeOptions(): CreateThemeOptions { + return { + symbolPresetOverride: currentSymbolPresetOverride, + colorBlindMode: currentColorBlindMode, + }; +} + +export async function initTheme( + themeName?: string, + enableWatcher: boolean = false, + symbolPreset?: SymbolPreset, + colorBlindMode?: boolean, +): Promise { const name = themeName ?? getDefaultTheme(); currentThemeName = name; currentSymbolPresetOverride = symbolPreset; + currentColorBlindMode = colorBlindMode ?? false; try { - theme = loadTheme(name, undefined, symbolPreset); + theme = await loadTheme(name, getCurrentThemeOptions()); if (enableWatcher) { - startThemeWatcher(); + await startThemeWatcher(); } } catch (err) { logger.debug("Theme loading failed, falling back to dark theme", { error: String(err) }); currentThemeName = "dark"; - theme = loadTheme("dark", undefined, symbolPreset); + theme = await loadTheme("dark", getCurrentThemeOptions()); // Don't start watcher for fallback theme } } -export function setTheme(name: string, enableWatcher: boolean = false): { success: boolean; error?: string } { +export async function setTheme( + name: string, + enableWatcher: boolean = false, +): Promise<{ success: boolean; error?: string }> { currentThemeName = name; try { - theme = loadTheme(name, undefined, currentSymbolPresetOverride); + theme = await loadTheme(name, getCurrentThemeOptions()); if (enableWatcher) { - startThemeWatcher(); + await startThemeWatcher(); } if (onThemeChangeCallback) { onThemeChangeCallback(); @@ -1791,7 +1834,7 @@ export function setTheme(name: string, enableWatcher: boolean = false): { succes } catch (error) { // Theme is invalid - fall back to dark theme currentThemeName = "dark"; - theme = loadTheme("dark", undefined, currentSymbolPresetOverride); + theme = await loadTheme("dark", getCurrentThemeOptions()); // Don't start watcher for fallback theme return { success: false, @@ -1812,14 +1855,14 @@ export function setThemeInstance(themeInstance: Theme): void { /** * Set the symbol preset override, recreating the theme with the new preset. */ -export function setSymbolPreset(preset: SymbolPreset): void { +export async function setSymbolPreset(preset: SymbolPreset): Promise { currentSymbolPresetOverride = preset; if (currentThemeName) { try { - theme = loadTheme(currentThemeName, undefined, preset); + theme = await loadTheme(currentThemeName, getCurrentThemeOptions()); } catch { // Fall back to dark theme with new preset - theme = loadTheme("dark", undefined, preset); + theme = await loadTheme("dark", getCurrentThemeOptions()); } if (onThemeChangeCallback) { onThemeChangeCallback(); @@ -1834,6 +1877,32 @@ export function getSymbolPresetOverride(): SymbolPreset | undefined { return currentSymbolPresetOverride; } +/** + * Set color blind mode, recreating the theme with the new setting. + * When enabled, uses blue instead of green for diff additions. + */ +export async function setColorBlindMode(enabled: boolean): Promise { + currentColorBlindMode = enabled; + if (currentThemeName) { + try { + theme = await loadTheme(currentThemeName, getCurrentThemeOptions()); + } catch { + // Fall back to dark theme + theme = await loadTheme("dark", getCurrentThemeOptions()); + } + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + } +} + +/** + * Get the current color blind mode setting. + */ +export function getColorBlindMode(): boolean { + return currentColorBlindMode; +} + export function onThemeChange(callback: () => void): void { onThemeChangeCallback = callback; } @@ -1852,7 +1921,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset { return preset === "unicode" || preset === "nerd" || preset === "ascii"; } -function startThemeWatcher(): void { +async function startThemeWatcher(): Promise { // Stop existing watcher if any if (themeWatcher) { themeWatcher.close(); @@ -1877,30 +1946,36 @@ function startThemeWatcher(): void { if (eventType === "change") { // Debounce rapid changes setTimeout(() => { - try { - // Reload the theme with current symbol preset override - theme = loadTheme(currentThemeName!, undefined, currentSymbolPresetOverride); - // Notify callback (to invalidate UI) - if (onThemeChangeCallback) { - onThemeChangeCallback(); - } - } catch (err) { - logger.debug("Theme reload error during file change", { error: String(err) }); - } + loadTheme(currentThemeName!, getCurrentThemeOptions()) + .then((loadedTheme) => { + theme = loadedTheme; + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + }) + .catch((err) => { + logger.debug("Theme reload error during file change", { error: String(err) }); + }); }, 100); } else if (eventType === "rename") { // File was deleted or renamed - fall back to default theme setTimeout(() => { if (!fs.existsSync(themeFile)) { currentThemeName = "dark"; - theme = loadTheme("dark"); + loadTheme("dark", getCurrentThemeOptions()) + .then((loadedTheme) => { + theme = loadedTheme; + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + }) + .catch((err) => { + logger.debug("Theme reload error during rename fallback", { error: String(err) }); + }); if (themeWatcher) { themeWatcher.close(); themeWatcher = undefined; } - if (onThemeChangeCallback) { - onThemeChangeCallback(); - } } }, 100); } @@ -1971,10 +2046,10 @@ function ansi256ToHex(index: number): string { * Get resolved theme colors as CSS-compatible hex strings. * Used by HTML export to generate CSS custom properties. */ -export function getResolvedThemeColors(themeName?: string): Record { +export async function getResolvedThemeColors(themeName?: string): Promise> { const name = themeName ?? getDefaultTheme(); const isLight = name === "light"; - const themeJson = loadThemeJson(name); + const themeJson = await loadThemeJson(name); const resolved = resolveThemeColors(themeJson.colors, themeJson.vars); // Default text color for empty values (terminal uses default fg color) @@ -2006,14 +2081,14 @@ export function isLightTheme(themeName?: string): boolean { * Get explicit export colors from theme JSON, if specified. * Returns undefined for each color that isn't explicitly set. */ -export function getThemeExportColors(themeName?: string): { +export async function getThemeExportColors(themeName?: string): Promise<{ pageBg?: string; cardBg?: string; infoBg?: string; -} { +}> { const name = themeName ?? getDefaultTheme(); try { - const themeJson = loadThemeJson(name); + const themeJson = await loadThemeJson(name); const exportSection = themeJson.export; if (!exportSection) return {}; diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index a50333d05..f6a6a82b0 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -132,9 +132,9 @@ export interface InteractiveModeContext { handleExportCommand(text: string): Promise; handleShareCommand(): Promise; handleCopyCommand(): Promise; - handleSessionCommand(): void; + handleSessionCommand(): Promise; handleUsageCommand(reports?: UsageReport[] | null): Promise; - handleChangelogCommand(): void; + handleChangelogCommand(): Promise; handleHotkeysCommand(): void; handleDumpCommand(): Promise; handleClearCommand(): Promise; diff --git a/packages/coding-agent/src/patch/applicator.ts b/packages/coding-agent/src/patch/applicator.ts index b8d578f91..dac7cf0d0 100644 --- a/packages/coding-agent/src/patch/applicator.ts +++ b/packages/coding-agent/src/patch/applicator.ts @@ -5,8 +5,8 @@ * for robust handling of whitespace and formatting differences. */ -import { mkdirSync, unlinkSync } from "node:fs"; -import { dirname } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { resolveToCwd } from "../tools/path-utils"; import { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch, seekSequence } from "./fuzzy"; import { @@ -51,10 +51,10 @@ export const defaultFileSystem: FileSystem = { await Bun.write(path, content); }, async delete(path: string): Promise { - unlinkSync(path); + await fs.unlink(path); }, async mkdir(path: string): Promise { - mkdirSync(path, { recursive: true }); + await fs.mkdir(path, { recursive: true }); }, }; @@ -1113,7 +1113,7 @@ async function applyNormalizedPatch( const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`; if (!dryRun) { - const parentDir = dirname(absolutePath); + const parentDir = path.dirname(absolutePath); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir); } @@ -1182,7 +1182,7 @@ async function applyNormalizedPatch( if (!dryRun) { if (isMove) { - const parentDir = dirname(destPath); + const parentDir = path.dirname(destPath); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir); } diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 878e97329..102d7b0b5 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -8,7 +8,7 @@ * The mode is determined by the `edit.patchMode` setting. */ -import { mkdir } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; import { Type } from "@sinclair/typebox"; @@ -160,7 +160,7 @@ class LspFileSystem implements FileSystem { } async mkdir(path: string): Promise { - await mkdir(path, { recursive: true }); + await fs.mkdir(path, { recursive: true }); } getDiagnostics(): FileDiagnosticsResult | undefined { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 0b21c4398..414e20871 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -26,9 +26,8 @@ * ``` */ -import { existsSync } from "node:fs"; -import { rename } from "node:fs/promises"; -import { join } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path 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"; @@ -44,12 +43,9 @@ import { formatModelString, parseModelString } from "./config/model-resolver"; import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates"; import { type Settings, SettingsManager, type SkillsSettings } from "./config/settings-manager"; import { CursorExecHandlers } from "./cursor"; +import "./discovery"; import { initializeWithSettings } from "./discovery"; import { TtsrManager } from "./export/ttsr"; -import { disposeAllKernelSessions } from "./ipy/executor"; -import { closeAllConnections } from "./ssh/connection-manager"; -import { unmountAll } from "./ssh/sshfs-mount"; -import "./discovery"; import { type CustomCommandsLoadResult, loadCustomCommands as loadCustomCommandsInternal, @@ -77,12 +73,15 @@ import { RuleProtocolHandler, SkillProtocolHandler, } from "./internal-urls"; +import { disposeAllKernelSessions } from "./ipy/executor"; import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp"; import { AgentSession } from "./session/agent-session"; import { AuthStorage } from "./session/auth-storage"; import { convertToLlm } from "./session/messages"; import { SessionManager } from "./session/session-manager"; import { migrateJsonStorage } from "./session/storage-migration"; +import { closeAllConnections } from "./ssh/connection-manager"; +import { unmountAll } from "./ssh/sshfs-mount"; import { buildSystemPrompt as buildSystemPromptInternal, loadProjectContextFiles as loadContextFilesInternal, @@ -207,10 +206,7 @@ export interface CreateAgentSessionResult { export type { PromptTemplate } from "./config/prompt-templates"; export type { Settings, SkillsSettings } from "./config/settings-manager"; -export type { - CustomCommand, - CustomCommandFactory, -} from "./extensibility/custom-commands/types"; +export type { CustomCommand, CustomCommandFactory } from "./extensibility/custom-commands/types"; export type { CustomTool, CustomToolFactory } from "./extensibility/custom-tools/types"; export type { ExtensionAPI, @@ -254,7 +250,7 @@ function getDefaultAgentDir(): string { * Reads from primary path first, then falls back to legacy paths (.pi, .claude). */ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise { - const primaryPath = join(agentDir, "auth.json"); + const primaryPath = path.join(agentDir, "auth.json"); // Get all auth.json paths (user-level only), excluding the primary const allPaths = getConfigDirPaths("auth.json", { project: false }); const fallbackPaths = allPaths.filter((p) => p !== primaryPath); @@ -264,7 +260,7 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir( // Migrate legacy JSON files (settings.json, auth.json) to SQLite before loading await migrateJsonStorage({ agentDir, - settingsPath: join(agentDir, "settings.json"), + settingsPath: path.join(agentDir, "settings.json"), authPaths: [primaryPath, ...fallbackPaths], }); @@ -282,27 +278,37 @@ export async function discoverModels( authStorage: AuthStorage, agentDir: string = getDefaultAgentDir(), ): Promise { - const yamlPath = join(agentDir, "models.yml"); - const jsonPath = join(agentDir, "models.json"); + const yamlPath = path.join(agentDir, "models.yml"); + const jsonPath = path.join(agentDir, "models.json"); + + // Check existence of yaml and json files + const [yamlExists, jsonExists] = await Promise.all([Bun.file(yamlPath).exists(), Bun.file(jsonPath).exists()]); // Migrate models.json to models.yml if yaml doesn't exist but json does - if (!existsSync(yamlPath) && existsSync(jsonPath)) { + if (!yamlExists && jsonExists) { await migrateModelsJsonToYaml(jsonPath, yamlPath); } // Prefer models.yml, fall back to models.json - const primaryPath = existsSync(yamlPath) ? yamlPath : jsonPath; + const primaryPath = yamlExists ? yamlPath : jsonPath; // Get all models config paths (user-level only), excluding the primary const yamlPaths = getConfigDirPaths("models.yml", { project: false }); const jsonPaths = getConfigDirPaths("models.json", { project: false }); const allPaths = [...yamlPaths, ...jsonPaths]; - const fallbackPaths = allPaths.filter((p) => p !== primaryPath && existsSync(p)); + const existenceResults = await Promise.all( + allPaths.map((p) => + Bun.file(p) + .exists() + .then((exists) => ({ p, exists })), + ), + ); + const fallbackPaths = existenceResults.filter(({ p, exists }) => p !== primaryPath && exists).map(({ p }) => p); logger.debug("discoverModels", { primaryPath, fallbackPaths }); const registry = new ModelRegistry(authStorage, primaryPath, fallbackPaths); - registry.refresh(); + await registry.refresh(); return registry; } @@ -319,7 +325,7 @@ async function migrateModelsJsonToYaml(jsonPath: string, yamlPath: string): Prom return; } await Bun.write(yamlPath, YAML.stringify(parsed, null, 2)); - await rename(jsonPath, `${jsonPath}.bak`); + await fs.rename(jsonPath, `${jsonPath}.bak`); logger.debug("migrateModelsJsonToYaml: migrated models.json to models.yml", { from: jsonPath, to: yamlPath }); } catch (error) { logger.warn("migrateModelsJsonToYaml: migration failed", { error: String(error) }); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 207ad33d1..8576a16df 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13,7 +13,6 @@ * Modes use this class and add their own I/O layer on top. */ -import { existsSync, readFileSync } from "node:fs"; import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, @@ -26,7 +25,7 @@ import type { UsageReport, } 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 { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import type { Rule } from "../capability/rule"; import { getAgentDbPath } from "../config"; @@ -214,9 +213,9 @@ const noOpUIContext: ExtensionUIContext = { get theme() { return theme; }, - getAllThemes: () => [], - getTheme: () => undefined, - setTheme: (_theme) => ({ success: false, error: "UI not available" }), + getAllThemes: () => Promise.resolve([]), + getTheme: () => Promise.resolve(undefined), + setTheme: (_theme) => Promise.resolve({ success: false, error: "UI not available" }), setFooter: () => {}, setHeader: () => {}, setEditorComponent: () => {}, @@ -599,20 +598,18 @@ export class AgentSession { if (!path) return; const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); - this._ensureFileCache(resolvedPath); + void this._ensureFileCache(resolvedPath); } - private _ensureFileCache(resolvedPath: string): void { + private async _ensureFileCache(resolvedPath: string): Promise { if (this._streamingEditFileCache.has(resolvedPath)) return; try { - if (existsSync(resolvedPath)) { - const rawText = readFileSync(resolvedPath, "utf8"); - const { text } = stripBom(rawText); - this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); - } + const rawText = await Bun.file(resolvedPath).text(); + const { text } = stripBom(rawText); + this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); } catch { - // Don't cache on read errors - let the edit tool handle them + // Don't cache on read errors (including ENOENT) - let the edit tool handle them } } @@ -701,7 +698,6 @@ export class AgentSession { ): Promise { if (this._streamingEditAbortTriggered) return; try { - if (!(await Bun.file(resolvedPath).exists())) return; const { text } = stripBom(await Bun.file(resolvedPath).text()); const normalizedContent = normalizeToLF(text); const missing = removedLines.find((line) => !normalizedContent.includes(normalizeToLF(line))); @@ -714,8 +710,12 @@ export class AgentSession { }); this.agent.abort(); } - } catch { - // Ignore errors during async fallback + } catch (err) { + // Ignore ENOENT (file not found) - let the edit tool handle missing files + // Also ignore other errors during async fallback + if (!isEnoent(err)) { + // Log unexpected errors but don't abort + } } } @@ -2022,17 +2022,15 @@ export class AgentSession { if (!sessionFile) return; const todoPath = `${sessionFile.slice(0, -6)}/todos.json`; - const file = Bun.file(todoPath); - if (!(await file.exists())) { - this._todoReminderCount = 0; - return; - } let todos: TodoItem[]; try { - const data = await file.json(); + const data = await Bun.file(todoPath).json(); todos = data?.todos ?? []; - } catch { + } catch (err) { + if (isEnoent(err)) { + this._todoReminderCount = 0; + } return; } diff --git a/packages/coding-agent/src/session/agent-storage.ts b/packages/coding-agent/src/session/agent-storage.ts index 3e889c21b..24b8f5f8c 100644 --- a/packages/coding-agent/src/session/agent-storage.ts +++ b/packages/coding-agent/src/session/agent-storage.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; -import { chmodSync, existsSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDbPath } from "../config"; import type { Settings } from "../config/settings-manager"; @@ -484,20 +484,20 @@ CREATE TABLE settings ( * @param dbPath - Path to the database file */ private ensureDir(dbPath: string): void { - mkdirSync(dirname(dbPath), { recursive: true }); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); } private hardenPermissions(dbPath: string): void { - const dir = dirname(dbPath); + const dir = path.dirname(dbPath); try { - chmodSync(dir, 0o700); + fs.chmodSync(dir, 0o700); } catch (error) { logger.warn("AgentStorage failed to chmod agent dir", { path: dir, error: String(error) }); } - if (!existsSync(dbPath)) return; + if (!fs.existsSync(dbPath)) return; try { - chmodSync(dbPath, 0o600); + fs.chmodSync(dbPath, 0o600); } catch (error) { logger.warn("AgentStorage failed to chmod db file", { path: dbPath, error: String(error) }); } diff --git a/packages/coding-agent/src/session/artifacts.ts b/packages/coding-agent/src/session/artifacts.ts index 3b81ea3a0..730272c79 100644 --- a/packages/coding-agent/src/session/artifacts.ts +++ b/packages/coding-agent/src/session/artifacts.ts @@ -5,8 +5,8 @@ * accessible via artifact:// URLs or the $ARTIFACTS environment variable. */ -import { mkdir, readdir } from "node:fs/promises"; -import { join } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; /** * Manages artifact storage for a session. @@ -38,7 +38,7 @@ export class ArtifactManager { async #ensureDir(): Promise { if (!this.#dirCreated) { - await mkdir(this.#dir, { recursive: true }); + await fs.mkdir(this.#dir, { recursive: true }); this.#dirCreated = true; } if (!this.#initialized) { @@ -82,7 +82,7 @@ export class ArtifactManager { await this.#ensureDir(); const id = String(this.allocateId()); const filename = `${id}.${toolType}.log`; - return { id, path: join(this.#dir, filename) }; + return { id, path: path.join(this.#dir, filename) }; } /** @@ -113,7 +113,7 @@ export class ArtifactManager { */ async listFiles(): Promise { try { - return await readdir(this.#dir); + return await fs.readdir(this.#dir); } catch { return []; } @@ -128,6 +128,6 @@ export class ArtifactManager { async getPath(id: string): Promise { const files = await this.listFiles(); const match = files.find((f) => f.startsWith(`${id}.`)); - return match ? join(this.#dir, match) : null; + return match ? path.join(this.#dir, match) : null; } } diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index f6cc3e8f8..3cd373063 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -4,7 +4,7 @@ */ import { Buffer } from "node:buffer"; -import { dirname, join } from "node:path"; +import * as path from "node:path"; import { antigravityUsageProvider, claudeUsageProvider, @@ -282,7 +282,7 @@ export class AuthStorage { if (authPath.endsWith(".db")) { return authPath; } - return getAgentDbPath(dirname(authPath)); + return getAgentDbPath(path.dirname(authPath)); } /** @@ -313,10 +313,10 @@ export class AuthStorage { * Migrates legacy auth.json/settings.json on first load. */ async reload(): Promise { - const agentDir = dirname(this.dbPath); + const agentDir = path.dirname(this.dbPath); await migrateJsonStorage({ agentDir, - settingsPath: join(agentDir, "settings.json"), + settingsPath: path.join(agentDir, "settings.json"), authPaths: [this.authPath, ...this.fallbackPaths], }); diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index f32134d33..c4626de16 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; -import { mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import { getAgentDir } from "../config"; @@ -81,7 +81,7 @@ END; this.lastPromptCache = last?.prompt ?? null; } - static open(dbPath: string = join(getAgentDir(), "history.db")): HistoryStorage { + static open(dbPath: string = path.join(getAgentDir(), "history.db")): HistoryStorage { if (!HistoryStorage.instance) { HistoryStorage.instance = new HistoryStorage(dbPath); } @@ -134,8 +134,8 @@ END; } private ensureDir(dbPath: string): void { - const dir = dirname(dbPath); - mkdirSync(dir, { recursive: true }); + const dir = path.dirname(dbPath); + fs.mkdirSync(dir, { recursive: true }); } private normalizeLimit(limit: number): number { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 0a4f18ba4..b3f96c263 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1,7 +1,7 @@ -import { basename, join, resolve } from "node:path"; +import * as path 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 { isEnoent, logger } from "@oh-my-pi/pi-utils"; import { nanoid } from "nanoid"; import { getAgentDir as getDefaultAgentDir } from "../config"; import { resizeImage } from "../utils/image-resize"; @@ -432,16 +432,24 @@ export function buildSessionContext( */ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(getDefaultAgentDir(), "sessions", safePath); + const sessionDir = path.join(getDefaultAgentDir(), "sessions", safePath); storage.ensureDirSync(sessionDir); return sessionDir; } /** Exported for testing */ -export function loadEntriesFromFile(filePath: string, storage: SessionStorage = new FileSessionStorage()): FileEntry[] { - if (!storage.existsSync(filePath)) return []; +export async function loadEntriesFromFile( + filePath: string, + storage: SessionStorage = new FileSessionStorage(), +): Promise { + let content: string; + try { + content = await storage.readText(filePath); + } catch (err) { + if (isEnoent(err)) return []; + throw err; + } - const content = storage.readTextSync(filePath); const entries: FileEntry[] = []; const lines = content.trim().split("\n"); @@ -555,15 +563,13 @@ function extractFirstUserPrompt(lines: string[]): string | undefined { * Uses low-level file I/O to efficiently read only the first 4KB of each file * to extract the JSON header and first user message without loading entire session logs into memory. */ -function getSortedSessions(sessionDir: string, storage: SessionStorage): RecentSessionInfo[] { +async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise { try { - const buf = Buffer.alloc(4096); const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl"); - return files - .map((path: string) => { + const results = await Promise.all( + files.map(async (path: string) => { try { - const length = storage.readTextPrefixSync(path, buf); - const content = buf.toString("utf-8", 0, length); + const content = await storage.readTextPrefix(path, 4096); const lines = content.split("\n"); const firstLine = lines[0]; if (!firstLine || !firstLine.trim()) return null; @@ -575,20 +581,20 @@ function getSortedSessions(sessionDir: string, storage: SessionStorage): RecentS } catch { return null; } - }) - .filter((item): item is RecentSessionInfo => item !== null) - .sort((a, b) => b.mtime - a.mtime); + }), + ); + return results.filter((item): item is RecentSessionInfo => item !== null).sort((a, b) => b.mtime - a.mtime); } catch { return []; } } /** Exported for testing */ -export function findMostRecentSession( +export async function findMostRecentSession( sessionDir: string, storage: SessionStorage = new FileSessionStorage(), -): string | null { - const sessions = getSortedSessions(sessionDir, storage); +): Promise { + const sessions = await getSortedSessions(sessionDir, storage); return sessions[0]?.path || null; } @@ -843,12 +849,13 @@ class NdjsonFileWriter { } /** Get recent sessions for display in welcome screen */ -export function getRecentSessions( +export async function getRecentSessions( sessionDir: string, limit = 3, storage: SessionStorage = new FileSessionStorage(), -): RecentSessionInfo[] { - return getSortedSessions(sessionDir, storage).slice(0, limit); +): Promise { + const sessions = await getSortedSessions(sessionDir, storage); + return sessions.slice(0, limit); } /** @@ -886,12 +893,12 @@ function extractTextFromContent(content: Message["content"]): string { .join(" "); } -function collectSessionsFromFiles(files: string[], storage: SessionStorage): SessionInfo[] { +async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise { const sessions: SessionInfo[] = []; for (const file of files) { try { - const content = storage.readTextSync(file); + const content = await storage.readText(file); const lines = content.trim().split("\n"); if (lines.length === 0) continue; @@ -1003,9 +1010,9 @@ export class SessionManager { await this._closePersistWriter(); this.persistError = undefined; this.persistErrorReported = false; - this.sessionFile = resolve(sessionFile); - if (this.storage.existsSync(this.sessionFile)) { - this.fileEntries = loadEntriesFromFile(this.sessionFile!, this.storage); + this.sessionFile = path.resolve(sessionFile); + this.fileEntries = await loadEntriesFromFile(this.sessionFile, this.storage); + if (this.fileEntries.length > 0) { const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; this.sessionId = header?.id ?? nanoid(); this.sessionTitle = header?.title; @@ -1053,7 +1060,7 @@ export class SessionManager { if (this.persist) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + this.sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } return this.sessionFile; } @@ -1154,8 +1161,8 @@ export class SessionManager { private async _writeEntriesAtomically(entries: FileEntry[]): Promise { if (!this.sessionFile) return; - const dir = resolve(this.sessionFile, ".."); - const tempPath = join(dir, `.${basename(this.sessionFile)}.${nanoid(6)}.tmp`); + const dir = path.resolve(this.sessionFile, ".."); + const tempPath = path.join(dir, `.${path.basename(this.sessionFile)}.${nanoid(6)}.tmp`); const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" }); try { for (const entry of entries) { @@ -1709,18 +1716,18 @@ export class SessionManager { * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { - const path = this.getBranch(leafId); - if (path.length === 0) { + const branchPath = this.getBranch(leafId); + if (branchPath.length === 0) { throw new Error(`Entry ${leafId} not found`); } // Filter out LabelEntry from path - we'll recreate them from the resolved map - const pathWithoutLabels = path.filter((e) => e.type !== "label"); + const pathWithoutLabels = branchPath.filter((e) => e.type !== "label"); const newSessionId = nanoid(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); + const newSessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); const header: SessionHeader = { type: "session", @@ -1816,7 +1823,7 @@ export class SessionManager { ): Promise { const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); const manager = new SessionManager(cwd, dir, true, storage); - const forkEntries = structuredClone(loadEntriesFromFile(sourcePath, storage)) as FileEntry[]; + const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[]; migrateToCurrentVersion(forkEntries); const sourceHeader = forkEntries.find((e) => e.type === "session") as SessionHeader | undefined; const historyEntries = forkEntries.filter((entry) => entry.type !== "session") as SessionEntry[]; @@ -1836,18 +1843,18 @@ export class SessionManager { * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. */ static async open( - path: string, + filePath: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { // Extract cwd from session header if possible, otherwise use process.cwd() - const entries = loadEntriesFromFile(path, storage); + const entries = await loadEntriesFromFile(filePath, storage); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const cwd = header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory - const dir = sessionDir ?? resolve(path, ".."); + const dir = sessionDir ?? path.resolve(filePath, ".."); const manager = new SessionManager(cwd, dir, true, storage); - await manager._initSessionFile(path); + await manager._initSessionFile(filePath); return manager; } @@ -1862,7 +1869,7 @@ export class SessionManager { storage: SessionStorage = new FileSessionStorage(), ): Promise { const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); - const mostRecent = findMostRecentSession(dir, storage); + const mostRecent = await findMostRecentSession(dir, storage); const manager = new SessionManager(cwd, dir, true, storage); if (mostRecent) { await manager._initSessionFile(mostRecent); @@ -1884,11 +1891,15 @@ export class SessionManager { * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ - static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] { + static async list( + cwd: string, + sessionDir?: string, + storage: SessionStorage = new FileSessionStorage(), + ): Promise { const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); try { const files = storage.listFilesSync(dir, "*.jsonl"); - return collectSessionsFromFiles(files, storage); + return await collectSessionsFromFiles(files, storage); } catch { return []; } @@ -1897,13 +1908,13 @@ export class SessionManager { /** * List all sessions across all project directories. */ - static listAll(storage: SessionStorage = new FileSessionStorage()): SessionInfo[] { - const sessionsRoot = join(getDefaultAgentDir(), "sessions"); + static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise { + const sessionsRoot = path.join(getDefaultAgentDir(), "sessions"); try { const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map((name) => - join(sessionsRoot, name), + path.join(sessionsRoot, name), ); - return collectSessionsFromFiles(files, storage); + return await collectSessionsFromFiles(files, storage); } catch { return []; } diff --git a/packages/coding-agent/src/session/session-storage.ts b/packages/coding-agent/src/session/session-storage.ts index 9c37e1f59..f94c69e12 100644 --- a/packages/coding-agent/src/session/session-storage.ts +++ b/packages/coding-agent/src/session/session-storage.ts @@ -1,17 +1,7 @@ -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - readSync, - statSync, - writeFileSync, - writeSync, -} from "node:fs"; -import { rename as renameAsync } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { isEnoent } from "@oh-my-pi/pi-utils"; export interface SessionStorageStat { size: number; @@ -30,14 +20,13 @@ export interface SessionStorageWriter { export interface SessionStorage { ensureDirSync(dir: string): void; existsSync(path: string): boolean; - readTextSync(path: string): string; - readTextPrefixSync(path: string, buf: Buffer): number; writeTextSync(path: string, content: string): void; statSync(path: string): SessionStorageStat; listFilesSync(dir: string, pattern: string): string[]; exists(path: string): Promise; readText(path: string): Promise; + readTextPrefix(path: string, maxBytes: number): Promise; writeText(path: string, content: string): Promise; rename(path: string, nextPath: string): Promise; unlink(path: string): Promise; @@ -52,7 +41,7 @@ function toError(value: unknown): Error { // FinalizationRegistry to clean up leaked file descriptors const writerRegistry = new FinalizationRegistry((fd) => { try { - closeSync(fd); + fs.closeSync(fd); } catch { // Ignore - fd may already be closed or invalid } @@ -64,16 +53,16 @@ class FileSessionStorageWriter implements SessionStorageWriter { private error: Error | undefined; private onError: ((err: Error) => void) | undefined; - constructor(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) { + constructor(fpath: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) { this.onError = options?.onError; const flags = options?.flags ?? "a"; // Ensure parent directory exists - const dir = dirname(path); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + const dir = path.dirname(fpath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); } // Open file once, keep fd for lifetime - this.fd = openSync(path, flags === "w" ? "w" : "a"); + this.fd = fs.openSync(fpath, flags === "w" ? "w" : "a"); // Register for cleanup if abandoned without close() writerRegistry.register(this, this.fd, this); } @@ -92,7 +81,7 @@ class FileSessionStorageWriter implements SessionStorageWriter { const buf = Buffer.from(line, "utf-8"); let offset = 0; while (offset < buf.length) { - const written = writeSync(this.fd, buf, offset, buf.length - offset); + const written = fs.writeSync(this.fd, buf, offset, buf.length - offset); if (written === 0) { throw new Error("Short write"); } @@ -112,7 +101,7 @@ class FileSessionStorageWriter implements SessionStorageWriter { if (this.closed) throw new Error("Writer closed"); if (this.error) throw this.error; try { - fsyncSync(this.fd); + fs.fsyncSync(this.fd); } catch (err) { throw this.recordError(err); } @@ -124,7 +113,7 @@ class FileSessionStorageWriter implements SessionStorageWriter { // Unregister from finalization - we're closing properly writerRegistry.unregister(this); try { - closeSync(this.fd); + fs.closeSync(this.fd); } catch { // Ignore close errors } @@ -137,53 +126,56 @@ class FileSessionStorageWriter implements SessionStorageWriter { export class FileSessionStorage implements SessionStorage { ensureDirSync(dir: string): void { - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); } } existsSync(path: string): boolean { - return existsSync(path); + return fs.existsSync(path); } - readTextSync(path: string): string { - return readFileSync(path, "utf-8"); - } - - readTextPrefixSync(path: string, buf: Buffer): number { - const fd = openSync(path, "r"); - try { - const bytesRead = readSync(fd, buf, 0, buf.length, 0); - return bytesRead; - } finally { - closeSync(fd); - } - } - - writeTextSync(path: string, content: string): void { - this.ensureDirSync(dirname(path)); - writeFileSync(path, content); + writeTextSync(fpath: string, content: string): void { + this.ensureDirSync(path.dirname(fpath)); + fs.writeFileSync(fpath, content); } statSync(path: string): SessionStorageStat { - const stats = statSync(path); + const stats = fs.statSync(path); return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime }; } listFilesSync(dir: string, pattern: string): string[] { try { - return Array.from(new Bun.Glob(pattern).scanSync(dir)).map((name) => join(dir, name)); + return Array.from(new Bun.Glob(pattern).scanSync(dir)).map((name) => path.join(dir, name)); } catch { return []; } } - exists(path: string): Promise { - return Bun.file(path).exists(); + async exists(path: string): Promise { + try { + await fs.promises.access(path); + return true; + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } } readText(path: string): Promise { - return Bun.file(path).text(); + return fs.promises.readFile(path, "utf-8"); + } + + async readTextPrefix(path: string, maxBytes: number): Promise { + const handle = await fs.promises.open(path, "r"); + try { + const buffer = Buffer.alloc(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); + return buffer.subarray(0, bytesRead).toString("utf-8"); + } finally { + await handle.close(); + } } async writeText(path: string, content: string): Promise { @@ -192,23 +184,23 @@ export class FileSessionStorage implements SessionStorage { async rename(path: string, nextPath: string): Promise { try { - await renameAsync(path, nextPath); + await fs.promises.rename(path, nextPath); } catch (err) { throw toError(err); } } unlink(path: string): Promise { - return Bun.file(path).unlink(); + return fs.promises.unlink(path); } fsyncDirSync(dir: string): void { try { - const fd = openSync(dir, "r"); + const fd = fs.openSync(dir, "r"); try { - fsyncSync(fd); + fs.fsyncSync(fd); } finally { - closeSync(fd); + fs.closeSync(fd); } } catch { // Best-effort: some platforms/filesystems don't support fsync on directories. @@ -265,7 +257,7 @@ class MemorySessionStorageWriter implements SessionStorageWriter { await this.ready; if (this.error) throw this.error; try { - const existing = this.storage.existsSync(this.path) ? this.storage.readTextSync(this.path) : ""; + const existing = this.storage.existsSync(this.path) ? await this.storage.readText(this.path) : ""; await this.storage.writeText(this.path, `${existing}${line}`); } catch (err) { throw this.recordError(err); @@ -305,17 +297,6 @@ export class MemorySessionStorage implements SessionStorage { return this.files.has(path); } - readTextSync(path: string): string { - const entry = this.files.get(path); - if (!entry) throw new Error(`File not found: ${path}`); - return entry.content; - } - - readTextPrefixSync(path: string, buf: Buffer): number { - const content = this.readTextSync(path); - return buf.write(content, 0, buf.length, "utf-8"); - } - writeTextSync(path: string, content: string): void { this.files.set(path, { content, mtimeMs: Date.now() }); } @@ -348,7 +329,15 @@ export class MemorySessionStorage implements SessionStorage { } readText(path: string): Promise { - return Promise.resolve(this.readTextSync(path)); + const entry = this.files.get(path); + if (!entry) return Promise.reject(new Error(`File not found: ${path}`)); + return Promise.resolve(entry.content); + } + + readTextPrefix(path: string, maxBytes: number): Promise { + const entry = this.files.get(path); + if (!entry) return Promise.reject(new Error(`File not found: ${path}`)); + return Promise.resolve(entry.content.slice(0, maxBytes)); } writeText(path: string, content: string): Promise { diff --git a/packages/coding-agent/src/ssh/connection-manager.ts b/packages/coding-agent/src/ssh/connection-manager.ts index 074f03b0b..52ff5ba24 100644 --- a/packages/coding-agent/src/ssh/connection-manager.ts +++ b/packages/coding-agent/src/ssh/connection-manager.ts @@ -1,8 +1,9 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import * as fs from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import * as path from "node:path"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; + import { CONFIG_DIR_NAME } from "../config"; export interface SSHConnectionTarget { @@ -25,32 +26,28 @@ export interface SSHHostInfo { compatEnabled: boolean; } -const CONTROL_DIR = join(homedir(), CONFIG_DIR_NAME, "ssh-control"); -const CONTROL_PATH = join(CONTROL_DIR, "%h.sock"); -const HOST_INFO_DIR = join(homedir(), CONFIG_DIR_NAME, "remote-host"); +const CONTROL_DIR = path.join(homedir(), CONFIG_DIR_NAME, "ssh-control"); +const CONTROL_PATH = path.join(CONTROL_DIR, "%h.sock"); +const HOST_INFO_DIR = path.join(homedir(), CONFIG_DIR_NAME, "remote-host"); const HOST_INFO_VERSION = 2; const activeHosts = new Map(); const pendingConnections = new Map>(); const hostInfoCache = new Map(); -function ensureControlDir(): void { - if (!existsSync(CONTROL_DIR)) { - mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 }); - } +async function ensureControlDir(): Promise { + await fs.mkdir(CONTROL_DIR, { recursive: true, mode: 0o700 }); try { - chmodSync(CONTROL_DIR, 0o700); + await fs.chmod(CONTROL_DIR, 0o700); } catch (err) { logger.debug("SSH control dir chmod failed", { path: CONTROL_DIR, error: String(err) }); } } -function ensureHostInfoDir(): void { - if (!existsSync(HOST_INFO_DIR)) { - mkdirSync(HOST_INFO_DIR, { recursive: true, mode: 0o700 }); - } +async function ensureHostInfoDir(): Promise { + await fs.mkdir(HOST_INFO_DIR, { recursive: true, mode: 0o700 }); try { - chmodSync(HOST_INFO_DIR, 0o700); + await fs.chmod(HOST_INFO_DIR, 0o700); } catch (err) { logger.debug("SSH host info dir chmod failed", { path: HOST_INFO_DIR, error: String(err) }); } @@ -62,15 +59,20 @@ function sanitizeHostName(name: string): string { } function getHostInfoPath(name: string): string { - return join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`); + return path.join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`); } -function validateKeyPermissions(keyPath?: string): void { +async function validateKeyPermissions(keyPath?: string): Promise { if (!keyPath) return; - if (!existsSync(keyPath)) { - throw new Error(`SSH key not found: ${keyPath}`); + let stats: Awaited>; + try { + stats = await fs.stat(keyPath); + } catch (err) { + if (isEnoent(err)) { + throw new Error(`SSH key not found: ${keyPath}`); + } + throw err; } - const stats = statSync(keyPath); if (!stats.isFile()) { throw new Error(`SSH key is not a file: ${keyPath}`); } @@ -208,31 +210,31 @@ function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): bo return false; } -function loadHostInfoFromDisk(host: SSHConnectionTarget): SSHHostInfo | undefined { +async function loadHostInfoFromDisk(host: SSHConnectionTarget): Promise { const path = getHostInfoPath(host.name); - if (!existsSync(path)) return undefined; try { - const raw = readFileSync(path, "utf-8"); + const raw = await fs.readFile(path, "utf-8"); const parsed = parseHostInfo(JSON.parse(raw)); if (!parsed) return undefined; const resolved = applyCompatOverride(host, parsed); hostInfoCache.set(host.name, resolved); return resolved; } catch (err) { + if (isEnoent(err)) return undefined; logger.warn("Failed to load SSH host info", { host: host.name, error: String(err) }); return undefined; } } -function loadHostInfoFromDiskByName(hostName: string): SSHHostInfo | undefined { +async function loadHostInfoFromDiskByName(hostName: string): Promise { const path = getHostInfoPath(hostName); - if (!existsSync(path)) return undefined; try { - const raw = readFileSync(path, "utf-8"); + const raw = await fs.readFile(path, "utf-8"); const parsed = parseHostInfo(JSON.parse(raw)); if (!parsed) return undefined; return parsed; } catch (err) { + if (isEnoent(err)) return undefined; logger.warn("Failed to load SSH host info", { host: hostName, error: String(err) }); return undefined; } @@ -240,7 +242,7 @@ function loadHostInfoFromDiskByName(hostName: string): SSHHostInfo | undefined { async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Promise { try { - ensureHostInfoDir(); + await ensureHostInfoDir(); const path = getHostInfoPath(host.name); const payload = { ...info, version: HOST_INFO_VERSION }; hostInfoCache.set(host.name, payload); @@ -252,7 +254,7 @@ async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Pr async function probeHostInfo(host: SSHConnectionTarget): Promise { const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"'; - const result = await runSshCaptureSync(buildRemoteCommand(host, command)); + const result = await runSshCaptureSync(await buildRemoteCommand(host, command)); if (result.exitCode !== 0 && !result.stdout) { logger.debug("SSH host probe failed", { host: host.name, error: result.stderr }); const fallback: SSHHostInfo = { @@ -315,11 +317,11 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise { const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash"); let compatShell: SSHHostInfo["compatShell"]; if (os === "windows" && host.compat !== false) { - const bashProbe = await runSshCaptureSync(buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"')); + const bashProbe = await runSshCaptureSync(await buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"')); if (bashProbe.exitCode === 0 && bashProbe.stdout.includes("OMP_BASH_OK")) { compatShell = "bash"; } else { - const shProbe = await runSshCaptureSync(buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"')); + const shProbe = await runSshCaptureSync(await buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"')); if (shProbe.exitCode === 0 && shProbe.stdout.includes("OMP_SH_OK")) { compatShell = "sh"; } @@ -344,18 +346,20 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise { return info; } -export function getHostInfo(hostName: string): SSHHostInfo | undefined { - return hostInfoCache.get(hostName) ?? loadHostInfoFromDiskByName(hostName); +export async function getHostInfo(hostName: string): Promise { + const cached = hostInfoCache.get(hostName); + if (cached) return cached; + return loadHostInfoFromDiskByName(hostName); } -export function getHostInfoForHost(host: SSHConnectionTarget): SSHHostInfo | undefined { +export async function getHostInfoForHost(host: SSHConnectionTarget): Promise { const cached = hostInfoCache.get(host.name); if (cached) { const resolved = applyCompatOverride(host, cached); if (resolved !== cached) hostInfoCache.set(host.name, resolved); return resolved; } - return loadHostInfoFromDisk(host); + return await loadHostInfoFromDisk(host); } export async function ensureHostInfo(host: SSHConnectionTarget): Promise { @@ -365,7 +369,7 @@ export async function ensureHostInfo(host: SSHConnectionTarget): Promise { + await validateKeyPermissions(host.keyPath); return [...buildCommonArgs(host), buildSshTarget(host), command]; } @@ -388,14 +392,14 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise const promise = (async () => { ensureSshBinary(); - ensureControlDir(); - validateKeyPermissions(host.keyPath); + await ensureControlDir(); + await validateKeyPermissions(host.keyPath); const target = buildSshTarget(host); const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]); if (check.exitCode === 0) { activeHosts.set(key, host); - if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) { + if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) { await probeHostInfo(host); } return; @@ -408,7 +412,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise } activeHosts.set(key, host); - if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) { + if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) { await probeHostInfo(host); } })(); diff --git a/packages/coding-agent/src/ssh/ssh-executor.ts b/packages/coding-agent/src/ssh/ssh-executor.ts index a234c1a44..d605d0e25 100644 --- a/packages/coding-agent/src/ssh/ssh-executor.ts +++ b/packages/coding-agent/src/ssh/ssh-executor.ts @@ -76,7 +76,7 @@ export async function executeSSH( } } - const child = cspawn(["ssh", ...buildRemoteCommand(host, resolvedCommand)], { + const child = cspawn(["ssh", ...(await buildRemoteCommand(host, resolvedCommand))], { signal: options?.signal, timeout: options?.timeout, }); diff --git a/packages/coding-agent/src/ssh/sshfs-mount.ts b/packages/coding-agent/src/ssh/sshfs-mount.ts index 520e2d8e3..ba1b5917e 100644 --- a/packages/coding-agent/src/ssh/sshfs-mount.ts +++ b/packages/coding-agent/src/ssh/sshfs-mount.ts @@ -1,25 +1,21 @@ -import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import * as fs from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import * as path from "node:path"; import { $ } from "bun"; import { CONFIG_DIR_NAME } from "../config"; import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager"; -const REMOTE_DIR = join(homedir(), CONFIG_DIR_NAME, "remote"); +const REMOTE_DIR = path.join(homedir(), CONFIG_DIR_NAME, "remote"); const CONTROL_DIR = getControlDir(); const CONTROL_PATH = getControlPathTemplate(); const mountedPaths = new Set(); -function ensureDir(path: string, mode = 0o700): void { - if (!existsSync(path)) { - mkdirSync(path, { recursive: true, mode }); - } +async function ensureDir(path: string, mode = 0o700): Promise { try { - chmodSync(path, mode); - } catch (err) { - logger.debug("SSHFS dir chmod failed", { path, error: String(err) }); + await fs.promises.mkdir(path, { recursive: true, mode }); + } catch { + await fs.promises.chmod(path, mode).catch((e) => void e); } } @@ -30,7 +26,7 @@ function getMountName(host: SSHConnectionTarget): string { } function getMountPath(host: SSHConnectionTarget): string { - return join(REMOTE_DIR, getMountName(host)); + return path.join(REMOTE_DIR, getMountName(host)); } function buildSshTarget(host: SSHConnectionTarget): string { @@ -95,11 +91,8 @@ export async function isMounted(path: string): Promise { export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"): Promise { if (!hasSshfs()) return undefined; - ensureDir(REMOTE_DIR); - ensureDir(CONTROL_DIR); - const mountPath = getMountPath(host); - ensureDir(mountPath); + await Promise.all([ensureDir(REMOTE_DIR), ensureDir(CONTROL_DIR), ensureDir(mountPath)]); if (await isMounted(mountPath)) { mountedPaths.add(mountPath); diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index bf96c2e7b..0fba43dfe 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -2,9 +2,8 @@ * System prompt construction and project context loading */ -import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { $ } from "bun"; import chalk from "chalk"; import { contextFileCapability } from "./capability/context-file"; @@ -430,14 +429,15 @@ interface SystemInfoCache { } function getSystemInfoCachePath(): string { - return join(homedir(), ".omp", "system_info.json"); + return path.join(homedir(), ".omp", "system_info.json"); } async function loadSystemInfoCache(): Promise { try { const cachePath = getSystemInfoCachePath(); - if (!existsSync(cachePath)) return null; - const content = await Bun.file(cachePath).json(); + const file = Bun.file(cachePath); + if (!(await file.exists())) return null; + const content = await file.json(); return content as SystemInfoCache; } catch { return null; diff --git a/packages/coding-agent/src/task/agents.ts b/packages/coding-agent/src/task/agents.ts index 4d9642172..71fe5748c 100644 --- a/packages/coding-agent/src/task/agents.ts +++ b/packages/coding-agent/src/task/agents.ts @@ -74,25 +74,51 @@ const EMBEDDED_AGENTS: { name: string; content: string }[] = EMBEDDED_AGENT_DEFS content: buildAgentContent(def), })); +export class AgentParsingError extends Error { + constructor( + error: Error, + public readonly source?: unknown, + ) { + super(`Failed to parse agent: ${error.message}`, { cause: error }); + this.name = "AgentParsingError"; + } + + toString(): string { + const details: string[] = [this.message]; + if (this.source !== undefined) { + details.push(`Source: ${JSON.stringify(this.source)}`); + } + if (this.cause && typeof this.cause === "object" && "stack" in this.cause && this.cause.stack) { + details.push(`Stack:\n${this.cause.stack}`); + } else if (this.stack) { + details.push(`Stack:\n${this.stack}`); + } + return details.join("\n\n"); + } +} + /** * Parse an agent from embedded content. */ -function parseAgent(fileName: string, content: string, source: AgentSource): AgentDefinition | null { +export function parseAgent( + filePath: string, + content: string, + source: AgentSource, + level: "fatal" | "warn" | "off" = "fatal", +): AgentDefinition { const { frontmatter, body } = parseFrontmatter(content, { - source: `embedded:${fileName}`, - level: "fatal", + location: filePath, + level, }); const fields = parseAgentFields(frontmatter); - if (!fields) { - return null; + throw new AgentParsingError(new Error("Invalid agent fields"), filePath); } - return { ...fields, systemPrompt: body, source, - filePath: `embedded:${fileName}`, + filePath, }; } @@ -107,18 +133,8 @@ export function loadBundledAgents(): AgentDefinition[] { if (bundledAgentsCache !== null) { return bundledAgentsCache; } - - const agents: AgentDefinition[] = []; - - for (const { name, content } of EMBEDDED_AGENTS) { - const agent = parseAgent(name, content, "bundled"); - if (agent) { - agents.push(agent); - } - } - - bundledAgentsCache = agents; - return agents; + bundledAgentsCache = EMBEDDED_AGENTS.map(({ name, content }) => parseAgent(`embedded:${name}`, content, "bundled")); + return bundledAgentsCache; } /** diff --git a/packages/coding-agent/src/task/discovery.ts b/packages/coding-agent/src/task/discovery.ts index 478f8406c..5c842b24a 100644 --- a/packages/coding-agent/src/task/discovery.ts +++ b/packages/coding-agent/src/task/discovery.ts @@ -12,12 +12,11 @@ * Agent files use markdown with YAML frontmatter. */ -import * as fs from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { logger } from "@oh-my-pi/pi-utils"; import { findAllNearestProjectConfigDirs, getConfigDirs } from "../config"; -import { parseAgentFields } from "../discovery/helpers"; -import { parseFrontmatter } from "../utils/frontmatter"; -import { loadBundledAgents } from "./agents"; +import { loadBundledAgents, parseAgent } from "./agents"; import type { AgentDefinition, AgentSource } from "./types"; /** Result of agent discovery */ @@ -29,55 +28,23 @@ export interface DiscoveryResult { /** * Load agents from a directory. */ -function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[] { - const agents: AgentDefinition[] = []; - - if (!fs.existsSync(dir)) { - return agents; - } - - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return agents; - } - - for (const entry of entries) { - if (!entry.name.endsWith(".md")) continue; - - const filePath = path.resolve(dir, entry.name); - - // Handle both regular files and symlinks - try { - if (!fs.statSync(filePath).isFile()) continue; - } catch { - continue; - } - - let content: string; - try { - content = fs.readFileSync(filePath, "utf-8"); - } catch { - continue; - } - - const { frontmatter, body } = parseFrontmatter(content, { source: filePath }); - const fields = parseAgentFields(frontmatter); - - if (!fields) { - continue; - } - - agents.push({ - ...fields, - systemPrompt: body, - source, - filePath, +async function loadAgentsFromDir(dir: string, source: AgentSource): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((file) => { + const filePath = path.join(dir, file.name); + return fs + .readFile(filePath, "utf-8") + .then((content) => parseAgent(filePath, content, source, "warn")) + .catch((error) => { + logger.warn("Failed to read agent file", { filePath, error }); + return null; + }); }); - } - return agents; + return (await Promise.all(files)).filter(Boolean) as AgentDefinition[]; } /** @@ -120,26 +87,24 @@ export async function discoverAgents(cwd: string): Promise { if (user) orderedDirs.push({ dir: user.path, source: "user" }); } - const agents: AgentDefinition[] = []; const seen = new Set(); - - for (const { dir, source } of orderedDirs) { - for (const agent of loadAgentsFromDir(dir, source)) { - if (seen.has(agent.name)) continue; - agents.push(agent); + const loadedAgents = (await Promise.all(orderedDirs.map(({ dir, source }) => loadAgentsFromDir(dir, source)))) + .flat() + .filter((agent) => { + if (seen.has(agent.name)) return false; seen.add(agent.name); - } - } + return true; + }); - for (const agent of loadBundledAgents()) { - if (seen.has(agent.name)) continue; - agents.push(agent); + const bundledAgents = loadBundledAgents().filter((agent) => { + if (seen.has(agent.name)) return false; seen.add(agent.name); - } + return true; + }); const projectAgentsDir = projectDirs.length > 0 ? projectDirs[0].path : null; - return { agents, projectAgentsDir }; + return { agents: [...loadedAgents, ...bundledAgents], projectAgentsDir }; } /** diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 09ab5e9cb..9512a3dc9 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -13,7 +13,7 @@ * - Session artifacts for debugging */ -import { mkdir, rm, stat } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; @@ -310,7 +310,7 @@ export class TaskTool implements AgentTool(); @@ -573,7 +573,7 @@ export class TaskTool implements AgentTool ({ patchPath, - size: (await stat(patchPath)).size, + size: (await fs.stat(patchPath)).size, })), ); const nonEmptyPatches = patchStats.filter((patch) => patch.size > 0).map((patch) => patch.patchPath); @@ -604,7 +604,7 @@ export class TaskTool implements AgentTool { await $`git worktree remove -f ${dir}`.cwd(repoRoot).quiet().nothrow(); } } finally { - await rm(dir, { recursive: true, force: true }); + await fs.rm(dir, { recursive: true, force: true }); } } diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 833a17855..806c40a26 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,4 +1,4 @@ -import { relative, resolve, sep } from "node:path"; +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"; @@ -165,13 +165,14 @@ function formatBashCommand(args: BashRenderArgs, uiTheme: Theme): string { let displayWorkdir = args.cwd; if (displayWorkdir) { - const resolvedCwd = resolve(cwd); - const resolvedWorkdir = resolve(displayWorkdir); + const resolvedCwd = path.resolve(cwd); + const resolvedWorkdir = path.resolve(displayWorkdir); if (resolvedWorkdir === resolvedCwd) { displayWorkdir = undefined; } else { - const relativePath = relative(resolvedCwd, resolvedWorkdir); - const isWithinCwd = relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`); + const relativePath = path.relative(resolvedCwd, resolvedWorkdir); + const isWithinCwd = + relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`); if (isWithinCwd) { displayWorkdir = relativePath; } diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index f158df54b..a3decc502 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -1,4 +1,4 @@ -import { mkdir, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; 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"; @@ -450,7 +450,7 @@ async function renderHtmlToText( timeout: number, scratchDir: string, ): Promise<{ content: string; ok: boolean; method: string }> { - await mkdir(scratchDir, { recursive: true }); + await fs.mkdir(scratchDir, { recursive: true }); const tmpFile = path.join(scratchDir, `omp-${nanoid()}.html`); try { @@ -479,7 +479,7 @@ async function renderHtmlToText( return { content: "", ok: false, method: "none" }; } finally { try { - await rm(tmpFile, { force: true }); + await fs.rm(tmpFile, { force: true }); } catch {} } } diff --git a/packages/coding-agent/src/tools/gemini-image.ts b/packages/coding-agent/src/tools/gemini-image.ts index 905354ec6..e71ab837b 100644 --- a/packages/coding-agent/src/tools/gemini-image.ts +++ b/packages/coding-agent/src/tools/gemini-image.ts @@ -1,5 +1,5 @@ import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path 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"; @@ -489,7 +489,7 @@ function getExtensionForMime(mimeType: string): string { async function saveImageToTemp(image: InlineImageData): Promise { const ext = getExtensionForMime(image.mimeType); const filename = `omp-image-${nanoid()}.${ext}`; - const filepath = join(tmpdir(), filename); + const filepath = path.join(tmpdir(), filename); await Bun.write(filepath, Buffer.from(image.data, "base64")); return filepath; } diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index d3f6126d3..5ed47114f 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -72,6 +72,7 @@ import type { ArtifactManager } from "../session/artifacts"; import { TaskTool } from "../task"; import type { AgentOutputManager } from "../task/output-manager"; import type { EventBus } from "../utils/event-bus"; +import { time } from "../utils/timings"; import { WebSearchTool } from "../web/search"; import { AskTool } from "./ask"; import { BashTool } from "./bash"; @@ -213,6 +214,7 @@ function getPythonModeFromEnv(): PythonToolMode | null { * Create tools from BUILTIN_TOOLS registry. */ export async function createTools(session: ToolSession, toolNames?: string[]): Promise { + time("createTools:start"); const includeComplete = session.requireCompleteTool === true; const enableLsp = session.enableLsp ?? true; const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined; @@ -222,18 +224,21 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P pythonMode !== "bash-only" && (requestedTools === undefined || requestedTools.includes("python") || pythonMode === "ipy-only"); const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test"; + const skipPythonWarm = isTestEnv || process.env.OMP_PYTHON_SKIP_CHECK === "1"; if (shouldCheckPython) { const availability = await checkPythonKernelAvailability(session.cwd); + time("createTools:pythonCheck"); pythonAvailable = availability.ok; if (!availability.ok) { logger.warn("Python kernel unavailable, falling back to bash", { reason: availability.reason, }); - } else if (!isTestEnv && getPreludeDocs().length === 0) { + } else if (!skipPythonWarm && getPreludeDocs().length === 0) { const sessionFile = session.getSessionFile?.() ?? undefined; const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`; try { await warmPythonEnvironment(session.cwd, warmSessionId, session.settings?.getPythonSharedGateway?.()); + time("createTools:warmPython"); } catch (err) { logger.warn("Failed to warm Python environment", { error: err instanceof Error ? err.message : String(err), @@ -274,8 +279,24 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P ...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)), ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : []), ]; - const results = await Promise.all(entries.map(([, factory]) => factory(session))); - const tools = results.filter((t): t is Tool => t !== null); + time("createTools:beforeFactories"); + const slowTools: Array<{ name: string; ms: number }> = []; + const results = await Promise.all( + entries.map(async ([name, factory]) => { + const start = performance.now(); + const tool = await factory(session); + const elapsed = performance.now() - start; + if (elapsed > 5) { + slowTools.push({ name, ms: Math.round(elapsed) }); + } + return { name, tool }; + }), + ); + time("createTools:afterFactories"); + if (slowTools.length > 0 && process.env.OMP_TIMING === "1") { + console.error(` [slow tools: ${slowTools.map((t) => `${t.name}=${t.ms}ms`).join(", ")}]`); + } + const tools = results.filter((r) => r.tool !== null).map((r) => r.tool as Tool); const wrappedTools = wrapToolsWithMetaNotice(tools); if (filteredRequestedTools !== undefined) { diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 20a08be07..d17c11820 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -1,6 +1,6 @@ -import { accessSync, constants } from "node:fs"; +import * as fs from "node:fs"; import * as os from "node:os"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import * as path from "node:path"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; @@ -15,7 +15,7 @@ function tryMacOSScreenshotPath(filePath: string): string { function fileExists(filePath: string): boolean { try { - accessSync(filePath, constants.F_OK); + fs.accessSync(filePath, fs.constants.F_OK); return true; } catch { return false; @@ -39,10 +39,10 @@ export function expandPath(filePath: string): string { */ export function resolveToCwd(filePath: string, cwd: string): string { const expanded = expandPath(filePath); - if (isAbsolute(expanded)) { + if (path.isAbsolute(expanded)) { return expanded; } - return resolvePath(cwd, expanded); + return path.resolve(cwd, expanded); } export function resolveReadPath(filePath: string, cwd: string): string { diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index e538ec9c9..2dc01af73 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -1,4 +1,4 @@ -import { relative, resolve, sep } from "node:path"; +import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; @@ -177,9 +177,8 @@ export class PythonTool implements AgentTool { // Clamp to reasonable range: 1s - 600s (10 min) timeoutSec = Math.max(1, Math.min(600, timeoutSec)); const timeoutMs = timeoutSec * 1000; - const controller = new AbortController(); - const onAbort = () => controller.abort(); - signal?.addEventListener("abort", onAbort, { once: true }); + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; let outputSink: OutputSink | undefined; let outputSummary: OutputSummary | undefined; let outputDumped = false; @@ -267,7 +266,7 @@ export class PythonTool implements AgentTool { const baseExecutorOptions: Omit = { cwd: commandCwd, timeoutMs, - signal: controller.signal, + signal: combinedSignal, sessionId, kernelMode: this.session.settings?.getPythonKernelMode?.() ?? "session", useSharedGateway: this.session.settings?.getPythonSharedGateway?.() ?? true, @@ -398,7 +397,6 @@ export class PythonTool implements AgentTool { return resultBuilder.done(); } finally { - signal?.removeEventListener("abort", onAbort); if (!outputDumped) { try { await finalizeOutput(); @@ -767,13 +765,14 @@ export const pythonToolRenderer = { let displayWorkdir = args.cwd; if (displayWorkdir) { - const resolvedCwd = resolve(cwd); - const resolvedWorkdir = resolve(displayWorkdir); + const resolvedCwd = path.resolve(cwd); + const resolvedWorkdir = path.resolve(displayWorkdir); if (resolvedWorkdir === resolvedCwd) { displayWorkdir = undefined; } else { - const relativePath = relative(resolvedCwd, resolvedWorkdir); - const isWithinCwd = relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`); + const relativePath = path.relative(resolvedCwd, resolvedWorkdir); + const isWithinCwd = + relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`); if (isWithinCwd) { displayWorkdir = relativePath; } diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 71138d254..7e943649d 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -32,8 +32,8 @@ export interface SSHToolDetails { meta?: OutputMeta; } -function formatHostEntry(host: SSHHost): string { - const info = getHostInfoForHost(host); +async function formatHostEntry(host: SSHHost): Promise { + const info = await getHostInfoForHost(host); let shell: string; if (!info) { @@ -58,12 +58,12 @@ function formatHostEntry(host: SSHHost): string { return `- ${host.name} (${host.host}) | ${shell}`; } -function formatDescription(hosts: SSHHost[]): string { +async function formatDescription(hosts: SSHHost[]): Promise { const baseDescription = renderPromptTemplate(sshDescriptionBase); if (hosts.length === 0) { return baseDescription; } - const hostList = hosts.map(formatHostEntry).join("\n"); + const hostList = (await Promise.all(hosts.map(formatHostEntry))).join("\n"); return `${baseDescription}\n\nAvailable hosts:\n${hostList}`; } @@ -135,17 +135,12 @@ export class SshTool implements AgentTool { private readonly hostsByName: Map; private readonly hostNames: string[]; - constructor(session: ToolSession, hostNames: string[], hostsByName: Map) { + constructor(session: ToolSession, hostNames: string[], hostsByName: Map, description: string) { this.session = session; this.hostNames = hostNames; this.hostsByName = hostsByName; this.allowedHosts = new Set(hostNames); - - const descriptionHosts = hostNames - .map((name) => hostsByName.get(name)) - .filter((host): host is SSHHost => host !== undefined); - - this.description = formatDescription(descriptionHosts); + this.description = description; } public async execute( @@ -214,7 +209,13 @@ export async function loadSshTool(session: ToolSession): Promise if (hostNames.length === 0) { return null; } - return new SshTool(session, hostNames, hostsByName); + + const descriptionHosts = hostNames + .map((name) => hostsByName.get(name)) + .filter((host): host is SSHHost => host !== undefined); + const description = await formatDescription(descriptionHosts); + + return new SshTool(session, hostNames, hostsByName, description); } // ============================================================================= diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 292644a76..6a0ed71c0 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; export interface ChangelogEntry { major: number; @@ -11,13 +11,9 @@ export interface ChangelogEntry { * Parse changelog entries from CHANGELOG.md * Scans for ## lines and collects content until next ## or EOF */ -export function parseChangelog(changelogPath: string): ChangelogEntry[] { - if (!existsSync(changelogPath)) { - return []; - } - +export async function parseChangelog(changelogPath: string): Promise { try { - const content = readFileSync(changelogPath, "utf-8"); + const content = await Bun.file(changelogPath).text(); const lines = content.split("\n"); const entries: ChangelogEntry[] = []; @@ -65,7 +61,10 @@ export function parseChangelog(changelogPath: string): ChangelogEntry[] { return entries; } catch (error) { - console.error(`Warning: Could not parse changelog: ${error}`); + if (isEnoent(error)) { + return []; + } + logger.error(`Warning: Could not parse changelog: ${error}`); return []; } } diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 79f1f9128..6e6e43225 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,4 +1,4 @@ -import { unlink } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { platform } from "node:os"; import { $ } from "bun"; import { nanoid } from "nanoid"; @@ -207,7 +207,7 @@ async function readImageMacOS(): Promise { const file = Bun.file(tempFile); if (await file.exists()) { const buffer = await file.bytes(); - await unlink(tempFile).catch(() => {}); + await fs.unlink(tempFile).catch(() => {}); if (buffer.length > 0) { return { diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts index 734c720c8..06da3ede8 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -38,7 +38,9 @@ export class FrontmatterError extends Error { } export interface FrontmatterOptions { - /** Source of the content */ + /** Source of the content (alias: source) */ + location?: unknown; + /** Source of the content (alias for location) */ source?: unknown; /** Fallback frontmatter values */ fallback?: Record; @@ -56,7 +58,8 @@ export function parseFrontmatter( content: string, options?: FrontmatterOptions, ): { frontmatter: Record; body: string } { - const { source, fallback, normalize = true, level = "warn" } = options ?? {}; + const { location, source, fallback, normalize = true, level = "warn" } = options ?? {}; + const loc = location ?? source; const frontmatter: Record = Object.assign({}, fallback); const normalized = normalize ? stripHtmlComments(content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")) : content; @@ -77,7 +80,7 @@ export function parseFrontmatter( const loaded = YAML.parse(metadata.replaceAll("\t", " ")) as Record | null; return { frontmatter: Object.assign(frontmatter, loaded), body: body }; } catch (error) { - const err = new FrontmatterError(toError(error), source ?? `Inline '${truncate(content, 64)}'`); + const err = new FrontmatterError(toError(error), loc ?? `Inline '${truncate(content, 64)}'`); if (level === "warn" || level === "fatal") { logger.warn("Failed to parse YAML frontmatter", { err: err.toString() }); } diff --git a/packages/coding-agent/src/utils/mime.ts b/packages/coding-agent/src/utils/mime.ts index f9ded46e2..3653f632b 100644 --- a/packages/coding-agent/src/utils/mime.ts +++ b/packages/coding-agent/src/utils/mime.ts @@ -1,4 +1,4 @@ -import { open } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { fileTypeFromBuffer } from "file-type"; const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); @@ -6,7 +6,7 @@ const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image const FILE_TYPE_SNIFF_BYTES = 4100; export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise { - const fileHandle = await open(filePath, "r"); + const fileHandle = await fs.open(filePath, "r"); try { const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES); const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0); diff --git a/packages/coding-agent/src/utils/shell-snapshot.ts b/packages/coding-agent/src/utils/shell-snapshot.ts index cd59eac0c..c99ea29a2 100644 --- a/packages/coding-agent/src/utils/shell-snapshot.ts +++ b/packages/coding-agent/src/utils/shell-snapshot.ts @@ -6,10 +6,9 @@ * shell experience. */ -import { unlinkSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; +import * as fs from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { postmortem } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; @@ -20,9 +19,9 @@ let cachedSnapshotPath: string | null = null; */ function getShellConfigFile(shell: string): string { const home = homedir(); - if (shell.includes("zsh")) return join(home, ".zshrc"); - if (shell.includes("bash")) return join(home, ".bashrc"); - return join(home, ".profile"); + if (shell.includes("zsh")) return path.join(home, ".zshrc"); + if (shell.includes("bash")) return path.join(home, ".bashrc"); + return path.join(home, ".profile"); } /** @@ -131,12 +130,12 @@ export async function getOrCreateSnapshot( const rcFile = getShellConfigFile(shell); // Create snapshot directory - const snapshotDir = join(tmpdir(), "omp-shell-snapshots"); - await mkdir(snapshotDir, { recursive: true }); + const snapshotDir = path.join(tmpdir(), "omp-shell-snapshots"); + await fs.promises.mkdir(snapshotDir, { recursive: true }); // Generate unique snapshot path const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh"; - const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`); + const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`); // Generate and execute snapshot script const script = await generateSnapshotScript(shell, snapshotPath, rcFile); @@ -167,6 +166,6 @@ export function getSnapshotSourceCommand(snapshotPath: string | null): string { postmortem.register("shell-snapshot", () => { if (cachedSnapshotPath) { - unlinkSync(cachedSnapshotPath); + fs.unlinkSync(cachedSnapshotPath); } }); diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index b50277f9c..c912ae4ef 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,5 +1,4 @@ -import { constants } from "node:fs"; -import { access } from "node:fs/promises"; +import * as fs from "node:fs"; import { $ } from "bun"; import { SettingsManager } from "../config/settings-manager"; @@ -17,7 +16,7 @@ let cachedShellConfig: ShellConfig | null = null; */ async function isExecutable(path: string): Promise { try { - await access(path, constants.X_OK); + await fs.promises.access(path, fs.constants.X_OK); return true; } catch { return false; diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 4af4b0758..c95fb9c84 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,7 +1,7 @@ -import { chmod, mkdir, rename, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { arch, platform } from "node:os"; -import { join } from "node:path"; -import { createTempDir, logger } from "@oh-my-pi/pi-utils"; +import * as path from "node:path"; +import { logger, TempDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { APP_NAME, getBinDir } from "../config"; @@ -148,7 +148,7 @@ export async function getToolPath(tool: ToolName): Promise { if (!config) return null; // Check our tools directory first - const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); + const localPath = path.join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); if (await Bun.file(localPath).exists()) { return localPath; } @@ -200,64 +200,64 @@ async function downloadTool(tool: ToolName): Promise { } // Create tools directory - await mkdir(TOOLS_DIR, { recursive: true }); + await fs.mkdir(TOOLS_DIR, { recursive: true }); const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`; const binaryExt = plat === "win32" ? ".exe" : ""; - const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt); + const binaryPath = path.join(TOOLS_DIR, config.binaryName + binaryExt); // Handle direct binary downloads (no archive extraction needed) if (config.isDirectBinary) { await downloadFile(downloadUrl, binaryPath); if (plat !== "win32") { - await chmod(binaryPath, 0o755); + await fs.chmod(binaryPath, 0o755); } return binaryPath; } // Download archive - const archivePath = join(TOOLS_DIR, assetName); + const archivePath = path.join(TOOLS_DIR, assetName); await downloadFile(downloadUrl, archivePath); // Extract - const tmp = await createTempDir("@omp-tools-extract-"); + const tmp = await TempDir.create("@omp-tools-extract-"); try { if (assetName.endsWith(".tar.gz")) { const archive = new Bun.Archive(await Bun.file(archivePath).arrayBuffer()); const files = await archive.files(); - for (const [path, file] of files) { - await Bun.write(join(tmp.path, path), file); + for (const [filePath, file] of files) { + await Bun.write(path.join(tmp.path(), filePath), file); } } else if (assetName.endsWith(".zip")) { - await mkdir(tmp.path, { recursive: true }); - await $`unzip -o ${archivePath} -d ${tmp.path}`.quiet().nothrow(); + await fs.mkdir(tmp.path(), { recursive: true }); + await $`unzip -o ${archivePath} -d ${tmp.path()}`.quiet().nothrow(); } // Find the binary in extracted files // ast-grep releases the binary directly in the zip, not in a subdirectory let extractedBinary: string; if (tool === "sg") { - extractedBinary = join(tmp.path, config.binaryName + binaryExt); + extractedBinary = path.join(tmp.path(), config.binaryName + binaryExt); } else { - const extractedDir = join(tmp.path, assetName.replace(/\.(tar\.gz|zip)$/, "")); - extractedBinary = join(extractedDir, config.binaryName + binaryExt); + const extractedDir = path.join(tmp.path(), assetName.replace(/\.(tar\.gz|zip)$/, "")); + extractedBinary = path.join(extractedDir, config.binaryName + binaryExt); } if (await Bun.file(extractedBinary).exists()) { - await rename(extractedBinary, binaryPath); + await fs.rename(extractedBinary, binaryPath); } else { throw new Error(`Binary not found in archive: ${extractedBinary}`); } // Make executable (Unix only) if (plat !== "win32") { - await chmod(binaryPath, 0o755); + await fs.chmod(binaryPath, 0o755); } } finally { // Cleanup await tmp.remove(); - await rm(archivePath, { force: true }); + await fs.rm(archivePath, { force: true }); } return binaryPath; diff --git a/packages/coding-agent/src/web/scrapers/utils.ts b/packages/coding-agent/src/web/scrapers/utils.ts index 35dab425f..c482d5b72 100644 --- a/packages/coding-agent/src/web/scrapers/utils.ts +++ b/packages/coding-agent/src/web/scrapers/utils.ts @@ -1,4 +1,4 @@ -import { rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { ptree } from "@oh-my-pi/pi-utils"; @@ -61,7 +61,7 @@ export async function convertWithMarkitdown( return { content: stdout, ok: true }; } finally { try { - await rm(tmpFile, { force: true }); + await fs.rm(tmpFile, { force: true }); } catch {} } } diff --git a/packages/coding-agent/src/web/scrapers/youtube.ts b/packages/coding-agent/src/web/scrapers/youtube.ts index ac02dbc26..65f11dc85 100644 --- a/packages/coding-agent/src/web/scrapers/youtube.ts +++ b/packages/coding-agent/src/web/scrapers/youtube.ts @@ -1,4 +1,4 @@ -import { unlinkSync } from "node:fs"; +import * as fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { cspawn } from "@oh-my-pi/pi-utils"; @@ -302,15 +302,10 @@ export const handleYouTube: SpecialHandler = async ( } } } finally { - // Cleanup temp files using sync unlink to avoid leaving handles open - try { - const tmpFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*`).scan({ absolute: true })); - for (const f of tmpFiles) { - try { - unlinkSync(f); - } catch {} - } - } catch {} + // Cleanup temp files (fire-and-forget with error suppression) + Array.fromAsync(new Bun.Glob(`${tmpBase}*`).scan({ absolute: true })) + .then((tmpFiles) => Promise.all(tmpFiles.map((f) => fs.unlink(f).catch(() => {})))) + .catch(() => {}); } // Build markdown output diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index d62b0405e..2e378d27f 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -5,7 +5,6 @@ * Returns structured search results with optional content extraction. */ -import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types"; import { WebSearchProviderError } from "../../../web/search/types"; @@ -30,9 +29,7 @@ export interface ExaSearchParams { async function parseEnvFile(filePath: string): Promise> { const result: Record = {}; try { - if (!existsSync(filePath)) return result; - - const content = readFileSync(filePath, "utf-8"); + const content = await Bun.file(filePath).text(); for (const line of content.split("\n")) { let trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -54,7 +51,7 @@ async function parseEnvFile(filePath: string): Promise> { result[key] = value; } } catch { - // Ignore read errors + // Ignore read errors (including ENOENT for missing files) } return result; } diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index dae45be57..0240d6df1 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -8,9 +8,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -29,16 +29,16 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { beforeEach(() => { // Create temp directory for session files - tempDir = join(tmpdir(), `pi-branching-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `pi-branching-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { if (session) { session.dispose(); } - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -63,7 +63,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir); const settingsManager = await SettingsManager.create(tempDir, tempDir); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage, tempDir); session = new AgentSession({ @@ -101,7 +101,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { // Session file should exist (new branch) expect(session.sessionFile).not.toBeNull(); - expect(existsSync(session.sessionFile!)).toBe(true); + expect(fs.existsSync(session.sessionFile!)).toBe(true); }); it("should support in-memory branching in --no-session mode", async () => { diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index d39034239..763a07261 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -8,9 +8,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -30,8 +30,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { beforeEach(() => { // Create temp directory for session files - tempDir = join(tmpdir(), `omp-compaction-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `omp-compaction-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); // Track events events = []; @@ -41,8 +41,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { if (session) { session.dispose(); } - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -69,7 +69,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { const settingsManager = await SettingsManager.create(tempDir, tempDir); // Use minimal keepRecentTokens so small test conversations have something to summarize settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); session = new AgentSession({ diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 91b7ae4d9..0f7314d81 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -3,9 +3,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -54,16 +54,16 @@ describe("AgentSession concurrent prompt guard", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-concurrent-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `pi-concurrent-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { if (session) { session.dispose(); } - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -99,7 +99,7 @@ describe("AgentSession concurrent prompt guard", () => { const sessionManager = SessionManager.inMemory(); const settingsManager = await SettingsManager.create(tempDir, tempDir); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage, tempDir); // Set a runtime API key so validation passes authStorage.setRuntimeApiKey("anthropic", "test-key"); @@ -190,7 +190,7 @@ describe("AgentSession concurrent prompt guard", () => { const sessionManager = SessionManager.inMemory(); const settingsManager = await SettingsManager.create(tempDir, tempDir); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage, tempDir); authStorage.setRuntimeApiKey("anthropic", "test-key"); diff --git a/packages/coding-agent/test/block-images.test.ts b/packages/coding-agent/test/block-images.test.ts index e68307e91..2d8176a1f 100644 --- a/packages/coding-agent/test/block-images.test.ts +++ b/packages/coding-agent/test/block-images.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { processFileArguments } from "@oh-my-pi/pi-coding-agent/cli/file-processor"; import { SettingsManager } from "@oh-my-pi/pi-coding-agent/config/settings-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; @@ -56,18 +56,18 @@ describe("blockImages setting", () => { let testDir: string; beforeEach(() => { - testDir = join(tmpdir(), `block-images-test-${Date.now()}-${Math.random()}`); - mkdirSync(testDir, { recursive: true }); + testDir = path.join(tmpdir(), `block-images-test-${Date.now()}-${Math.random()}`); + fs.mkdirSync(testDir, { recursive: true }); }); afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); + fs.rmSync(testDir, { recursive: true, force: true }); }); it("should always read images (filtering happens at convertToLlm layer)", async () => { // Create test image - const imagePath = join(testDir, "test.png"); - writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + const imagePath = path.join(testDir, "test.png"); + fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); const tool = new ReadTool(createTestToolSession(testDir)); const result = await tool.execute("test-1", { path: imagePath }); @@ -80,8 +80,8 @@ describe("blockImages setting", () => { it("should read text files normally", async () => { // Create test text file - const textPath = join(testDir, "test.txt"); - writeFileSync(textPath, "Hello, world!"); + const textPath = path.join(testDir, "test.txt"); + fs.writeFileSync(textPath, "Hello, world!"); const tool = new ReadTool(createTestToolSession(testDir)); const result = await tool.execute("test-2", { path: textPath }); @@ -97,18 +97,18 @@ describe("blockImages setting", () => { let testDir: string; beforeEach(() => { - testDir = join(tmpdir(), `block-images-process-test-${Date.now()}-${Math.random()}`); - mkdirSync(testDir, { recursive: true }); + testDir = path.join(tmpdir(), `block-images-process-test-${Date.now()}-${Math.random()}`); + fs.mkdirSync(testDir, { recursive: true }); }); afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); + fs.rmSync(testDir, { recursive: true, force: true }); }); it("should always process images (filtering happens at convertToLlm layer)", async () => { // Create test image - const imagePath = join(testDir, "test.png"); - writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + const imagePath = path.join(testDir, "test.png"); + fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); const result = await processFileArguments([imagePath]); @@ -118,8 +118,8 @@ describe("blockImages setting", () => { it("should process text files normally", async () => { // Create test text file - const textPath = join(testDir, "test.txt"); - writeFileSync(textPath, "Hello, world!"); + const textPath = path.join(testDir, "test.txt"); + fs.writeFileSync(textPath, "Hello, world!"); const result = await processFileArguments([textPath]); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 5942aec80..f0e56e757 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -3,9 +3,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -33,8 +33,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { let capturedEvents: SessionEvent[]; beforeEach(() => { - tempDir = join(tmpdir(), `omp-compaction-hooks-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `omp-compaction-hooks-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); capturedEvents = []; }); @@ -42,8 +42,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { if (session) { session.dispose(); } - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -104,7 +104,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const sessionManager = SessionManager.create(tempDir); const settingsManager = await SettingsManager.create(tempDir, tempDir); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry); diff --git a/packages/coding-agent/test/compaction-thinking-model.test.ts b/packages/coding-agent/test/compaction-thinking-model.test.ts index b8da25b1d..1178902b7 100644 --- a/packages/coding-agent/test/compaction-thinking-model.test.ts +++ b/packages/coding-agent/test/compaction-thinking-model.test.ts @@ -9,9 +9,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { getModel, type Model } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -32,16 +32,16 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-thinking-compaction-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `pi-thinking-compaction-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { if (session) { session.dispose(); } - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -75,7 +75,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra const sessionManager = SessionManager.inMemory(); const settingsManager = await SettingsManager.create(tempDir, tempDir); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); session = new AgentSession({ @@ -142,16 +142,16 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-thinking-compaction-anthropic-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `pi-thinking-compaction-anthropic-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { if (session) { session.dispose(); } - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -177,7 +177,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic const sessionManager = SessionManager.inMemory(); const settingsManager = await SettingsManager.create(tempDir, tempDir); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); session = new AgentSession({ diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index b93ce272e..c624d7dd6 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { getModel } from "@oh-my-pi/pi-ai"; @@ -29,9 +28,9 @@ import { // Test fixtures // ============================================================================ -function loadLargeSessionEntries(): SessionEntry[] { - const sessionPath = join(__dirname, "fixtures/large-session.jsonl"); - const content = readFileSync(sessionPath, "utf-8"); +async function loadLargeSessionEntries(): Promise { + const sessionPath = path.join(__dirname, "fixtures/large-session.jsonl"); + const content = await Bun.file(sessionPath).text(); const entries = parseSessionEntries(content); migrateSessionEntries(entries); // Add id/parentId for v1 fixtures return entries.filter((e): e is SessionEntry => e.type !== "session"); @@ -362,16 +361,16 @@ describe("buildSessionContext", () => { // ============================================================================ describe("Large session fixture", () => { - it("should parse the large session", () => { - const entries = loadLargeSessionEntries(); + it("should parse the large session", async () => { + const entries = await loadLargeSessionEntries(); expect(entries.length).toBeGreaterThan(100); const messageCount = entries.filter((e) => e.type === "message").length; expect(messageCount).toBeGreaterThan(100); }); - it("should find cut point in large session", () => { - const entries = loadLargeSessionEntries(); + it("should find cut point in large session", async () => { + const entries = await loadLargeSessionEntries(); const result = findCutPoint(entries, 0, entries.length, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens); // Cut point should be at a message entry (user or assistant) @@ -380,8 +379,8 @@ describe("Large session fixture", () => { expect(role === "user" || role === "assistant").toBe(true); }); - it("should load session correctly", () => { - const entries = loadLargeSessionEntries(); + it("should load session correctly", async () => { + const entries = await loadLargeSessionEntries(); const loaded = buildSessionContext(entries); expect(loaded.messages.length).toBeGreaterThan(100); @@ -395,7 +394,7 @@ describe("Large session fixture", () => { describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { it("should generate a compaction result for the large session", async () => { - const entries = loadLargeSessionEntries(); + const entries = await loadLargeSessionEntries(); const model = getModel("anthropic", "claude-sonnet-4-5")!; const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS); @@ -415,7 +414,7 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { }, 60000); it("should produce valid session after compaction", async () => { - const entries = loadLargeSessionEntries(); + const entries = await loadLargeSessionEntries(); const loaded = buildSessionContext(entries); const model = getModel("anthropic", "claude-sonnet-4-5")!; diff --git a/packages/coding-agent/test/core/apply-patch-adverserial.test.ts b/packages/coding-agent/test/core/apply-patch-adverserial.test.ts index 4a41abedf..45f61736c 100644 --- a/packages/coding-agent/test/core/apply-patch-adverserial.test.ts +++ b/packages/coding-agent/test/core/apply-patch-adverserial.test.ts @@ -1,38 +1,38 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { ApplyPatchError, applyPatch } from "@oh-my-pi/pi-coding-agent/patch"; describe("applyPatch adversarial inputs", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `apply-patch-adversarial-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `apply-patch-adversarial-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { try { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); test("rejects rename when it matches path", async () => { - const filePath = join(tempDir, "same.txt"); + const filePath = path.join(tempDir, "same.txt"); await Bun.write(filePath, "foo\n"); await expect( applyPatch({ path: "same.txt", op: "update", rename: "same.txt", diff: "@@\n-foo\n+bar" }, { cwd: tempDir }), ).rejects.toThrow(ApplyPatchError); - expect(readFileSync(filePath, "utf-8")).toBe("foo\n"); + expect(await Bun.file(filePath).text()).toBe("foo\n"); }); test("respects changeContext for pure additions", async () => { - const filePath = join(tempDir, "add-context.ts"); + const filePath = path.join(tempDir, "add-context.ts"); await Bun.write(filePath, "function foo() {\n return 1;\n}\nfunction bar() {\n return 2;\n}\n"); await applyPatch( @@ -44,13 +44,13 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe( + expect(await Bun.file(filePath).text()).toBe( "function foo() {\n return 1;\n}\nfunction bar() {\n console.log('x');\n return 2;\n}\n", ); }); test("rejects multi-file patch markers in a single-file update", async () => { - const filePath = join(tempDir, "single.txt"); + const filePath = path.join(tempDir, "single.txt"); await Bun.write(filePath, "foo\nbar\n"); await expect( @@ -66,7 +66,7 @@ describe("applyPatch adversarial inputs", () => { }); test("preserves context lines that look like diff metadata", async () => { - const filePath = join(tempDir, "metadata-context.txt"); + const filePath = path.join(tempDir, "metadata-context.txt"); await Bun.write(filePath, "diff --git a b\nalpha\nmid\ndiff --git a b\nalpha\n"); await applyPatch( @@ -78,11 +78,11 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("diff --git a b\nALPHA\nmid\ndiff --git a b\nalpha\n"); + expect(await Bun.file(filePath).text()).toBe("diff --git a b\nALPHA\nmid\ndiff --git a b\nalpha\n"); }); test("applies hunks regardless of order", async () => { - const filePath = join(tempDir, "order.txt"); + const filePath = path.join(tempDir, "order.txt"); await Bun.write(filePath, "first\nkeep\nsecond\nkeep\n"); await applyPatch( @@ -94,11 +94,11 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("first\nKEEP1\nsecond\nKEEP2\n"); + expect(await Bun.file(filePath).text()).toBe("first\nKEEP1\nsecond\nKEEP2\n"); }); test("rejects ambiguous changeContext matches", async () => { - const filePath = join(tempDir, "ambiguous-context.ts"); + const filePath = path.join(tempDir, "ambiguous-context.ts"); await Bun.write(filePath, "if (a) {\n return foo;\n}\nif (b) {\n return foo;\n}\n"); await expect( @@ -114,7 +114,7 @@ describe("applyPatch adversarial inputs", () => { }); test("rejects ambiguous prefix/substring matches", async () => { - const filePath = join(tempDir, "ambiguous-prefix.ts"); + const filePath = path.join(tempDir, "ambiguous-prefix.ts"); await Bun.write(filePath, "const enabled = true;\nconst enabled = true; // secondary\n"); await expect( @@ -130,7 +130,7 @@ describe("applyPatch adversarial inputs", () => { }); test("rejects out-of-range line hints for insertions", async () => { - const filePath = join(tempDir, "line-hint.txt"); + const filePath = path.join(tempDir, "line-hint.txt"); await Bun.write(filePath, "a\nb\n"); await expect( @@ -146,7 +146,7 @@ describe("applyPatch adversarial inputs", () => { }); test("retains trailing blank context lines for disambiguation", async () => { - const filePath = join(tempDir, "blank-context.txt"); + const filePath = path.join(tempDir, "blank-context.txt"); await Bun.write(filePath, "section\nvalue\nx\nsection\nvalue\n\n"); await applyPatch( @@ -158,11 +158,11 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("section\nvalue\nx\nsection\nVALUE\n\n"); + expect(await Bun.file(filePath).text()).toBe("section\nvalue\nx\nsection\nVALUE\n\n"); }); test("fuzzy under-indented context can shift indentation", async () => { - const filePath = join(tempDir, "under-indent.ts"); + const filePath = path.join(tempDir, "under-indent.ts"); await Bun.write( filePath, "function sum(values, offset) {\n" + @@ -194,7 +194,7 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe( + expect(await Bun.file(filePath).text()).toBe( "function sum(values, offset) {\n" + " let total = 0;\n" + ' const tag = "sum";\n' + @@ -208,37 +208,37 @@ describe("applyPatch adversarial inputs", () => { }); test("preserves CRLF endings and trailing newline", async () => { - const filePath = join(tempDir, "crlf.txt"); + const filePath = path.join(tempDir, "crlf.txt"); await Bun.write(filePath, "foo\r\nbar\r\n"); await applyPatch({ path: "crlf.txt", op: "update", diff: "@@\n-foo\n+FOO" }, { cwd: tempDir }); - const content = readFileSync(filePath, "utf-8"); + const content = await Bun.file(filePath).text(); expect(content).toBe("FOO\r\nbar\r\n"); }); test("preserves UTF-8 BOM and CRLF endings", async () => { - const filePath = join(tempDir, "bom.txt"); + const filePath = path.join(tempDir, "bom.txt"); await Bun.write(filePath, "\uFEFFfoo\r\nbar\r\n"); await applyPatch({ path: "bom.txt", op: "update", diff: "@@\n-foo\n+FOO" }, { cwd: tempDir }); - const content = readFileSync(filePath, "utf-8"); + const content = await Bun.file(filePath).text(); expect(content).toBe("\uFEFFFOO\r\nbar\r\n"); }); test("preserves missing trailing newline", async () => { - const filePath = join(tempDir, "nonewline.txt"); + const filePath = path.join(tempDir, "nonewline.txt"); await Bun.write(filePath, "foo\nbar"); await applyPatch({ path: "nonewline.txt", op: "update", diff: "@@\n-bar\n+baz" }, { cwd: tempDir }); - const content = readFileSync(filePath, "utf-8"); + const content = await Bun.file(filePath).text(); expect(content).toBe("foo\nbaz"); }); test("normalizes tab-indented diff to space-indented file", async () => { - const filePath = join(tempDir, "tabs-to-spaces.js"); + const filePath = path.join(tempDir, "tabs-to-spaces.js"); // File uses 4-space indentation await Bun.write( filePath, @@ -261,7 +261,7 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - const content = readFileSync(filePath, "utf-8"); + const content = await Bun.file(filePath).text(); // Added line should use spaces, not tabs expect(content).toBe(`class Foo { method() { @@ -276,7 +276,7 @@ describe("applyPatch adversarial inputs", () => { }); test("preserves indentation when trailing context has less indent than additions", async () => { - const filePath = join(tempDir, "dedent-context.js"); + const filePath = path.join(tempDir, "dedent-context.js"); await Bun.write( filePath, `class Foo { @@ -296,7 +296,7 @@ describe("applyPatch adversarial inputs", () => { { cwd: tempDir }, ); - const content = readFileSync(filePath, "utf-8"); + const content = await Bun.file(filePath).text(); // The closing brace of other() should have 4-space indent, not 0 expect(content).toBe(`class Foo { method() { diff --git a/packages/coding-agent/test/core/apply-patch-regression.test.ts b/packages/coding-agent/test/core/apply-patch-regression.test.ts index a2484da74..110f719de 100644 --- a/packages/coding-agent/test/core/apply-patch-regression.test.ts +++ b/packages/coding-agent/test/core/apply-patch-regression.test.ts @@ -7,25 +7,25 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { applyPatch, findContextLine, seekSequence } from "@oh-my-pi/pi-coding-agent/patch"; describe("regression: indentation adjustment for line-based replacements (2B)", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `regression-2b-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `regression-2b-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("line-based patch adjusts indentation when fuzzy matching at different indent level", async () => { - const filePath = join(tempDir, "indent.ts"); + const filePath = path.join(tempDir, "indent.ts"); // File has 4-space indentation await Bun.write( filePath, @@ -52,13 +52,13 @@ describe("regression: indentation adjustment for line-based replacements (2B)", { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain(" this.value = 42;"); expect(result).toContain(' this.name = "updated";'); }); test("multi-hunk patch adjusts indentation independently per hunk", async () => { - const filePath = join(tempDir, "multi-indent.ts"); + const filePath = path.join(tempDir, "multi-indent.ts"); await Bun.write( filePath, `function outer() { @@ -87,7 +87,7 @@ describe("regression: indentation adjustment for line-based replacements (2B)", { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain(" return 10;"); // 4 spaces for inner1 expect(result).toContain(" return 20;"); // 6 spaces for inner2 }); @@ -97,16 +97,16 @@ describe("regression: ambiguity detection for context-less hunks (2C)", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `regression-2c-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `regression-2c-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("single-hunk simple diff rejects multiple occurrences", async () => { - const filePath = join(tempDir, "dupe.txt"); + const filePath = path.join(tempDir, "dupe.txt"); await Bun.write(filePath, "foo\nbar\nfoo\nbaz\n"); await expect( @@ -122,7 +122,7 @@ describe("regression: ambiguity detection for context-less hunks (2C)", () => { }); test("multi-hunk context-less diff rejects ambiguous patterns", async () => { - const filePath = join(tempDir, "multi-dupe.txt"); + const filePath = path.join(tempDir, "multi-dupe.txt"); // Each pattern appears twice await Bun.write(filePath, "aaa\nbbb\naaa\nccc\nbbb\nddd\n"); @@ -140,7 +140,7 @@ describe("regression: ambiguity detection for context-less hunks (2C)", () => { }); test("context lines disambiguate otherwise ambiguous patterns", async () => { - const filePath = join(tempDir, "context-disambig.txt"); + const filePath = path.join(tempDir, "context-disambig.txt"); await Bun.write(filePath, "header\nfoo\nbar\nmiddle\nfoo\nbaz\nfooter\n"); // Context line "middle" disambiguates which "foo" to change @@ -153,7 +153,7 @@ describe("regression: ambiguity detection for context-less hunks (2C)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n"); + expect(await Bun.file(filePath).text()).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n"); }); }); @@ -161,16 +161,16 @@ describe("regression: context search uses line hints (2D)", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `regression-2d-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `regression-2d-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("unified diff line numbers help locate correct position", async () => { - const filePath = join(tempDir, "hints.txt"); + const filePath = path.join(tempDir, "hints.txt"); // File with repeated function definitions await Bun.write( filePath, @@ -202,14 +202,14 @@ function process() { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); // First unchanged expect(result).toContain("return 200;"); // Second changed expect(result).toContain("return 3;"); // Third unchanged }); test("line hint overrides context-only search when appropriate", async () => { - const filePath = join(tempDir, "hint-priority.txt"); + const filePath = path.join(tempDir, "hint-priority.txt"); await Bun.write( filePath, `# Section A @@ -235,7 +235,7 @@ def helper(): { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); const lines = result.split("\n"); expect(lines[2]).toBe(" pass"); // Section A unchanged expect(lines[6]).toBe(" return True"); // Section B changed @@ -246,16 +246,16 @@ describe("regression: insertion uses newStartLine fallback (2E)", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `regression-2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `regression-2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("pure addition with context uses context to find insertion point", async () => { - const filePath = join(tempDir, "insert.txt"); + const filePath = path.join(tempDir, "insert.txt"); await Bun.write(filePath, "line1\nline2\nline3\n"); // Insert after line1 using context @@ -270,11 +270,11 @@ describe("regression: insertion uses newStartLine fallback (2E)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("line1\ninserted\nline2\nline3\n"); + expect(await Bun.file(filePath).text()).toBe("line1\ninserted\nline2\nline3\n"); }); test("pure addition with line hint inserts at correct position", async () => { - const filePath = join(tempDir, "insert-hint.txt"); + const filePath = path.join(tempDir, "insert-hint.txt"); await Bun.write(filePath, "aaa\nbbb\nccc\n"); // Use unified diff format line hints to insert at specific location @@ -289,11 +289,11 @@ describe("regression: insertion uses newStartLine fallback (2E)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("aaa\nbbb\ninserted after bbb\nccc\n"); + expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\ninserted after bbb\nccc\n"); }); test("insertion at end of file works correctly", async () => { - const filePath = join(tempDir, "append.txt"); + const filePath = path.join(tempDir, "append.txt"); await Bun.write(filePath, "first\nsecond\n"); await applyPatch( @@ -307,7 +307,7 @@ describe("regression: insertion uses newStartLine fallback (2E)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("first\nsecond\nappended line\n"); + expect(await Bun.file(filePath).text()).toBe("first\nsecond\nappended line\n"); }); }); @@ -413,16 +413,16 @@ describe("plan: partial line matching for @@ context", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `plan-partial-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `plan-partial-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ context matches when actual line contains it as substring", async () => { - const filePath = join(tempDir, "imports.ts"); + const filePath = path.join(tempDir, "imports.ts"); // Actual line has more content than the @@ context await Bun.write( filePath, @@ -443,12 +443,12 @@ describe("plan: partial line matching for @@ context", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain('rmSync("temp", { recursive: true });'); }); test("@@ context matches function signature even with trailing content", async () => { - const filePath = join(tempDir, "funcs.ts"); + const filePath = path.join(tempDir, "funcs.ts"); await Bun.write( filePath, `function processItems(items: Item[], options?: Options): Result { @@ -469,7 +469,7 @@ describe("plan: partial line matching for @@ context", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("filter(i => i.valid)"); + expect(await Bun.file(filePath).text()).toContain("filter(i => i.valid)"); }); }); @@ -477,16 +477,16 @@ describe("plan: unified diff format line numbers", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `plan-unified-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `plan-unified-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ -10,6 +10,7 @@ is parsed as line numbers not literal text", async () => { - const filePath = join(tempDir, "lines.txt"); + const filePath = path.join(tempDir, "lines.txt"); // Create file with 15 lines const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`); await Bun.write(filePath, `${lines.join("\n")}\n`); @@ -505,14 +505,14 @@ describe("plan: unified diff format line numbers", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("LINE ELEVEN"); expect(result).toContain("line 10"); // unchanged expect(result).toContain("line 12"); // unchanged }); test("unified diff line numbers take precedence over context search", async () => { - const filePath = join(tempDir, "repeat.txt"); + const filePath = path.join(tempDir, "repeat.txt"); // Same pattern appears at lines 3 and 8 await Bun.write( filePath, @@ -540,7 +540,7 @@ line 9 { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); const lines = result.split("\n"); expect(lines[2]).toBe("target line"); // First unchanged expect(lines[7]).toBe("MODIFIED TARGET"); // Second changed @@ -551,16 +551,16 @@ describe("plan: Codex-style wrapped patches", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `plan-codex-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `plan-codex-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("strips *** Begin Patch / *** End Patch wrapper", async () => { - const filePath = join(tempDir, "wrapped.txt"); + const filePath = path.join(tempDir, "wrapped.txt"); await Bun.write(filePath, "old content\n"); // Full Codex-style wrapper - the diff inside should be extracted @@ -577,11 +577,11 @@ describe("plan: Codex-style wrapped patches", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("new content\n"); + expect(await Bun.file(filePath).text()).toBe("new content\n"); }); test("strips partial wrapper (only *** End Patch)", async () => { - const filePath = join(tempDir, "partial.txt"); + const filePath = path.join(tempDir, "partial.txt"); await Bun.write(filePath, "original\n"); // Only end marker present @@ -597,11 +597,11 @@ describe("plan: Codex-style wrapped patches", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("modified\n"); + expect(await Bun.file(filePath).text()).toBe("modified\n"); }); test("strips bare *** terminator (model hallucination)", async () => { - const filePath = join(tempDir, "bare-asterisk.txt"); + const filePath = path.join(tempDir, "bare-asterisk.txt"); await Bun.write(filePath, "line1\nline2\nline3\n"); // Model sometimes outputs just *** as end marker @@ -617,11 +617,11 @@ describe("plan: Codex-style wrapped patches", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("line1\nLINE TWO\nline3\n"); + expect(await Bun.file(filePath).text()).toBe("line1\nLINE TWO\nline3\n"); }); test("strips bare *** terminator in multi-hunk diff", async () => { - const filePath = join(tempDir, "multi-hunk-asterisk.txt"); + const filePath = path.join(tempDir, "multi-hunk-asterisk.txt"); await Bun.write(filePath, "aaa\nbbb\nccc\nddd\n"); // Multiple hunks with *** terminator at end @@ -640,11 +640,11 @@ describe("plan: Codex-style wrapped patches", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("AAA\nbbb\nCCC\nddd\n"); + expect(await Bun.file(filePath).text()).toBe("AAA\nbbb\nCCC\nddd\n"); }); test("strips bare *** at beginning of diff", async () => { - const filePath = join(tempDir, "leading-asterisk.txt"); + const filePath = path.join(tempDir, "leading-asterisk.txt"); await Bun.write(filePath, "old\n"); await applyPatch( @@ -659,11 +659,11 @@ describe("plan: Codex-style wrapped patches", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("new\n"); + expect(await Bun.file(filePath).text()).toBe("new\n"); }); test("strips unified diff metadata lines", async () => { - const filePath = join(tempDir, "unified-meta.txt"); + const filePath = path.join(tempDir, "unified-meta.txt"); await Bun.write(filePath, "first\nsecond\nthird\n"); // Full unified diff format with metadata @@ -684,7 +684,7 @@ index abc123..def456 100644 { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("first\nSECOND\nthird\n"); + expect(await Bun.file(filePath).text()).toBe("first\nSECOND\nthird\n"); }); }); @@ -692,12 +692,12 @@ describe("plan: strip + prefix from file creation", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `plan-create-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `plan-create-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("create file strips + prefix when all lines have it", async () => { @@ -712,7 +712,7 @@ describe("plan: strip + prefix from file creation", () => { { cwd: tempDir }, ); - expect(readFileSync(join(tempDir, "newfile.txt"), "utf-8")).toBe("line one\nline two\nline three\n"); + expect(await Bun.file(path.join(tempDir, "newfile.txt")).text()).toBe("line one\nline two\nline three\n"); }); test("create file strips + space prefix", async () => { @@ -726,7 +726,7 @@ describe("plan: strip + prefix from file creation", () => { { cwd: tempDir }, ); - expect(readFileSync(join(tempDir, "spaced.txt"), "utf-8")).toBe("first line\nsecond line\n"); + expect(await Bun.file(path.join(tempDir, "spaced.txt")).text()).toBe("first line\nsecond line\n"); }); test("create file preserves content when not all lines have + prefix", async () => { @@ -742,7 +742,7 @@ regular line ); // Should preserve as-is since not all lines have + - expect(readFileSync(join(tempDir, "mixed.txt"), "utf-8")).toBe("+line one\nregular line\n+line three\n"); + expect(await Bun.file(path.join(tempDir, "mixed.txt")).text()).toBe("+line one\nregular line\n+line three\n"); }); }); @@ -750,16 +750,16 @@ describe("regression: *** End of File marker handling (2A/2G)", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `regression-eof-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `regression-eof-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("*** End of File marker is preserved in hunk parsing", async () => { - const filePath = join(tempDir, "eof.txt"); + const filePath = path.join(tempDir, "eof.txt"); await Bun.write(filePath, "line1\nline2\nlast line\n"); await applyPatch( @@ -774,11 +774,11 @@ describe("regression: *** End of File marker handling (2A/2G)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("line1\nline2\nmodified last line\n"); + expect(await Bun.file(filePath).text()).toBe("line1\nline2\nmodified last line\n"); }); test("EOF marker targets end of file for pattern matching", async () => { - const filePath = join(tempDir, "eof-target.txt"); + const filePath = path.join(tempDir, "eof-target.txt"); // Pattern appears twice - EOF should target the last one await Bun.write(filePath, "item\nmore content\nitem\n"); @@ -794,7 +794,7 @@ describe("regression: *** End of File marker handling (2A/2G)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toBe("item\nmore content\nFINAL ITEM\n"); }); }); @@ -809,16 +809,16 @@ describe("regression: model edit attempt - @@ line N syntax (session 2026-01-19) let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `model-line-n-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `model-line-n-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ line 125 is parsed as line hint, not literal context search", async () => { - const filePath = join(tempDir, "settings.ts"); + const filePath = path.join(tempDir, "settings.ts"); // Create file with enough lines - the target is around line 125 const lines: string[] = []; for (let i = 1; i <= 130; i++) { @@ -851,7 +851,7 @@ describe("regression: model edit attempt - @@ line N syntax (session 2026-01-19) { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("patchMode?: boolean; // default: true"); expect(result).not.toContain("patchMode?: boolean; // default: false"); }); @@ -861,16 +861,16 @@ describe("regression: model edit attempt - nested @@ anchors (session 2026-01-19 let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `model-nested-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `model-nested-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ class X followed by @@ method on next line is parsed as nested anchors", async () => { - const filePath = join(tempDir, "patch.ts"); + const filePath = path.join(tempDir, "patch.ts"); await Bun.write( filePath, `class OtherTool { @@ -906,14 +906,14 @@ class PatchTool { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); // Should change PatchTool's constructor, not OtherTool's expect(result).toContain("this.patchMode = session.settings?.getEditPatchMode?.() ?? true;"); expect(result).toContain("this.mode = false;"); // OtherTool unchanged }); test("nested @@ anchors disambiguate between multiple matching methods", async () => { - const filePath = join(tempDir, "multi-class.ts"); + const filePath = path.join(tempDir, "multi-class.ts"); await Bun.write( filePath, `class Alpha { @@ -944,7 +944,7 @@ class Beta { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain('return "alpha"'); // Alpha unchanged expect(result).toContain('return "BETA"'); // Beta changed }); @@ -954,16 +954,16 @@ describe("regression: model edit attempt - space-separated anchors (session 2026 let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `model-space-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `model-space-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ class PatchTool constructor is parsed as hierarchical anchors", async () => { - const filePath = join(tempDir, "tool.ts"); + const filePath = path.join(tempDir, "tool.ts"); await Bun.write( filePath, `class OtherTool { @@ -994,13 +994,13 @@ class PatchTool { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("this.value = 1;"); // OtherTool unchanged expect(result).toContain("this.value = 200;"); // PatchTool changed }); test("space-separated anchors work with function keyword", async () => { - const filePath = join(tempDir, "funcs.ts"); + const filePath = path.join(tempDir, "funcs.ts"); await Bun.write( filePath, `function outer() { @@ -1032,7 +1032,7 @@ function process() { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); // outer's helper unchanged expect(result).toContain("return 200;"); // process's helper changed }); @@ -1042,16 +1042,16 @@ describe("regression: model edit attempt - unique substring on long line (sessio let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `model-long-line-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `model-long-line-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ class ClassName matches long export line when unique", async () => { - const filePath = join(tempDir, "tool.ts"); + const filePath = path.join(tempDir, "tool.ts"); // Real-world pattern: long line with export, implements, generics await Bun.write( filePath, @@ -1083,12 +1083,12 @@ export class EditTool implements AgentTool { - const filePath = join(tempDir, "multi.ts"); + const filePath = path.join(tempDir, "multi.ts"); await Bun.write( filePath, `export class EditTool implements AgentTool { @@ -1112,7 +1112,7 @@ export class EditTool implements AgentTool { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("value = 100;"); expect(result).toContain("value = 2;"); }); @@ -1122,16 +1122,16 @@ describe("regression: bench edit failures (2026-01-19)", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `bench-regression-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `bench-regression-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test("@@ @@ is treated as empty context", async () => { - const filePath = join(tempDir, "empty-context.txt"); + const filePath = path.join(tempDir, "empty-context.txt"); await Bun.write(filePath, "alpha\nbeta\ngamma\n"); await applyPatch( @@ -1143,7 +1143,7 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("alpha\nBETA\ngamma\n"); + expect(await Bun.file(filePath).text()).toBe("alpha\nBETA\ngamma\n"); }); test.each([ @@ -1153,7 +1153,7 @@ describe("regression: bench edit failures (2026-01-19)", () => { ["@@ line 3-5 @@", 3], ["@@ @@ line 3", 3], ])("line hint variants (%s) target the correct line", async (header: string, targetLine: number) => { - const filePath = join(tempDir, `line-hint-${targetLine}.txt`); + const filePath = path.join(tempDir, `line-hint-${targetLine}.txt`); await Bun.write(filePath, "line 1\nline 2\nline 3\nline 4\nline 5\n"); await applyPatch( @@ -1165,12 +1165,12 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const lines = readFileSync(filePath, "utf-8").split("\n"); + const lines = (await Bun.file(filePath).text()).split("\n"); expect(lines[2]).toBe("LINE THREE"); }); test("top of file header anchors to line 1", async () => { - const filePath = join(tempDir, "top-of-file.txt"); + const filePath = path.join(tempDir, "top-of-file.txt"); await Bun.write(filePath, "first\nsecond\n"); await applyPatch( @@ -1182,11 +1182,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("FIRST\nsecond\n"); + expect(await Bun.file(filePath).text()).toBe("FIRST\nsecond\n"); }); test("function name with empty params matches signature", async () => { - const filePath = join(tempDir, "functions.ts"); + const filePath = path.join(tempDir, "functions.ts"); await Bun.write( filePath, `function retryIfBlockedOn(reason: string) {\n return reason;\n}\n\nfunction describeNode(node: object) {\n return String(node);\n}\n`, @@ -1210,13 +1210,13 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("return reason.toUpperCase();"); expect(result).toContain("return JSON.stringify(node);"); }); test("label context falls back to unique old lines", async () => { - const filePath = join(tempDir, "imports.js"); + const filePath = path.join(tempDir, "imports.js"); await Bun.write( filePath, `import { startLoggingProfilingEvents, stopLoggingProfilingEvents } from "../SchedulerProfiling";\n\nexport function run() {\n return startLoggingProfilingEvents();\n}\n`, @@ -1231,13 +1231,13 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain( + expect(await Bun.file(filePath).text()).toContain( 'import { stopLoggingProfilingEvents, startLoggingProfilingEvents } from "../SchedulerProfiling";', ); }); test("ambiguous @@ context resolves via unique old lines", async () => { - const filePath = join(tempDir, "ambiguous.ts"); + const filePath = path.join(tempDir, "ambiguous.ts"); await Bun.write( filePath, `function getState() {\n return 1;\n}\n\nfunction getState() {\n return 2;\n}\n\nfunction getState() {\n return 3;\n}\n`, @@ -1252,14 +1252,14 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); expect(result).toContain("return 200;"); expect(result).toContain("return 3;"); }); test("duplicate context lines collapse for matching", async () => { - const filePath = join(tempDir, "duplicate-context.txt"); + const filePath = path.join(tempDir, "duplicate-context.txt"); await Bun.write(filePath, "alpha\nbeta\ngamma\n"); await applyPatch( @@ -1271,11 +1271,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("alpha\nbeta\nGAMMA\n"); + expect(await Bun.file(filePath).text()).toBe("alpha\nbeta\nGAMMA\n"); }); test("repeated context blocks collapse when duplicated", async () => { - const filePath = join(tempDir, "repeated-block.txt"); + const filePath = path.join(tempDir, "repeated-block.txt"); await Bun.write(filePath, "if (ready) {\n handle();\n}\n"); await applyPatch( @@ -1287,11 +1287,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("if (ready) {\n handleNext();\n}\n"); + expect(await Bun.file(filePath).text()).toBe("if (ready) {\n handleNext();\n}\n"); }); test("shared prefix/suffix context is trimmed when mismatched", async () => { - const filePath = join(tempDir, "trim-context.txt"); + const filePath = path.join(tempDir, "trim-context.txt"); await Bun.write(filePath, "function doThing() {\n return 1;\n}\n"); await applyPatch( @@ -1303,11 +1303,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("function doThing() {\n return 2;\n}\n"); + expect(await Bun.file(filePath).text()).toBe("function doThing() {\n return 2;\n}\n"); }); test("single-line change fallback uses the unique changed line", async () => { - const filePath = join(tempDir, "single-line-change.txt"); + const filePath = path.join(tempDir, "single-line-change.txt"); await Bun.write(filePath, "function getState() {\n return 1;\n}\n\nfunction getState() {\n return 2;\n}\n"); await applyPatch( @@ -1319,13 +1319,13 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); expect(result).toContain("return 200;"); }); test("implicit context lines without prefixes are accepted", async () => { - const filePath = join(tempDir, "implicit-context.ts"); + const filePath = path.join(tempDir, "implicit-context.ts"); await Bun.write( filePath, `function getMousePosition(\n relativeContainer: null,\n mouseEvent: SyntheticMouseEvent,\n) {\n if (relativeContainer !== null) {\n return initialTooltipState;\n }\n}\n`, @@ -1340,11 +1340,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("if (relativeContainer === null)"); + expect(await Bun.file(filePath).text()).toContain("if (relativeContainer === null)"); }); test("context lines preserve original file indentation when fuzzy matched", async () => { - const filePath = join(tempDir, "context-indent.js"); + const filePath = path.join(tempDir, "context-indent.js"); // File has 4-space indentation throughout the table await Bun.write( filePath, @@ -1380,7 +1380,7 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); // The changed line should have correct value (false instead of true) expect(result).toContain("$false | $fallback"); // Context lines should preserve original 4-space indentation, not become 3-space @@ -1389,7 +1389,7 @@ describe("regression: bench edit failures (2026-01-19)", () => { }); test("duplicate context lines are resolved via adjacent match to @@ anchor", async () => { - const filePath = join(tempDir, "ReactFlightDOMClientNode.js"); + const filePath = path.join(tempDir, "ReactFlightDOMClientNode.js"); await Bun.write( filePath, `const handleEnd = () => { @@ -1415,14 +1415,14 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); const lines = result.split("\n"); expect(lines[1]).toContain("--streamEndedCount"); expect(lines[4]).toContain("++streamEndedCount"); }); test("strip line-number prefixes from diff content", async () => { - const filePath = join(tempDir, "line-numbers.txt"); + const filePath = path.join(tempDir, "line-numbers.txt"); await Bun.write( filePath, `Permission is hereby granted, free of charge\nA copy of this software and associated docs\nThe above copyright notice\n`, @@ -1437,12 +1437,12 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("A copy of this software AND associated docs"); }); test("ellipsis placeholder lines are ignored during matching", async () => { - const filePath = join(tempDir, "ellipsis.ts"); + const filePath = path.join(tempDir, "ellipsis.ts"); await Bun.write( filePath, `function progress(done: boolean, value: string) {\n if (done) {\n return;\n }\n const buffer = value;\n return buffer;\n}\n`, @@ -1457,11 +1457,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("const buffer = value.toUpperCase();"); + expect(await Bun.file(filePath).text()).toContain("const buffer = value.toUpperCase();"); }); test("context anchor retryIfBlockedOn() matches signature without params", async () => { - const filePath = join(tempDir, "context-anchor.ts"); + const filePath = path.join(tempDir, "context-anchor.ts"); await Bun.write(filePath, `function retryIfBlockedOn(reason: string, blockedOn: mixed) {\n return reason;\n}\n`); await applyPatch( @@ -1473,11 +1473,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("return reason.toUpperCase();"); + expect(await Bun.file(filePath).text()).toContain("return reason.toUpperCase();"); }); test("ambiguous context falls back to unique old lines", async () => { - const filePath = join(tempDir, "ambiguous-context.ts"); + const filePath = path.join(tempDir, "ambiguous-context.ts"); await Bun.write( filePath, `function getState() {\n return 1;\n}\n\nfunction getState() {\n return 2;\n}\n\nfunction getState() {\n return 3;\n}\n`, @@ -1492,14 +1492,14 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - const result = readFileSync(filePath, "utf-8"); + const result = await Bun.file(filePath).text(); expect(result).toContain("return 1;"); expect(result).toContain("return 200;"); expect(result).toContain("return 3;"); }); test("comment-prefix mismatches still match expected lines", async () => { - const filePath = join(tempDir, "comment-prefix.txt"); + const filePath = path.join(tempDir, "comment-prefix.txt"); await Bun.write( filePath, `/*\n * LICENSE file in the root directory.\n * Copyright (c) Meta Platforms, Inc.\n */\n`, @@ -1514,11 +1514,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("/ LICENSE file in the root directory."); + expect(await Bun.file(filePath).text()).toContain("/ LICENSE file in the root directory."); }); test("context-less fuzzy match applies even with spacing differences", async () => { - const filePath = join(tempDir, "fuzzy-contextless.ts"); + const filePath = path.join(tempDir, "fuzzy-contextless.ts"); await Bun.write(filePath, "const value = computeTotal(items);\n"); await applyPatch( @@ -1530,11 +1530,11 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("calculateTotal"); + expect(await Bun.file(filePath).text()).toContain("calculateTotal"); }); test("@@ header without space is accepted", async () => { - const filePath = join(tempDir, "header-nospace.ts"); + const filePath = path.join(tempDir, "header-nospace.ts"); await Bun.write(filePath, `const value = 1;\nconst other = 2;\n`); await applyPatch( @@ -1546,6 +1546,6 @@ describe("regression: bench edit failures (2026-01-19)", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toContain("const value = 100;"); + expect(await Bun.file(filePath).text()).toContain("const value = 100;"); }); }); diff --git a/packages/coding-agent/test/core/apply-patch.test.ts b/packages/coding-agent/test/core/apply-patch.test.ts index dfc3f6d3b..702b4625f 100644 --- a/packages/coding-agent/test/core/apply-patch.test.ts +++ b/packages/coding-agent/test/core/apply-patch.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { ApplyPatchError, applyPatch, @@ -332,62 +332,63 @@ describe("parseDiffHunks", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("apply-patch scenarios", () => { - const fixturesDir = join(import.meta.dir, "../fixtures/apply-patch/scenarios"); + const fixturesDir = path.join(import.meta.dir, "../fixtures/apply-patch/scenarios"); let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `apply-patch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `apply-patch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { try { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); - function snapshotDir(dir: string): Map { + async function snapshotDir(dir: string): Promise> { const entries = new Map(); - if (!readdirSync(dir, { withFileTypes: true }).length) { + if (!fs.readdirSync(dir, { withFileTypes: true }).length) { return entries; } - function walk(currentDir: string, relativePath: string) { - for (const entry of readdirSync(currentDir, { withFileTypes: true })) { - const fullPath = join(currentDir, entry.name); + async function walk(currentDir: string, relativePath: string) { + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const fullPath = path.join(currentDir, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { entries.set(relPath, "dir"); - walk(fullPath, relPath); + await walk(fullPath, relPath); } else if (entry.isFile()) { - entries.set(relPath, readFileSync(fullPath, "utf-8")); + entries.set(relPath, await Bun.file(fullPath).text()); } } } - walk(dir, ""); + await walk(dir, ""); return entries; } function copyDirRecursive(src: string, dst: string) { - cpSync(src, dst, { recursive: true }); + fs.cpSync(src, dst, { recursive: true }); } // Get all scenario directories - const scenarioDirs = readdirSync(fixturesDir, { withFileTypes: true }) + const scenarioDirs = fs + .readdirSync(fixturesDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort(); for (const scenarioName of scenarioDirs) { test(scenarioName, async () => { - const scenarioDir = join(fixturesDir, scenarioName); + const scenarioDir = path.join(fixturesDir, scenarioName); // Copy input files to temp directory - const inputDir = join(scenarioDir, "input"); + const inputDir = path.join(scenarioDir, "input"); try { copyDirRecursive(inputDir, tempDir); } catch { @@ -395,8 +396,8 @@ describe("apply-patch scenarios", () => { } // Read the patch - const patchPath = join(scenarioDir, "patch.txt"); - const patch = readFileSync(patchPath, "utf-8"); + const patchPath = path.join(scenarioDir, "patch.txt"); + const patch = await Bun.file(patchPath).text(); // Apply the patch using legacy parser (catching errors for rejection tests) try { @@ -406,9 +407,9 @@ describe("apply-patch scenarios", () => { } // Compare final state to expected - const expectedDir = join(scenarioDir, "expected"); - const expectedSnapshot = snapshotDir(expectedDir); - const actualSnapshot = snapshotDir(tempDir); + const expectedDir = path.join(scenarioDir, "expected"); + const expectedSnapshot = await snapshotDir(expectedDir); + const actualSnapshot = await snapshotDir(tempDir); expect(actualSnapshot).toEqual(expectedSnapshot); }); @@ -423,13 +424,13 @@ describe("applyPatch", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `apply-patch-unit-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `apply-patch-unit-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { try { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } @@ -439,11 +440,11 @@ describe("applyPatch", () => { const result = await applyPatch({ path: "add.txt", op: "create", diff: "ab\ncd" }, { cwd: tempDir }); expect(result.change.type).toBe("create"); - expect(readFileSync(join(tempDir, "add.txt"), "utf-8")).toBe("ab\ncd\n"); + expect(await Bun.file(path.join(tempDir, "add.txt")).text()).toBe("ab\ncd\n"); }); test("delete file", async () => { - const filePath = join(tempDir, "del.txt"); + const filePath = path.join(tempDir, "del.txt"); await Bun.write(filePath, "x"); const result = await applyPatch({ path: "del.txt", op: "delete" }, { cwd: tempDir }); @@ -453,7 +454,7 @@ describe("applyPatch", () => { }); test("update file", async () => { - const filePath = join(tempDir, "update.txt"); + const filePath = path.join(tempDir, "update.txt"); await Bun.write(filePath, "foo\nbar\n"); const result = await applyPatch( @@ -462,11 +463,11 @@ describe("applyPatch", () => { ); expect(result.change.type).toBe("update"); - expect(readFileSync(filePath, "utf-8")).toBe("foo\nbaz\n"); + expect(await Bun.file(filePath).text()).toBe("foo\nbaz\n"); }); test("update with move", async () => { - const srcPath = join(tempDir, "src.txt"); + const srcPath = path.join(tempDir, "src.txt"); await Bun.write(srcPath, "line\n"); const result = await applyPatch( @@ -475,22 +476,22 @@ describe("applyPatch", () => { ); expect(result.change.type).toBe("update"); - expect(result.change.newPath).toBe(join(tempDir, "dst.txt")); + expect(result.change.newPath).toBe(path.join(tempDir, "dst.txt")); expect(await Bun.file(srcPath).exists()).toBe(false); - expect(readFileSync(join(tempDir, "dst.txt"), "utf-8")).toBe("line2\n"); + expect(await Bun.file(path.join(tempDir, "dst.txt")).text()).toBe("line2\n"); }); test("multiple hunks in single update", async () => { - const filePath = join(tempDir, "multi.txt"); + const filePath = path.join(tempDir, "multi.txt"); await Bun.write(filePath, "foo\nbar\nbaz\nqux\n"); await applyPatch({ path: "multi.txt", op: "update", diff: "@@\n-bar\n+BAR\n@@\n-qux\n+QUX" }, { cwd: tempDir }); - expect(readFileSync(filePath, "utf-8")).toBe("foo\nBAR\nbaz\nQUX\n"); + expect(await Bun.file(filePath).text()).toBe("foo\nBAR\nbaz\nQUX\n"); }); test("@@ scope and first context line can be identical", async () => { - const filePath = join(tempDir, "scope.txt"); + const filePath = path.join(tempDir, "scope.txt"); await Bun.write(filePath, "## [Unreleased]\n\n### Changed\n\n- Old entry\n"); await applyPatch( @@ -502,13 +503,13 @@ describe("applyPatch", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe( + expect(await Bun.file(filePath).text()).toBe( "## [Unreleased]\n\n### Added\n\n- New feature\n\n### Changed\n\n- Old entry\n", ); }); test("unicode dash matching", async () => { - const filePath = join(tempDir, "unicode.py"); + const filePath = path.join(tempDir, "unicode.py"); await Bun.write(filePath, "import asyncio # local import \u2013 avoids top\u2011level dep\n"); await applyPatch( @@ -520,11 +521,11 @@ describe("applyPatch", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("import asyncio # HELLO\n"); + expect(await Bun.file(filePath).text()).toBe("import asyncio # HELLO\n"); }); test("dry run does not modify files", async () => { - const filePath = join(tempDir, "dryrun.txt"); + const filePath = path.join(tempDir, "dryrun.txt"); await Bun.write(filePath, "original\n"); const result = await applyPatch( @@ -533,7 +534,7 @@ describe("applyPatch", () => { ); expect(result.change.newContent).toBe("modified\n"); - expect(readFileSync(filePath, "utf-8")).toBe("original\n"); + expect(await Bun.file(filePath).text()).toBe("original\n"); }); test("missing file for update fails", async () => { @@ -543,7 +544,7 @@ describe("applyPatch", () => { }); test("update without diff fails", async () => { - const filePath = join(tempDir, "nodiff.txt"); + const filePath = path.join(tempDir, "nodiff.txt"); await Bun.write(filePath, "content\n"); await expect(applyPatch({ path: "nodiff.txt", op: "update" }, { cwd: tempDir })).rejects.toThrow("requires diff"); @@ -552,12 +553,12 @@ describe("applyPatch", () => { test("creates parent directories for create", async () => { await applyPatch({ path: "nested/deep/file.txt", op: "create", diff: "content" }, { cwd: tempDir }); - const filePath = join(tempDir, "nested/deep/file.txt"); - expect(readFileSync(filePath, "utf-8")).toBe("content\n"); + const filePath = path.join(tempDir, "nested/deep/file.txt"); + expect(await Bun.file(filePath).text()).toBe("content\n"); }); test("creates parent directories for move", async () => { - const srcPath = join(tempDir, "src.txt"); + const srcPath = path.join(tempDir, "src.txt"); await Bun.write(srcPath, "line\n"); await applyPatch( @@ -565,8 +566,8 @@ describe("applyPatch", () => { { cwd: tempDir }, ); - const dstPath = join(tempDir, "nested/deep/dst.txt"); - expect(readFileSync(dstPath, "utf-8")).toBe("newline\n"); + const dstPath = path.join(tempDir, "nested/deep/dst.txt"); + expect(await Bun.file(dstPath).text()).toBe("newline\n"); }); }); @@ -578,20 +579,20 @@ describe("simple replace mode", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `simple-replace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `simple-replace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { try { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); test("simple -/+ only diff uses character-based fuzzy matching", async () => { - const filePath = join(tempDir, "fuzzy.txt"); + const filePath = path.join(tempDir, "fuzzy.txt"); // File has smart quotes, diff uses ASCII quotes await Bun.write(filePath, 'console.log("Hello");\n'); @@ -605,11 +606,11 @@ describe("simple replace mode", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe('console.log("World");\n'); + expect(await Bun.file(filePath).text()).toBe('console.log("World");\n'); }); test("simple diff adjusts indentation to match actual content", async () => { - const filePath = join(tempDir, "indent.ts"); + const filePath = path.join(tempDir, "indent.ts"); // File content is indented with 4 spaces await Bun.write(filePath, "function test() {\n const x = 1;\n return x;\n}\n"); @@ -623,11 +624,11 @@ describe("simple replace mode", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("function test() {\n const x = 42;\n return x;\n}\n"); + expect(await Bun.file(filePath).text()).toBe("function test() {\n const x = 42;\n return x;\n}\n"); }); test("diff with context lines falls back to line-based matching", async () => { - const filePath = join(tempDir, "context.txt"); + const filePath = path.join(tempDir, "context.txt"); // Create a file with repeated patterns that need context to disambiguate await Bun.write(filePath, "header\nfoo\nbar\nmiddle\nfoo\nbaz\nfooter\n"); @@ -642,11 +643,11 @@ describe("simple replace mode", () => { ); // Only the second "foo" should be changed - expect(readFileSync(filePath, "utf-8")).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n"); + expect(await Bun.file(filePath).text()).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n"); }); test("multiple chunks use line-based matching", async () => { - const filePath = join(tempDir, "multi.txt"); + const filePath = path.join(tempDir, "multi.txt"); await Bun.write(filePath, "aaa\nbbb\nccc\nddd\n"); // Multiple chunks in a single diff @@ -659,11 +660,11 @@ describe("simple replace mode", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("aaa\nBBB\nccc\nDDD\n"); + expect(await Bun.file(filePath).text()).toBe("aaa\nBBB\nccc\nDDD\n"); }); test("simple diff with @@ context uses line-based matching", async () => { - const filePath = join(tempDir, "scoped.txt"); + const filePath = path.join(tempDir, "scoped.txt"); await Bun.write(filePath, "class Foo {\n method() {\n return 1;\n }\n}\n"); // Even without context lines, @@ marker triggers line-based mode @@ -676,11 +677,11 @@ describe("simple replace mode", () => { { cwd: tempDir }, ); - expect(readFileSync(filePath, "utf-8")).toBe("class Foo {\n method() {\n return 42;\n }\n}\n"); + expect(await Bun.file(filePath).text()).toBe("class Foo {\n method() {\n return 42;\n }\n}\n"); }); test("simple diff rejects multiple occurrences", async () => { - const filePath = join(tempDir, "dupe.txt"); + const filePath = path.join(tempDir, "dupe.txt"); await Bun.write(filePath, "foo\nbar\nfoo\n"); await expect( diff --git a/packages/coding-agent/test/core/python-executor-per-call.test.ts b/packages/coding-agent/test/core/python-executor-per-call.test.ts index 6e72ca243..7134e377e 100644 --- a/packages/coding-agent/test/core/python-executor-per-call.test.ts +++ b/packages/coding-agent/test/core/python-executor-per-call.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor"; import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; interface KernelStub { execute: (code: string, options?: KernelExecuteOptions) => Promise; @@ -12,7 +12,7 @@ interface KernelStub { describe("executePython (per-call)", () => { it("shuts down kernel on timed-out cancellation", async () => { process.env.OMP_PYTHON_SKIP_CHECK = "1"; - using tempDir = createTempDirSync("@omp-python-executor-per-call-"); + using tempDir = TempDir.createSync("@omp-python-executor-per-call-"); let shutdownCalls = 0; const kernel: KernelStub = { @@ -37,7 +37,7 @@ describe("executePython (per-call)", () => { const result = await executePython("sleep(10)", { kernelMode: "per-call", timeoutMs: 2000, - cwd: tempDir.path, + cwd: tempDir.path(), }); expect(result.cancelled).toBe(true); diff --git a/packages/coding-agent/test/core/python-executor.test.ts b/packages/coding-agent/test/core/python-executor.test.ts index 58ad1452f..b794915dc 100644 --- a/packages/coding-agent/test/core/python-executor.test.ts +++ b/packages/coding-agent/test/core/python-executor.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { join } from "node:path"; +import * as path from "node:path"; import { disposeAllKernelSessions, executePythonWithKernel, @@ -15,7 +15,7 @@ import { PythonKernel, } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/tools/truncate"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; class FakeKernel implements PythonKernelExecutor { private result: KernelExecuteResult; @@ -135,8 +135,8 @@ describe("executePythonWithKernel", () => { await options?.onChunk?.(largeOutput); }, ); - using tempDir = createTempDirSync("@python-executor-artifacts-"); - const artifactPath = join(tempDir.path, "0.python.txt"); + using tempDir = TempDir.createSync("@python-executor-artifacts-"); + const artifactPath = path.join(tempDir.path(), "0.python.txt"); const result = await executePythonWithKernel(kernel, "print('big')", { artifactPath, @@ -162,7 +162,7 @@ describe("warmPythonEnvironment", () => { it("caches prelude docs on warmup", async () => { const previousSkip = process.env.OMP_PYTHON_SKIP_CHECK; process.env.OMP_PYTHON_SKIP_CHECK = "1"; - using tempDir = createTempDirSync("@python-executor-"); + using tempDir = TempDir.createSync("@python-executor-"); const docs: PreludeHelper[] = [ { name: "read", @@ -179,7 +179,7 @@ describe("warmPythonEnvironment", () => { }; const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernel); - const result = await warmPythonEnvironment(tempDir.path, "session-1"); + const result = await warmPythonEnvironment(tempDir.path(), "session-1"); expect(result.ok).toBe(true); expect(result.docs).toEqual(docs); diff --git a/packages/coding-agent/test/core/python-kernel-env.test.ts b/packages/coding-agent/test/core/python-kernel-env.test.ts index 76468fd8c..aaf88e58f 100644 --- a/packages/coding-agent/test/core/python-kernel-env.test.ts +++ b/packages/coding-agent/test/core/python-kernel-env.test.ts @@ -3,7 +3,7 @@ import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; import { PYTHON_PRELUDE } from "@oh-my-pi/pi-coding-agent/ipy/prelude"; import * as shell from "@oh-my-pi/pi-coding-agent/utils/shell"; import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; class FakeWebSocket { static OPEN = 1; @@ -99,8 +99,8 @@ describe("PythonKernel.start (local gateway)", () => { .spyOn(PythonKernel.prototype, "execute") .mockResolvedValue({ status: "ok", cancelled: false, timedOut: false, stdinRequested: false }); - using tempDir = createTempDirSync("@python-kernel-env-"); - const kernel = await PythonKernel.start({ cwd: tempDir.path, env: { CUSTOM_VAR: "ok" } }); + using tempDir = TempDir.createSync("@python-kernel-env-"); + const kernel = await PythonKernel.start({ cwd: tempDir.path(), env: { CUSTOM_VAR: "ok" } }); const createCall = fetchSpy.mock.calls.find(([input, init]: [string | URL, RequestInit?]) => { const url = typeof input === "string" ? input : input.toString(); @@ -119,7 +119,7 @@ describe("PythonKernel.start (local gateway)", () => { expect(spawnEnv?.CUSTOM_VAR).toBe("ok"); expect(spawnEnv?.OPENAI_API_KEY).toBeUndefined(); expect(spawnEnv?.UNSAFE_TOKEN).toBeUndefined(); - expect(spawnEnv?.PYTHONPATH).toBe(tempDir.path); + expect(spawnEnv?.PYTHONPATH).toBe(tempDir.path()); expect(executeSpy).toHaveBeenCalledWith( PYTHON_PRELUDE, diff --git a/packages/coding-agent/test/core/python-kernel-session.test.ts b/packages/coding-agent/test/core/python-kernel-session.test.ts index a52a54179..61dfc6c2f 100644 --- a/packages/coding-agent/test/core/python-kernel-session.test.ts +++ b/packages/coding-agent/test/core/python-kernel-session.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor"; import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; class FakeKernel { executeCalls = 0; @@ -57,18 +57,18 @@ describe("executePython kernel reuse", () => { }); it("reuses kernels for session mode", async () => { - using tempDir = createTempDirSync("@python-kernel-session-"); - await executePython("print('one')", { cwd: tempDir.path, sessionId: "session-a", kernelMode: "session" }); - await executePython("print('two')", { cwd: tempDir.path, sessionId: "session-a", kernelMode: "session" }); + using tempDir = TempDir.createSync("@python-kernel-session-"); + await executePython("print('one')", { cwd: tempDir.path(), sessionId: "session-a", kernelMode: "session" }); + await executePython("print('two')", { cwd: tempDir.path(), sessionId: "session-a", kernelMode: "session" }); expect(startCalls).toBe(1); expect(kernels[0]?.executeCalls).toBe(2); }); it("creates and disposes per-call kernels", async () => { - using tempDir = createTempDirSync("@python-kernel-session-"); - await executePython("print('one')", { cwd: tempDir.path, kernelMode: "per-call" }); - await executePython("print('two')", { cwd: tempDir.path, kernelMode: "per-call" }); + using tempDir = TempDir.createSync("@python-kernel-session-"); + await executePython("print('one')", { cwd: tempDir.path(), kernelMode: "per-call" }); + await executePython("print('two')", { cwd: tempDir.path(), kernelMode: "per-call" }); expect(startCalls).toBe(2); expect(kernels[0]?.shutdownCalls).toBe(1); @@ -76,10 +76,10 @@ describe("executePython kernel reuse", () => { }); it("resets the session kernel when requested", async () => { - using tempDir = createTempDirSync("@python-kernel-session-"); - await executePython("print('one')", { cwd: tempDir.path, sessionId: "session-b", kernelMode: "session" }); + using tempDir = TempDir.createSync("@python-kernel-session-"); + await executePython("print('one')", { cwd: tempDir.path(), sessionId: "session-b", kernelMode: "session" }); await executePython("print('two')", { - cwd: tempDir.path, + cwd: tempDir.path(), sessionId: "session-b", kernelMode: "session", reset: true, diff --git a/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts b/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts index c9fa5dae3..7eb53b63e 100644 --- a/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts +++ b/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { rmSync } from "node:fs"; import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; -import { createTempDirSync, type SyncTempDir } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; import type { Subprocess } from "bun"; type SpawnOptions = Parameters[1]; @@ -84,11 +83,11 @@ describe("PythonKernel gateway lifecycle", () => { const originalGatewayToken = process.env.OMP_PYTHON_GATEWAY_TOKEN; const originalBunEnv = process.env.BUN_ENV; - let tempDir: SyncTempDir; + let tempDir: TempDir; let env: MockEnvironment; beforeEach(() => { - tempDir = createTempDirSync("@omp-python-kernel-"); + tempDir = TempDir.createSync("@omp-python-kernel-"); env = { fetchCalls: [], spawnCalls: [] }; process.env.BUN_ENV = "test"; @@ -121,7 +120,7 @@ describe("PythonKernel gateway lifecycle", () => { afterEach(() => { if (tempDir) { - rmSync(tempDir.path, { recursive: true, force: true }); + tempDir.removeSync(); } if (originalBunEnv === undefined) { @@ -168,7 +167,7 @@ describe("PythonKernel gateway lifecycle", () => { return createResponse({ ok: true }) as unknown as Response; }) as typeof fetch; - const kernel = await PythonKernel.start({ cwd: tempDir.path, useSharedGateway: false }); + const kernel = await PythonKernel.start({ cwd: tempDir.path(), useSharedGateway: false }); expect(env.spawnCalls).toHaveLength(1); expect(env.spawnCalls[0].cmd).toEqual( @@ -211,7 +210,7 @@ describe("PythonKernel gateway lifecycle", () => { return createResponse({ ok: true }) as unknown as Response; }) as typeof fetch; - await expect(PythonKernel.start({ cwd: tempDir.path, useSharedGateway: false })).rejects.toThrow( + await expect(PythonKernel.start({ cwd: tempDir.path(), useSharedGateway: false })).rejects.toThrow( "Kernel gateway failed to start", ); expect(env.spawnCalls).toHaveLength(3); @@ -239,7 +238,7 @@ describe("PythonKernel gateway lifecycle", () => { return createResponse({ ok: true }) as unknown as Response; }) as typeof fetch; - const kernel = await PythonKernel.start({ cwd: tempDir.path }); + const kernel = await PythonKernel.start({ cwd: tempDir.path() }); await expect(kernel.shutdown()).resolves.toBeUndefined(); }); diff --git a/packages/coding-agent/test/core/python-modules.test.ts b/packages/coding-agent/test/core/python-modules.test.ts index 503274516..d227c9eae 100644 --- a/packages/coding-agent/test/core/python-modules.test.ts +++ b/packages/coding-agent/test/core/python-modules.test.ts @@ -1,50 +1,50 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { discoverPythonModules, loadPythonModules, type PythonModuleExecutor, } from "@oh-my-pi/pi-coding-agent/ipy/modules"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; -const fixturesDir = resolve(import.meta.dir, "../../test/fixtures/python-modules"); +const fixturesDir = path.resolve(import.meta.dir, "../../test/fixtures/python-modules"); -const readFixture = (name: string): string => readFileSync(join(fixturesDir, name), "utf-8"); +const readFixture = (name: string): Promise => Bun.file(path.join(fixturesDir, name)).text(); -const writeModule = (dir: string, name: string, tag: string) => { - mkdirSync(dir, { recursive: true }); - const base = readFixture(name); - writeFileSync(join(dir, name), `${base}\n# ${tag}`); +const writeModule = async (dir: string, name: string, tag: string) => { + await fs.mkdir(dir, { recursive: true }); + const base = await readFixture(name); + await Bun.write(path.join(dir, name), `${base}\n# ${tag}`); }; describe("python modules", () => { - let tempRoot: ReturnType | null = null; + let tempRoot: TempDir | null = null; afterEach(() => { if (tempRoot) { - tempRoot.remove(); + tempRoot.removeSync(); } tempRoot = null; }); it("discovers modules with project override and sorted order", async () => { - tempRoot = createTempDirSync("@omp-python-modules-"); - const homeDir = join(tempRoot.path, "home"); - const cwd = join(tempRoot.path, "project"); + tempRoot = TempDir.createSync("@omp-python-modules-"); + const homeDir = path.join(tempRoot.path(), "home"); + const cwd = path.join(tempRoot.path(), "project"); - writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); - writeModule(join(homeDir, ".pi", "agent", "modules"), "beta.py", "user-pi"); - writeModule(join(homeDir, ".pi", "agent", "modules"), "delta.py", "user-pi"); + await writeModule(path.join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); + await writeModule(path.join(homeDir, ".pi", "agent", "modules"), "beta.py", "user-pi"); + await writeModule(path.join(homeDir, ".pi", "agent", "modules"), "delta.py", "user-pi"); - writeModule(join(cwd, ".omp", "modules"), "alpha.py", "project-omp"); - writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp"); - writeModule(join(cwd, ".pi", "modules"), "gamma.py", "project-pi"); + await writeModule(path.join(cwd, ".omp", "modules"), "alpha.py", "project-omp"); + await writeModule(path.join(cwd, ".omp", "modules"), "beta.py", "project-omp"); + await writeModule(path.join(cwd, ".pi", "modules"), "gamma.py", "project-pi"); const modules = await discoverPythonModules({ cwd, homeDir }); - const names = modules.map((module) => basename(module.path)); + const names = modules.map((module) => path.basename(module.path)); expect(names).toEqual(["alpha.py", "beta.py", "delta.py", "gamma.py"]); - expect(modules.map((module) => ({ name: basename(module.path), source: module.source }))).toEqual([ + expect(modules.map((module) => ({ name: path.basename(module.path), source: module.source }))).toEqual([ { name: "alpha.py", source: "project" }, { name: "beta.py", source: "project" }, { name: "delta.py", source: "user" }, @@ -55,12 +55,12 @@ describe("python modules", () => { }); it("loads modules in sorted order with silent execution", async () => { - tempRoot = createTempDirSync("@omp-python-modules-"); - const homeDir = join(tempRoot.path, "home"); - const cwd = join(tempRoot.path, "project"); + tempRoot = TempDir.createSync("@omp-python-modules-"); + const homeDir = path.join(tempRoot.path(), "home"); + const cwd = path.join(tempRoot.path(), "project"); - writeModule(join(homeDir, ".omp", "agent", "modules"), "beta.py", "user-omp"); - writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); + await writeModule(path.join(homeDir, ".omp", "agent", "modules"), "beta.py", "user-omp"); + await writeModule(path.join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); const calls: Array<{ name: string; options?: { silent?: boolean; storeHistory?: boolean } }> = []; const executor: PythonModuleExecutor = { @@ -79,12 +79,12 @@ describe("python modules", () => { }); it("fails fast when a module fails to execute", async () => { - tempRoot = createTempDirSync("@omp-python-modules-"); - const homeDir = join(tempRoot.path, "home"); - const cwd = join(tempRoot.path, "project"); + tempRoot = TempDir.createSync("@omp-python-modules-"); + const homeDir = path.join(tempRoot.path(), "home"); + const cwd = path.join(tempRoot.path(), "project"); - writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); - writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp"); + await writeModule(path.join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp"); + await writeModule(path.join(cwd, ".omp", "modules"), "beta.py", "project-omp"); const executor: PythonModuleExecutor = { execute: async (code: string) => { diff --git a/packages/coding-agent/test/core/python-prelude.test.ts b/packages/coding-agent/test/core/python-prelude.test.ts index 62be2a6e6..c1af2f752 100644 --- a/packages/coding-agent/test/core/python-prelude.test.ts +++ b/packages/coding-agent/test/core/python-prelude.test.ts @@ -1,17 +1,19 @@ import { describe, expect, it } from "bun:test"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { resetPreludeDocsCache, warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/ipy/executor"; import { getPythonToolDescription, PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python"; const resolvePythonPath = (): string | null => { const venvPath = process.env.VIRTUAL_ENV; - const candidates = [venvPath, join(process.cwd(), ".venv"), join(process.cwd(), "venv")].filter(Boolean) as string[]; + const candidates = [venvPath, path.join(process.cwd(), ".venv"), path.join(process.cwd(), "venv")].filter( + Boolean, + ) as string[]; for (const candidate of candidates) { const binDir = process.platform === "win32" ? "Scripts" : "bin"; const exeName = process.platform === "win32" ? "python.exe" : "python"; - const pythonCandidate = join(candidate, binDir, exeName); - if (existsSync(pythonCandidate)) { + const pythonCandidate = path.join(candidate, binDir, exeName); + if (fs.existsSync(pythonCandidate)) { return pythonCandidate; } } diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 76ae5ebe2..ddc1a862f 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -3,22 +3,22 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { discoverAndLoadExtensions, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe("extensions discovery", () => { - let tempDir: ReturnType; + let tempDir: TempDir; let extensionsDir: string; beforeEach(() => { - tempDir = createTempDirSync("@pi-ext-test-"); - extensionsDir = path.join(tempDir.path, ".omp", "extensions"); + tempDir = TempDir.createSync("@pi-ext-test-"); + extensionsDir = path.join(tempDir.path(), ".omp", "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); }); afterEach(() => { - tempDir.remove(); + tempDir.removeSync(); }); const extensionCode = ` @@ -44,7 +44,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode); fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); @@ -54,7 +54,7 @@ describe("extensions discovery", () => { it("discovers direct .js files in extensions/", async () => { fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -66,7 +66,7 @@ describe("extensions discovery", () => { fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -79,7 +79,7 @@ describe("extensions discovery", () => { fs.mkdirSync(subdir); fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -92,7 +92,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -115,7 +115,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -138,7 +138,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); @@ -159,7 +159,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -181,7 +181,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -194,7 +194,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode); fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); @@ -208,7 +208,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(nested, "index.ts"), extensionCode); // No index.ts or package.json in container/ - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); @@ -229,7 +229,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode); fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } })); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(3); @@ -248,7 +248,7 @@ describe("extensions discovery", () => { }), ); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -258,7 +258,7 @@ describe("extensions discovery", () => { it("loads extensions and registers commands", async () => { fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -268,7 +268,7 @@ describe("extensions discovery", () => { it("loads extensions and registers tools", async () => { fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool")); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -278,7 +278,7 @@ describe("extensions discovery", () => { it("reports errors for invalid extension code", async () => { fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export"); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(1); expect(result.errors[0].path).toContain("invalid.ts"); @@ -286,11 +286,11 @@ describe("extensions discovery", () => { }); it("handles explicitly configured paths", async () => { - const customPath = path.join(tempDir.path, "custom-location", "my-ext.ts"); + const customPath = path.join(tempDir.path(), "custom-location", "my-ext.ts"); fs.mkdirSync(path.dirname(customPath), { recursive: true }); fs.writeFileSync(customPath, extensionCode); - const result = await discoverAndLoadExtensions([customPath], tempDir.path); + const result = await discoverAndLoadExtensions([customPath], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -301,7 +301,7 @@ describe("extensions discovery", () => { // Load the real chalk-logger extension from examples const chalkLoggerPath = path.resolve(__dirname, "../examples/extensions/chalk-logger.ts"); - const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir.path); + const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -314,7 +314,7 @@ describe("extensions discovery", () => { // Load extension that has its own package.json and node_modules with 'ms' package const extPath = path.resolve(__dirname, "../examples/extensions/with-deps"); - const result = await discoverAndLoadExtensions([extPath], tempDir.path); + const result = await discoverAndLoadExtensions([extPath], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -333,7 +333,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -348,7 +348,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(1); expect(result.errors[0].error).toContain("Initialization failed!"); @@ -363,7 +363,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(1); expect(result.errors[0].error).toContain("does not export a valid factory function"); @@ -374,7 +374,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a")); fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b")); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(2); @@ -399,7 +399,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -419,7 +419,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -437,7 +437,7 @@ describe("extensions discovery", () => { `; fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -449,11 +449,11 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCodeWithTool("discovered")); // Create explicit extension outside discovery path - const explicitPath = path.join(tempDir.path, "explicit.ts"); + const explicitPath = path.join(tempDir.path(), "explicit.ts"); fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit")); // Use loadExtensions directly to skip discovery - const result = await loadExtensions([explicitPath], tempDir.path); + const result = await loadExtensions([explicitPath], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -466,7 +466,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode); // Use loadExtensions directly with empty paths - const result = await loadExtensions([], tempDir.path); + const result = await loadExtensions([], tempDir.path()); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(0); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 9dc200e53..8b006c54a 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -3,32 +3,32 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { createTempDirSync, logger } from "@oh-my-pi/pi-utils"; +import { logger, TempDir } from "@oh-my-pi/pi-utils"; describe("ExtensionRunner", () => { - let tempDir: ReturnType; + let tempDir: TempDir; let extensionsDir: string; let sessionManager: SessionManager; let modelRegistry: ModelRegistry; beforeEach(async () => { - tempDir = createTempDirSync("@pi-runner-test-"); - extensionsDir = join(tempDir.path, ".omp", "extensions"); - mkdirSync(extensionsDir, { recursive: true }); + tempDir = TempDir.createSync("@pi-runner-test-"); + extensionsDir = path.join(tempDir.path(), ".omp", "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); sessionManager = SessionManager.inMemory(); - const authStorage = await AuthStorage.create(join(tempDir.path, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.json")); modelRegistry = new ModelRegistry(authStorage); }); afterEach(() => { - tempDir.remove(); + tempDir.removeSync(); }); describe("shortcut conflicts", () => { @@ -41,15 +41,15 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "conflict.ts"), extCode); + fs.writeFileSync(path.join(extensionsDir, "conflict.ts"), extCode); const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -79,16 +79,16 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "ext1.ts"), extCode1); - writeFileSync(join(extensionsDir, "ext2.ts"), extCode2); + fs.writeFileSync(path.join(extensionsDir, "ext1.ts"), extCode1); + fs.writeFileSync(path.join(extensionsDir, "ext2.ts"), extCode2); const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -116,14 +116,14 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "tool-a.ts"), toolCode("tool_a")); - writeFileSync(join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); + fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), toolCode("tool_a")); + fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -144,14 +144,14 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); - writeFileSync(join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); + fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); + fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -170,13 +170,13 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "cmd.ts"), cmdCode); + fs.writeFileSync(path.join(extensionsDir, "cmd.ts"), cmdCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -200,13 +200,13 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "throws.ts"), extCode); + fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -232,13 +232,13 @@ describe("ExtensionRunner", () => { pi.registerMessageRenderer("my-type", (message, options, theme) => null); } `; - writeFileSync(join(extensionsDir, "renderer.ts"), extCode); + fs.writeFileSync(path.join(extensionsDir, "renderer.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -261,13 +261,13 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "with-flag.ts"), extCode); + fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -285,13 +285,13 @@ describe("ExtensionRunner", () => { }); } `; - writeFileSync(join(extensionsDir, "flag.ts"), extCode); + fs.writeFileSync(path.join(extensionsDir, "flag.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); @@ -311,13 +311,13 @@ describe("ExtensionRunner", () => { pi.on("tool_call", async () => undefined); } `; - writeFileSync(join(extensionsDir, "handler.ts"), extCode); + fs.writeFileSync(path.join(extensionsDir, "handler.ts"), extCode); - const result = await discoverAndLoadExtensions([], tempDir.path); + const result = await discoverAndLoadExtensions([], tempDir.path()); const runner = new ExtensionRunner( result.extensions, result.runtime, - tempDir.path, + tempDir.path(), sessionManager, modelRegistry, ); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 6003f5dfb..a4da87dde 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { nanoid } from "nanoid"; @@ -12,15 +12,15 @@ describe("ModelRegistry", () => { let authStorage: AuthStorage; beforeEach(async () => { - tempDir = join(tmpdir(), `pi-test-model-registry-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); - modelsJsonPath = join(tempDir, "models.json"); - authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + tempDir = path.join(tmpdir(), `pi-test-model-registry-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); + modelsJsonPath = path.join(tempDir, "models.json"); + authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); }); afterEach(() => { - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }); @@ -47,7 +47,7 @@ describe("ModelRegistry", () => { } function writeModelsJson(providers: Record>) { - writeFileSync(modelsJsonPath, JSON.stringify({ providers })); + fs.writeFileSync(modelsJsonPath, JSON.stringify({ providers })); } function getModelsForProvider(registry: ModelRegistry, provider: string) { @@ -61,7 +61,7 @@ describe("ModelRegistry", () => { /** Write raw providers config (for mixed override/replacement scenarios) */ function writeRawModelsJson(providers: Record) { - writeFileSync(modelsJsonPath, JSON.stringify({ providers })); + fs.writeFileSync(modelsJsonPath, JSON.stringify({ providers })); } describe("baseUrl override (no custom models)", () => { diff --git a/packages/coding-agent/test/python-tool-settings.test.ts b/packages/coding-agent/test/python-tool-settings.test.ts index bb1a5bbc9..7152b7817 100644 --- a/packages/coding-agent/test/python-tool-settings.test.ts +++ b/packages/coding-agent/test/python-tool-settings.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor"; import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel"; import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; @@ -38,13 +38,13 @@ describe("python tool settings", () => { let testDir: string; beforeEach(() => { - testDir = join(tmpdir(), `python-tool-settings-${crypto.randomUUID()}`); - mkdirSync(testDir, { recursive: true }); + testDir = path.join(tmpdir(), `python-tool-settings-${crypto.randomUUID()}`); + fs.mkdirSync(testDir, { recursive: true }); }); afterEach(() => { vi.restoreAllMocks(); - rmSync(testDir, { recursive: true, force: true }); + fs.rmSync(testDir, { recursive: true, force: true }); }); it("exposes python tool when kernel is available", async () => { diff --git a/packages/coding-agent/test/rpc-example.ts b/packages/coding-agent/test/rpc-example.ts index 34ff50b1f..2fd31aec2 100644 --- a/packages/coding-agent/test/rpc-example.ts +++ b/packages/coding-agent/test/rpc-example.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import * as path from "node:path"; import * as readline from "node:readline"; import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client"; @@ -9,7 +9,7 @@ import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client"; async function main() { const client = new RpcClient({ - cliPath: join(import.meta.dir, "../src/cli.ts"), + cliPath: path.join(import.meta.dir, "../src/cli.ts"), provider: "anthropic", model: "claude-sonnet-4-20250514", args: ["--no-session"], diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index f7d5897d6..c6b0b9054 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import type { AgentEvent } from "@oh-my-pi/pi-agent-core"; import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client"; import { nanoid } from "nanoid"; @@ -14,10 +14,10 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T let sessionDir: string; beforeEach(() => { - sessionDir = join(tmpdir(), `omp-rpc-test-${nanoid()}`); + sessionDir = path.join(tmpdir(), `omp-rpc-test-${nanoid()}`); client = new RpcClient({ - cliPath: join(import.meta.dir, "..", "dist", "cli.js"), - cwd: join(import.meta.dir, ".."), + cliPath: path.join(import.meta.dir, "..", "dist", "cli.js"), + cwd: path.join(import.meta.dir, ".."), env: { OMP_CODING_AGENT_DIR: sessionDir }, provider: "anthropic", model: "claude-sonnet-4-5", @@ -26,8 +26,8 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T afterEach(async () => { await client.stop(); - if (sessionDir && existsSync(sessionDir)) { - rmSync(sessionDir, { recursive: true }); + if (sessionDir && fs.existsSync(sessionDir)) { + fs.rmSync(sessionDir, { recursive: true }); } }); @@ -56,21 +56,18 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T await Bun.sleep(200); // Verify session file - const sessionsPath = join(sessionDir, "sessions"); - expect(existsSync(sessionsPath)).toBe(true); + const sessionsPath = path.join(sessionDir, "sessions"); + expect(fs.existsSync(sessionsPath)).toBe(true); - const sessionDirs = readdirSync(sessionsPath); + const sessionDirs = fs.readdirSync(sessionsPath); expect(sessionDirs.length).toBeGreaterThan(0); - const cwdSessionDir = join(sessionsPath, sessionDirs[0]); - const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); + const cwdSessionDir = path.join(sessionsPath, sessionDirs[0]); + const sessionFiles = fs.readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); expect(sessionFiles.length).toBe(1); - const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8"); - const entries = sessionContent - .trim() - .split("\n") - .map((line) => JSON.parse(line)); + const sessionContent = await Bun.file(path.join(cwdSessionDir, sessionFiles[0])).text(); + const entries = sessionContent.split("\n").map((line) => JSON.parse(line)); // First entry should be session header expect(entries[0].type).toBe("session"); @@ -99,11 +96,11 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T await Bun.sleep(200); // Verify compaction in session file - const sessionsPath = join(sessionDir, "sessions"); - const sessionDirs = readdirSync(sessionsPath); - const cwdSessionDir = join(sessionsPath, sessionDirs[0]); - const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); - const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8"); + const sessionsPath = path.join(sessionDir, "sessions"); + const sessionDirs = fs.readdirSync(sessionsPath); + const cwdSessionDir = path.join(sessionsPath, sessionDirs[0]); + const sessionFiles = fs.readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); + const sessionContent = await Bun.file(path.join(cwdSessionDir, sessionFiles[0])).text(); const entries = sessionContent .trim() .split("\n") @@ -137,11 +134,11 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T await Bun.sleep(200); // Verify bash message in session - const sessionsPath = join(sessionDir, "sessions"); - const sessionDirs = readdirSync(sessionsPath); - const cwdSessionDir = join(sessionsPath, sessionDirs[0]); - const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); - const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8"); + const sessionsPath = path.join(sessionDir, "sessions"); + const sessionDirs = fs.readdirSync(sessionsPath); + const cwdSessionDir = path.join(sessionsPath, sessionDirs[0]); + const sessionFiles = fs.readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); + const sessionContent = await Bun.file(path.join(cwdSessionDir, sessionFiles[0])).text(); const entries = sessionContent .trim() .split("\n") @@ -263,7 +260,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T const result = await client.exportHtml(); expect(result.path).toBeDefined(); expect(result.path.endsWith(".html")).toBe(true); - expect(existsSync(result.path)).toBe(true); + expect(fs.existsSync(result.path)).toBe(true); }, 90000); test("should get last assistant text", async () => { diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index 3b649b869..9ea2ceafd 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import type { Skill } from "@oh-my-pi/pi-coding-agent/sdk"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -11,14 +11,14 @@ describe("createAgentSession skills option", () => { let skillsDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-sdk-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDir = path.join(tmpdir(), `pi-sdk-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); // Create skill in .omp/skills/ for native project-level discovery - skillsDir = join(tempDir, ".omp", "skills", "test-skill"); - mkdirSync(skillsDir, { recursive: true }); + skillsDir = path.join(tempDir, ".omp", "skills", "test-skill"); + fs.mkdirSync(skillsDir, { recursive: true }); // Create a test skill in the pi skills directory - writeFileSync( - join(skillsDir, "SKILL.md"), + fs.writeFileSync( + path.join(skillsDir, "SKILL.md"), `--- name: test-skill description: A test skill for SDK tests. @@ -33,7 +33,7 @@ This is a test skill. afterEach(() => { if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); } }); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 38b215a4f..ecf6a79ce 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { findMostRecentSession, loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { nanoid } from "nanoid"; @@ -9,59 +9,59 @@ describe("loadEntriesFromFile", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `session-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `session-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); - it("returns empty array for non-existent file", () => { - const entries = loadEntriesFromFile(join(tempDir, "nonexistent.jsonl")); + it("returns empty array for non-existent file", async () => { + const entries = await loadEntriesFromFile(path.join(tempDir, "nonexistent.jsonl")); expect(entries).toEqual([]); }); - it("returns empty array for empty file", () => { - const file = join(tempDir, "empty.jsonl"); - writeFileSync(file, ""); - expect(loadEntriesFromFile(file)).toEqual([]); + it("returns empty array for empty file", async () => { + const file = path.join(tempDir, "empty.jsonl"); + fs.writeFileSync(file, ""); + expect(await loadEntriesFromFile(file)).toEqual([]); }); - it("returns empty array for file without valid session header", () => { - const file = join(tempDir, "no-header.jsonl"); - writeFileSync(file, '{"type":"message","id":"1"}\n'); - expect(loadEntriesFromFile(file)).toEqual([]); + it("returns empty array for file without valid session header", async () => { + const file = path.join(tempDir, "no-header.jsonl"); + fs.writeFileSync(file, '{"type":"message","id":"1"}\n'); + expect(await loadEntriesFromFile(file)).toEqual([]); }); - it("returns empty array for malformed JSON", () => { - const file = join(tempDir, "malformed.jsonl"); - writeFileSync(file, "not json\n"); - expect(loadEntriesFromFile(file)).toEqual([]); + it("returns empty array for malformed JSON", async () => { + const file = path.join(tempDir, "malformed.jsonl"); + fs.writeFileSync(file, "not json\n"); + expect(await loadEntriesFromFile(file)).toEqual([]); }); - it("loads valid session file", () => { - const file = join(tempDir, "valid.jsonl"); - writeFileSync( + it("loads valid session file", async () => { + const file = path.join(tempDir, "valid.jsonl"); + fs.writeFileSync( file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', ); - const entries = loadEntriesFromFile(file); + const entries = await loadEntriesFromFile(file); expect(entries).toHaveLength(2); expect(entries[0].type).toBe("session"); expect(entries[1].type).toBe("message"); }); - it("skips malformed lines but keeps valid ones", () => { - const file = join(tempDir, "mixed.jsonl"); - writeFileSync( + it("skips malformed lines but keeps valid ones", async () => { + const file = path.join(tempDir, "mixed.jsonl"); + fs.writeFileSync( file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + "not valid json\n" + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', ); - const entries = loadEntriesFromFile(file); + const entries = await loadEntriesFromFile(file); expect(entries).toHaveLength(2); }); }); @@ -70,59 +70,59 @@ describe("findMostRecentSession", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `session-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `session-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); }); - it("returns null for empty directory", () => { - expect(findMostRecentSession(tempDir)).toBeNull(); + it("returns null for empty directory", async () => { + expect(await findMostRecentSession(tempDir)).toBeNull(); }); - it("returns null for non-existent directory", () => { - expect(findMostRecentSession(join(tempDir, "nonexistent"))).toBeNull(); + it("returns null for non-existent directory", async () => { + expect(await findMostRecentSession(path.join(tempDir, "nonexistent"))).toBeNull(); }); - it("ignores non-jsonl files", () => { - writeFileSync(join(tempDir, "file.txt"), "hello"); - writeFileSync(join(tempDir, "file.json"), "{}"); - expect(findMostRecentSession(tempDir)).toBeNull(); + it("ignores non-jsonl files", async () => { + fs.writeFileSync(path.join(tempDir, "file.txt"), "hello"); + fs.writeFileSync(path.join(tempDir, "file.json"), "{}"); + expect(await findMostRecentSession(tempDir)).toBeNull(); }); - it("ignores jsonl files without valid session header", () => { - writeFileSync(join(tempDir, "invalid.jsonl"), '{"type":"message"}\n'); - expect(findMostRecentSession(tempDir)).toBeNull(); + it("ignores jsonl files without valid session header", async () => { + fs.writeFileSync(path.join(tempDir, "invalid.jsonl"), '{"type":"message"}\n'); + expect(await findMostRecentSession(tempDir)).toBeNull(); }); - it("returns single valid session file", () => { - const file = join(tempDir, "session.jsonl"); - writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); - expect(findMostRecentSession(tempDir)).toBe(file); + it("returns single valid session file", async () => { + const file = path.join(tempDir, "session.jsonl"); + fs.writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + expect(await findMostRecentSession(tempDir)).toBe(file); }); it("returns most recently modified session", async () => { - const file1 = join(tempDir, "older.jsonl"); - const file2 = join(tempDir, "newer.jsonl"); + const file1 = path.join(tempDir, "older.jsonl"); + const file2 = path.join(tempDir, "newer.jsonl"); - writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + fs.writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); // Small delay to ensure different mtime await new Promise((r) => setTimeout(r, 10)); - writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + fs.writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); - expect(findMostRecentSession(tempDir)).toBe(file2); + expect(await findMostRecentSession(tempDir)).toBe(file2); }); it("skips invalid files and returns valid one", async () => { - const invalid = join(tempDir, "invalid.jsonl"); - const valid = join(tempDir, "valid.jsonl"); + const invalid = path.join(tempDir, "invalid.jsonl"); + const valid = path.join(tempDir, "valid.jsonl"); - writeFileSync(invalid, '{"type":"not-session"}\n'); + fs.writeFileSync(invalid, '{"type":"not-session"}\n'); await new Promise((r) => setTimeout(r, 10)); - writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + fs.writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); - expect(findMostRecentSession(tempDir)).toBe(valid); + expect(await findMostRecentSession(tempDir)).toBe(valid); }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 9645aec3f..5683e2165 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { SettingsManager } from "@oh-my-pi/pi-coding-agent/config/settings-manager"; import { YAML } from "bun"; @@ -11,18 +11,18 @@ describe("SettingsManager", () => { beforeEach(() => { // Use random UUID to isolate parallel test runs (SQLite files can't be shared) - testDir = join(process.cwd(), "test-settings-tmp", crypto.randomUUID()); - agentDir = join(testDir, "agent"); - projectDir = join(testDir, "project"); + testDir = path.join(process.cwd(), "test-settings-tmp", crypto.randomUUID()); + agentDir = path.join(testDir, "agent"); + projectDir = path.join(testDir, "project"); - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true }); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); } - mkdirSync(agentDir, { recursive: true }); - mkdirSync(join(projectDir, ".pi"), { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + fs.mkdirSync(path.join(projectDir, ".pi"), { recursive: true }); }); - const getConfigPath = () => join(agentDir, "config.yml"); + const getConfigPath = () => path.join(agentDir, "config.yml"); const writeSettings = async (settings: Record) => { await Bun.write(getConfigPath(), YAML.stringify(settings, null, 2)); @@ -38,8 +38,8 @@ describe("SettingsManager", () => { }; afterEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true }); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); } }); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 9ef050c46..c643ff4fb 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "bun:test"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import * as path from "node:path"; import { loadSkills, loadSkillsFromDir, type Skill } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; -const fixturesDir = resolve(__dirname, "fixtures/skills"); -const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision"); +const fixturesDir = path.resolve(__dirname, "fixtures/skills"); +const collisionFixturesDir = path.resolve(__dirname, "fixtures/skills-collision"); describe("skills", () => { describe("loadSkillsFromDir", () => { - it("should load a valid skill", () => { - const { skills, warnings } = loadSkillsFromDir({ - dir: join(fixturesDir, "valid-skill"), + it("should load a valid skill", async () => { + const { skills, warnings } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "valid-skill"), source: "test", }); @@ -21,9 +21,9 @@ describe("skills", () => { expect(warnings).toHaveLength(0); }); - it("should load skill when name doesn't match parent directory", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "name-mismatch"), + it("should load skill when name doesn't match parent directory", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "name-mismatch"), source: "test", }); @@ -31,45 +31,45 @@ describe("skills", () => { expect(skills[0].name).toBe("different-name"); }); - it("should load skill with invalid name characters", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "invalid-name-chars"), + it("should load skill with invalid name characters", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "invalid-name-chars"), source: "test", }); expect(skills).toHaveLength(1); }); - it("should load skill when name exceeds 64 characters", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "long-name"), + it("should load skill when name exceeds 64 characters", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "long-name"), source: "test", }); expect(skills).toHaveLength(1); }); - it("should skip skill when description is missing", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "missing-description"), + it("should skip skill when description is missing", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "missing-description"), source: "test", }); expect(skills).toHaveLength(0); }); - it("should load skill with unknown frontmatter fields", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "unknown-field"), + it("should load skill with unknown frontmatter fields", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "unknown-field"), source: "test", }); expect(skills).toHaveLength(1); }); - it("should load nested skills recursively", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "nested"), + it("should load nested skills recursively", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "nested"), source: "test", }); @@ -77,9 +77,9 @@ describe("skills", () => { expect(skills[0].name).toBe("child-skill"); }); - it("should skip files without frontmatter description", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "no-frontmatter"), + it("should skip files without frontmatter description", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "no-frontmatter"), source: "test", }); @@ -87,17 +87,17 @@ describe("skills", () => { expect(skills).toHaveLength(0); }); - it("should load skill with consecutive hyphens in name", () => { - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "consecutive-hyphens"), + it("should load skill with consecutive hyphens in name", async () => { + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "consecutive-hyphens"), source: "test", }); expect(skills).toHaveLength(1); }); - it("should load all skills from fixture directory", () => { - const { skills } = loadSkillsFromDir({ + it("should load all skills from fixture directory", async () => { + const { skills } = await loadSkillsFromDir({ dir: fixturesDir, source: "test", }); @@ -108,8 +108,8 @@ describe("skills", () => { expect(skills.length).toBeGreaterThanOrEqual(6); }); - it("should return empty for non-existent directory", () => { - const { skills } = loadSkillsFromDir({ + it("should return empty for non-existent directory", async () => { + const { skills } = await loadSkillsFromDir({ dir: "/non/existent/path", source: "test", }); @@ -117,12 +117,12 @@ describe("skills", () => { expect(skills).toHaveLength(0); }); - it("should use parent directory name when name not in frontmatter", () => { + it("should use parent directory name when name not in frontmatter", async () => { // The no-frontmatter fixture has no name in frontmatter, so it should use "no-frontmatter" // But it also has no description, so it won't load // Let's test with a valid skill that relies on directory name - const { skills } = loadSkillsFromDir({ - dir: join(fixturesDir, "valid-skill"), + const { skills } = await loadSkillsFromDir({ + dir: path.join(fixturesDir, "valid-skill"), source: "test", }); @@ -153,7 +153,7 @@ describe("skills", () => { enableClaudeProject: false, enablePiUser: false, enablePiProject: false, - customDirectories: [join(fixturesDir, "valid-skill")], + customDirectories: [path.join(fixturesDir, "valid-skill")], ignoredSkills: ["valid-skill"], }); expect(skills).toHaveLength(0); @@ -188,7 +188,7 @@ describe("skills", () => { }); it("should expand ~ in customDirectories", async () => { - const homeSkillsDir = join(homedir(), ".omp/agent/skills"); + const homeSkillsDir = path.join(homedir(), ".omp/agent/skills"); const { skills: withTilde } = await loadSkills({ enableCodexUser: false, enableClaudeUser: false, @@ -282,15 +282,15 @@ describe("skills", () => { }); describe("collision handling", () => { - it("should detect name collisions and keep first skill", () => { + it("should detect name collisions and keep first skill", async () => { // Load from first directory - const first = loadSkillsFromDir({ - dir: join(collisionFixturesDir, "first"), + const first = await loadSkillsFromDir({ + dir: path.join(collisionFixturesDir, "first"), source: "first", }); - const second = loadSkillsFromDir({ - dir: join(collisionFixturesDir, "second"), + const second = await loadSkillsFromDir({ + dir: path.join(collisionFixturesDir, "second"), source: "second", }); diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index ecf24be61..f5012788e 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -3,9 +3,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type AssistantMessage, @@ -110,7 +110,7 @@ async function createSession(tempDir: string, streamFn: Agent["streamFn"], tool: const sessionManager = SessionManager.inMemory(tempDir); const settingsManager = SettingsManager.inMemory({ edit: { streamingAbort: true } }); - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, tempDir); @@ -214,18 +214,18 @@ describe("streaming edit abort", () => { const seeds = [7, 21, 42, 84, 128]; beforeEach(() => { - tempDir = join(tmpdir(), `pi-streaming-edit-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = path.join(tmpdir(), `pi-streaming-edit-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); } }); it("does not abort for successful patches across random streams", async () => { - await Bun.write(join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); + await Bun.write(path.join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); const diff = "@@\n-beta\n+beta2\n"; for (const seed of seeds) { @@ -244,7 +244,7 @@ describe("streaming edit abort", () => { }); it("aborts for failing patches across random streams", async () => { - await Bun.write(join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); + await Bun.write(path.join(tempDir, "sample.txt"), "alpha\nbeta\ngamma\n"); const diff = "@@\n-omega\n+beta2\n"; for (const seed of seeds) { diff --git a/packages/coding-agent/test/streaming-render-debug.ts b/packages/coding-agent/test/streaming-render-debug.ts index f233ffc75..3048c5209 100644 --- a/packages/coding-agent/test/streaming-render-debug.ts +++ b/packages/coding-agent/test/streaming-render-debug.ts @@ -4,8 +4,7 @@ * Run with: npx tsx test/streaming-render-debug.ts */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -16,24 +15,24 @@ import { sleep } from "bun"; process.env.COLORTERM = "truecolor"; initTheme("dark"); -// Load the real fixture that caused the bug -const fixtureMessage: AssistantMessage = JSON.parse( - readFileSync(join(import.meta.dir, "fixtures/assistant-message-with-thinking-code.json"), "utf-8"), -); - -// Extract thinking and text content -const thinkingContent = fixtureMessage.content.find((c) => c.type === "thinking"); -const textContent = fixtureMessage.content.find((c) => c.type === "text"); - -if (!thinkingContent || thinkingContent.type !== "thinking") { - console.error("No thinking content in fixture"); - process.exit(1); -} - -const fullThinkingText = thinkingContent.thinking; -const fullTextContent = textContent && textContent.type === "text" ? textContent.text : ""; - async function main() { + // Load the real fixture that caused the bug + const fixtureMessage: AssistantMessage = JSON.parse( + await Bun.file(path.join(import.meta.dir, "fixtures/assistant-message-with-thinking-code.json")).text(), + ); + + // Extract thinking and text content + const thinkingContent = fixtureMessage.content.find((c) => c.type === "thinking"); + const textContent = fixtureMessage.content.find((c) => c.type === "text"); + + if (!thinkingContent || thinkingContent.type !== "thinking") { + console.error("No thinking content in fixture"); + process.exit(1); + } + + const fullThinkingText = thinkingContent.thinking; + const fullTextContent = textContent && textContent.type === "text" ? textContent.text : ""; + const terminal = new ProcessTerminal(); const tui = new TUI(terminal); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 424bdf034..fcc3b8515 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; @@ -50,8 +50,8 @@ describe("Coding Agent Tools", () => { process.env.OMP_EDIT_VARIANT = "replace"; // Create a unique temporary directory for each test - testDir = join(tmpdir(), `coding-agent-test-${nanoid()}`); - mkdirSync(testDir, { recursive: true }); + testDir = path.join(tmpdir(), `coding-agent-test-${nanoid()}`); + fs.mkdirSync(testDir, { recursive: true }); // Create tools for this test directory const session = createTestToolSession(testDir); @@ -66,7 +66,7 @@ describe("Coding Agent Tools", () => { afterEach(() => { // Clean up test directory - rmSync(testDir, { recursive: true, force: true }); + fs.rmSync(testDir, { recursive: true, force: true }); // Restore original edit variant if (originalEditVariant === undefined) { @@ -78,9 +78,9 @@ describe("Coding Agent Tools", () => { describe("read tool", () => { it("should read file contents that fit within limits", async () => { - const testFile = join(testDir, "test.txt"); + const testFile = path.join(testDir, "test.txt"); const content = "Hello, world!\nLine 2\nLine 3"; - writeFileSync(testFile, content); + fs.writeFileSync(testFile, content); const result = await readTool.execute("test-call-1", { path: testFile, lines: false }); @@ -91,15 +91,15 @@ describe("Coding Agent Tools", () => { }); it("should handle non-existent files", async () => { - const testFile = join(testDir, "nonexistent.txt"); + const testFile = path.join(testDir, "nonexistent.txt"); await expect(readTool.execute("test-call-2", { path: testFile })).rejects.toThrow(/ENOENT|not found/i); }); it("should truncate files exceeding line limit", async () => { - const testFile = join(testDir, "large.txt"); + const testFile = path.join(testDir, "large.txt"); const lines = Array.from({ length: 3500 }, (_, i) => `Line ${i + 1}`); - writeFileSync(testFile, lines.join("\n")); + fs.writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-3", { path: testFile }); const output = getTextOutput(result); @@ -111,10 +111,10 @@ describe("Coding Agent Tools", () => { }); it("should truncate when byte limit exceeded", async () => { - const testFile = join(testDir, "large-bytes.txt"); + const testFile = path.join(testDir, "large-bytes.txt"); // Create file that exceeds 50KB byte limit but has fewer than 3000 lines const lines = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}: ${"x".repeat(200)}`); - writeFileSync(testFile, lines.join("\n")); + fs.writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-4", { path: testFile }); const output = getTextOutput(result); @@ -127,9 +127,9 @@ describe("Coding Agent Tools", () => { }); it("should handle offset parameter", async () => { - const testFile = join(testDir, "offset-test.txt"); + const testFile = path.join(testDir, "offset-test.txt"); const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); - writeFileSync(testFile, lines.join("\n")); + fs.writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-5", { path: testFile, offset: 51 }); const output = getTextOutput(result); @@ -142,9 +142,9 @@ describe("Coding Agent Tools", () => { }); it("should handle limit parameter", async () => { - const testFile = join(testDir, "limit-test.txt"); + const testFile = path.join(testDir, "limit-test.txt"); const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); - writeFileSync(testFile, lines.join("\n")); + fs.writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-6", { path: testFile, limit: 10 }); const output = getTextOutput(result); @@ -156,9 +156,9 @@ describe("Coding Agent Tools", () => { }); it("should handle offset + limit together", async () => { - const testFile = join(testDir, "offset-limit-test.txt"); + const testFile = path.join(testDir, "offset-limit-test.txt"); const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); - writeFileSync(testFile, lines.join("\n")); + fs.writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-7", { path: testFile, @@ -175,8 +175,8 @@ describe("Coding Agent Tools", () => { }); it("should show error when offset is beyond file length", async () => { - const testFile = join(testDir, "short.txt"); - writeFileSync(testFile, "Line 1\nLine 2\nLine 3"); + const testFile = path.join(testDir, "short.txt"); + fs.writeFileSync(testFile, "Line 1\nLine 2\nLine 3"); const result = await readTool.execute("test-call-8", { path: testFile, offset: 100 }); const output = getTextOutput(result); @@ -186,9 +186,9 @@ describe("Coding Agent Tools", () => { }); it("should include truncation details when truncated", async () => { - const testFile = join(testDir, "large-file.txt"); + const testFile = path.join(testDir, "large-file.txt"); const lines = Array.from({ length: 3500 }, (_, i) => `Line ${i + 1}`); - writeFileSync(testFile, lines.join("\n")); + fs.writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-9", { path: testFile }); @@ -205,8 +205,8 @@ describe("Coding Agent Tools", () => { "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII="; const pngBuffer = Buffer.from(png1x1Base64, "base64"); - const testFile = join(testDir, "image.txt"); - writeFileSync(testFile, pngBuffer); + const testFile = path.join(testDir, "image.txt"); + fs.writeFileSync(testFile, pngBuffer); const result = await readTool.execute("test-call-img-1", { path: testFile }); @@ -223,8 +223,8 @@ describe("Coding Agent Tools", () => { }); it("should treat files with image extension but non-image content as text", async () => { - const testFile = join(testDir, "not-an-image.png"); - writeFileSync(testFile, "definitely not a png"); + const testFile = path.join(testDir, "not-an-image.png"); + fs.writeFileSync(testFile, "definitely not a png"); const result = await readTool.execute("test-call-img-2", { path: testFile }); const output = getTextOutput(result); @@ -236,7 +236,7 @@ describe("Coding Agent Tools", () => { describe("write tool", () => { it("should write file contents", async () => { - const testFile = join(testDir, "write-test.txt"); + const testFile = path.join(testDir, "write-test.txt"); const content = "Test content"; const result = await writeTool.execute("test-call-3", { path: testFile, content }); @@ -246,7 +246,7 @@ describe("Coding Agent Tools", () => { }); it("should create parent directories", async () => { - const testFile = join(testDir, "nested", "dir", "test.txt"); + const testFile = path.join(testDir, "nested", "dir", "test.txt"); const content = "Nested content"; const result = await writeTool.execute("test-call-4", { path: testFile, content }); @@ -257,9 +257,9 @@ describe("Coding Agent Tools", () => { describe("edit tool", () => { it("should replace text in file", async () => { - const testFile = join(testDir, "edit-test.txt"); + const testFile = path.join(testDir, "edit-test.txt"); const originalContent = "Hello, world!"; - writeFileSync(testFile, originalContent); + fs.writeFileSync(testFile, originalContent); const result = await editTool.execute("test-call-5", { path: testFile, @@ -275,9 +275,9 @@ describe("Coding Agent Tools", () => { }); it("should fail if text not found", async () => { - const testFile = join(testDir, "edit-test.txt"); + const testFile = path.join(testDir, "edit-test.txt"); const originalContent = "Hello, world!"; - writeFileSync(testFile, originalContent); + fs.writeFileSync(testFile, originalContent); await expect( editTool.execute("test-call-6", { @@ -289,9 +289,9 @@ describe("Coding Agent Tools", () => { }); it("should fail if text appears multiple times", async () => { - const testFile = join(testDir, "edit-test.txt"); + const testFile = path.join(testDir, "edit-test.txt"); const originalContent = "foo foo foo"; - writeFileSync(testFile, originalContent); + fs.writeFileSync(testFile, originalContent); await expect( editTool.execute("test-call-7", { @@ -303,8 +303,8 @@ describe("Coding Agent Tools", () => { }); it("should replace all occurrences with all: true", async () => { - const testFile = join(testDir, "edit-all-test.txt"); - writeFileSync(testFile, "foo bar foo baz foo"); + const testFile = path.join(testDir, "edit-all-test.txt"); + fs.writeFileSync(testFile, "foo bar foo baz foo"); const result = await editTool.execute("test-all-1", { path: testFile, @@ -314,14 +314,14 @@ describe("Coding Agent Tools", () => { }); expect(getTextOutput(result)).toContain("Successfully replaced 3 occurrences"); - const content = readFileSync(testFile, "utf-8"); + const content = await Bun.file(testFile).text(); expect(content).toBe("qux bar qux baz qux"); }); it("should reject all: true when multiple fuzzy matches are ambiguous", async () => { - const testFile = join(testDir, "edit-all-fuzzy.txt"); + const testFile = path.join(testDir, "edit-all-fuzzy.txt"); // File has two similar blocks with different indentation - writeFileSync( + fs.writeFileSync( testFile, `function a() { if (x) { @@ -348,8 +348,8 @@ function b() { }); it("should fail with all: true if no matches found", async () => { - const testFile = join(testDir, "edit-all-nomatch.txt"); - writeFileSync(testFile, "hello world"); + const testFile = path.join(testDir, "edit-all-nomatch.txt"); + fs.writeFileSync(testFile, "hello world"); await expect( editTool.execute("test-all-nomatch", { @@ -362,8 +362,8 @@ function b() { }); it("should replace multiline text with all: true", async () => { - const testFile = join(testDir, "edit-all-multiline.txt"); - writeFileSync(testFile, "start\nfoo\nbar\nend\nstart\nfoo\nbar\nend"); + const testFile = path.join(testDir, "edit-all-multiline.txt"); + fs.writeFileSync(testFile, "start\nfoo\nbar\nend\nstart\nfoo\nbar\nend"); const result = await editTool.execute("test-all-multiline", { path: testFile, @@ -373,13 +373,13 @@ function b() { }); expect(getTextOutput(result)).toContain("Successfully replaced 2 occurrences"); - const content = readFileSync(testFile, "utf-8"); + const content = await Bun.file(testFile).text(); expect(content).toBe("start\nreplaced\nend\nstart\nreplaced\nend"); }); it("should work with all: true when only one occurrence exists", async () => { - const testFile = join(testDir, "edit-all-single.txt"); - writeFileSync(testFile, "hello world"); + const testFile = path.join(testDir, "edit-all-single.txt"); + fs.writeFileSync(testFile, "hello world"); const result = await editTool.execute("test-all-single", { path: testFile, @@ -389,7 +389,7 @@ function b() { }); expect(getTextOutput(result)).toContain("Successfully replaced text"); - const content = readFileSync(testFile, "utf-8"); + const content = await Bun.file(testFile).text(); expect(content).toBe("hello universe"); }); }); @@ -442,8 +442,8 @@ function b() { describe("grep tool", () => { it("should include filename when searching a single file", async () => { - const testFile = join(testDir, "example.txt"); - writeFileSync(testFile, "first line\nmatch line\nlast line"); + const testFile = path.join(testDir, "example.txt"); + fs.writeFileSync(testFile, "first line\nmatch line\nlast line"); const result = await grepTool.execute("test-call-11", { pattern: "match", @@ -455,9 +455,9 @@ function b() { }); it("should respect global limit and include context lines", async () => { - const testFile = join(testDir, "context.txt"); + const testFile = path.join(testDir, "context.txt"); const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n"); - writeFileSync(testFile, content); + fs.writeFileSync(testFile, content); const result = await grepTool.execute("test-call-12", { pattern: "match", @@ -478,10 +478,10 @@ function b() { describe("find tool", () => { it("should include hidden files that are not gitignored", async () => { - const hiddenDir = join(testDir, ".secret"); - mkdirSync(hiddenDir); - writeFileSync(join(hiddenDir, "hidden.txt"), "hidden"); - writeFileSync(join(testDir, "visible.txt"), "visible"); + const hiddenDir = path.join(testDir, ".secret"); + fs.mkdirSync(hiddenDir); + fs.writeFileSync(path.join(hiddenDir, "hidden.txt"), "hidden"); + fs.writeFileSync(path.join(testDir, "visible.txt"), "visible"); const result = await findTool.execute("test-call-13", { pattern: "**/*.txt", @@ -499,9 +499,9 @@ function b() { }); it("should respect .gitignore", async () => { - writeFileSync(join(testDir, ".gitignore"), "ignored.txt\n"); - writeFileSync(join(testDir, "ignored.txt"), "ignored"); - writeFileSync(join(testDir, "kept.txt"), "kept"); + fs.writeFileSync(path.join(testDir, ".gitignore"), "ignored.txt\n"); + fs.writeFileSync(path.join(testDir, "ignored.txt"), "ignored"); + fs.writeFileSync(path.join(testDir, "kept.txt"), "kept"); const result = await findTool.execute("test-call-14", { pattern: "**/*.txt", @@ -516,8 +516,8 @@ function b() { describe("ls tool", () => { it("should list dotfiles and directories", async () => { - writeFileSync(join(testDir, ".hidden-file"), "secret"); - mkdirSync(join(testDir, ".hidden-dir")); + fs.writeFileSync(path.join(testDir, ".hidden-file"), "secret"); + fs.mkdirSync(path.join(testDir, ".hidden-dir")); const result = await lsTool.execute("test-call-15", { path: testDir }); const output = getTextOutput(result); @@ -538,13 +538,13 @@ describe("edit tool CRLF handling", () => { originalEditVariant = process.env.OMP_EDIT_VARIANT; process.env.OMP_EDIT_VARIANT = "replace"; - testDir = join(tmpdir(), `coding-agent-crlf-test-${nanoid()}`); - mkdirSync(testDir, { recursive: true }); + testDir = path.join(tmpdir(), `coding-agent-crlf-test-${nanoid()}`); + fs.mkdirSync(testDir, { recursive: true }); editTool = new EditTool(createTestToolSession(testDir)); }); afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); + fs.rmSync(testDir, { recursive: true, force: true }); // Restore original edit variant if (originalEditVariant === undefined) { @@ -555,9 +555,9 @@ describe("edit tool CRLF handling", () => { }); it("should match LF old_text against CRLF file content", async () => { - const testFile = join(testDir, "crlf-test.txt"); + const testFile = path.join(testDir, "crlf-test.txt"); - writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n"); + fs.writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n"); const result = await editTool.execute("test-crlf-1", { path: testFile, @@ -569,8 +569,8 @@ describe("edit tool CRLF handling", () => { }); it("should preserve CRLF line endings after edit", async () => { - const testFile = join(testDir, "crlf-preserve.txt"); - writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n"); + const testFile = path.join(testDir, "crlf-preserve.txt"); + fs.writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n"); await editTool.execute("test-crlf-2", { path: testFile, @@ -578,13 +578,13 @@ describe("edit tool CRLF handling", () => { new_text: "REPLACED\n", }); - const content = readFileSync(testFile, "utf-8"); + const content = await Bun.file(testFile).text(); expect(content).toBe("first\r\nREPLACED\r\nthird\r\n"); }); it("should preserve LF line endings for LF files", async () => { - const testFile = join(testDir, "lf-preserve.txt"); - writeFileSync(testFile, "first\nsecond\nthird\n"); + const testFile = path.join(testDir, "lf-preserve.txt"); + fs.writeFileSync(testFile, "first\nsecond\nthird\n"); await editTool.execute("test-lf-1", { path: testFile, @@ -592,14 +592,14 @@ describe("edit tool CRLF handling", () => { new_text: "REPLACED\n", }); - const content = readFileSync(testFile, "utf-8"); + const content = await Bun.file(testFile).text(); expect(content).toBe("first\nREPLACED\nthird\n"); }); it("should detect duplicates across CRLF/LF variants", async () => { - const testFile = join(testDir, "mixed-endings.txt"); + const testFile = path.join(testDir, "mixed-endings.txt"); - writeFileSync(testFile, "hello\r\nworld\r\n---\r\nhello\nworld\n"); + fs.writeFileSync(testFile, "hello\r\nworld\r\n---\r\nhello\nworld\n"); await expect( editTool.execute("test-crlf-dup", { @@ -612,8 +612,8 @@ describe("edit tool CRLF handling", () => { // TODO: CRLF preservation broken by LSP formatting - fix later it.skip("should preserve UTF-8 BOM after edit", async () => { - const testFile = join(testDir, "bom-test.txt"); - writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n"); + const testFile = path.join(testDir, "bom-test.txt"); + fs.writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n"); await editTool.execute("test-bom", { path: testFile, @@ -621,7 +621,7 @@ describe("edit tool CRLF handling", () => { new_text: "REPLACED\n", }); - const content = readFileSync(testFile, "utf-8"); + const content = await Bun.file(testFile).text(); expect(content).toBe("\uFEFFfirst\r\nREPLACED\r\nthird\r\n"); }); }); diff --git a/packages/coding-agent/test/tools/lsp-batching.test.ts b/packages/coding-agent/test/tools/lsp-batching.test.ts index 664a82b31..c88925b6a 100644 --- a/packages/coding-agent/test/tools/lsp-batching.test.ts +++ b/packages/coding-agent/test/tools/lsp-batching.test.ts @@ -1,19 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { join } from "node:path"; +import * as path from "node:path"; import { createLspWritethrough } from "@oh-my-pi/pi-coding-agent/lsp"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; describe("createLspWritethrough batching", () => { - let tempDir: ReturnType; + let tempDir: TempDir; beforeEach(() => { - tempDir = createTempDirSync("@omp-lsp-batch-"); + tempDir = TempDir.createSync("@omp-lsp-batch-"); }); afterEach(() => { vi.restoreAllMocks(); - tempDir.remove(); + tempDir.removeSync(); }); it("defers LSP work until the batch flush", async () => { @@ -21,10 +21,10 @@ describe("createLspWritethrough batching", () => { .spyOn(lspConfig, "loadConfig") .mockResolvedValue({ servers: {}, idleTimeoutMs: undefined }); const getServersSpy = vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]); - const writethrough = createLspWritethrough(tempDir.path, { enableFormat: true, enableDiagnostics: true }); + const writethrough = createLspWritethrough(tempDir.path(), { enableFormat: true, enableDiagnostics: true }); - const fileA = join(tempDir.path, "a.ts"); - const fileB = join(tempDir.path, "b.ts"); + const fileA = path.join(tempDir.path(), "a.ts"); + const fileB = path.join(tempDir.path(), "b.ts"); const batchId = `batch-${Date.now()}`; const firstResult = await writethrough(fileA, "const a = 1;\n", undefined, undefined, { @@ -54,9 +54,9 @@ describe("createLspWritethrough batching", () => { .spyOn(lspConfig, "loadConfig") .mockResolvedValue({ servers: {}, idleTimeoutMs: undefined }); const getServersSpy = vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]); - const writethrough = createLspWritethrough(tempDir.path, { enableFormat: true, enableDiagnostics: true }); + const writethrough = createLspWritethrough(tempDir.path(), { enableFormat: true, enableDiagnostics: true }); - const filePath = join(tempDir.path, "single.ts"); + const filePath = path.join(tempDir.path(), "single.ts"); const result = await writethrough(filePath, "const single = true;\n"); expect(result).toBeUndefined(); diff --git a/packages/coding-agent/test/tools/python-execution.test.ts b/packages/coding-agent/test/tools/python-execution.test.ts index 750b0fc5e..9ed3f504c 100644 --- a/packages/coding-agent/test/tools/python-execution.test.ts +++ b/packages/coding-agent/test/tools/python-execution.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "bun:test"; import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; function createSession(cwd: string): ToolSession { return { @@ -27,7 +27,7 @@ function createSession(cwd: string): ToolSession { describe("python tool execution", () => { it("passes kernel options from settings and args", async () => { - const tempDir = createTempDirSync("@python-tool-"); + const tempDir = TempDir.createSync("@python-tool-"); const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({ output: "ok", exitCode: 0, @@ -41,10 +41,10 @@ describe("python tool execution", () => { stdinRequested: false, }); - const tool = new PythonTool(createSession(tempDir.path)); + const tool = new PythonTool(createSession(tempDir.path())); const result = await tool.execute( "call-id", - { cells: [{ code: "print('hi')" }], timeout: 5, cwd: tempDir.path, reset: true }, + { cells: [{ code: "print('hi')" }], timeout: 5, cwd: tempDir.path(), reset: true }, undefined, undefined, undefined, @@ -53,9 +53,9 @@ describe("python tool execution", () => { expect(executeSpy).toHaveBeenCalledWith( "print('hi')", expect.objectContaining({ - cwd: tempDir.path, + cwd: tempDir.path(), timeoutMs: 5000, - sessionId: `session:session-file:cwd:${tempDir.path}`, + sessionId: `session:session-file:cwd:${tempDir.path()}`, kernelMode: "per-call", reset: true, }), @@ -64,6 +64,6 @@ describe("python tool execution", () => { expect(text).toBe("ok"); executeSpy.mockRestore(); - tempDir.remove(); + tempDir.removeSync(); }); }); diff --git a/packages/coding-agent/test/tools/python-renderer.test.ts b/packages/coding-agent/test/tools/python-renderer.test.ts index fcf4f3f5d..a943b64b8 100644 --- a/packages/coding-agent/test/tools/python-renderer.test.ts +++ b/packages/coding-agent/test/tools/python-renderer.test.ts @@ -4,8 +4,8 @@ import { pythonToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/python"; import stripAnsi from "strip-ansi"; describe("pythonToolRenderer", () => { - it("renders truncated output when collapsed and full output when expanded", () => { - const theme = getThemeByName("dark"); + it("renders truncated output when collapsed and full output when expanded", async () => { + const theme = await getThemeByName("dark"); expect(theme).toBeDefined(); const uiTheme = theme!; diff --git a/packages/coding-agent/test/tools/python.test.ts b/packages/coding-agent/test/tools/python.test.ts index 914195475..14a7b5174 100644 --- a/packages/coding-agent/test/tools/python.test.ts +++ b/packages/coding-agent/test/tools/python.test.ts @@ -2,12 +2,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "bun:test"; import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor"; import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python"; -import { createTempDirSync, type SyncTempDir } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; let previousSkipCheck: string | undefined; -let tempDir: SyncTempDir; +let tempDir: TempDir; beforeAll(() => { - tempDir = createTempDirSync("@omp-python-test-"); + tempDir = TempDir.createSync("@omp-python-test-"); previousSkipCheck = process.env.OMP_PYTHON_SKIP_CHECK; process.env.OMP_PYTHON_SKIP_CHECK = "1"; }); @@ -18,12 +18,12 @@ afterAll(() => { return; } process.env.OMP_PYTHON_SKIP_CHECK = previousSkipCheck; - tempDir.remove(); + tempDir.removeSync(); }); function createSession(overrides: Partial = {}): ToolSession { return { - cwd: tempDir.path, + cwd: tempDir.path(), hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 1ea1d9c5d..27d537f72 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -2,9 +2,9 @@ * Shared test utilities for coding-agent tests. */ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -78,8 +78,8 @@ export interface TestSessionContext { * Use this for e2e tests that need real LLM calls. */ export async function createTestSession(options: TestSessionOptions = {}): Promise { - const tempDir = join(tmpdir(), `omp-test-${nanoid()}`); - mkdirSync(tempDir, { recursive: true }); + const tempDir = path.join(tmpdir(), `omp-test-${nanoid()}`); + fs.mkdirSync(tempDir, { recursive: true }); const toolSession: ToolSession = { cwd: tempDir, @@ -106,7 +106,7 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi settingsManager.applyOverrides(options.settingsOverrides); } - const authStorage = await AuthStorage.create(join(tempDir, "auth.json")); + const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage, tempDir); const session = new AgentSession({ @@ -121,8 +121,8 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi const cleanup = () => { session.dispose(); - if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true }); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); } }; diff --git a/packages/pi-utils/package.json b/packages/pi-utils/package.json index c611c3980..f11cba989 100644 --- a/packages/pi-utils/package.json +++ b/packages/pi-utils/package.json @@ -32,7 +32,7 @@ "strip-ansi": "^7.1.2" }, "devDependencies": { - "@types/node": "^24.3.0" + "@types/node": "^25.0.10" }, "engines": { "bun": ">=1.0.0" diff --git a/packages/pi-utils/src/color.ts b/packages/pi-utils/src/color.ts new file mode 100644 index 000000000..a7bdf74f1 --- /dev/null +++ b/packages/pi-utils/src/color.ts @@ -0,0 +1,216 @@ +/** + * Color manipulation utilities for hex colors. + * + * @example + * ```ts + * import { shiftHue, hexToHsv, hsvToHex } from "@oh-my-pi/pi-utils"; + * + * // Shift green toward blue for colorblind accessibility + * const blue = shiftHue("#4ade80", 90); // ~90° shift + * + * // Or work with HSV directly + * const hsv = hexToHsv("#4ade80"); + * hsv.h = (hsv.h + 90) % 360; + * const newHex = hsvToHex(hsv); + * ``` + */ + +export interface HSV { + /** Hue in degrees (0-360) */ + h: number; + /** Saturation (0-1) */ + s: number; + /** Value/brightness (0-1) */ + v: number; +} + +export interface RGB { + /** Red (0-255) */ + r: number; + /** Green (0-255) */ + g: number; + /** Blue (0-255) */ + b: number; +} + +/** + * Parse a hex color string to RGB. + * Supports #RGB, #RRGGBB formats. + */ +export function hexToRgb(hex: string): RGB { + const h = hex.startsWith("#") ? hex.slice(1) : hex; + if (h.length === 3) { + return { + r: parseInt(h[0] + h[0], 16), + g: parseInt(h[1] + h[1], 16), + b: parseInt(h[2] + h[2], 16), + }; + } + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; +} + +/** + * Convert RGB to hex color string. + */ +export function rgbToHex(rgb: RGB): string { + const toHex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0"); + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`; +} + +/** + * Convert RGB to HSV. + */ +export function rgbToHsv(rgb: RGB): HSV { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + + let h = 0; + if (d !== 0) { + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + } + + return { + h: h * 360, + s: max === 0 ? 0 : d / max, + v: max, + }; +} + +/** + * Convert HSV to RGB. + */ +export function hsvToRgb(hsv: HSV): RGB { + const { s, v } = hsv; + const h = ((hsv.h % 360) + 360) % 360; // Normalize to 0-360 + + const i = Math.floor(h / 60); + const f = h / 60 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + let r: number, g: number, b: number; + switch (i % 6) { + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + default: + r = v; + g = p; + b = q; + break; + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255), + }; +} + +/** + * Convert hex color to HSV. + */ +export function hexToHsv(hex: string): HSV { + return rgbToHsv(hexToRgb(hex)); +} + +/** + * Convert HSV to hex color. + */ +export function hsvToHex(hsv: HSV): string { + return rgbToHex(hsvToRgb(hsv)); +} + +/** + * Shift the hue of a hex color by a given number of degrees. + * + * @param hex - Hex color string (#RGB or #RRGGBB) + * @param degrees - Degrees to shift (positive = toward blue, negative = toward red) + * @returns New hex color string + * + * @example + * ```ts + * // Shift green 90° toward blue (for colorblind accessibility) + * shiftHue("#4ade80", 90) // Returns a cyan/blue color + * ``` + */ +export function shiftHue(hex: string, degrees: number): string { + const hsv = hexToHsv(hex); + hsv.h = (hsv.h + degrees) % 360; + if (hsv.h < 0) hsv.h += 360; + return hsvToHex(hsv); +} + +export interface HSVAdjustment { + /** Hue shift in degrees (additive) */ + h?: number; + /** Saturation multiplier */ + s?: number; + /** Value/brightness multiplier */ + v?: number; +} + +/** + * Adjust HSV components of a hex color. + * + * @param hex - Hex color string (#RGB or #RRGGBB) + * @param adj - Adjustments: h is additive degrees, s and v are multipliers + * @returns New hex color string + * + * @example + * ```ts + * // Shift hue +60°, reduce saturation to 71% + * adjustHsv("#00ff88", { h: 60, s: 0.71 }) // "#4a9eff" + * ``` + */ +export function adjustHsv(hex: string, adj: HSVAdjustment): string { + const hsv = hexToHsv(hex); + if (adj.h !== undefined) { + hsv.h = (hsv.h + adj.h) % 360; + if (hsv.h < 0) hsv.h += 360; + } + if (adj.s !== undefined) { + hsv.s = Math.max(0, Math.min(1, hsv.s * adj.s)); + } + if (adj.v !== undefined) { + hsv.v = Math.max(0, Math.min(1, hsv.v * adj.v)); + } + return hsvToHex(hsv); +} diff --git a/packages/pi-utils/src/fs-error.ts b/packages/pi-utils/src/fs-error.ts new file mode 100644 index 000000000..e9115fdac --- /dev/null +++ b/packages/pi-utils/src/fs-error.ts @@ -0,0 +1,56 @@ +/** + * Type-safe filesystem error handling utilities. + * + * Use these to check error codes without string matching on messages: + * + * @example + * ```ts + * import { isEnoent, isFsError } from "@oh-my-pi/pi-utils"; + * + * try { + * return await Bun.file(path).text(); + * } catch (err) { + * if (isEnoent(err)) return null; + * throw err; + * } + * ``` + */ + +export interface FsError extends Error { + code: string; + errno?: number; + syscall?: string; + path?: string; +} + +export function isFsError(err: unknown): err is FsError { + return err instanceof Error && "code" in err && typeof (err as FsError).code === "string"; +} + +export function isEnoent(err: unknown): err is FsError { + return isFsError(err) && err.code === "ENOENT"; +} + +export function isEacces(err: unknown): err is FsError { + return isFsError(err) && err.code === "EACCES"; +} + +export function isEisdir(err: unknown): err is FsError { + return isFsError(err) && err.code === "EISDIR"; +} + +export function isEnotdir(err: unknown): err is FsError { + return isFsError(err) && err.code === "ENOTDIR"; +} + +export function isEexist(err: unknown): err is FsError { + return isFsError(err) && err.code === "EEXIST"; +} + +export function isEnotempty(err: unknown): err is FsError { + return isFsError(err) && err.code === "ENOTEMPTY"; +} + +export function hasFsCode(err: unknown, code: string): err is FsError { + return isFsError(err) && err.code === code; +} diff --git a/packages/pi-utils/src/index.ts b/packages/pi-utils/src/index.ts index bcaa102fc..271d03040 100644 --- a/packages/pi-utils/src/index.ts +++ b/packages/pi-utils/src/index.ts @@ -1,4 +1,6 @@ export * from "./abortable"; +export * from "./color"; +export * from "./fs-error"; export * as logger from "./logger"; export * as postmortem from "./postmortem"; export * as ptree from "./ptree"; diff --git a/packages/pi-utils/src/logger.ts b/packages/pi-utils/src/logger.ts index d92c83e2c..efe795c1c 100644 --- a/packages/pi-utils/src/logger.ts +++ b/packages/pi-utils/src/logger.ts @@ -5,22 +5,22 @@ * Each log entry includes process.pid for traceability. */ -import { existsSync, mkdirSync } from "node:fs"; +import * as fs from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import winston from "winston"; import DailyRotateFile from "winston-daily-rotate-file"; /** Get the logs directory (~/.omp/logs/) */ function getLogsDir(): string { - return join(homedir(), ".omp", "logs"); + return path.join(homedir(), ".omp", "logs"); } /** Ensure logs directory exists */ function ensureLogsDir(): string { const logsDir = getLogsDir(); - if (!existsSync(logsDir)) { - mkdirSync(logsDir, { recursive: true }); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); } return logsDir; } diff --git a/packages/pi-utils/src/stream.ts b/packages/pi-utils/src/stream.ts index 1c4e10383..0c1e62217 100644 --- a/packages/pi-utils/src/stream.ts +++ b/packages/pi-utils/src/stream.ts @@ -87,7 +87,7 @@ export function createSanitizerStream(): TransformStream { * Create a transform stream that decodes text. */ export function createTextDecoderStream(): TransformStream { - return new TextDecoderStream("utf-8", { ignoreBOM: true }); + return new TextDecoderStream("utf-8", { ignoreBOM: true }) as TransformStream; } /** diff --git a/packages/pi-utils/src/temp.ts b/packages/pi-utils/src/temp.ts index ce0b8d133..65c4c22a5 100644 --- a/packages/pi-utils/src/temp.ts +++ b/packages/pi-utils/src/temp.ts @@ -1,73 +1,77 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join, sep } from "node:path"; +import * as path from "node:path"; -export interface AsyncTempDir { - path: string; - remove(): Promise; - toString(): string; - [Symbol.asyncDispose](): Promise; -} +export class TempDir { + #path: string; + private constructor(path: string) { + this.#path = path; + } -export interface SyncTempDir { - path: string; - remove(): void; - toString(): string; - [Symbol.dispose](): void; + static createSync(prefix?: string): TempDir { + return new TempDir(fs.mkdtempSync(normalizePrefix(prefix))); + } + + static async create(prefix?: string): Promise { + return new TempDir(await fs.promises.mkdtemp(normalizePrefix(prefix))); + } + + #removePromise: Promise | null = null; + + path(): string { + return this.#path; + } + + absolute(): string { + return path.resolve(this.#path); + } + + remove(): Promise { + if (this.#removePromise) { + return this.#removePromise; + } + const removePromise = fs.promises.rm(this.#path, { recursive: true, force: true }); + this.#removePromise = removePromise; + return removePromise; + } + + removeSync(): void { + fs.rmSync(this.#path, { recursive: true, force: true }); + this.#removePromise = Promise.resolve(); + } + + toString(): string { + return this.#path; + } + + join(...paths: string[]): string { + return path.join(this.#path, ...paths); + } + + async [Symbol.asyncDispose](): Promise { + try { + await this.remove(); + } catch { + // Ignore cleanup errors + } + } + + [Symbol.dispose](): void { + try { + this.removeSync(); + } catch { + // Ignore cleanup errors + } + } } const kTempDir = tmpdir(); function normalizePrefix(prefix?: string): string { if (!prefix) { - return `${kTempDir}${sep}pi-temp-`; + return `${kTempDir}${path.sep}pi-temp-`; } else if (prefix.startsWith("@")) { - return join(kTempDir, prefix.slice(1)); + return path.join(kTempDir, prefix.slice(1)); } return prefix; } - -export async function createTempDir(prefix?: string): Promise { - const path = await mkdtemp(normalizePrefix(prefix)); - - let promise: Promise | null = null; - const remove = () => { - if (promise) { - return promise; - } - promise = rm(path, { recursive: true, force: true }).catch(() => {}); - return promise; - }; - - return { - path: path!, - remove, - toString: () => path, - [Symbol.asyncDispose]: remove, - }; -} - -export function createTempDirSync(prefix?: string): SyncTempDir { - const path = mkdtempSync(normalizePrefix(prefix)); - - let done = false; - const remove = () => { - if (done) { - return; - } - done = true; - try { - rmSync(path, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }; - - return { - path, - toString: () => path, - remove, - [Symbol.dispose]: remove, - }; -} diff --git a/packages/react-edit-benchmark/formatter.ts b/packages/react-edit-benchmark/formatter.ts index 3d4a0758e..3eafc9c29 100644 --- a/packages/react-edit-benchmark/formatter.ts +++ b/packages/react-edit-benchmark/formatter.ts @@ -2,8 +2,8 @@ * Prettier formatting utilities for edit benchmarks. */ -import { readdirSync } from "node:fs"; -import { join, extname } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import * as prettier from "prettier"; export const PRETTIER_OPTIONS: prettier.Options = { @@ -36,14 +36,14 @@ const parsersByExtension: Record = { ".html": ["html"], }; -function listFiles(rootDir: string, subPath = ""): string[] { - const entries = readdirSync(join(rootDir, subPath), { withFileTypes: true }); +async function listFiles(rootDir: string, subPath = ""): Promise { + const entries = await fs.readdir(path.join(rootDir, subPath), { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { - const relativePath = join(subPath, entry.name); + const relativePath = path.join(subPath, entry.name); if (entry.isDirectory()) { - files.push(...listFiles(rootDir, relativePath)); + files.push(...(await listFiles(rootDir, relativePath))); } else if (entry.isFile()) { files.push(relativePath); } @@ -58,7 +58,7 @@ export interface FormatResult { } export async function formatContent(filePath: string, content: string): Promise { - const parsers = parsersByExtension[extname(filePath).toLowerCase()]; + const parsers = parsersByExtension[path.extname(filePath).toLowerCase()]; if (!parsers) { return { formatted: content, didFormat: false }; } @@ -76,10 +76,10 @@ export async function formatContent(filePath: string, content: string): Promise< } export async function formatDirectory(rootDir: string): Promise { - const files = listFiles(rootDir); + const files = await listFiles(rootDir); for (const file of files) { - const fullPath = join(rootDir, file); + const fullPath = path.join(rootDir, file); const content = await Bun.file(fullPath).text(); const result = await formatContent(fullPath, content); if (result.didFormat && result.formatted !== content) { diff --git a/packages/react-edit-benchmark/generate.ts b/packages/react-edit-benchmark/generate.ts index 0e0fdc019..418da83cf 100644 --- a/packages/react-edit-benchmark/generate.ts +++ b/packages/react-edit-benchmark/generate.ts @@ -19,16 +19,16 @@ * - nightmare: Long files where target line repeats, minimal info */ -import { createTempDirSync } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { existsSync, mkdirSync, readdirSync } from "node:fs"; +import * as fs from "node:fs"; import { basename, dirname, join, relative } from "node:path"; import { parseArgs } from "node:util"; import { ALL_MUTATIONS, CATEGORY_MAP, type Mutation, type MutationInfo } from "./mutations"; const SCRIPT_DIR = import.meta.dir; const SUPPORTED_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"]); -using DEFAULT_REACT_DIR = createTempDirSync("@react-source"); +using DEFAULT_REACT_DIR = TempDir.createSync("@react-source"); const DEFAULT_OUTPUT = join(SCRIPT_DIR, "fixtures.tar.gz"); const REACT_REPO_URL = "https://github.com/facebook/react.git"; @@ -81,7 +81,7 @@ interface Args { function parseArguments(): Args { const { values } = parseArgs({ options: { - "react-dir": { type: "string", default: DEFAULT_REACT_DIR.path }, + "react-dir": { type: "string", default: DEFAULT_REACT_DIR.path() }, output: { type: "string", default: DEFAULT_OUTPUT }, "count-per-type": { type: "string", default: "20" }, seed: { type: "string", default: "42" }, @@ -93,7 +93,7 @@ function parseArguments(): Args { }); return { - reactDir: values["react-dir"] ?? DEFAULT_REACT_DIR.path, + reactDir: values["react-dir"] ?? DEFAULT_REACT_DIR.path(), output: values.output ?? DEFAULT_OUTPUT, countPerType: parseInt(values["count-per-type"] ?? "20", 10), seed: parseInt(values.seed ?? "42", 10), @@ -105,14 +105,14 @@ function parseArguments(): Args { } async function ensureReactSource(reactDir: string): Promise { - if (existsSync(reactDir)) { + if (fs.existsSync(reactDir)) { const packagesDir = join(reactDir, "packages"); - if (existsSync(packagesDir)) return; + if (fs.existsSync(packagesDir)) return; throw new Error(`Directory exists but missing packages/: ${reactDir}`); } console.log(`Cloning React repository to ${reactDir}...`); - mkdirSync(dirname(reactDir), { recursive: true }); + fs.mkdirSync(dirname(reactDir), { recursive: true }); const result = await $`git clone --depth 1 ${REACT_REPO_URL} ${reactDir}`.quiet().nothrow(); if (result.exitCode !== 0) { const decoder = new TextDecoder(); @@ -138,17 +138,17 @@ function hasStructure(content: string): boolean { return ["function ", "class ", "export ", "=>"].some((token) => content.includes(token)); } -function collectFiles(reactDir: string): string[] { +async function collectFiles(reactDir: string): Promise { const packagesDir = join(reactDir, "packages"); const candidates: string[] = []; - function walk(dir: string): void { + async function walk(dir: string): Promise { if (isExcluded(dir)) return; - const entries = readdirSync(dir, { withFileTypes: true }); + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { - walk(fullPath); + await walk(fullPath); } else if (entry.isFile()) { const ext = "." + entry.name.split(".").pop(); if (SUPPORTED_EXTENSIONS.has(ext) && !isExcluded(fullPath)) { @@ -158,7 +158,7 @@ function collectFiles(reactDir: string): string[] { } } - walk(packagesDir); + await walk(packagesDir); return candidates.sort(); } @@ -367,17 +367,14 @@ function getCandidatesForDifficulty(files: FileEntry[], difficulty: Difficulty): } async function bunCheck(content: string, suffix: string): Promise { - const tempPath = `/tmp/bench-check-${crypto.randomUUID()}${suffix}`; + await using tempPath = await TempDir.create("@rb-bench-check-"); try { - await $`rm -f ${tempPath}`; - const file = Bun.file(tempPath); - await Bun.write(file, content); - const result = await $`timeout 5s bun build ${tempPath} --no-bundle`; + const absPath = join(tempPath.path(), suffix); + await Bun.write(absPath, content); + const result = await $`timeout 5s bun build ${absPath} --no-bundle`; return result.exitCode === 0; } catch { return false; - } finally { - await $`rm -f ${tempPath}`; } } @@ -616,7 +613,7 @@ async function main(): Promise { await ensureReactSource(reactDir); - const rawFiles = collectFiles(reactDir); + const rawFiles = await collectFiles(reactDir); const files = await filterFiles(rawFiles); if (files.length === 0) { console.error("No eligible files found."); diff --git a/packages/react-edit-benchmark/index.ts b/packages/react-edit-benchmark/index.ts index 6ff6fba73..b52788fb2 100644 --- a/packages/react-edit-benchmark/index.ts +++ b/packages/react-edit-benchmark/index.ts @@ -10,10 +10,9 @@ */ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { createTempDir } from "@oh-my-pi/pi-utils"; -import { existsSync, mkdirSync, readdirSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import * as fs from "node:fs"; +import { join } from "node:path"; import { parseArgs } from "node:util"; import { generateJsonReport, generateReport } from "./report"; import { runBenchmark, type BenchmarkConfig, type ProgressEvent } from "./runner"; @@ -84,8 +83,8 @@ Examples: `); } -function resolveExtractedDir(tempDir: string): string { - const entries = readdirSync(tempDir, { withFileTypes: true }); +async function resolveExtractedDir(tempDir: string): Promise { + const entries = await fs.promises.readdir(tempDir, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory()); const files = entries.filter((entry) => entry.isFile()); if (dirs.length === 1 && files.length === 0) { @@ -95,8 +94,8 @@ function resolveExtractedDir(tempDir: string): string { } async function extractTarGz(archivePath: string): Promise<{ dir: string; cleanupDir: string }> { - const tempDirObj = await createTempDir("@reach-benchmark-fixtures-"); - const tempDir = tempDirObj.path; + const tempDirObj = await TempDir.create("@reach-benchmark-fixtures-"); + const tempDir = tempDirObj.path(); try { const bytes = await Bun.file(archivePath).arrayBuffer(); const archive = new Bun.Archive(bytes); @@ -112,7 +111,7 @@ async function extractTarGz(archivePath: string): Promise<{ dir: string; cleanup throw new Error(`Failed to extract archive: ${message}`, { cause: error }); } - return { dir: resolveExtractedDir(tempDir), cleanupDir: tempDir }; + return { dir: await resolveExtractedDir(tempDir), cleanupDir: tempDir }; } async function resolveFixtures(fixturesArg?: string): Promise<{ tasks: EditTask[]; cleanup?: () => Promise }> { @@ -124,7 +123,7 @@ async function resolveFixtures(fixturesArg?: string): Promise<{ tasks: EditTask[ const extracted = await extractTarGz(fixturesArg); return { tasks: await loadTasksFromDir(extracted.dir), - cleanup: () => rm(extracted.cleanupDir, { recursive: true, force: true }), + cleanup: () => fs.promises.rm(extracted.cleanupDir, { recursive: true, force: true }), }; } diff --git a/packages/react-edit-benchmark/package.json b/packages/react-edit-benchmark/package.json index 1f95907d5..e4b14b1a1 100644 --- a/packages/react-edit-benchmark/package.json +++ b/packages/react-edit-benchmark/package.json @@ -21,6 +21,7 @@ "prettier": "^3.3.3" }, "devDependencies": { - "@types/diff": "^7.0.2" + "@types/diff": "^7.0.2", + "bun-types": "^1.2.19" } } diff --git a/packages/react-edit-benchmark/runner.ts b/packages/react-edit-benchmark/runner.ts index 09f39edcc..920ebb4e5 100644 --- a/packages/react-edit-benchmark/runner.ts +++ b/packages/react-edit-benchmark/runner.ts @@ -7,15 +7,15 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { RpcClient } from "@oh-my-pi/pi-coding-agent"; -import { createTempDir } from "@oh-my-pi/pi-utils"; -import { appendFile, cp, mkdir, readdir, rm } from "node:fs/promises"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import * as fs from "node:fs/promises"; import { join } from "node:path"; import { formatDirectory } from "./formatter"; import { extractTaskFiles, type EditTask } from "./tasks"; import { verifyExpectedFileSubset, verifyExpectedFiles } from "./verify"; -const TMP_DIR = await createTempDir("@reach-benchmark-"); -const TMP = TMP_DIR.path; +const TMP_DIR = await TempDir.create("@reach-benchmark-"); +const TMP = TMP_DIR.path(); export interface BenchmarkConfig { provider: string; @@ -131,9 +131,11 @@ async function copyFixtures(task: EditTask, destDir: string): Promise { if (task.tarballPath) { await extractTaskFiles(task.tarballPath, task.id, destDir, "input"); } else if (task.inputDir) { - const entries = await readdir(task.inputDir, { withFileTypes: true }); + const entries = await fs.readdir(task.inputDir, { withFileTypes: true }); await Promise.all( - entries.map((entry) => cp(join(task.inputDir!, entry.name), join(destDir, entry.name), { recursive: true })), + entries.map((entry) => + fs.cp(join(task.inputDir!, entry.name), join(destDir, entry.name), { recursive: true }), + ), ); } else { throw new Error(`Task ${task.id} has neither tarballPath nor inputDir`); @@ -146,12 +148,12 @@ async function getExpectedDir(task: EditTask): Promise<{ dir: string; cleanup: ( } if (task.tarballPath) { const tempDir = join(TMP, `expected-${task.id}-${crypto.randomUUID()}`); - await mkdir(tempDir, { recursive: true }); + await fs.mkdir(tempDir, { recursive: true }); await extractTaskFiles(task.tarballPath, task.id, tempDir, "expected"); return { dir: tempDir, cleanup: async () => { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); }, }; } @@ -189,11 +191,11 @@ async function runSingleTask( const logFile = join(TMP, `run-${task.id}-${runIndex}.jsonl`); const logEvent = async (event: unknown) => { - await appendFile(logFile, JSON.stringify(event) + "\n"); + await fs.appendFile(logFile, JSON.stringify(event) + "\n"); }; try { - await appendFile(logFile, `{"type":"meta","task":"${task.id}","run":${runIndex},"workDir":"${cwd}"}\n`); + await fs.appendFile(logFile, `{"type":"meta","task":"${task.id}","run":${runIndex},"workDir":"${cwd}"}\n`); const env: Record = { OMP_NO_TITLE: "1" }; if (config.editVariant !== undefined) { @@ -237,7 +239,7 @@ ${ : "Read the relevant files first, then use the edit tool to apply the fix." }`; - await appendFile(logFile, `{"type":"prompt","message":${JSON.stringify(promptWithContext)}}\n`); + await fs.appendFile(logFile, `{"type":"prompt","message":${JSON.stringify(promptWithContext)}}\n`); // Collect events with logging const events: Array<{ type: string; [key: string]: unknown }> = []; @@ -410,17 +412,17 @@ async function runBatchedTask( const logFile = join(TMP, `run-${task.id}-${runIndex}.jsonl`); const logEvent = async (event: unknown) => { - await appendFile(logFile, JSON.stringify(event) + "\n"); + await fs.appendFile(logFile, JSON.stringify(event) + "\n"); }; try { - await appendFile( + await fs.appendFile( logFile, `{"type":"meta","task":"${task.id}","run":${runIndex},"workDir":"${cwd}","batched":true}\n`, ); const promptWithContext = buildPrompt(task, config); - await appendFile(logFile, `{"type":"prompt","message":${JSON.stringify(promptWithContext)}}\n`); + await fs.appendFile(logFile, `{"type":"prompt","message":${JSON.stringify(promptWithContext)}}\n`); const statsBefore = await client.getSessionStats(); const events = await collectPromptEvents(client, promptWithContext, config, logEvent); @@ -746,7 +748,7 @@ async function runBatch( onProgress?: (event: ProgressEvent) => void, ): Promise> { const workDir = join(TMP, `batch-${crypto.randomUUID()}`); - await mkdir(workDir, { recursive: true }); + await fs.mkdir(workDir, { recursive: true }); const results: Array<{ task: EditTask; result: TaskRunResult }> = []; let client: RpcClient | null = null; const expectedDirs = new Map Promise }>(); @@ -822,7 +824,7 @@ async function runBatch( } } try { - await rm(workDir, { recursive: true, force: true }); + await fs.rm(workDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } @@ -836,21 +838,21 @@ export async function runTask( config: BenchmarkConfig, onProgress?: (event: ProgressEvent) => void, ): Promise { - const tempDirs: Array>> = []; + const tempDirs: TempDir[] = []; const { dir: expectedDir, cleanup: cleanupExpected } = await getExpectedDir(task); const cliPath = join(import.meta.dir, "../coding-agent/src/cli.ts"); try { for (let i = 0; i < config.runsPerTask; i++) { - const tempDir = await createTempDir(join(TMP, `${task.id}-`)); + const tempDir = await TempDir.create(join(TMP, `${task.id}-`)); tempDirs.push(tempDir); - await copyFixtures(task, tempDir.path); + await copyFixtures(task, tempDir.path()); } const runPromises = tempDirs.map(async (tempDirObj, index) => { onProgress?.({ taskId: task.id, runIndex: index, status: "started" }); - const result = await runSingleTask(task, index, config, tempDirObj.path, expectedDir, cliPath); + const result = await runSingleTask(task, index, config, tempDirObj.path(), expectedDir, cliPath); onProgress?.({ taskId: task.id, runIndex: index, status: "completed", result }); return result; }); diff --git a/packages/react-edit-benchmark/tarball.ts b/packages/react-edit-benchmark/tarball.ts index 034037eff..826f9edcb 100644 --- a/packages/react-edit-benchmark/tarball.ts +++ b/packages/react-edit-benchmark/tarball.ts @@ -3,8 +3,8 @@ * Uses Bun.Archive for native tar.gz handling. */ -import { mkdir } from "node:fs/promises"; -import { join, dirname, basename } from "node:path"; +import * as fs from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; export interface TarballTask { id: string; @@ -39,7 +39,7 @@ export async function readTarball(tarballPath: string): Promise const entries: TarballEntry[] = []; for (const [path, file] of files) { - const content = Buffer.from(await file.arrayBuffer()); + const content = Buffer.from(await file.bytes()); entries.push({ path, content }); } @@ -189,7 +189,7 @@ export async function extractTaskFiles( if (!relativePath) continue; const destPath = join(destDir, relativePath); - await mkdir(dirname(destPath), { recursive: true }); + await fs.mkdir(dirname(destPath), { recursive: true }); await Bun.write(destPath, await file.arrayBuffer()); } } diff --git a/packages/react-edit-benchmark/tasks.ts b/packages/react-edit-benchmark/tasks.ts index 11c1e58f4..0e55e667c 100644 --- a/packages/react-edit-benchmark/tasks.ts +++ b/packages/react-edit-benchmark/tasks.ts @@ -8,7 +8,7 @@ /// -import { readdirSync, statSync } from "node:fs"; +import * as fs from "node:fs/promises"; import { basename, join } from "node:path"; import { extractTaskFiles, @@ -49,19 +49,19 @@ function titleize(id: string): string { .join(" "); } -function listFiles(rootDir: string, subPath = ""): string[] { - const entries = readdirSync(join(rootDir, subPath), { withFileTypes: true }); +async function listFiles(rootDir: string, subPath = ""): Promise { + const entries = await fs.readdir(join(rootDir, subPath), { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const relativePath = join(subPath, entry.name); const absolutePath = join(rootDir, relativePath); if (entry.isDirectory()) { - files.push(...listFiles(rootDir, relativePath)); + files.push(...(await listFiles(rootDir, relativePath))); } else if (entry.isFile()) { files.push(relativePath); } else if (entry.isSymbolicLink()) { - const stats = statSync(absolutePath, { throwIfNoEntry: false }); + const stats = await fs.stat(absolutePath).catch(() => null); if (stats?.isFile()) { files.push(relativePath); } @@ -72,7 +72,7 @@ function listFiles(rootDir: string, subPath = ""): string[] { } export async function loadTasksFromDir(fixturesDir: string): Promise { - const entries = readdirSync(fixturesDir, { withFileTypes: true }); + const entries = await fs.readdir(fixturesDir, { withFileTypes: true }); const tasks: EditTask[] = []; for (const entry of entries) { @@ -88,16 +88,18 @@ export async function loadTasksFromDir(fixturesDir: string): Promise throw new Error(`Missing prompt.md for ${entry.name}`); } - if (!statSync(inputDir, { throwIfNoEntry: false })?.isDirectory()) { + const inputDirStat = await fs.stat(inputDir).catch(() => null); + if (!inputDirStat?.isDirectory()) { throw new Error(`Missing input directory for ${entry.name}`); } - if (!statSync(expectedDir, { throwIfNoEntry: false })?.isDirectory()) { + const expectedDirStat = await fs.stat(expectedDir).catch(() => null); + if (!expectedDirStat?.isDirectory()) { throw new Error(`Missing expected directory for ${entry.name}`); } const prompt = (await promptFile.text()).trim(); - const files = listFiles(inputDir); + const files = await listFiles(inputDir); const metadata = await loadMetadata(metadataPath); tasks.push({ @@ -143,7 +145,7 @@ export async function validateFixtures(fixturesPath?: string): Promise { - const entries = readdirSync(fixturesPath, { withFileTypes: true }); + const entries = await fs.readdir(fixturesPath, { withFileTypes: true }); const issues: FixtureValidationIssue[] = []; for (const entry of entries) { @@ -162,17 +164,17 @@ export async function validateFixturesFromDir(fixturesPath: string): Promise null); + if (!inputDirStat?.isDirectory()) { issues.push({ taskId, message: "input directory is missing" }); } - if (!statSync(expectedDir, { throwIfNoEntry: false })?.isDirectory()) { + const expectedDirStat = await fs.stat(expectedDir).catch(() => null); + if (!expectedDirStat?.isDirectory()) { issues.push({ taskId, message: "expected directory is missing" }); } - const inputFiles = statSync(inputDir, { throwIfNoEntry: false })?.isDirectory() ? listFiles(inputDir) : []; - const expectedFiles = statSync(expectedDir, { throwIfNoEntry: false })?.isDirectory() - ? listFiles(expectedDir) - : []; + const inputFiles = inputDirStat?.isDirectory() ? await listFiles(inputDir) : []; + const expectedFiles = expectedDirStat?.isDirectory() ? await listFiles(expectedDir) : []; if (inputFiles.length === 0) { issues.push({ taskId, message: "input directory is empty" }); diff --git a/packages/react-edit-benchmark/test/verify.test.ts b/packages/react-edit-benchmark/test/verify.test.ts index d261806f0..6cacf2c34 100644 --- a/packages/react-edit-benchmark/test/verify.test.ts +++ b/packages/react-edit-benchmark/test/verify.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { createTempDir } from "@oh-my-pi/pi-utils"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { TempDir } from "@oh-my-pi/pi-utils"; import { verifyExpectedFiles } from "../verify"; async function createTempDirs(): Promise<{ @@ -10,14 +10,13 @@ async function createTempDirs(): Promise<{ actualDir: string; cleanup: () => Promise; }> { - const tempDir = await createTempDir("@reach-benchmark-verify-"); - const root = tempDir.path; - const expectedDir = join(root, "expected"); - const actualDir = join(root, "actual"); - await mkdir(expectedDir, { recursive: true }); - await mkdir(actualDir, { recursive: true }); + const tempDir = await TempDir.create("@reach-benchmark-verify-"); + const expectedDir = tempDir.join("expected"); + const actualDir = tempDir.join("actual"); + await fs.mkdir(expectedDir, { recursive: true }); + await fs.mkdir(actualDir, { recursive: true }); return { - root, + root: tempDir.absolute(), expectedDir, actualDir, cleanup: async () => { @@ -30,7 +29,7 @@ describe("verifyExpectedFiles", () => { it("reports missing files", async () => { const { expectedDir, actualDir, cleanup } = await createTempDirs(); try { - await Bun.write(join(expectedDir, "index.ts"), "export const value = 1;\n"); + await Bun.write(path.join(expectedDir, "index.ts"), "export const value = 1;\n"); const result = await verifyExpectedFiles(expectedDir, actualDir); @@ -44,9 +43,9 @@ describe("verifyExpectedFiles", () => { it("reports unexpected files", async () => { const { expectedDir, actualDir, cleanup } = await createTempDirs(); try { - await Bun.write(join(expectedDir, "index.ts"), "export const value = 1;\n"); - await Bun.write(join(actualDir, "index.ts"), "export const value = 1;\n"); - await Bun.write(join(actualDir, "extra.ts"), "export const extra = true;\n"); + await Bun.write(path.join(expectedDir, "index.ts"), "export const value = 1;\n"); + await Bun.write(path.join(actualDir, "index.ts"), "export const value = 1;\n"); + await Bun.write(path.join(actualDir, "extra.ts"), "export const extra = true;\n"); const result = await verifyExpectedFiles(expectedDir, actualDir); @@ -60,8 +59,8 @@ describe("verifyExpectedFiles", () => { it("fails with diff output when formatted content differs", async () => { const { expectedDir, actualDir, cleanup } = await createTempDirs(); try { - await Bun.write(join(expectedDir, "index.ts"), "const value = 1;\n"); - await Bun.write(join(actualDir, "index.ts"), "const value = 2;\n"); + await Bun.write(path.join(expectedDir, "index.ts"), "const value = 1;\n"); + await Bun.write(path.join(actualDir, "index.ts"), "const value = 2;\n"); const result = await verifyExpectedFiles(expectedDir, actualDir); @@ -80,11 +79,11 @@ describe("verifyExpectedFiles", () => { try { const expected = "function test() {\n return 1;\n}\n"; const actual = "function test(){\nreturn 1;\n}\n"; - await Bun.write(join(expectedDir, "index.ts"), expected); - await Bun.write(join(actualDir, "index.ts"), actual); + await Bun.write(path.join(expectedDir, "index.ts"), expected); + await Bun.write(path.join(actualDir, "index.ts"), actual); const result = await verifyExpectedFiles(expectedDir, actualDir); - const actualAfter = await Bun.file(join(actualDir, "index.ts")).text(); + const actualAfter = await Bun.file(path.join(actualDir, "index.ts")).text(); expect(result.success).toBe(true); expect(result.formattedEquivalent).toBe(true); @@ -97,8 +96,8 @@ describe("verifyExpectedFiles", () => { it("normalizes line endings before comparison", async () => { const { expectedDir, actualDir, cleanup } = await createTempDirs(); try { - await Bun.write(join(expectedDir, "index.ts"), "export const value = 1;\r\n"); - await Bun.write(join(actualDir, "index.ts"), "export const value = 1;\n"); + await Bun.write(path.join(expectedDir, "index.ts"), "export const value = 1;\r\n"); + await Bun.write(path.join(actualDir, "index.ts"), "export const value = 1;\n"); const result = await verifyExpectedFiles(expectedDir, actualDir); diff --git a/packages/react-edit-benchmark/tsconfig.build.json b/packages/react-edit-benchmark/tsconfig.build.json new file mode 100644 index 000000000..c9011ba2a --- /dev/null +++ b/packages/react-edit-benchmark/tsconfig.build.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} \ No newline at end of file diff --git a/packages/react-edit-benchmark/tsconfig.json b/packages/react-edit-benchmark/tsconfig.json index 319f9d43e..31b31c06a 100644 --- a/packages/react-edit-benchmark/tsconfig.json +++ b/packages/react-edit-benchmark/tsconfig.json @@ -1,10 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "noEmit": true, - "rootDir": ".", - "outDir": "dist", - "types": ["bun-types"] + "noEmit": true }, "include": ["*.ts", "test/**/*.ts"], "exclude": ["dist", "node_modules"] diff --git a/packages/react-edit-benchmark/tsconfig.publish.json b/packages/react-edit-benchmark/tsconfig.publish.json new file mode 100644 index 000000000..ea6be8e9a --- /dev/null +++ b/packages/react-edit-benchmark/tsconfig.publish.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/packages/react-edit-benchmark/verify.ts b/packages/react-edit-benchmark/verify.ts index 043da2568..1e4a7c5e1 100644 --- a/packages/react-edit-benchmark/verify.ts +++ b/packages/react-edit-benchmark/verify.ts @@ -4,8 +4,8 @@ * Compares output files against expected fixtures with byte-for-byte equality. */ -import { readdirSync } from "node:fs"; -import { join } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { formatContent } from "./formatter"; import { diffLines } from "diff"; @@ -24,14 +24,14 @@ export interface DiffStats { charsChanged: number; } -function listFiles(rootDir: string, subPath = ""): string[] { - const entries = readdirSync(join(rootDir, subPath), { withFileTypes: true }); +async function listFiles(rootDir: string, subPath = ""): Promise { + const entries = await fs.readdir(path.join(rootDir, subPath), { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { - const relativePath = join(subPath, entry.name); + const relativePath = path.join(subPath, entry.name); if (entry.isDirectory()) { - files.push(...listFiles(rootDir, relativePath)); + files.push(...(await listFiles(rootDir, relativePath))); } else if (entry.isFile()) { files.push(relativePath); } @@ -106,9 +106,9 @@ export async function verifyExpectedFileSubset( let fileCount = 0; try { - const expectedFixtureFiles = listFiles(expectedDir); + const expectedFixtureFiles = await listFiles(expectedDir); const expectedFiles = files?.length ? files.slice().sort() : expectedFixtureFiles; - const actualFiles = listFiles(actualDir); + const actualFiles = await listFiles(actualDir); const missingFiles = expectedFiles.filter((file) => !actualFiles.includes(file)); const extraFiles = actualFiles.filter((file) => !expectedFiles.includes(file)); @@ -139,8 +139,8 @@ export async function verifyExpectedFileSubset( } for (const file of expectedFiles) { - const expectedPath = join(expectedDir, file); - const actualPath = join(actualDir, file); + const expectedPath = path.join(expectedDir, file); + const actualPath = path.join(actualDir, file); const expectedRaw = await Bun.file(expectedPath).text(); const actualRaw = await Bun.file(actualPath).text(); const expectedNormalized = normalizeLineEndings(expectedRaw); diff --git a/packages/stats/build.ts b/packages/stats/build.ts index 2ad44744b..2c7ec275c 100644 --- a/packages/stats/build.ts +++ b/packages/stats/build.ts @@ -1,7 +1,7 @@ -import { rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; // Clean dist -await rm("./dist/client", { recursive: true, force: true }); +await fs.rm("./dist/client", { recursive: true, force: true }); // Build React app const result = await Bun.build({ diff --git a/packages/stats/package.json b/packages/stats/package.json index 26ae2f3b8..9140f3062 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -50,7 +50,7 @@ "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", "date-fns": "^4.1.0", - "lucide-react": "^0.562.0", + "lucide-react": "^0.563.0", "react": "^19.2.3", "react-dom": "^19.2.3", "recharts": "^3.6.0" diff --git a/packages/stats/src/aggregator.ts b/packages/stats/src/aggregator.ts index 5c0e28e66..4a2d2cdb7 100644 --- a/packages/stats/src/aggregator.ts +++ b/packages/stats/src/aggregator.ts @@ -1,4 +1,4 @@ -import { stat } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { getRecentErrors as dbGetRecentErrors, getRecentRequests as dbGetRecentRequests, @@ -22,9 +22,9 @@ import type { DashboardStats, MessageStats, RequestDetails } from "./types"; */ async function syncSessionFile(sessionFile: string): Promise { // Get file stats - let fileStats: Awaited>; + let fileStats: Awaited>; try { - fileStats = await stat(sessionFile); + fileStats = await fs.stat(sessionFile); } catch { return 0; } diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index df315f23e..adf3fe7b6 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -1,10 +1,10 @@ import { Database } from "bun:sqlite"; -import { mkdir } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import type { AggregatedStats, FolderStats, MessageStats, ModelStats, TimeSeriesPoint } from "./types"; -const DB_PATH = join(homedir(), ".omp", "stats.db"); +const DB_PATH = path.join(homedir(), ".omp", "stats.db"); let db: Database | null = null; @@ -15,7 +15,7 @@ export async function initDb(): Promise { if (db) return db; // Ensure directory exists - await mkdir(join(homedir(), ".omp"), { recursive: true }); + await fs.mkdir(path.join(homedir(), ".omp"), { recursive: true }); db = new Database(DB_PATH); db.exec("PRAGMA journal_mode = WAL"); diff --git a/packages/stats/src/parser.ts b/packages/stats/src/parser.ts index a12d42536..22a26da81 100644 --- a/packages/stats/src/parser.ts +++ b/packages/stats/src/parser.ts @@ -1,10 +1,10 @@ -import { readdir } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import type { MessageStats, SessionEntry, SessionMessageEntry } from "./types"; -const SESSIONS_DIR = join(homedir(), ".omp", "agent", "sessions"); +const SESSIONS_DIR = path.join(homedir(), ".omp", "agent", "sessions"); /** * Extract folder name from session filename. @@ -12,7 +12,7 @@ const SESSIONS_DIR = join(homedir(), ".omp", "agent", "sessions"); * The folder part uses -- as path separator. */ function extractFolderFromPath(sessionPath: string): string { - const dir = basename(sessionPath.replace(/\/[^/]+\.jsonl$/, "")); + const dir = path.basename(sessionPath.replace(/\/[^/]+\.jsonl$/, "")); // Convert --work--pi-- to /work/pi return dir.replace(/^--/, "/").replace(/--/g, "/"); } @@ -105,8 +105,8 @@ export async function parseSessionFile( */ export async function listSessionFolders(): Promise { try { - const entries = await readdir(SESSIONS_DIR, { withFileTypes: true }); - return entries.filter((e) => e.isDirectory()).map((e) => join(SESSIONS_DIR, e.name)); + const entries = await fs.readdir(SESSIONS_DIR, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => path.join(SESSIONS_DIR, e.name)); } catch { return []; } @@ -117,8 +117,8 @@ export async function listSessionFolders(): Promise { */ export async function listSessionFiles(folderPath: string): Promise { try { - const entries = await readdir(folderPath, { withFileTypes: true }); - return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join(folderPath, e.name)); + const entries = await fs.readdir(folderPath, { withFileTypes: true }); + return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => path.join(folderPath, e.name)); } catch { return []; } diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index e50745a06..3f87058ec 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -1,5 +1,5 @@ -import { rm } from "node:fs/promises"; -import { join } from "node:path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { getDashboardStats, getRecentErrors, @@ -9,17 +9,17 @@ import { syncAllSessions, } from "./aggregator"; -const CLIENT_DIR = join(import.meta.dir, "client"); -const STATIC_DIR = join(import.meta.dir, "..", "dist", "client"); +const CLIENT_DIR = path.join(import.meta.dir, "client"); +const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client"); const ensureClientBuild = async () => { - const indexFile = Bun.file(join(STATIC_DIR, "index.html")); + const indexFile = Bun.file(path.join(STATIC_DIR, "index.html")); if (await indexFile.exists()) return; - await rm(STATIC_DIR, { recursive: true, force: true }); + await fs.rm(STATIC_DIR, { recursive: true, force: true }); const result = await Bun.build({ - entrypoints: [join(CLIENT_DIR, "index.tsx")], + entrypoints: [path.join(CLIENT_DIR, "index.tsx")], outdir: STATIC_DIR, minify: true, naming: "[dir]/[name].[ext]", @@ -69,7 +69,7 @@ const ensureClientBuild = async () => { `; - await Bun.write(join(STATIC_DIR, "index.html"), indexHtml); + await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml); }; /** @@ -134,9 +134,9 @@ async function handleApi(req: Request): Promise { /** * Handle static file requests. */ -async function handleStatic(path: string): Promise { - const filePath = path === "/" ? "/index.html" : path; - const fullPath = join(STATIC_DIR, filePath); +async function handleStatic(requestPath: string): Promise { + const filePath = requestPath === "/" ? "/index.html" : requestPath; + const fullPath = path.join(STATIC_DIR, filePath); const file = Bun.file(fullPath); if (await file.exists()) { @@ -144,7 +144,7 @@ async function handleStatic(path: string): Promise { } // SPA fallback - const index = Bun.file(join(STATIC_DIR, "index.html")); + const index = Bun.file(path.join(STATIC_DIR, "index.html")); if (await index.exists()) { return new Response(index); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2ae7c9e6b..8d055921e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Updated TypeScript configuration for better publish-time configuration handling with tsconfig.publish.json +- Migrated file system operations from synchronous to asynchronous APIs in autocomplete provider for non-blocking I/O ### Fixed - Fixed crash when terminal becomes unavailable (EIO errors) by exiting gracefully instead of throwing diff --git a/packages/tui/package.json b/packages/tui/package.json index f4fde7225..475fc5430 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -47,14 +47,14 @@ "bun": ">=1.0.0" }, "dependencies": { - "@types/mime-types": "^2.1.4", + "@types/mime-types": "^3.0.1", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", + "marked": "^17.0.1", "mime-types": "^3.0.1" }, "devDependencies": { - "@xterm/headless": "^5.5.0", - "@xterm/xterm": "^5.5.0" + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0" } } diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index b763d66f2..f0d1b33ac 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -1,36 +1,34 @@ -import { readdirSync, statSync } from "node:fs"; +import * as fs from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import * as path from "node:path"; -// Use fd to walk directory tree (fast, respects .gitignore) -function walkDirectoryWithFd( +async function walkDirectoryWithFd( baseDir: string, fdPath: string, query: string, maxResults: number, -): Array<{ path: string; isDirectory: boolean }> { +): Promise> { const args = ["--base-directory", baseDir, "--max-results", String(maxResults), "--type", "f", "--type", "d"]; - // Add query as pattern if provided if (query) { args.push(query); } - const result = Bun.spawnSync([fdPath, ...args], { + const proc = Bun.spawn([fdPath, ...args], { stdout: "pipe", stderr: "pipe", }); - if (!result.success || !result.stdout) { + const exitCode = await proc.exited; + if (exitCode !== 0 || !proc.stdout) { return []; } - const stdout = new TextDecoder().decode(result.stdout); + const stdout = await new Response(proc.stdout).text(); const lines = stdout.trim().split("\n").filter(Boolean); const results: Array<{ path: string; isDirectory: boolean }> = []; for (const line of lines) { - // fd outputs directories with trailing / const isDirectory = line.endsWith("/"); results.push({ path: line, @@ -62,10 +60,10 @@ export interface AutocompleteProvider { lines: string[], cursorLine: number, cursorCol: number, - ): { + ): Promise<{ items: AutocompleteItem[]; prefix: string; // What we're matching against (e.g., "/" or "src/") - } | null; + } | null>; // Apply the selected item // Returns the new text and cursor position @@ -87,6 +85,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { private commands: (SlashCommand | AutocompleteItem)[]; private basePath: string; private fdPath: string | null; + private dirCache: Map = new Map(); + private readonly DIR_CACHE_TTL = 2000; // 2 seconds constructor( commands: (SlashCommand | AutocompleteItem)[] = [], @@ -98,11 +98,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { this.fdPath = fdPath ?? Bun.which("fd") ?? Bun.which("fdfind"); } - getSuggestions( + async getSuggestions( lines: string[], cursorLine: number, cursorCol: number, - ): { items: AutocompleteItem[]; prefix: string } | null { + ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); @@ -111,9 +111,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (atMatch) { const prefix = atMatch[1] ?? "@"; // The @... part const query = prefix.slice(1); // Remove the @ - const suggestions = query.length > 0 ? this.getFuzzyFileSuggestions(query) : this.getFileSuggestions("@"); + const suggestions = + query.length > 0 ? await this.getFuzzyFileSuggestions(query) : await this.getFileSuggestions("@"); if (suggestions.length === 0 && query.length > 0) { - const fallback = this.getFileSuggestions(prefix); + const fallback = await this.getFileSuggestions(prefix); if (fallback.length === 0) return null; return { items: fallback, prefix }; } @@ -178,7 +179,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const pathMatch = this.extractPathPrefix(textBeforeCursor, false); if (pathMatch !== null) { - const suggestions = this.getFileSuggestions(pathMatch); + const suggestions = await this.getFileSuggestions(pathMatch); if (suggestions.length === 0) return null; // Check if we have an exact match that is a directory @@ -311,19 +312,51 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Expand home directory (~/) to actual home path - private expandHomePath(path: string): string { - if (path.startsWith("~/")) { - const expandedPath = join(homedir(), path.slice(2)); + private expandHomePath(filePath: string): string { + if (filePath.startsWith("~/")) { + const expandedPath = path.join(homedir(), filePath.slice(2)); // Preserve trailing slash if original path had one - return path.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath; - } else if (path === "~") { + return filePath.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath; + } else if (filePath === "~") { return homedir(); } - return path; + return filePath; + } + + private async getCachedDirEntries(searchDir: string): Promise { + const now = Date.now(); + const cached = this.dirCache.get(searchDir); + + if (cached && now - cached.timestamp < this.DIR_CACHE_TTL) { + return cached.entries; + } + + const entries = await fs.promises.readdir(searchDir, { withFileTypes: true }); + this.dirCache.set(searchDir, { entries, timestamp: now }); + + if (this.dirCache.size > 100) { + const sortedKeys = [...this.dirCache.entries()] + .sort((a, b) => a[1].timestamp - b[1].timestamp) + .slice(0, 50) + .map(([key]) => key); + for (const key of sortedKeys) { + this.dirCache.delete(key); + } + } + + return entries; + } + + public invalidateDirCache(dir?: string): void { + if (dir) { + this.dirCache.delete(dir); + } else { + this.dirCache.clear(); + } } // Get file/directory suggestions for a given path prefix - private getFileSuggestions(prefix: string): AutocompleteItem[] { + private async getFileSuggestions(prefix: string): Promise { try { let searchDir: string; let searchPrefix: string; @@ -354,7 +387,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (prefix.startsWith("~") || expandedPrefix === "/") { searchDir = expandedPrefix; } else { - searchDir = join(this.basePath, expandedPrefix); + searchDir = path.join(this.basePath, expandedPrefix); } searchPrefix = ""; } else if (expandedPrefix.endsWith("/")) { @@ -362,22 +395,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = expandedPrefix; } else { - searchDir = join(this.basePath, expandedPrefix); + searchDir = path.join(this.basePath, expandedPrefix); } searchPrefix = ""; } else { // Split into directory and file prefix - const dir = dirname(expandedPrefix); - const file = basename(expandedPrefix); + const dir = path.dirname(expandedPrefix); + const file = path.basename(expandedPrefix); if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = dir; } else { - searchDir = join(this.basePath, dir); + searchDir = path.join(this.basePath, dir); } searchPrefix = file; } - const entries = readdirSync(searchDir, { withFileTypes: true }); + const entries = await this.getCachedDirEntries(searchDir); const suggestions: AutocompleteItem[] = []; for (const entry of entries) { @@ -389,8 +422,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { let isDirectory = entry.isDirectory(); if (!isDirectory && entry.isSymbolicLink()) { try { - const fullPath = join(searchDir, entry.name); - isDirectory = statSync(fullPath).isDirectory(); + const fullPath = path.join(searchDir, entry.name); + isDirectory = (await fs.promises.stat(fullPath)).isDirectory(); } catch { // Broken symlink, file deleted between readdir and stat, or permission error continue; @@ -408,10 +441,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } else if (pathWithoutAt.includes("/")) { if (pathWithoutAt.startsWith("~/")) { const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/ - const dir = dirname(homeRelativeDir); - relativePath = `@~/${dir === "." ? name : join(dir, name)}`; + const dir = path.dirname(homeRelativeDir); + relativePath = `@~/${dir === "." ? name : path.join(dir, name)}`; } else { - relativePath = `@${join(dirname(pathWithoutAt), name)}`; + relativePath = `@${path.join(path.dirname(pathWithoutAt), name)}`; } } else { if (pathWithoutAt.startsWith("~")) { @@ -427,18 +460,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Preserve ~/ format for home directory paths if (prefix.startsWith("~/")) { const homeRelativeDir = prefix.slice(2); // Remove ~/ - const dir = dirname(homeRelativeDir); - relativePath = `~/${dir === "." ? name : join(dir, name)}`; + const dir = path.dirname(homeRelativeDir); + relativePath = `~/${dir === "." ? name : path.join(dir, name)}`; } else if (prefix.startsWith("/")) { // Absolute path - construct properly - const dir = dirname(prefix); + const dir = path.dirname(prefix); if (dir === "/") { relativePath = `/${name}`; } else { relativePath = `${dir}/${name}`; } } else { - relativePath = join(dirname(prefix), name); + relativePath = path.join(path.dirname(prefix), name); } } else { // For standalone entries, preserve ~/ if original prefix was ~/ @@ -474,7 +507,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Score an entry against the query (higher = better match) // isDirectory adds bonus to prioritize folders private scoreEntry(filePath: string, query: string, isDirectory: boolean): number { - const fileName = basename(filePath); + const fileName = path.basename(filePath); const lowerFileName = fileName.toLowerCase(); const lowerQuery = query.toLowerCase(); @@ -495,17 +528,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return score; } - // Fuzzy file search using fd (fast, respects .gitignore) - private getFuzzyFileSuggestions(query: string): AutocompleteItem[] { + private async getFuzzyFileSuggestions(query: string): Promise { if (!this.fdPath) { - // fd not available, return empty results return []; } try { - const entries = walkDirectoryWithFd(this.basePath, this.fdPath, query, 100); + const entries = await walkDirectoryWithFd(this.basePath, this.fdPath, query, 100); - // Score entries const scoredEntries = entries .map((entry) => ({ ...entry, @@ -513,16 +543,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { })) .filter((entry) => entry.score > 0); - // Sort by score (descending) and take top 20 scoredEntries.sort((a, b) => b.score - a.score); const topEntries = scoredEntries.slice(0, 20); - // Build suggestions const suggestions: AutocompleteItem[] = []; for (const { path: entryPath, isDirectory } of topEntries) { - // fd already includes trailing / for directories const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; - const entryName = basename(pathWithoutSlash); + const entryName = path.basename(pathWithoutSlash); suggestions.push({ value: `@${entryPath}`, @@ -533,17 +560,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return suggestions; } catch { - // Directory doesn't exist or not accessible return []; } } // Force file completion (called on Tab key) - always returns suggestions - getForceFileSuggestions( + async getForceFileSuggestions( lines: string[], cursorLine: number, cursorCol: number, - ): { items: AutocompleteItem[]; prefix: string } | null { + ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); @@ -555,7 +581,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Force extract path prefix - this will always return something const pathMatch = this.extractPathPrefix(textBeforeCursor, true); if (pathMatch !== null) { - const suggestions = this.getFileSuggestions(pathMatch); + const suggestions = await this.getFileSuggestions(pathMatch); if (suggestions.length === 0) return null; return { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 58ca3112a..c29f963f0 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -310,6 +310,9 @@ export class Editor implements Component, Focusable { private undoStack: EditorState[] = []; private suspendUndo = false; + // Debounce timer for autocomplete updates + private autocompleteTimeout: ReturnType | null = null; + public onSubmit?: (text: string) => void; public onAltEnter?: (text: string) => void; public onChange?: (text: string) => void; @@ -1119,8 +1122,8 @@ export class Editor implements Component, Focusable { this.tryTriggerAutocomplete(); } } - // Also auto-trigger when typing letters in a slash command context - else if (/[a-zA-Z0-9.\-_]/.test(char)) { + // Also auto-trigger when typing letters/path chars in a slash command context + else if (/[a-zA-Z0-9.\-_/]/.test(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Check if we're in a slash command (with or without space for arguments) @@ -1133,7 +1136,7 @@ export class Editor implements Component, Focusable { } } } else { - this.updateAutocomplete(); + this.debouncedUpdateAutocomplete(); } } @@ -1296,7 +1299,7 @@ export class Editor implements Component, Focusable { // Update or re-trigger autocomplete after backspace if (this.isAutocompleting) { - this.updateAutocomplete(); + this.debouncedUpdateAutocomplete(); } else { // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1382,7 +1385,7 @@ export class Editor implements Component, Focusable { } if (this.isAutocompleting) { - this.updateAutocomplete(); + this.debouncedUpdateAutocomplete(); } else { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); @@ -1607,7 +1610,7 @@ export class Editor implements Component, Focusable { // Update or re-trigger autocomplete after forward delete if (this.isAutocompleting) { - this.updateAutocomplete(); + this.debouncedUpdateAutocomplete(); } else { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); @@ -1835,7 +1838,7 @@ export class Editor implements Component, Focusable { } // Autocomplete methods - private tryTriggerAutocomplete(explicitTab: boolean = false): void { + private async tryTriggerAutocomplete(explicitTab: boolean = false): Promise { if (!this.autocompleteProvider) return; // Check if we should trigger file completion on Tab @@ -1849,7 +1852,7 @@ export class Editor implements Component, Focusable { } } - const suggestions = this.autocompleteProvider.getSuggestions( + const suggestions = await this.autocompleteProvider.getSuggestions( this.state.lines, this.state.cursorLine, this.state.cursorCol, @@ -1859,8 +1862,10 @@ export class Editor implements Component, Focusable { this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); this.isAutocompleting = true; + this.onAutocompleteUpdate?.(); } else { this.cancelAutocomplete(); + this.onAutocompleteUpdate?.(); } } @@ -1887,7 +1892,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19 536643416/job/55932288317 havea look at .gi */ - private forceFileAutocomplete(): void { + private async forceFileAutocomplete(): Promise { if (!this.autocompleteProvider) return; // Check if provider supports force file suggestions via runtime check @@ -1895,11 +1900,11 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"]; }; if (typeof provider.getForceFileSuggestions !== "function") { - this.tryTriggerAutocomplete(true); + await this.tryTriggerAutocomplete(true); return; } - const suggestions = provider.getForceFileSuggestions( + const suggestions = await provider.getForceFileSuggestions( this.state.lines, this.state.cursorLine, this.state.cursorCol, @@ -1909,13 +1914,16 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); this.isAutocompleting = true; + this.onAutocompleteUpdate?.(); } else { this.cancelAutocomplete(); + this.onAutocompleteUpdate?.(); } } private cancelAutocomplete(notifyCancel: boolean = false): void { const wasAutocompleting = this.isAutocompleting; + this.clearAutocompleteTimeout(); this.isAutocompleting = false; this.autocompleteList = undefined; this.autocompletePrefix = ""; @@ -1928,10 +1936,10 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ return this.isAutocompleting; } - private updateAutocomplete(): void { + private async updateAutocomplete(): Promise { if (!this.isAutocompleting || !this.autocompleteProvider) return; - const suggestions = this.autocompleteProvider.getSuggestions( + const suggestions = await this.autocompleteProvider.getSuggestions( this.state.lines, this.state.cursorLine, this.state.cursorCol, @@ -1941,8 +1949,27 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.autocompletePrefix = suggestions.prefix; // Always create new SelectList to ensure update this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); + this.onAutocompleteUpdate?.(); } else { this.cancelAutocomplete(); + this.onAutocompleteUpdate?.(); + } + } + + private debouncedUpdateAutocomplete(): void { + if (this.autocompleteTimeout) { + clearTimeout(this.autocompleteTimeout); + } + this.autocompleteTimeout = setTimeout(() => { + this.updateAutocomplete(); + this.autocompleteTimeout = null; + }, 100); + } + + private clearAutocompleteTimeout(): void { + if (this.autocompleteTimeout) { + clearTimeout(this.autocompleteTimeout); + this.autocompleteTimeout = null; } } } diff --git a/packages/tui/src/mermaid.ts b/packages/tui/src/mermaid.ts index 9690094cc..9c0e82164 100644 --- a/packages/tui/src/mermaid.ts +++ b/packages/tui/src/mermaid.ts @@ -1,6 +1,6 @@ -import { mkdir, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as path from "node:path"; import { $ } from "bun"; export interface MermaidImage { @@ -31,12 +31,12 @@ export async function renderMermaidToPng( return null; } - const tmpDir = join(tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`); - const inputPath = join(tmpDir, "input.mmd"); - const outputPath = join(tmpDir, "output.png"); + const tmpDir = path.join(tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const inputPath = path.join(tmpDir, "input.mmd"); + const outputPath = path.join(tmpDir, "output.png"); try { - await mkdir(tmpDir, { recursive: true }); + await fs.mkdir(tmpDir, { recursive: true }); await Bun.write(inputPath, source); const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"]; @@ -64,7 +64,7 @@ export async function renderMermaidToPng( return null; } - const buffer = Buffer.from(await outputFile.arrayBuffer()); + const buffer = Buffer.from(await outputFile.bytes()); const base64 = buffer.toString("base64"); const dims = parsePngDimensions(buffer); @@ -80,7 +80,7 @@ export async function renderMermaidToPng( } catch { return null; } finally { - await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } } diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 8bbc17e27..742f23f24 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -3,13 +3,13 @@ import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; describe("CombinedAutocompleteProvider", () => { describe("extractPathPrefix", () => { - it("extracts / from 'hey /' when forced", () => { + it("extracts / from 'hey /' when forced", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["hey /"]; const cursorLine = 0; const cursorCol = 5; // After the "/" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await provider.getForceFileSuggestions(lines, cursorLine, cursorCol); expect(result).not.toBeNull(); if (result) { @@ -17,13 +17,13 @@ describe("CombinedAutocompleteProvider", () => { } }); - it("extracts /A from '/A' when forced", () => { + it("extracts /A from '/A' when forced", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["/A"]; const cursorLine = 0; const cursorCol = 2; // After the "A" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await provider.getForceFileSuggestions(lines, cursorLine, cursorCol); // This might return null if /A doesn't match anything, which is fine // We're mainly testing that the prefix extraction works @@ -32,24 +32,24 @@ describe("CombinedAutocompleteProvider", () => { } }); - it("does not trigger for slash commands", () => { + it("does not trigger for slash commands", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["/model"]; const cursorLine = 0; const cursorCol = 6; // After "model" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await provider.getForceFileSuggestions(lines, cursorLine, cursorCol); expect(result).toBe(null); }); - it("triggers for absolute paths after slash command argument", () => { + it("triggers for absolute paths after slash command argument", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["/command /"]; const cursorLine = 0; const cursorCol = 10; // After the second "/" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await provider.getForceFileSuggestions(lines, cursorLine, cursorCol); expect(result).not.toBeNull(); if (result) { diff --git a/scripts/dump-edit-history.ts b/scripts/dump-edit-history.ts index 7f66d03b6..f338b3426 100644 --- a/scripts/dump-edit-history.ts +++ b/scripts/dump-edit-history.ts @@ -15,8 +15,9 @@ */ import { Glob } from "bun"; -import { existsSync, readFileSync } from "node:fs"; + import { basename } from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; // ═══════════════════════════════════════════════════════════════════════════ // Types @@ -70,8 +71,8 @@ function classifyError(resultText: string): string { return "unknown"; } -function extractEditAttempts(sessionPath: string): EditAttempt[] { - const content = readFileSync(sessionPath, "utf-8"); +async function extractEditAttempts(sessionPath: string): Promise { + const content = await Bun.file(sessionPath).text(); const lines = content.trim().split("\n"); const messages: Message[] = lines.map((line) => JSON.parse(line)); @@ -316,8 +317,13 @@ async function expandGlobs(patterns: string[]): Promise { for await (const file of glob.scan({ absolute: true })) { files.push(file); } - } else if (existsSync(pattern)) { - files.push(pattern); + } else { + try { + await Bun.file(pattern).text(); + files.push(pattern); + } catch (err) { + if (!isEnoent(err)) throw err; + } } } return files; @@ -354,7 +360,7 @@ Examples: for (const file of files) { try { - let attempts = extractEditAttempts(file); + let attempts = await extractEditAttempts(file); // Filter if (options.failures) attempts = attempts.filter((a) => a.isError); diff --git a/tsconfig.base.json b/tsconfig.base.json index a6cc2221b..8170b9acf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,8 +36,10 @@ "@oh-my-pi/omp-stats/*": ["./packages/stats/src/*"], "@oh-my-pi/pi-tui": ["./packages/tui/src/index.ts"], "@oh-my-pi/pi-tui/*": ["./packages/tui/src/*"], - "@sinclair/typebox": ["./node_modules/@sinclair/typebox/build/esm/index.d.mts"], - "@sinclair/typebox/*": ["./node_modules/@sinclair/typebox/build/esm/*"] + "@oh-my-pi/react-edit-benchmark": ["./packages/react-edit-benchmark/src/index.ts"], + "@oh-my-pi/react-edit-benchmark/*": ["./packages/react-edit-benchmark/src/*"], + "@sinclair/typebox": ["./node_modules/@sinclair/typebox/build/esm/index.d.mts"], + "@sinclair/typebox/*": ["./node_modules/@sinclair/typebox/build/esm/*"] } }, "files": ["./types/assets/index.d.ts"]