diff --git a/README.md b/README.md index 7a31b97aa..18dcaf224 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Structured code review with priority-based findings: - **`/review` command**: Interactive mode selection (branch comparison, uncommitted changes, commit review) - **Structured findings**: `report_finding` tool with priority levels (P0-P3: critical → nit) -- **Verdict rendering**: `submit_review` aggregates findings into approve/request-changes/comment +- **Verdict rendering**: aggregates findings into approve/request-changes/comment - Combined result tree showing verdict and all findings ## + Task Tool (Subagent System) diff --git a/bun.lock b/bun.lock index e216c2073..d926c96e7 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ "name": "@oh-my-pi/pi-agent-core", "version": "3.30.0", "dependencies": { - "@mariozechner/pi-ai": "^0.37.4", + "@mariozechner/pi-ai": "^0.37.8", "@oh-my-pi/pi-tui": "workspace:*", }, "devDependencies": { @@ -37,7 +37,7 @@ "omp": "src/cli.ts", }, "dependencies": { - "@mariozechner/pi-ai": "^0.37.4", + "@mariozechner/pi-ai": "^0.37.8", "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", diff --git a/packages/agent/package.json b/packages/agent/package.json index b5ad2e57f..b029c61e6 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -13,7 +13,7 @@ "test": "vitest --run" }, "dependencies": { - "@mariozechner/pi-ai": "^0.37.4", + "@mariozechner/pi-ai": "^0.37.8", "@oh-my-pi/pi-tui": "workspace:*" }, "keywords": [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b9acc36d3..5df28f9a0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] ### Added +- Added in-memory session storage implementation for testing and ephemeral sessions +- Added `createToolUIKit` helper to consolidate common UI formatting utilities across tool renderers +- Added configurable bash interceptor rules via `bashInterceptor.patterns` setting for custom command blocking +- Added `bashInterceptor.simpleLs` setting to control interception of bare ls commands +- Added LSP server configuration via external JSON defaults file for easier customization +- Added abort signal propagation to web scrapers for improved cancellation handling +- Added `diagnosticsVersion` tracking to LSP client for more reliable diagnostic polling - Added 80+ specialized web scrapers for structured content extraction from popular sites including GitHub, GitLab, npm, PyPI, crates.io, Wikipedia, YouTube, Stack Overflow, Hacker News, Reddit, arXiv, PubMed, and many more - Added site-specific API integrations for package registries (npm, PyPI, crates.io, Hex, Hackage, NuGet, Maven, RubyGems, Packagist, pub.dev, Go packages) - Added scrapers for social platforms (Mastodon, Bluesky, Lemmy, Lobsters, Dev.to, Discourse) @@ -17,8 +24,31 @@ ### Changed +- Changed task tool to require explicit task `id` field instead of auto-generating names from agent type +- Changed grep and find tools to use native Bun file APIs instead of Node.js fs module for improved performance +- Changed YouTube scraper to use async command execution with proper stream handling +- Improved rust-analyzer diagnostic polling to use version-based stability detection instead of time-based delays +- Changed theme icons for extension types to use Unicode symbols (✧, ⚒) instead of text abbreviations (SK, TL, MCP) +- Changed task tool to use short CamelCase task IDs instead of agent-based naming (e.g., 'SessionStore' instead of 'explore_0') +- Changed task tool to accept single `agent` parameter at top level instead of per-task agent specification +- Changed reviewer agent to use `complete` tool instead of `submit_review` for finishing reviews +- Changed theme icons for extensions to use Unicode symbols instead of text abbreviations +- Changed LSP file type matching to support exact filename matches in addition to extensions +- Improved rust-analyzer diagnostic polling to use version-based stability detection - Refactored web-fetch tool to use modular scraper architecture for improved maintainability +### Removed + +- Removed `submit_review` tool - reviewers now finish via `complete` tool with structured output + +### Fixed + +- Fixed byte counting in task output truncation to correctly handle multi-byte Unicode characters +- Fixed parallel task execution to propagate abort signals and fail fast on first error +- Fixed task worker abort handling to properly clean up on cancellation +- Fixed parallel task execution to fail fast on first error instead of waiting for all workers +- Fixed byte counting in task output truncation to handle multi-byte Unicode characters correctly + ## [3.30.0] - 2026-01-07 ### Added diff --git a/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md b/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md deleted file mode 100644 index a6706993f..000000000 --- a/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: reviewer -description: Code review specialist for quality and security analysis -tools: read, grep, find, ls, bash -model: claude-sonnet-4-5 ---- - -You are a senior code reviewer. Analyze code for quality, security, and maintainability. - -Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds. -Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only. - -Strategy: -1. Run `git diff` to see recent changes (if applicable) -2. Read the modified files -3. Check for bugs, security issues, code smells - -Output format: - -## Files Reviewed -- `path/to/file.ts` (lines X-Y) - -## Critical (must fix) -- `file.ts:42` - Issue description - -## Warnings (should fix) -- `file.ts:100` - Issue description - -## Suggestions (consider) -- `file.ts:150` - Improvement idea - -## Summary -Overall assessment in 2-3 sentences. - -Be specific with file paths and line numbers. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 3625ae24d..0cc8a654c 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -39,7 +39,7 @@ "prepublishOnly": "bun run generate-template && bun run clean && bun run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.37.4", + "@mariozechner/pi-ai": "^0.37.8", "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 2d6673a4e..c446ed4f0 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -10,6 +10,7 @@ import { createWriteStream, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Subprocess } from "bun"; +import { nanoid } from "nanoid"; import stripAnsi from "strip-ansi"; import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell"; import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot"; @@ -77,7 +78,7 @@ function createOutputSink( // Spill to temp file if needed if (totalBytes > spillThreshold && !fullOutputPath) { - fullOutputPath = join(tmpdir(), `omp-${crypto.randomUUID()}.buffer`); + fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`); const ts = createWriteStream(fullOutputPath); chunks.forEach((c) => { ts.write(c); diff --git a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts index edf19d940..e0c4ceb21 100644 --- a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts +++ b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts @@ -300,7 +300,7 @@ async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise { validateDisjointScopes(args.map((t) => t.scope)); - const sessionId = `parallel-${Date.now()}`; + const sessionId = `parallel-${nanoid()}`; const agent = await pickAgent(ctx.cwd); const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = []; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 60946c6a4..ebcda5558 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,17 +1,3 @@ -import { - closeSync, - createWriteStream, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - readSync, - renameSync, - statSync, - unlinkSync, - type WriteStream, -} from "node:fs"; import { basename, join, resolve } from "node:path"; import type { ImageContent, Message, TextContent, Usage } from "@mariozechner/pi-ai"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; @@ -26,6 +12,8 @@ import { createCustomMessage, type HookMessage, } from "./messages"; +import type { SessionStorage, SessionStorageWriter } from "./session-storage"; +import { FileSessionStorage, MemorySessionStorage } from "./session-storage"; export const CURRENT_SESSION_VERSION = 3; @@ -437,20 +425,18 @@ export function buildSessionContext( * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/. */ -function getDefaultSessionDir(cwd: string): string { +function getDefaultSessionDir(cwd: string, storage: SessionStorage): string { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; const sessionDir = join(getDefaultAgentDir(), "sessions", safePath); - if (!existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); - } + storage.ensureDirSync(sessionDir); return sessionDir; } /** Exported for testing */ -export function loadEntriesFromFile(filePath: string): FileEntry[] { - if (!existsSync(filePath)) return []; +export function loadEntriesFromFile(filePath: string, storage: SessionStorage = new FileSessionStorage()): FileEntry[] { + if (!storage.existsSync(filePath)) return []; - const content = readFileSync(filePath, "utf-8"); + const content = storage.readTextSync(filePath); const entries: FileEntry[] = []; const lines = content.trim().split("\n"); @@ -523,57 +509,38 @@ class RecentSessionInfo { * Uses low-level file I/O to efficiently read only the first 512 bytes of each file * to extract the JSON header without loading entire session logs into memory. */ -function getSortedSessions(sessionDir: string): RecentSessionInfo[] { +function getSortedSessions(sessionDir: string, storage: SessionStorage): RecentSessionInfo[] { try { - // Reusable buffer for reading file headers - const buf = Buffer.allocUnsafe(512); - - /** - * Reads the first line (JSON header) from an open file descriptor. - * Returns null if the file is empty or doesn't start with valid JSON. - */ - const readHeader = (fd: number) => { - const bytesRead = readSync(fd, buf, 0, 512, 0); - if (bytesRead === 0) return null; - const sub = buf.subarray(0, bytesRead); - // Quick check: first char must be '{' for valid JSON object - if (sub.at(0) !== "{".charCodeAt(0)) return null; - // Find end of first JSON line - const eol = sub.indexOf("}\n"); - if (eol <= 0) return null; - const header = JSON.parse(sub.toString("utf8", 0, eol + 1)); - // Validate session header - if (header.type !== "session" || typeof header.id !== "string") return null; - return header; - }; - - return Array.from(new Bun.Glob("*.jsonl").scanSync(sessionDir)) - .map((f) => { + const buf = Buffer.alloc(512); + const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl"); + return files + .map((path: string) => { try { - const path = join(sessionDir, f); - const fd = openSync(path, "r"); - try { - const header = readHeader(fd); - if (!header) return null; - const mtime = statSync(path).mtimeMs; - return new RecentSessionInfo(path, mtime, header); - } finally { - closeSync(fd); - } + const length = storage.readTextPrefixSync(path, buf); + const content = buf.toString("utf-8", 0, length); + const firstLine = content.split("\n")[0]; + if (!firstLine || !firstLine.trim()) return null; + const header = JSON.parse(firstLine) as Record; + if (header.type !== "session" || typeof header.id !== "string") return null; + const mtime = storage.statSync(path).mtimeMs; + return new RecentSessionInfo(path, mtime, header); } catch { return null; } }) - .filter((x) => x !== null) - .sort((a, b) => b.mtime - a.mtime); // Sort newest first + .filter((item): item is RecentSessionInfo => item !== null) + .sort((a, b) => b.mtime - a.mtime); } catch { return []; } } /** Exported for testing */ -export function findMostRecentSession(sessionDir: string): string | null { - const sessions = getSortedSessions(sessionDir); +export function findMostRecentSession( + sessionDir: string, + storage: SessionStorage = new FileSessionStorage(), +): string | null { + const sessions = getSortedSessions(sessionDir, storage); return sessions[0]?.path || null; } @@ -599,19 +566,6 @@ const PLACEHOLDER_IMAGE_DATA = const TEXT_CONTENT_KEY = "content"; -function fsyncDirSync(dir: string): void { - try { - const fd = openSync(dir, "r"); - try { - fsyncSync(fd); - } finally { - closeSync(fd); - } - } catch { - // Best-effort: some platforms/filesystems don't support fsync on directories. - } -} - /** * Recursively truncate large strings in an object for session persistence. * - Truncates any oversized string fields (key-agnostic) @@ -724,81 +678,48 @@ async function prepareEntryForPersistence(entry: FileEntry): Promise } class NdjsonFileWriter { - private writeStream: WriteStream; + private writer: SessionStorageWriter; private closed = false; private closing = false; private error: Error | undefined; private pendingWrites: Promise = Promise.resolve(); - private ready: Promise; - private fd: number | null = null; private onError: ((err: Error) => void) | undefined; - constructor(path: string, options?: { flags?: string; onError?: (err: Error) => void }) { + constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) { this.onError = options?.onError; - this.writeStream = createWriteStream(path, { flags: options?.flags ?? "a" }); - this.ready = new Promise((resolve, reject) => { - const onOpen = (fd: number) => { - this.fd = fd; - this.writeStream.off("error", onError); - resolve(); - }; - const onError = (err: Error) => { - this.writeStream.off("open", onOpen); - reject(err); - }; - this.writeStream.once("open", onOpen); - this.writeStream.once("error", onError); - }); - this.writeStream.on("error", (err: Error) => { - const writeErr = toError(err); - if (!this.error) this.error = writeErr; - this.onError?.(writeErr); + this.writer = storage.openWriter(path, { + flags: options?.flags ?? "a", + onError: (err: Error) => this.recordError(err), }); } + private recordError(err: unknown): Error { + const writeErr = toError(err); + if (!this.error) this.error = writeErr; + this.onError?.(writeErr); + return writeErr; + } + private enqueue(task: () => Promise): Promise { const run = async () => { if (this.error) throw this.error; await task(); }; const next = this.pendingWrites.then(run); - this.pendingWrites = next.catch((err) => { + void next.catch((err: unknown) => { if (!this.error) this.error = toError(err); }); + this.pendingWrites = next; return next; } private async writeLine(line: string): Promise { if (this.error) throw this.error; - await new Promise((resolve, reject) => { - let settled = false; - const onError = (err: Error) => { - if (settled) return; - settled = true; - const writeErr = toError(err); - if (!this.error) this.error = writeErr; - this.writeStream.off("error", onError); - reject(writeErr); - }; - this.writeStream.once("error", onError); - this.writeStream.write(line, (err) => { - if (settled) return; - settled = true; - this.writeStream.off("error", onError); - if (err) { - const writeErr = toError(err); - if (!this.error) this.error = writeErr; - reject(writeErr); - } else { - resolve(); - } - }); - if (this.error && !settled) { - settled = true; - this.writeStream.off("error", onError); - reject(this.error); - } - }); + try { + await this.writer.writeLine(line); + } catch (err) { + throw this.recordError(err); + } } /** Queue a write. Returns a promise so callers can await if needed. */ @@ -809,7 +730,7 @@ class NdjsonFileWriter { return this.enqueue(() => this.writeLine(line)); } - /** Flush all buffered data to disk. Waits for all queued writes and fsync. */ + /** Flush all buffered data to disk. Waits for all queued writes. */ async flush(): Promise { if (this.closed) return; if (this.error) throw this.error; @@ -818,19 +739,22 @@ class NdjsonFileWriter { if (this.error) throw this.error; - await this.ready; - const fd = this.fd; - if (typeof fd === "number") { - try { - fsyncSync(fd); - } catch (err) { - const fsyncErr = toError(err); - if (!this.error) this.error = fsyncErr; - throw fsyncErr; - } + try { + await this.writer.flush(); + } catch (err) { + throw this.recordError(err); } + } + /** Sync data to persistent storage. */ + async fsync(): Promise { + if (this.closed) return; if (this.error) throw this.error; + try { + await this.writer.fsync(); + } catch (err) { + throw this.recordError(err); + } } /** Close the writer, flushing all data. */ @@ -845,25 +769,23 @@ class NdjsonFileWriter { closeError = toError(err); } - await this.pendingWrites; + try { + await this.pendingWrites; + } catch (err) { + if (!closeError) closeError = toError(err); + } - await new Promise((resolve, reject) => { - this.writeStream.end((err?: Error | null) => { - if (err) { - const endErr = toError(err); - if (!this.error) this.error = endErr; - reject(endErr); - } else { - resolve(); - } - }); - }); + try { + await this.writer.close(); + } catch (err) { + const endErr = this.recordError(err); + if (!closeError) closeError = endErr; + } this.closed = true; - this.writeStream.removeAllListeners(); + if (!closeError && this.error) closeError = this.error; if (closeError) throw closeError; - if (this.error) throw this.error; } /** Check if there's a stored error. */ @@ -873,8 +795,12 @@ class NdjsonFileWriter { } /** Get recent sessions for display in welcome screen */ -export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] { - return getSortedSessions(sessionDir).slice(0, limit); +export function getRecentSessions( + sessionDir: string, + limit = 3, + storage: SessionStorage = new FileSessionStorage(), +): RecentSessionInfo[] { + return getSortedSessions(sessionDir, storage).slice(0, limit); } /** @@ -921,21 +847,22 @@ export class SessionManager { private persistWriterPath: string | undefined; private persistChain: Promise = Promise.resolve(); private persistError: Error | undefined; - private persistErrorReported = false; + private storage: SessionStorage; - private constructor(cwd: string, sessionDir: string, persist: boolean) { + private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) { this.cwd = cwd; this.sessionDir = sessionDir; this.persist = persist; - if (persist && sessionDir && !existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); + this.storage = storage; + if (persist && sessionDir) { + this.storage.ensureDirSync(sessionDir); } // Note: call _initSession() or _initSessionFile() after construction } /** Initialize with a specific session file (used by factory methods) */ - private _initSessionFile(sessionFile: string): void { - this.setSessionFile(sessionFile); + private async _initSessionFile(sessionFile: string): Promise { + await this.setSessionFile(sessionFile); } /** Initialize with a new session (used by factory methods) */ @@ -944,25 +871,22 @@ export class SessionManager { } /** Switch to a different session file (used for resume and branching) */ - setSessionFile(sessionFile: string): void { - void this._closePersistWriter(); + async setSessionFile(sessionFile: string): Promise { + await this._closePersistWriter(); this.persistError = undefined; - this.persistErrorReported = false; this.sessionFile = resolve(sessionFile); - if (existsSync(this.sessionFile)) { - void (async () => { - this.fileEntries = await loadEntriesFromFile(this.sessionFile!); - const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; - this.sessionId = header?.id ?? nanoid(); - this.sessionTitle = header?.title; + if (this.storage.existsSync(this.sessionFile)) { + this.fileEntries = loadEntriesFromFile(this.sessionFile!, this.storage); + const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; + this.sessionId = header?.id ?? nanoid(); + this.sessionTitle = header?.title; - if (migrateToCurrentVersion(this.fileEntries)) { - await this._rewriteFile(); - } + if (migrateToCurrentVersion(this.fileEntries)) { + await this._rewriteFile(); + } - this._buildIndex(); - this.flushed = true; - })(); + this._buildIndex(); + this.flushed = true; } else { this._newSessionSync(); } @@ -978,7 +902,6 @@ export class SessionManager { private _newSessionSync(options?: NewSessionOptions): string | undefined { this.persistChain = Promise.resolve(); this.persistError = undefined; - this.persistErrorReported = false; this.sessionId = nanoid(); const timestamp = new Date().toISOString(); const header: SessionHeader = { @@ -1044,10 +967,7 @@ export class SessionManager { private _recordPersistError(err: unknown): Error { const normalized = toError(err); if (!this.persistError) this.persistError = normalized; - if (!this.persistErrorReported) { - this.persistErrorReported = true; - console.error("Session persistence error:", normalized); - } + console.error("Session persistence error:", normalized); return normalized; } @@ -1067,7 +987,7 @@ export class SessionManager { if (this.persistError) throw this.persistError; if (this.persistWriter && this.persistWriterPath === this.sessionFile) return this.persistWriter; // Note: caller must await _closePersistWriter() before calling this if switching files - this.persistWriter = new NdjsonFileWriter(this.sessionFile, { + this.persistWriter = new NdjsonFileWriter(this.storage, this.sessionFile, { onError: (err) => { this._recordPersistError(err); }, @@ -1097,18 +1017,23 @@ export class SessionManager { if (!this.sessionFile) return; const dir = resolve(this.sessionFile, ".."); const tempPath = join(dir, `.${basename(this.sessionFile)}.${nanoid(6)}.tmp`); - const writer = new NdjsonFileWriter(tempPath, { flags: "w" }); - for (const entry of entries) { - await writer.write(entry); - } - await writer.flush(); - await writer.close(); + const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" }); try { - renameSync(tempPath, this.sessionFile); - fsyncDirSync(dir); + for (const entry of entries) { + await writer.write(entry); + } + await writer.flush(); + await writer.close(); + await this.storage.rename(tempPath, this.sessionFile); + this.storage.fsyncDirSync(dir); } catch (err) { try { - unlinkSync(tempPath); + await writer.close(); + } catch { + // Ignore cleanup errors + } + try { + await this.storage.unlink(tempPath); } catch { // Ignore cleanup errors } @@ -1134,7 +1059,10 @@ export class SessionManager { async flush(): Promise { if (!this.persistWriter) return; await this._queuePersistTask(async () => { - if (this.persistWriter) await this.persistWriter.flush(); + if (this.persistWriter) { + await this.persistWriter.flush(); + await this.persistWriter.fsync(); + } }); if (this.persistError) throw this.persistError; } @@ -1175,43 +1103,8 @@ export class SessionManager { // Update the session file header with the title (if already flushed) const sessionFile = this.sessionFile; - if (this.persist && sessionFile && existsSync(sessionFile)) { - await this._queuePersistTask(async () => { - await this._closePersistWriterInternal(); - try { - const content = readFileSync(sessionFile, "utf-8"); - const lines = content.split("\n"); - if (lines.length > 0) { - const fileHeader = JSON.parse(lines[0]) as SessionHeader; - if (fileHeader.type === "session") { - fileHeader.title = title; - lines[0] = JSON.stringify(fileHeader); - const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`); - await Bun.write(tempPath, lines.join("\n")); - const fd = openSync(tempPath, "r"); - try { - fsyncSync(fd); - } finally { - closeSync(fd); - } - try { - renameSync(tempPath, sessionFile); - fsyncDirSync(resolve(sessionFile, "..")); - } catch (err) { - try { - unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - throw err; - } - } - } - } catch (err) { - this._recordPersistError(err); - throw err; - } - }); + if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) { + await this._rewriteFile(); } } @@ -1655,11 +1548,10 @@ export class SessionManager { } if (this.persist) { - const file = Bun.file(newSessionFile); - const writer = file.writer(); - writer.write(`${JSON.stringify(header)}\n`); + const lines: string[] = []; + lines.push(JSON.stringify(header)); for (const entry of pathWithoutLabels) { - writer.write(`${JSON.stringify(entry)}\n`); + lines.push(JSON.stringify(entry)); } // Write fresh label entries at the end const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; @@ -1674,12 +1566,12 @@ export class SessionManager { targetId, label, }; - writer.write(`${JSON.stringify(labelEntry)}\n`); + lines.push(JSON.stringify(labelEntry)); pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; } - writer.end(); + this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`); this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); @@ -1712,9 +1604,9 @@ export class SessionManager { * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ - static create(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); - const manager = new SessionManager(cwd, dir, true); + static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager { + const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); + const manager = new SessionManager(cwd, dir, true, storage); manager._initNewSession(); return manager; } @@ -1724,15 +1616,19 @@ export class SessionManager { * @param path Path to session file * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. */ - static async open(path: string, sessionDir?: string): Promise { + static async open( + path: string, + sessionDir?: string, + storage: SessionStorage = new FileSessionStorage(), + ): Promise { // Extract cwd from session header if possible, otherwise use process.cwd() - const entries = await loadEntriesFromFile(path); + const entries = loadEntriesFromFile(path, 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 manager = new SessionManager(cwd, dir, true); - manager._initSessionFile(path); + const manager = new SessionManager(cwd, dir, true, storage); + await manager._initSessionFile(path); return manager; } @@ -1741,12 +1637,16 @@ export class SessionManager { * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ - static continueRecent(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); - const mostRecent = findMostRecentSession(dir); - const manager = new SessionManager(cwd, dir, true); + static async continueRecent( + cwd: string, + sessionDir?: string, + storage: SessionStorage = new FileSessionStorage(), + ): Promise { + const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); + const mostRecent = findMostRecentSession(dir, storage); + const manager = new SessionManager(cwd, dir, true, storage); if (mostRecent) { - manager._initSessionFile(mostRecent); + await manager._initSessionFile(mostRecent); } else { manager._initNewSession(); } @@ -1754,8 +1654,8 @@ export class SessionManager { } /** Create an in-memory session (no file persistence) */ - static inMemory(cwd: string = process.cwd()): SessionManager { - const manager = new SessionManager(cwd, "", false); + static inMemory(cwd: string = process.cwd(), storage: SessionStorage = new MemorySessionStorage()): SessionManager { + const manager = new SessionManager(cwd, "", false, storage); manager._initNewSession(); return manager; } @@ -1765,16 +1665,16 @@ 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): SessionInfo[] { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] { + const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); const sessions: SessionInfo[] = []; try { - const files = Array.from(new Bun.Glob("*.jsonl").scanSync(dir)).map((f) => join(dir, f)); + const files = storage.listFilesSync(dir, "*.jsonl"); for (const file of files) { try { - const content = readFileSync(file, "utf-8"); + const content = storage.readTextSync(file); const lines = content.trim().split("\n"); if (lines.length === 0) continue; @@ -1790,7 +1690,7 @@ export class SessionManager { } if (!header) continue; - const stats = statSync(file); + const stats = storage.statSync(file); let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; diff --git a/packages/coding-agent/src/core/session-storage.ts b/packages/coding-agent/src/core/session-storage.ts new file mode 100644 index 000000000..9c37e1f59 --- /dev/null +++ b/packages/coding-agent/src/core/session-storage.ts @@ -0,0 +1,379 @@ +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"; + +export interface SessionStorageStat { + size: number; + mtimeMs: number; + mtime: Date; +} + +export interface SessionStorageWriter { + writeLine(line: string): Promise; + flush(): Promise; + fsync(): Promise; + close(): Promise; + getError(): Error | undefined; +} + +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; + writeText(path: string, content: string): Promise; + rename(path: string, nextPath: string): Promise; + unlink(path: string): Promise; + fsyncDirSync(dir: string): void; + openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter; +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +// FinalizationRegistry to clean up leaked file descriptors +const writerRegistry = new FinalizationRegistry((fd) => { + try { + closeSync(fd); + } catch { + // Ignore - fd may already be closed or invalid + } +}); + +class FileSessionStorageWriter implements SessionStorageWriter { + private fd: number; + private closed = false; + private error: Error | undefined; + private onError: ((err: Error) => void) | undefined; + + constructor(path: 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 }); + } + // Open file once, keep fd for lifetime + this.fd = openSync(path, flags === "w" ? "w" : "a"); + // Register for cleanup if abandoned without close() + writerRegistry.register(this, this.fd, this); + } + + private recordError(err: unknown): Error { + const error = toError(err); + if (!this.error) this.error = error; + this.onError?.(error); + return error; + } + + async writeLine(line: string): Promise { + if (this.closed) throw new Error("Writer closed"); + if (this.error) throw this.error; + try { + const buf = Buffer.from(line, "utf-8"); + let offset = 0; + while (offset < buf.length) { + const written = writeSync(this.fd, buf, offset, buf.length - offset); + if (written === 0) { + throw new Error("Short write"); + } + offset += written; + } + } catch (err) { + throw this.recordError(err); + } + } + + async flush(): Promise { + if (this.error) throw this.error; + // OS buffers are flushed on fsync, nothing to do here + } + + async fsync(): Promise { + if (this.closed) throw new Error("Writer closed"); + if (this.error) throw this.error; + try { + fsyncSync(this.fd); + } catch (err) { + throw this.recordError(err); + } + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + // Unregister from finalization - we're closing properly + writerRegistry.unregister(this); + try { + closeSync(this.fd); + } catch { + // Ignore close errors + } + } + + getError(): Error | undefined { + return this.error; + } +} + +export class FileSessionStorage implements SessionStorage { + ensureDirSync(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + + existsSync(path: string): boolean { + return 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); + } + + statSync(path: string): SessionStorageStat { + const stats = 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)); + } catch { + return []; + } + } + + exists(path: string): Promise { + return Bun.file(path).exists(); + } + + readText(path: string): Promise { + return Bun.file(path).text(); + } + + async writeText(path: string, content: string): Promise { + await Bun.write(path, content, { createPath: true }); + } + + async rename(path: string, nextPath: string): Promise { + try { + await renameAsync(path, nextPath); + } catch (err) { + throw toError(err); + } + } + + unlink(path: string): Promise { + return Bun.file(path).unlink(); + } + + fsyncDirSync(dir: string): void { + try { + const fd = openSync(dir, "r"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + } catch { + // Best-effort: some platforms/filesystems don't support fsync on directories. + } + } + + openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { + return new FileSessionStorageWriter(path, options); + } +} + +function matchesPattern(name: string, pattern: string): boolean { + if (pattern === "*") return true; + if (pattern.startsWith("*.")) { + return name.endsWith(pattern.slice(1)); + } + return name === pattern; +} + +class MemorySessionStorageWriter implements SessionStorageWriter { + private storage: MemorySessionStorage; + private path: string; + private closed = false; + private error: Error | undefined; + private onError: ((err: Error) => void) | undefined; + private ready: Promise; + + constructor( + storage: MemorySessionStorage, + path: string, + options?: { flags?: "a" | "w"; onError?: (err: Error) => void }, + ) { + this.storage = storage; + this.path = path; + this.onError = options?.onError; + this.ready = this.initialize(options?.flags ?? "a"); + } + + private async initialize(flags: "a" | "w"): Promise { + if (flags === "w") { + await this.storage.writeText(this.path, ""); + } + } + + private recordError(err: unknown): Error { + const error = toError(err); + if (!this.error) this.error = error; + this.onError?.(error); + return error; + } + + async writeLine(line: string): Promise { + if (this.closed) throw new Error("Writer closed"); + await this.ready; + if (this.error) throw this.error; + try { + const existing = this.storage.existsSync(this.path) ? this.storage.readTextSync(this.path) : ""; + await this.storage.writeText(this.path, `${existing}${line}`); + } catch (err) { + throw this.recordError(err); + } + } + + async flush(): Promise { + await this.ready; + if (this.error) throw this.error; + } + + async fsync(): Promise { + // No-op for in-memory storage + await this.ready; + if (this.error) throw this.error; + } + + async close(): Promise { + if (this.closed) return; + await this.ready; + this.closed = true; + } + + getError(): Error | undefined { + return this.error; + } +} + +export class MemorySessionStorage implements SessionStorage { + private files = new Map(); + + ensureDirSync(_dir: string): void { + // No-op for in-memory storage. + } + + existsSync(path: string): boolean { + 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() }); + } + + statSync(path: string): SessionStorageStat { + const entry = this.files.get(path); + if (!entry) throw new Error(`File not found: ${path}`); + return { + size: entry.content.length, + mtimeMs: entry.mtimeMs, + mtime: new Date(entry.mtimeMs), + }; + } + + listFilesSync(dir: string, pattern: string): string[] { + const prefix = dir.endsWith("/") ? dir : `${dir}/`; + const files: string[] = []; + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) continue; + const name = path.slice(prefix.length); + if (name.includes("/") || name.includes("\\")) continue; + if (!matchesPattern(name, pattern)) continue; + files.push(path); + } + return files; + } + + exists(path: string): Promise { + return Promise.resolve(this.existsSync(path)); + } + + readText(path: string): Promise { + return Promise.resolve(this.readTextSync(path)); + } + + writeText(path: string, content: string): Promise { + this.writeTextSync(path, content); + return Promise.resolve(); + } + + rename(path: string, nextPath: string): Promise { + const entry = this.files.get(path); + if (!entry) return Promise.reject(new Error(`File not found: ${path}`)); + this.files.set(nextPath, entry); + this.files.delete(path); + return Promise.resolve(); + } + + unlink(path: string): Promise { + this.files.delete(path); + return Promise.resolve(); + } + + fsyncDirSync(_dir: string): void { + // No-op for in-memory storage. + } + + openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { + return new MemorySessionStorageWriter(this, path, options); + } +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 11cdc639f..c26dca6be 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -70,8 +70,17 @@ export interface ProviderSettings { image?: ImageProviderOption; // default: "auto" (openrouter > gemini) } +export interface BashInterceptorRule { + pattern: string; + flags?: string; + tool: string; + message: string; +} + export interface BashInterceptorSettings { enabled?: boolean; // default: false (blocks shell commands that have dedicated tools) + simpleLs?: boolean; // default: true (intercept bare ls commands) + patterns?: BashInterceptorRule[]; // default: built-in rules } export interface GitSettings { @@ -191,6 +200,140 @@ export interface Settings { statusLine?: StatusLineSettings; // Status line configuration } +export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ + { + pattern: "^\\s*(cat|head|tail|less|more)\\s+", + tool: "read", + message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.", + }, + { + pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+", + tool: "grep", + message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.", + }, + { + pattern: "^\\s*git(\\s+|$)", + tool: "git", + message: + "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.", + }, + { + pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)", + tool: "find", + message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.", + }, + { + pattern: "^\\s*sed\\s+(-i|--in-place)", + tool: "edit", + message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.", + }, + { + pattern: "^\\s*perl\\s+.*-[pn]?i", + tool: "edit", + message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.", + }, + { + pattern: "^\\s*awk\\s+.*-i\\s+inplace", + tool: "edit", + message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.", + }, + { + pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S", + tool: "write", + message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.", + }, +]; + +const DEFAULT_BASH_INTERCEPTOR_SETTINGS: Required = { + enabled: false, + simpleLs: true, + patterns: DEFAULT_BASH_INTERCEPTOR_RULES, +}; + +const DEFAULT_SETTINGS: Settings = { + compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 }, + branchSummary: { enabled: false, reserveTokens: 16384 }, + retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 }, + skills: { + enabled: true, + enableCodexUser: true, + enableClaudeUser: true, + enableClaudeProject: true, + enablePiUser: true, + enablePiProject: true, + customDirectories: [], + ignoredSkills: [], + includeSkills: [], + }, + commands: { enableClaudeUser: true, enableClaudeProject: true }, + terminal: { showImages: true }, + images: { autoResize: true }, + notifications: { onComplete: "auto" }, + exa: { + enabled: true, + enableSearch: true, + enableLinkedin: false, + enableCompany: false, + enableResearcher: false, + enableWebsets: false, + }, + bashInterceptor: DEFAULT_BASH_INTERCEPTOR_SETTINGS, + git: { enabled: false }, + mcp: { enableProjectConfig: true }, + lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false }, + edit: { fuzzyMatch: true }, + ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 }, + voice: { + enabled: false, + transcriptionModel: "whisper-1", + ttsModel: "gpt-4o-mini-tts", + ttsVoice: "alloy", + ttsFormat: "wav", + }, + providers: { webSearch: "auto", image: "auto" }, +} satisfies Settings; + +function normalizeBashInterceptorRule(rule: unknown): BashInterceptorRule | null { + if (!rule || typeof rule !== "object" || Array.isArray(rule)) return null; + + const candidate = rule as Record; + const pattern = typeof candidate.pattern === "string" ? candidate.pattern : ""; + const tool = typeof candidate.tool === "string" ? candidate.tool : ""; + const message = typeof candidate.message === "string" ? candidate.message : ""; + const flags = typeof candidate.flags === "string" && candidate.flags.length > 0 ? candidate.flags : undefined; + + if (!pattern || !tool || !message) return null; + return { pattern, flags, tool, message }; +} + +function normalizeBashInterceptorSettings( + settings: BashInterceptorSettings | undefined, +): Required { + const enabled = settings?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled; + const simpleLs = settings?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs; + const rawPatterns = settings?.patterns; + let patterns: BashInterceptorRule[]; + if (rawPatterns === undefined) { + patterns = DEFAULT_BASH_INTERCEPTOR_RULES; + } else if (Array.isArray(rawPatterns)) { + patterns = rawPatterns + .map((rule) => normalizeBashInterceptorRule(rule)) + .filter((rule): rule is BashInterceptorRule => rule !== null); + } else { + patterns = DEFAULT_BASH_INTERCEPTOR_RULES; + } + + return { enabled, simpleLs, patterns }; +} + +function normalizeSettings(settings: Settings): Settings { + const merged = deepMergeSettings(DEFAULT_SETTINGS, settings); + return { + ...merged, + bashInterceptor: normalizeBashInterceptorSettings(merged.bashInterceptor), + }; +} + /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ function deepMergeSettings(base: Settings, overrides: Settings): Settings { const result: Settings = { ...base }; @@ -235,7 +378,7 @@ export class SettingsManager { this.persist = persist; this.globalSettings = initialSettings; const projectSettings = this.loadProjectSettings(); - this.settings = deepMergeSettings(this.globalSettings, projectSettings); + this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings)); } /** Create a SettingsManager that loads from files */ @@ -308,7 +451,7 @@ export class SettingsManager { /** Apply additional overrides on top of current settings */ applyOverrides(overrides: Partial): void { - this.settings = deepMergeSettings(this.settings, overrides); + this.settings = normalizeSettings(deepMergeSettings(this.settings, overrides)); } private save(): void { @@ -325,7 +468,7 @@ export class SettingsManager { // Re-merge project settings into active settings const projectSettings = this.loadProjectSettings(); - this.settings = deepMergeSettings(this.globalSettings, projectSettings); + this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings)); } catch (error) { console.error(`Warning: Could not save settings file: ${error}`); } @@ -680,7 +823,15 @@ export class SettingsManager { } getBashInterceptorEnabled(): boolean { - return this.settings.bashInterceptor?.enabled ?? false; + return this.settings.bashInterceptor?.enabled ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.enabled; + } + + getBashInterceptorSimpleLsEnabled(): boolean { + return this.settings.bashInterceptor?.simpleLs ?? DEFAULT_BASH_INTERCEPTOR_SETTINGS.simpleLs; + } + + getBashInterceptorRules(): BashInterceptorRule[] { + return [...(this.settings.bashInterceptor?.patterns ?? DEFAULT_BASH_INTERCEPTOR_RULES)]; } setBashInterceptorEnabled(enabled: boolean): void { diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index b0958d049..f8b592641 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -83,9 +83,49 @@ const toolDescriptions: Record = { web_fetch: "Fetch and render URLs into clean text for LLM consumption", web_search: "Search the web for information", report_finding: "Report a finding during code review", - submit_review: "Submit the final code review with all findings", }; +function applyTemplate(template: string, values: Record): string { + let output = template; + for (const [key, value] of Object.entries(values)) { + output = output.replaceAll(`{{${key}}}`, value); + } + return output; +} + +function appendBlock(prompt: string, block: string | null | undefined, separator = "\n\n"): string { + if (!block) return prompt; + if (block.startsWith("\n")) { + return `${prompt}${block}`; + } + return `${prompt}${separator}${block}`; +} + +function appendSection(prompt: string, title: string, content: string | null | undefined): string { + if (!content) return prompt; + return `${prompt}\n\n# ${title}\n\n${content}`; +} + +function formatProjectContext(contextFiles: Array<{ path: string; content: string; depth?: number }>): string | null { + if (contextFiles.length === 0) return null; + const parts: string[] = ["The following project context files have been loaded:", ""]; + for (const { path: filePath, content } of contextFiles) { + parts.push(`## ${filePath}`, "", content, ""); + } + return parts.join("\n").trimEnd(); +} + +function formatToolDescriptions(tools: Map | undefined): string | null { + if (!tools || tools.size === 0) return null; + return Array.from(tools.entries()) + .map(([name, { description }]) => `- ${name}: ${description}`) + .join("\n"); +} + +function buildPromptFooter(dateTime: string, cwd: string): string { + return `Current date and time: ${dateTime}\nCurrent working directory: ${cwd}`; +} + /** * Generate anti-bash rules section if the agent has both bash and specialized tools. * Only include rules for tools that are actually available. @@ -306,8 +346,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin timeZoneName: "short", }); - const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : ""; - // Resolve context files: use provided or discover const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd }); @@ -324,46 +362,22 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin ? `${systemPromptCustomization}\n\n${resolvedCustomPrompt}` : resolvedCustomPrompt; - if (appendSection) { - prompt += appendSection; - } + prompt = appendBlock(prompt, resolvedAppendPrompt); + prompt = appendSection(prompt, "Project Context", formatProjectContext(contextFiles)); + prompt = appendSection(prompt, "Tools", formatToolDescriptions(tools)); - // Append project context files - if (contextFiles.length > 0) { - prompt += "\n\n# Project Context\n\n"; - prompt += "The following project context files have been loaded:\n\n"; - for (const { path: filePath, content } of contextFiles) { - prompt += `## ${filePath}\n\n${content}\n\n`; - } - } - - // Append custom tool descriptions if provided - if (tools && tools.size > 0) { - prompt += "\n\n# Tools\n\n"; - prompt += Array.from(tools.entries()) - .map(([name, { description }]) => `- ${name}: ${description}`) - .join("\n"); - } - - // Append git context if in a git repo const gitContext = loadGitContext(resolvedCwd); - if (gitContext) { - prompt += `\n\n# Git Status\n\n${gitContext}`; - } + prompt = appendSection(prompt, "Git Status", gitContext); - // Append skills section (only if read tool is available) if (tools?.has("read") && skills.length > 0) { - prompt += formatSkillsForPrompt(skills); + prompt = appendBlock(prompt, formatSkillsForPrompt(skills)); } - // Append rules section (always enabled when rules exist) if (rulebookRules && rulebookRules.length > 0) { - prompt += formatRulesForPrompt(rulebookRules); + prompt = appendBlock(prompt, formatRulesForPrompt(rulebookRules)); } - // Add date/time and working directory last - prompt += `\nCurrent date and time: ${dateTime}`; - prompt += `\nCurrent working directory: ${resolvedCwd}`; + prompt = appendBlock(prompt, buildPromptFooter(dateTime, resolvedCwd), "\n"); return prompt; } @@ -428,46 +442,30 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Build the prompt with anti-bash rules prominently placed const antiBashBlock = antiBashSection ? `\n${antiBashSection}\n` : ""; - let prompt = systemPromptTemplate - .replaceAll("{{toolsList}}", toolsList) - .replaceAll("{{antiBashSection}}", antiBashBlock) - .replaceAll("{{guidelines}}", guidelines) - .replaceAll("{{readmePath}}", readmePath) - .replaceAll("{{docsPath}}", docsPath) - .replaceAll("{{examplesPath}}", examplesPath); + let prompt = applyTemplate(systemPromptTemplate, { + toolsList, + antiBashSection: antiBashBlock, + guidelines, + readmePath, + docsPath, + examplesPath, + }); - if (appendSection) { - prompt += appendSection; - } + prompt = appendBlock(prompt, resolvedAppendPrompt); + prompt = appendSection(prompt, "Project Context", formatProjectContext(contextFiles)); - // Append project context files - if (contextFiles.length > 0) { - prompt += "\n\n# Project Context\n\n"; - prompt += "The following project context files have been loaded:\n\n"; - for (const { path: filePath, content } of contextFiles) { - prompt += `## ${filePath}\n\n${content}\n\n`; - } - } - - // Append git context if in a git repo const gitContext = loadGitContext(resolvedCwd); - if (gitContext) { - prompt += `\n\n# Git Status\n\n${gitContext}`; - } + prompt = appendSection(prompt, "Git Status", gitContext); - // Append skills section (only if read tool is available) if (hasRead && skills.length > 0) { - prompt += formatSkillsForPrompt(skills); + prompt = appendBlock(prompt, formatSkillsForPrompt(skills)); } - // Append rules section (always enabled when rules exist) if (rulebookRules && rulebookRules.length > 0) { - prompt += formatRulesForPrompt(rulebookRules); + prompt = appendBlock(prompt, formatRulesForPrompt(rulebookRules)); } - // Add date/time and working directory last - prompt += `\nCurrent date and time: ${dateTime}`; - prompt += `\nCurrent working directory: ${resolvedCwd}`; + prompt = appendBlock(prompt, buildPromptFooter(dateTime, resolvedCwd), "\n"); // Prepend SYSTEM.md customization if present if (systemPromptCustomization) { diff --git a/packages/coding-agent/src/core/tools/ask.ts b/packages/coding-agent/src/core/tools/ask.ts index ad624ba6e..d1dfd3ccd 100644 --- a/packages/coding-agent/src/core/tools/ask.ts +++ b/packages/coding-agent/src/core/tools/ask.ts @@ -23,7 +23,7 @@ import { type Theme, theme } from "../../modes/interactive/theme/theme"; import askDescription from "../../prompts/tools/ask.md" with { type: "text" }; import type { RenderResultOptions } from "../custom-tools/types"; import type { ToolSession } from "./index"; -import { formatErrorMessage, formatMeta } from "./render-utils"; +import { createToolUIKit } from "./render-utils"; // ============================================================================= // Types @@ -218,17 +218,18 @@ interface AskRenderArgs { export const askToolRenderer = { renderCall(args: AskRenderArgs, uiTheme: Theme): Component { + const ui = createToolUIKit(uiTheme); if (!args.question) { - return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0); + return new Text(ui.errorMessage("No question provided"), 0, 0); } - const label = uiTheme.fg("toolTitle", uiTheme.bold("Ask")); + const label = ui.title("Ask"); let text = `${label} ${uiTheme.fg("accent", args.question)}`; const meta: string[] = []; if (args.multi) meta.push("multi"); if (args.options?.length) meta.push(`options:${args.options.length}`); - text += formatMeta(meta, uiTheme); + text += ui.meta(meta); if (args.options?.length) { for (let i = 0; i < args.options.length; i++) { diff --git a/packages/coding-agent/src/core/tools/bash-interceptor.ts b/packages/coding-agent/src/core/tools/bash-interceptor.ts index ab51a57e9..1feea66df 100644 --- a/packages/coding-agent/src/core/tools/bash-interceptor.ts +++ b/packages/coding-agent/src/core/tools/bash-interceptor.ts @@ -6,6 +6,8 @@ * the specialized tools instead. */ +import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../settings-manager"; + export interface InterceptionResult { /** If true, the bash command should be blocked */ block: boolean; @@ -16,62 +18,20 @@ export interface InterceptionResult { } /** - * Patterns that should NEVER use bash when specialized tools exist. - * Each pattern maps to a helpful error message. + * Compile bash interceptor rules into regexes, skipping invalid patterns. */ -const forbiddenPatterns: Array<{ - pattern: RegExp; - tool: string; - message: string; -}> = [ - // File reading - { - pattern: /^\s*(cat|head|tail|less|more)\s+/, - tool: "read", - message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.", - }, - // Content search (grep variants) - { - pattern: /^\s*(grep|rg|ripgrep|ag|ack)\s+/, - tool: "grep", - message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.", - }, - // Git operations - { - pattern: /^\s*git(\s+|$)/, - tool: "git", - message: - "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.", - }, - // File finding - { - pattern: /^\s*(find|fd|locate)\s+.*(-name|-iname|-type|--type|-glob)/, - tool: "find", - message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.", - }, - // In-place file editing - { - pattern: /^\s*sed\s+(-i|--in-place)/, - tool: "edit", - message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.", - }, - { - pattern: /^\s*perl\s+.*-[pn]?i/, - tool: "edit", - message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.", - }, - { - pattern: /^\s*awk\s+.*-i\s+inplace/, - tool: "edit", - message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.", - }, - // File creation via redirection (but allow legitimate uses like piping) - { - pattern: /^\s*(echo|printf|cat\s*<<)\s+.*[^|]>\s*\S/, - tool: "write", - message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.", - }, -]; +function compileRules(rules: BashInterceptorRule[]): Array<{ rule: BashInterceptorRule; regex: RegExp }> { + const compiled: Array<{ rule: BashInterceptorRule; regex: RegExp }> = []; + for (const rule of rules) { + const flags = rule.flags ?? ""; + try { + compiled.push({ rule, regex: new RegExp(rule.pattern, flags) }); + } catch { + // Skip invalid regex patterns + } + } + return compiled; +} /** * Check if a bash command should be intercepted. @@ -80,21 +40,26 @@ const forbiddenPatterns: Array<{ * @param availableTools Set of tool names that are available * @returns InterceptionResult indicating if the command should be blocked */ -export function checkBashInterception(command: string, availableTools: string[]): InterceptionResult { +export function checkBashInterception( + command: string, + availableTools: string[], + rules: BashInterceptorRule[] = DEFAULT_BASH_INTERCEPTOR_RULES, +): InterceptionResult { // Normalize command for pattern matching const normalizedCommand = command.trim(); + const compiled = compileRules(rules); - for (const { pattern, tool, message } of forbiddenPatterns) { + for (const { rule, regex } of compiled) { // Only block if the suggested tool is actually available - if (!availableTools.includes(tool)) { + if (!availableTools.includes(rule.tool)) { continue; } - if (pattern.test(normalizedCommand)) { + if (regex.test(normalizedCommand)) { return { block: true, - message: `❌ Blocked: ${message}\n\nOriginal command: ${command}`, - suggestedTool: tool, + message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`, + suggestedTool: rule.tool, }; } } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 38d46fb20..657c6ca56 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -8,7 +8,7 @@ import { executeBash } from "../bash-executor"; import type { RenderResultOptions } from "../custom-tools/types"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor"; import type { ToolSession } from "./index"; -import { formatBytes, wrapBrackets } from "./render-utils"; +import { createToolUIKit } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; const bashSchema = Type.Object({ @@ -36,13 +36,16 @@ export function createBashTool(session: ToolSession): AgentTool { // Check interception if enabled and available tools are known if (session.settings?.getBashInterceptorEnabled()) { - const interception = checkBashInterception(command, ctx?.toolNames ?? []); + const rules = session.settings?.getBashInterceptorRules?.(); + const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules); if (interception.block) { throw new Error(interception.message); } - const lsInterception = checkSimpleLsInterception(command, ctx?.toolNames ?? []); - if (lsInterception.block) { - throw new Error(lsInterception.message); + if (session.settings?.getBashInterceptorSimpleLsEnabled?.() !== false) { + const lsInterception = checkSimpleLsInterception(command, ctx?.toolNames ?? []); + if (lsInterception.block) { + throw new Error(lsInterception.message); + } } } @@ -127,8 +130,9 @@ interface BashRenderContext { export const bashToolRenderer = { renderCall(args: BashRenderArgs, uiTheme: Theme): Component { + const ui = createToolUIKit(uiTheme); const command = args.command || uiTheme.format.ellipsis; - const text = uiTheme.fg("toolTitle", uiTheme.bold(`$ ${command}`)); + const text = ui.title(`$ ${command}`); return new Text(text, 0, 0); }, @@ -140,6 +144,7 @@ export const bashToolRenderer = { options: RenderResultOptions & { renderContext?: BashRenderContext }, uiTheme: Theme, ): Component { + const ui = createToolUIKit(uiTheme); const { expanded, renderContext } = options; const details = result.details; const lines: string[] = []; @@ -195,11 +200,11 @@ export const bashToolRenderer = { warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); } else { warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, ); } } - lines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme))); + lines.push(uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")))); } return new Text(lines.join("\n"), 0, 0); diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 9814df04b..53265ea26 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -3,8 +3,6 @@ * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). */ -import { constants } from "node:fs"; -import { access, readFile } from "node:fs/promises"; import * as Diff from "diff"; import { resolveToCwd } from "./path-utils"; @@ -428,14 +426,23 @@ export async function computeEditDiff( try { // Check if file exists and is readable + const file = Bun.file(absolutePath); try { - await access(absolutePath, constants.R_OK); + if (!(await file.exists())) { + return { error: `File not found: ${path}` }; + } } catch { return { error: `File not found: ${path}` }; } // Read the file - const rawContent = await readFile(absolutePath, "utf-8"); + let rawContent: string; + try { + rawContent = await file.text(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: message || `Unable to read ${path}` }; + } // Strip BOM before matching (LLM won't include invisible BOM in oldText) const { text: content } = stripBom(rawContent); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 2573d6cb8..e9a878120 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -20,14 +20,7 @@ import { import type { ToolSession } from "./index"; import { createLspWritethrough, type FileDiagnosticsResult } from "./lsp/index"; import { resolveToCwd } from "./path-utils"; -import { - formatDiagnostics, - formatDiffStats, - getDiffStats, - shortenPath, - truncateDiffByHunk, - wrapBrackets, -} from "./render-utils"; +import { createToolUIKit, getDiffStats, shortenPath, truncateDiffByHunk } from "./render-utils"; const editSchema = Type.Object({ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), @@ -237,13 +230,14 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin export const editToolRenderer = { renderCall(args: EditRenderArgs, uiTheme: Theme): Component { + const ui = createToolUIKit(uiTheme); const rawPath = args.file_path || args.path || ""; const filePath = shortenPath(rawPath); const editLanguage = getLanguageFromPath(rawPath) ?? "text"; const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage)); const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); - const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Edit"))} ${editIcon} ${pathDisplay}`; + const text = `${ui.title("Edit")} ${editIcon} ${pathDisplay}`; return new Text(text, 0, 0); }, @@ -253,6 +247,7 @@ export const editToolRenderer = { uiTheme: Theme, args?: EditRenderArgs, ): Component { + const ui = createToolUIKit(uiTheme); const { expanded, renderContext } = options; const rawPath = args?.file_path || args?.path || ""; const filePath = shortenPath(rawPath); @@ -287,11 +282,10 @@ export const editToolRenderer = { text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`; } else if (editDiffPreview.diff) { const diffStats = getDiffStats(editDiffPreview.diff); - text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats( + text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${ui.formatDiffStats( diffStats.added, diffStats.removed, diffStats.hunks, - uiTheme, )}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`; const { @@ -309,7 +303,7 @@ export const editToolRenderer = { if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`); text += uiTheme.fg( "toolOutput", - `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${wrapBrackets("Ctrl+O to expand", uiTheme)}`, + `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${ui.wrapBrackets("Ctrl+O to expand")}`, ); } } @@ -317,7 +311,7 @@ export const editToolRenderer = { // Show LSP diagnostics if available if (result.details?.diagnostics) { - text += formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp) => + text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) => uiTheme.getLangIcon(getLanguageFromPath(fp)), ); } diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index ddb653707..93e3ad220 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,28 +1,16 @@ -import { existsSync, type Stats, statSync } from "node:fs"; import path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { Type } from "@sinclair/typebox"; -import { globSync } from "glob"; import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme"; import findDescription from "../../prompts/tools/find.md" with { type: "text" }; import { ensureTool } from "../../utils/tools-manager"; import type { RenderResultOptions } from "../custom-tools/types"; -import { untilAborted } from "../utils"; +import { ScopeSignal, untilAborted } from "../utils"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; -import { - formatCount, - formatEmptyMessage, - formatErrorMessage, - formatExpandHint, - formatMeta, - formatMoreItems, - formatScope, - formatTruncationSuffix, - PREVIEW_LIMITS, -} from "./render-utils"; +import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate"; const findSchema = Type.Object({ @@ -56,6 +44,53 @@ export interface FindToolDetails { error?: string; } +async function captureCommandOutput( + command: string, + args: string[], + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> { + const child = Bun.spawn([command, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + using scope = new ScopeSignal(signal ? { signal } : undefined); + scope.catch(() => { + child.kill(); + }); + + const stdoutReader = (child.stdout as ReadableStream).getReader(); + const stderrReader = (child.stderr as ReadableStream).getReader(); + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + + await Promise.all([ + (async () => { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + stdout += stdoutDecoder.decode(value, { stream: true }); + } + stdout += stdoutDecoder.decode(); + })(), + (async () => { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + stderr += stderrDecoder.decode(); + })(), + ]); + + const exitCode = await child.exited; + + return { stdout, stderr, exitCode, aborted: scope.aborted }; +} + export function createFindTool(session: ToolSession): AgentTool { return { name: "find", @@ -126,18 +161,19 @@ export function createFindTool(session: ToolSession): AgentTool(); const rootGitignore = path.join(searchPath, ".gitignore"); - if (existsSync(rootGitignore)) { + if (await Bun.file(rootGitignore).exists()) { gitignoreFiles.add(rootGitignore); } try { - const nestedGitignores = globSync("**/.gitignore", { - cwd: searchPath, - dot: true, - absolute: true, - ignore: ["**/node_modules/**", "**/.git/**"], - }); + const nestedGitignores = await Array.fromAsync( + new Bun.Glob("**/.gitignore").scan({ cwd: searchPath, dot: true, absolute: true }), + ); for (const file of nestedGitignores) { + const normalized = file.replace(/\\/g, "/"); + if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) { + continue; + } gitignoreFiles.add(file); } } catch { @@ -152,16 +188,16 @@ export function createFindTool(session: ToolSession): AgentTool 0) { - const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 })); + const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] })); indexed.sort((a, b) => b.mtime - a.mtime); relativized.length = 0; relativized.push(...indexed.map((item) => item.path)); @@ -279,7 +318,8 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS; export const findToolRenderer = { renderCall(args: FindRenderArgs, uiTheme: Theme): Component { - const label = uiTheme.fg("toolTitle", uiTheme.bold("Find")); + const ui = createToolUIKit(uiTheme); + const label = ui.title("Find"); let text = `${label} ${uiTheme.fg("accent", args.pattern || "*")}`; const meta: string[] = []; @@ -289,7 +329,7 @@ export const findToolRenderer = { if (args.sortByMtime) meta.push("sort:mtime"); if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - text += formatMeta(meta, uiTheme); + text += ui.meta(meta); return new Text(text, 0, 0); }, @@ -299,10 +339,11 @@ export const findToolRenderer = { { expanded }: RenderResultOptions, uiTheme: Theme, ): Component { + const ui = createToolUIKit(uiTheme); const details = result.details; if (details?.error) { - return new Text(formatErrorMessage(details.error, uiTheme), 0, 0); + return new Text(ui.errorMessage(details.error), 0, 0); } const hasDetailedData = details?.fileCount !== undefined; @@ -310,7 +351,7 @@ export const findToolRenderer = { if (!hasDetailedData) { if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") { - return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0); + return new Text(ui.emptyMessage("No files found"), 0, 0); } const lines = textContent.split("\n").filter((l) => l.trim()); @@ -320,8 +361,8 @@ export const findToolRenderer = { const hasMore = remaining > 0; const icon = uiTheme.styledSymbol("status.success", "success"); - const summary = formatCount("file", lines.length); - const expandHint = formatExpandHint(expanded, hasMore, uiTheme); + const summary = ui.count("file", lines.length); + const expandHint = ui.expandHint(expanded, hasMore); let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`; for (let i = 0; i < displayLines.length; i++) { @@ -330,7 +371,7 @@ export const findToolRenderer = { text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", displayLines[i])}`; } if (remaining > 0) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", formatMoreItems(remaining, "file", uiTheme))}`; + text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "file"))}`; } return new Text(text, 0, 0); } @@ -340,17 +381,17 @@ export const findToolRenderer = { const files = details?.files ?? []; if (fileCount === 0) { - return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0); + return new Text(ui.emptyMessage("No files found"), 0, 0); } const icon = uiTheme.styledSymbol("status.success", "success"); - const summaryText = formatCount("file", fileCount); - const scopeLabel = formatScope(details?.scopePath, uiTheme); + const summaryText = ui.count("file", fileCount); + const scopeLabel = ui.scope(details?.scopePath); const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT); const hasMoreFiles = files.length > maxFiles; - const expandHint = formatExpandHint(expanded, hasMoreFiles, uiTheme); + const expandHint = ui.expandHint(expanded, hasMoreFiles); - let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${scopeLabel}${expandHint}`; + let text = `${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`; const truncationReasons: string[] = []; if (details?.resultLimitReached) { @@ -380,7 +421,7 @@ export const findToolRenderer = { const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last; text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg( "muted", - formatMoreItems(files.length - maxFiles, "file", uiTheme), + ui.moreItems(files.length - maxFiles, "file"), )}`; } } diff --git a/packages/coding-agent/src/core/tools/gemini-image.ts b/packages/coding-agent/src/core/tools/gemini-image.ts index 26ed40bb7..7136f6d38 100644 --- a/packages/coding-agent/src/core/tools/gemini-image.ts +++ b/packages/coding-agent/src/core/tools/gemini-image.ts @@ -1,8 +1,7 @@ -import * as crypto from "node:crypto"; -import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type Static, Type } from "@sinclair/typebox"; +import { nanoid } from "nanoid"; import geminiImageDescription from "../../prompts/tools/gemini-image.md" with { type: "text" }; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime"; import type { CustomTool } from "../custom-tools/types"; @@ -311,14 +310,18 @@ function getExtensionForMime(mimeType: string): string { return map[mimeType] ?? "png"; } -function saveImageToTemp(image: InlineImageData): string { +async function saveImageToTemp(image: InlineImageData): Promise { const ext = getExtensionForMime(image.mimeType); - const filename = `omp-image-${crypto.randomUUID()}.${ext}`; + const filename = `omp-image-${nanoid()}.${ext}`; const filepath = join(tmpdir(), filename); - fs.writeFileSync(filepath, Buffer.from(image.data, "base64")); + await Bun.write(filepath, Buffer.from(image.data, "base64")); return filepath; } +async function saveImagesToTemp(images: InlineImageData[]): Promise { + return Promise.all(images.map(saveImageToTemp)); +} + function buildResponseSummary(model: string, imagePaths: string[], responseText: string | undefined): string { const lines = [`Model: ${model}`, `Generated ${imagePaths.length} image(s):`]; for (const p of imagePaths) { @@ -356,27 +359,9 @@ function combineParts(response: GeminiGenerateContentResponse): GeminiPart[] { return parts; } -function createAbortController( - signal: AbortSignal | undefined, - timeoutSeconds: number, -): { controller: AbortController; cleanup: () => void } { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000); - - let abortListener: (() => void) | undefined; - if (signal) { - abortListener = () => controller.abort(signal.reason); - signal.addEventListener("abort", abortListener, { once: true }); - } - - const cleanup = () => { - clearTimeout(timeout); - if (abortListener && signal) { - signal.removeEventListener("abort", abortListener); - } - }; - - return { controller, cleanup }; +function createRequestSignal(signal: AbortSignal | undefined, timeoutSeconds: number): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutSeconds * 1000); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; } export const geminiImageTool: CustomTool = { @@ -404,118 +389,28 @@ export const geminiImageTool: CustomTool; + if (provider === "openrouter") { + const contentParts: OpenRouterContentPart[] = [{ type: "text", text: params.prompt }]; for (const image of resolvedImages) { - parts.push({ inlineData: image }); - } - parts.push({ text: params.prompt }); - - const generationConfig: { - responseModalities: GeminiResponseModality[]; - imageConfig?: { aspectRatio?: string; imageSize?: string }; - } = { - responseModalities: ["Image"], - }; - - if (params.aspect_ratio || params.image_size) { - generationConfig.imageConfig = { - aspectRatio: params.aspect_ratio, - imageSize: params.image_size, - }; + contentParts.push({ type: "image_url", image_url: { url: toDataUrl(image) } }); } const requestBody = { - contents: [{ role: "user" as const, parts }], - generationConfig, + model: resolvedModel, + messages: [{ role: "user" as const, content: contentParts }], }; - const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": apiKey.apiKey, - }, - body: JSON.stringify(requestBody), - signal: controller.signal, + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey.apiKey}`, }, - ); + body: JSON.stringify(requestBody), + signal: requestSignal, + }); const rawText = await response.text(); if (!response.ok) { @@ -526,51 +421,137 @@ export const geminiImageTool: CustomTool; + for (const image of resolvedImages) { + parts.push({ inlineData: image }); + } + parts.push({ text: params.prompt }); + + const generationConfig: { + responseModalities: GeminiResponseModality[]; + imageConfig?: { aspectRatio?: string; imageSize?: string }; + } = { + responseModalities: ["Image"], + }; + + if (params.aspect_ratio || params.image_size) { + generationConfig.imageConfig = { + aspectRatio: params.aspect_ratio, + imageSize: params.image_size, + }; + } + + const requestBody = { + contents: [{ role: "user" as const, parts }], + generationConfig, + }; + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": apiKey.apiKey, + }, + body: JSON.stringify(requestBody), + signal: requestSignal, + }, + ); + + const rawText = await response.text(); + if (!response.ok) { + let message = rawText; + try { + const parsed = JSON.parse(rawText) as { error?: { message?: string } }; + message = parsed.error?.message ?? message; + } catch { + // Keep raw text. + } + throw new Error(`Gemini image request failed (${response.status}): ${message}`); + } + + const data = JSON.parse(rawText) as GeminiGenerateContentResponse; + const responseParts = combineParts(data); + const responseText = collectResponseText(responseParts); + const inlineImages = collectInlineImages(responseParts); + + if (inlineImages.length === 0) { + const blocked = data.promptFeedback?.blockReason + ? `Blocked: ${data.promptFeedback.blockReason}` + : "No image data returned."; + return { + content: [{ type: "text", text: `${blocked}${responseText ? `\n\n${responseText}` : ""}` }], + details: { + provider, + model, + imageCount: 0, + imagePaths: [], + images: [], + responseText, promptFeedback: data.promptFeedback, usage: data.usageMetadata, }, }; - } finally { - cleanup(); } + + const imagePaths = await saveImagesToTemp(inlineImages); + + return { + content: [{ type: "text", text: buildResponseSummary(model, imagePaths, responseText) }], + details: { + provider, + model, + imageCount: inlineImages.length, + imagePaths, + images: inlineImages, + responseText, + promptFeedback: data.promptFeedback, + usage: data.usageMetadata, + }, + }; }); }, }; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 86d33b79f..c5fbab730 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -1,4 +1,3 @@ -import { readFileSync, type Stats, statSync } from "node:fs"; import nodePath from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; @@ -9,19 +8,10 @@ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/t import grepDescription from "../../prompts/tools/grep.md" with { type: "text" }; import { ensureTool } from "../../utils/tools-manager"; import type { RenderResultOptions } from "../custom-tools/types"; +import { ScopeSignal, untilAborted } from "../utils"; import type { ToolSession } from "./index"; import { resolveToCwd } from "./path-utils"; -import { - formatCount, - formatEmptyMessage, - formatErrorMessage, - formatExpandHint, - formatMeta, - formatMoreItems, - formatScope, - formatTruncationSuffix, - PREVIEW_LIMITS, -} from "./render-utils"; +import { createToolUIKit, PREVIEW_LIMITS } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, @@ -119,155 +109,377 @@ export function createGrepTool(session: ToolSession): AgentTool { - if (signal?.aborted) { - throw new Error("Operation aborted"); - } - - const rgPath = await ensureTool("rg", true); - if (!rgPath) { - throw new Error("ripgrep (rg) is not available and could not be downloaded"); - } - - const searchPath = resolveToCwd(searchDir || ".", session.cwd); - const scopePath = (() => { - const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/"); - return relative.length === 0 ? "." : relative; - })(); - let searchStat: Stats; - try { - searchStat = statSync(searchPath); - } catch (_err) { - throw new Error(`Path not found: ${searchPath}`); - } - - const isDirectory = searchStat.isDirectory(); - const contextValue = context && context > 0 ? context : 0; - const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); - const effectiveOutputMode = outputMode ?? "content"; - const effectiveOffset = offset && offset > 0 ? offset : 0; - const hasHeadLimit = headLimit !== undefined && headLimit > 0; - - const formatPath = (filePath: string): string => { - if (isDirectory) { - const relative = nodePath.relative(searchPath, filePath); - if (relative && !relative.startsWith("..")) { - return relative.replace(/\\/g, "/"); - } + return untilAborted(signal, async () => { + const rgPath = await ensureTool("rg", true); + if (!rgPath) { + throw new Error("ripgrep (rg) is not available and could not be downloaded"); } - return nodePath.basename(filePath); - }; - const fileCache = new Map(); - const getFileLines = (filePath: string): string[] => { - let lines = fileCache.get(filePath); - if (!lines) { + const searchPath = resolveToCwd(searchDir || ".", session.cwd); + const scopePath = (() => { + const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/"); + return relative.length === 0 ? "." : relative; + })(); + let searchStat: Awaited>; + try { + searchStat = await Bun.file(searchPath).stat(); + } catch { + throw new Error(`Path not found: ${searchPath}`); + } + + const isDirectory = searchStat.isDirectory(); + const contextValue = context && context > 0 ? context : 0; + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + const effectiveOutputMode = outputMode ?? "content"; + const effectiveOffset = offset && offset > 0 ? offset : 0; + const hasHeadLimit = headLimit !== undefined && headLimit > 0; + + const formatPath = (filePath: string): string => { + if (isDirectory) { + const relative = nodePath.relative(searchPath, filePath); + if (relative && !relative.startsWith("..")) { + return relative.replace(/\\/g, "/"); + } + } + return nodePath.basename(filePath); + }; + + const fileCache = new Map>(); + const getFileLines = async (filePath: string): Promise => { + let linesPromise = fileCache.get(filePath); + if (!linesPromise) { + linesPromise = (async () => { + try { + const content = await Bun.file(filePath).text(); + return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + } catch { + return []; + } + })(); + fileCache.set(filePath, linesPromise); + } + return linesPromise; + }; + + const args: string[] = []; + + // Base arguments depend on output mode + if (effectiveOutputMode === "files_with_matches") { + args.push("--files-with-matches", "--color=never", "--hidden"); + } else if (effectiveOutputMode === "count") { + args.push("--count", "--color=never", "--hidden"); + } else { + args.push("--json", "--line-number", "--color=never", "--hidden"); + } + + if (caseSensitive) { + args.push("--case-sensitive"); + } else if (ignoreCase) { + args.push("--ignore-case"); + } else { + args.push("--smart-case"); + } + + if (multiline) { + args.push("--multiline"); + } + + if (literal) { + args.push("--fixed-strings"); + } + + if (glob) { + args.push("--glob", glob); + } + + if (type) { + args.push("--type", type); + } + + args.push(pattern, searchPath); + + const child: Subprocess = Bun.spawn([rgPath, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let stderr = ""; + let matchCount = 0; + let matchLimitReached = false; + let linesTruncated = false; + let aborted = false; + let killedDueToLimit = false; + const outputLines: string[] = []; + const files = new Set(); + const fileList: string[] = []; + const fileMatchCounts = new Map(); + + const recordFile = (filePath: string) => { + const relative = formatPath(filePath); + if (!files.has(relative)) { + files.add(relative); + fileList.push(relative); + } + }; + + const recordFileMatch = (filePath: string) => { + const relative = formatPath(filePath); + fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1); + }; + + const stopChild = (dueToLimit: boolean = false) => { + killedDueToLimit = dueToLimit; + child.kill(); + }; + + using signalScope = new ScopeSignal(signal ? { signal } : undefined); + signalScope.catch(() => { + aborted = true; + stopChild(); + }); + + // For simple output modes (files_with_matches, count), process text directly + if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") { + const stdoutReader = (child.stdout as ReadableStream).getReader(); + const stderrReader = (child.stderr as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + + await Promise.all([ + (async () => { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + })(), + (async () => { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + })(), + ]); + + const exitCode = await child.exited; + + if (aborted) { + throw new Error("Operation aborted"); + } + + if (exitCode !== 0 && exitCode !== 1) { + const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`; + throw new Error(errorMsg); + } + + const lines = stdout + .trim() + .split("\n") + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return { + content: [{ type: "text", text: "No matches found" }], + details: { + scopePath, + matchCount: 0, + fileCount: 0, + files: [], + mode: effectiveOutputMode, + truncated: false, + }, + }; + } + + // Apply offset and headLimit + let processedLines = lines; + if (effectiveOffset > 0) { + processedLines = processedLines.slice(effectiveOffset); + } + if (hasHeadLimit) { + processedLines = processedLines.slice(0, headLimit); + } + + let simpleMatchCount = 0; + let fileCount = 0; + const simpleFiles = new Set(); + const simpleFileList: string[] = []; + const simpleFileMatchCounts = new Map(); + + const recordSimpleFile = (filePath: string) => { + const relative = formatPath(filePath); + if (!simpleFiles.has(relative)) { + simpleFiles.add(relative); + simpleFileList.push(relative); + } + }; + + // Count mode: ripgrep provides total count per file, so we set directly (not increment) + const setFileMatchCount = (filePath: string, count: number) => { + const relative = formatPath(filePath); + simpleFileMatchCounts.set(relative, count); + }; + + if (effectiveOutputMode === "files_with_matches") { + for (const line of lines) { + recordSimpleFile(line); + } + fileCount = simpleFiles.size; + simpleMatchCount = fileCount; + } else { + for (const line of lines) { + const separatorIndex = line.lastIndexOf(":"); + const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex); + const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1); + const count = Number.parseInt(countPart, 10); + recordSimpleFile(filePart); + if (!Number.isNaN(count)) { + simpleMatchCount += count; + setFileMatchCount(filePart, count); + } + } + fileCount = simpleFiles.size; + } + + const truncatedByHeadLimit = hasHeadLimit && processedLines.length < lines.length; + + // For count mode, format as "path:count" + if (effectiveOutputMode === "count") { + const formatted = processedLines.map((line) => { + const separatorIndex = line.lastIndexOf(":"); + const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex)); + const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1); + return `${relative}:${count}`; + }); + const output = formatted.join("\n"); + return { + content: [{ type: "text", text: output }], + details: { + scopePath, + matchCount: simpleMatchCount, + fileCount, + files: simpleFileList, + fileMatches: simpleFileList.map((path) => ({ + path, + count: simpleFileMatchCounts.get(path) ?? 0, + })), + mode: effectiveOutputMode, + truncated: truncatedByHeadLimit, + headLimitReached: truncatedByHeadLimit ? headLimit : undefined, + }, + }; + } + + // For files_with_matches, format paths + const formatted = processedLines.map((line) => formatPath(line)); + const output = formatted.join("\n"); + return { + content: [{ type: "text", text: output }], + details: { + scopePath, + matchCount: simpleMatchCount, + fileCount, + files: simpleFileList, + mode: effectiveOutputMode, + truncated: truncatedByHeadLimit, + headLimitReached: truncatedByHeadLimit ? headLimit : undefined, + }, + }; + } + + // Content mode - existing JSON processing + const formatBlock = async (filePath: string, lineNumber: number): Promise => { + const relativePath = formatPath(filePath); + const lines = await getFileLines(filePath); + if (!lines.length) { + return [`${relativePath}:${lineNumber}: (unable to read file)`]; + } + + const block: string[] = []; + const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber; + const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber; + + for (let current = start; current <= end; current++) { + const lineText = lines[current - 1] ?? ""; + const sanitized = lineText.replace(/\r/g, ""); + const isMatchLine = current === lineNumber; + + const { text: truncatedText, wasTruncated } = truncateLine(sanitized); + if (wasTruncated) { + linesTruncated = true; + } + + if (isMatchLine) { + block.push(`${relativePath}:${current}: ${truncatedText}`); + } else { + block.push(`${relativePath}-${current}- ${truncatedText}`); + } + } + + return block; + }; + + const processLine = async (line: string): Promise => { + if (!line.trim() || matchCount >= effectiveLimit) { + return; + } + + let event: { type: string; data?: { path?: { text?: string }; line_number?: number } }; try { - const content = readFileSync(filePath, "utf-8"); - lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + event = JSON.parse(line); } catch { - lines = []; + return; } - fileCache.set(filePath, lines); - } - return lines; - }; - const args: string[] = []; + if (event.type === "match") { + matchCount++; + const filePath = event.data?.path?.text; + const lineNumber = event.data?.line_number; - // Base arguments depend on output mode - if (effectiveOutputMode === "files_with_matches") { - args.push("--files-with-matches", "--color=never", "--hidden"); - } else if (effectiveOutputMode === "count") { - args.push("--count", "--color=never", "--hidden"); - } else { - args.push("--json", "--line-number", "--color=never", "--hidden"); - } + if (filePath && typeof lineNumber === "number") { + recordFile(filePath); + recordFileMatch(filePath); + const block = await formatBlock(filePath, lineNumber); + outputLines.push(...block); + } - if (caseSensitive) { - args.push("--case-sensitive"); - } else if (ignoreCase) { - args.push("--ignore-case"); - } else { - args.push("--smart-case"); - } + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + stopChild(true); + } + } + }; - if (multiline) { - args.push("--multiline"); - } - - if (literal) { - args.push("--fixed-strings"); - } - - if (glob) { - args.push("--glob", glob); - } - - if (type) { - args.push("--type", type); - } - - args.push(pattern, searchPath); - - const child: Subprocess = Bun.spawn([rgPath, ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - let stderr = ""; - let matchCount = 0; - let matchLimitReached = false; - let linesTruncated = false; - let aborted = false; - let killedDueToLimit = false; - const outputLines: string[] = []; - const files = new Set(); - const fileList: string[] = []; - const fileMatchCounts = new Map(); - - const recordFile = (filePath: string) => { - const relative = formatPath(filePath); - if (!files.has(relative)) { - files.add(relative); - fileList.push(relative); - } - }; - - const recordFileMatch = (filePath: string) => { - const relative = formatPath(filePath); - fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1); - }; - - const stopChild = (dueToLimit: boolean = false) => { - killedDueToLimit = dueToLimit; - child.kill(); - }; - - const onAbort = () => { - aborted = true; - stopChild(); - }; - - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - - // For simple output modes (files_with_matches, count), process text directly - if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") { + // Read streams using Bun's ReadableStream API const stdoutReader = (child.stdout as ReadableStream).getReader(); const stderrReader = (child.stderr as ReadableStream).getReader(); const decoder = new TextDecoder(); - let stdout = ""; + let stdoutBuffer = ""; await Promise.all([ + // Process stdout line by line (async () => { while (true) { const { done, value } = await stdoutReader.read(); if (done) break; - stdout += decoder.decode(value, { stream: true }); + + stdoutBuffer += decoder.decode(value, { stream: true }); + const lines = stdoutBuffer.split("\n"); + // Keep the last incomplete line in the buffer + stdoutBuffer = lines.pop() ?? ""; + + for (const line of lines) { + await processLine(line); + } + } + // Process any remaining content + if (stdoutBuffer.trim()) { + await processLine(stdoutBuffer); } })(), + // Collect stderr (async () => { while (true) { const { done, value } = await stderrReader.read(); @@ -279,25 +491,16 @@ export function createGrepTool(session: ToolSession): AgentTool line.length > 0); - - if (lines.length === 0) { + if (matchCount === 0) { return { content: [{ type: "text", text: "No matches found" }], details: { @@ -311,8 +514,8 @@ export function createGrepTool(session: ToolSession): AgentTool 0) { processedLines = processedLines.slice(effectiveOffset); } @@ -320,278 +523,55 @@ export function createGrepTool(session: ToolSession): AgentTool(); - const simpleFileList: string[] = []; - const simpleFileMatchCounts = new Map(); + // Apply byte truncation (no line limit since we already have match limit) + const rawOutput = processedLines.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - const recordSimpleFile = (filePath: string) => { - const relative = formatPath(filePath); - if (!simpleFiles.has(relative)) { - simpleFiles.add(relative); - simpleFileList.push(relative); - } + let output = truncation.content; + const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length; + const details: GrepToolDetails = { + scopePath, + matchCount, + fileCount: files.size, + files: fileList, + fileMatches: fileList.map((path) => ({ + path, + count: fileMatchCounts.get(path) ?? 0, + })), + mode: effectiveOutputMode, + truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit, + headLimitReached: truncatedByHeadLimit ? headLimit : undefined, }; - const recordSimpleFileMatch = (filePath: string, count: number) => { - const relative = formatPath(filePath); - simpleFileMatchCounts.set(relative, count); - }; + // Build notices + const notices: string[] = []; - if (effectiveOutputMode === "files_with_matches") { - for (const line of lines) { - recordSimpleFile(line); - } - fileCount = simpleFiles.size; - simpleMatchCount = fileCount; - } else { - for (const line of lines) { - const separatorIndex = line.lastIndexOf(":"); - const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex); - const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1); - const count = Number.parseInt(countPart, 10); - recordSimpleFile(filePart); - if (!Number.isNaN(count)) { - simpleMatchCount += count; - recordSimpleFileMatch(filePart, count); - } - } - fileCount = simpleFiles.size; + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.matchLimitReached = effectiveLimit; } - const truncatedByHeadLimit = hasHeadLimit && processedLines.length < lines.length; - - // For count mode, format as "path:count" - if (effectiveOutputMode === "count") { - const formatted = processedLines.map((line) => { - const separatorIndex = line.lastIndexOf(":"); - const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex)); - const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1); - return `${relative}:${count}`; - }); - const output = formatted.join("\n"); - return { - content: [{ type: "text", text: output }], - details: { - scopePath, - matchCount: simpleMatchCount, - fileCount, - files: simpleFileList, - fileMatches: simpleFileList.map((path) => ({ - path, - count: simpleFileMatchCounts.get(path) ?? 0, - })), - mode: effectiveOutputMode, - truncated: truncatedByHeadLimit, - headLimitReached: truncatedByHeadLimit ? headLimit : undefined, - }, - }; + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (linesTruncated) { + notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`); + details.linesTruncated = true; + } + + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; } - // For files_with_matches, format paths - const formatted = processedLines.map((line) => formatPath(line)); - const output = formatted.join("\n"); return { content: [{ type: "text", text: output }], - details: { - scopePath, - matchCount: simpleMatchCount, - fileCount, - files: simpleFileList, - mode: effectiveOutputMode, - truncated: truncatedByHeadLimit, - headLimitReached: truncatedByHeadLimit ? headLimit : undefined, - }, + details: Object.keys(details).length > 0 ? details : undefined, }; - } - - // Content mode - existing JSON processing - const formatBlock = (filePath: string, lineNumber: number): string[] => { - const relativePath = formatPath(filePath); - const lines = getFileLines(filePath); - if (!lines.length) { - return [`${relativePath}:${lineNumber}: (unable to read file)`]; - } - - const block: string[] = []; - const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber; - const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber; - - for (let current = start; current <= end; current++) { - const lineText = lines[current - 1] ?? ""; - const sanitized = lineText.replace(/\r/g, ""); - const isMatchLine = current === lineNumber; - - const { text: truncatedText, wasTruncated } = truncateLine(sanitized); - if (wasTruncated) { - linesTruncated = true; - } - - if (isMatchLine) { - block.push(`${relativePath}:${current}: ${truncatedText}`); - } else { - block.push(`${relativePath}-${current}- ${truncatedText}`); - } - } - - return block; - }; - - const processLine = (line: string) => { - if (!line.trim() || matchCount >= effectiveLimit) { - return; - } - - let event: { type: string; data?: { path?: { text?: string }; line_number?: number } }; - try { - event = JSON.parse(line); - } catch { - return; - } - - if (event.type === "match") { - matchCount++; - const filePath = event.data?.path?.text; - const lineNumber = event.data?.line_number; - - if (filePath && typeof lineNumber === "number") { - recordFile(filePath); - recordFileMatch(filePath); - outputLines.push(...formatBlock(filePath, lineNumber)); - } - - if (matchCount >= effectiveLimit) { - matchLimitReached = true; - stopChild(true); - } - } - }; - - // Read streams using Bun's ReadableStream API - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - let stdoutBuffer = ""; - - await Promise.all([ - // Process stdout line by line - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - - stdoutBuffer += decoder.decode(value, { stream: true }); - const lines = stdoutBuffer.split("\n"); - // Keep the last incomplete line in the buffer - stdoutBuffer = lines.pop() ?? ""; - - for (const line of lines) { - processLine(line); - } - } - // Process any remaining content - if (stdoutBuffer.trim()) { - processLine(stdoutBuffer); - } - })(), - // Collect stderr - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - })(), - ]); - - const exitCode = await child.exited; - - // Cleanup - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (aborted) { - throw new Error("Operation aborted"); - } - - if (!killedDueToLimit && exitCode !== 0 && exitCode !== 1) { - const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`; - throw new Error(errorMsg); - } - - if (matchCount === 0) { - return { - content: [{ type: "text", text: "No matches found" }], - details: { - scopePath, - matchCount: 0, - fileCount: 0, - files: [], - mode: effectiveOutputMode, - truncated: false, - }, - }; - } - - // Apply offset and headLimit to output lines - let processedLines = outputLines; - if (effectiveOffset > 0) { - processedLines = processedLines.slice(effectiveOffset); - } - if (hasHeadLimit) { - processedLines = processedLines.slice(0, headLimit); - } - - // Apply byte truncation (no line limit since we already have match limit) - const rawOutput = processedLines.join("\n"); - const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - - let output = truncation.content; - const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length; - const details: GrepToolDetails = { - scopePath, - matchCount, - fileCount: files.size, - files: fileList, - fileMatches: fileList.map((path) => ({ - path, - count: fileMatchCounts.get(path) ?? 0, - })), - mode: effectiveOutputMode, - truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit, - headLimitReached: truncatedByHeadLimit ? headLimit : undefined, - }; - - // Build notices - const notices: string[] = []; - - if (matchLimitReached) { - notices.push( - `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, - ); - details.matchLimitReached = effectiveLimit; - } - - if (truncation.truncated) { - notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); - details.truncation = truncation; - } - - if (linesTruncated) { - notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`); - details.linesTruncated = true; - } - - if (notices.length > 0) { - output += `\n\n[${notices.join(". ")}]`; - } - - return { - content: [{ type: "text", text: output }], - details: Object.keys(details).length > 0 ? details : undefined, - }; + }); }, }; } @@ -619,7 +599,8 @@ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; export const grepToolRenderer = { renderCall(args: GrepRenderArgs, uiTheme: Theme): Component { - const label = uiTheme.fg("toolTitle", uiTheme.bold("Grep")); + const ui = createToolUIKit(uiTheme); + const label = ui.title("Grep"); let text = `${label} ${uiTheme.fg("accent", args.pattern || "?")}`; const meta: string[] = []; @@ -637,7 +618,7 @@ export const grepToolRenderer = { if (args.context !== undefined) meta.push(`context:${args.context}`); if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - text += formatMeta(meta, uiTheme); + text += ui.meta(meta); return new Text(text, 0, 0); }, @@ -647,10 +628,11 @@ export const grepToolRenderer = { { expanded }: RenderResultOptions, uiTheme: Theme, ): Component { + const ui = createToolUIKit(uiTheme); const details = result.details; if (details?.error) { - return new Text(formatErrorMessage(details.error, uiTheme), 0, 0); + return new Text(ui.errorMessage(details.error), 0, 0); } const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined; @@ -658,7 +640,7 @@ export const grepToolRenderer = { if (!hasDetailedData) { const textContent = result.content?.find((c) => c.type === "text")?.text; if (!textContent || textContent === "No matches found") { - return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0); + return new Text(ui.emptyMessage("No matches found"), 0, 0); } const lines = textContent.split("\n").filter((line) => line.trim() !== ""); @@ -668,8 +650,8 @@ export const grepToolRenderer = { const hasMore = remaining > 0; const icon = uiTheme.styledSymbol("status.success", "success"); - const summary = formatCount("item", lines.length); - const expandHint = formatExpandHint(expanded, hasMore, uiTheme); + const summary = ui.count("item", lines.length); + const expandHint = ui.expandHint(expanded, hasMore); let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`; for (let i = 0; i < displayLines.length; i++) { @@ -679,7 +661,7 @@ export const grepToolRenderer = { } if (remaining > 0) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", formatMoreItems(remaining, "item", uiTheme))}`; + text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "item"))}`; } return new Text(text, 0, 0); @@ -692,25 +674,25 @@ export const grepToolRenderer = { const files = details?.files ?? []; if (matchCount === 0) { - return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0); + return new Text(ui.emptyMessage("No matches found"), 0, 0); } const icon = uiTheme.styledSymbol("status.success", "success"); const summaryParts = mode === "files_with_matches" - ? [formatCount("file", fileCount)] - : [formatCount("match", matchCount), formatCount("file", fileCount)]; + ? [ui.count("file", fileCount)] + : [ui.count("match", matchCount), ui.count("file", fileCount)]; const summaryText = summaryParts.join(uiTheme.sep.dot); - const scopeLabel = formatScope(details?.scopePath, uiTheme); + const scopeLabel = ui.scope(details?.scopePath); const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count })) : files.map((path) => ({ path })); const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT); const hasMoreFiles = fileEntries.length > maxFiles; - const expandHint = formatExpandHint(expanded, hasMoreFiles, uiTheme); + const expandHint = ui.expandHint(expanded, hasMoreFiles); - let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${scopeLabel}${expandHint}`; + let text = `${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`; const truncationReasons: string[] = []; if (details?.matchLimitReached) { @@ -750,7 +732,7 @@ export const grepToolRenderer = { const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last; text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg( "muted", - formatMoreItems(fileEntries.length - maxFiles, "file", uiTheme), + ui.moreItems(fileEntries.length - maxFiles, "file"), )}`; } } diff --git a/packages/coding-agent/src/core/tools/index.test.ts b/packages/coding-agent/src/core/tools/index.test.ts index 6810131c2..6765f9b89 100644 --- a/packages/coding-agent/src/core/tools/index.test.ts +++ b/packages/coding-agent/src/core/tools/index.test.ts @@ -110,6 +110,8 @@ describe("createTools", () => { getEditFuzzyMatch: () => true, getGitToolEnabled: () => false, getBashInterceptorEnabled: () => true, + getBashInterceptorSimpleLsEnabled: () => true, + getBashInterceptorRules: () => [], }, }); const tools = await createTools(session); @@ -128,6 +130,8 @@ describe("createTools", () => { getEditFuzzyMatch: () => true, getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, + getBashInterceptorSimpleLsEnabled: () => true, + getBashInterceptorRules: () => [], }, }); const tools = await createTools(session); @@ -183,6 +187,6 @@ describe("createTools", () => { }); it("HIDDEN_TOOLS contains review tools", () => { - expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "report_finding", "submit_review"]); + expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual(["complete", "report_finding"]); }); }); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index c57fcd98a..0801d7c9c 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -24,7 +24,7 @@ export { export { createNotebookTool, type NotebookToolDetails } from "./notebook"; export { createOutputTool, type OutputToolDetails } from "./output"; export { createReadTool, type ReadToolDetails } from "./read"; -export { reportFindingTool, submitReviewTool } from "./review"; +export { reportFindingTool, type SubmitReviewDetails } from "./review"; export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook"; export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index"; export type { TruncationResult } from "./truncate"; @@ -53,6 +53,7 @@ export { createWriteTool, type WriteToolDetails } from "./write"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Rule } from "../../capability/rule"; import type { EventBus } from "../event-bus"; +import type { BashInterceptorRule } from "../settings-manager"; import { createAskTool } from "./ask"; import { createBashTool } from "./bash"; import { createCompleteTool } from "./complete"; @@ -65,7 +66,7 @@ import { createLspTool } from "./lsp/index"; import { createNotebookTool } from "./notebook"; import { createOutputTool } from "./output"; import { createReadTool } from "./read"; -import { reportFindingTool, submitReviewTool } from "./review"; +import { reportFindingTool } from "./review"; import { createRulebookTool } from "./rulebook"; import { createTaskTool } from "./task/index"; import { createWebFetchTool } from "./web-fetch"; @@ -102,6 +103,8 @@ export interface ToolSession { getEditFuzzyMatch(): boolean; getGitToolEnabled(): boolean; getBashInterceptorEnabled(): boolean; + getBashInterceptorSimpleLsEnabled(): boolean; + getBashInterceptorRules(): BashInterceptorRule[]; }; } @@ -129,7 +132,6 @@ export const BUILTIN_TOOLS: Record = { export const HIDDEN_TOOLS: Record = { complete: createCompleteTool, report_finding: () => reportFindingTool, - submit_review: () => submitReviewTool, }; export type ToolName = keyof typeof BUILTIN_TOOLS; diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 7f30962e4..1ef9caa0f 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,4 +1,3 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; import nodePath from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; @@ -53,23 +52,25 @@ export function createLsTool(session: ToolSession): AgentTool { const dirPath = resolveToCwd(path || ".", session.cwd); const effectiveLimit = limit ?? DEFAULT_LIMIT; - // Check if path exists - if (!existsSync(dirPath)) { + // Check if path exists and is a directory + let dirStat: Awaited>; + try { + dirStat = await Bun.file(dirPath).stat(); + } catch { throw new Error(`Path not found: ${dirPath}`); } - // Check if path is a directory - const stat = statSync(dirPath); - if (!stat.isDirectory()) { + if (!dirStat.isDirectory()) { throw new Error(`Not a directory: ${dirPath}`); } // Read directory entries let entries: string[]; try { - entries = readdirSync(dirPath); - } catch (e: any) { - throw new Error(`Cannot read directory: ${e.message}`); + entries = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: dirPath, dot: true, onlyFiles: false })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot read directory: ${message}`); } // Sort alphabetically (case-insensitive) @@ -82,6 +83,7 @@ export function createLsTool(session: ToolSession): AgentTool { let fileCount = 0; for (const entry of entries) { + signal?.throwIfAborted(); if (results.length >= effectiveLimit) { entryLimitReached = true; break; @@ -92,7 +94,7 @@ export function createLsTool(session: ToolSession): AgentTool { let age = ""; try { - const entryStat = statSync(fullPath); + const entryStat = await Bun.file(fullPath).stat(); if (entryStat.isDirectory()) { suffix = "/"; dirCount += 1; diff --git a/packages/coding-agent/src/core/tools/lsp/client.ts b/packages/coding-agent/src/core/tools/lsp/client.ts index 38f0ac4ec..0a1514168 100644 --- a/packages/coding-agent/src/core/tools/lsp/client.ts +++ b/packages/coding-agent/src/core/tools/lsp/client.ts @@ -266,6 +266,7 @@ async function startMessageReader(client: LspClient): Promise { if (message.method === "textDocument/publishDiagnostics" && message.params) { const params = message.params as { uri: string; diagnostics: Diagnostic[] }; client.diagnostics.set(params.uri, params.diagnostics); + client.diagnosticsVersion += 1; } } @@ -408,6 +409,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom config, requestId: 0, diagnostics: new Map(), + diagnosticsVersion: 0, openFiles: new Map(), pendingRequests: new Map(), messageBuffer: new Uint8Array(0), @@ -516,9 +518,15 @@ export async function ensureFileOpen(client: LspClient, filePath: string): Promi * Sync in-memory content to the LSP client without reading from disk. * Use this to provide instant feedback during edits before the file is saved. */ -export async function syncContent(client: LspClient, filePath: string, content: string): Promise { +export async function syncContent( + client: LspClient, + filePath: string, + content: string, + signal?: AbortSignal, +): Promise { const uri = fileToUri(filePath); const lockKey = `${client.name}:${uri}`; + signal?.throwIfAborted(); const existingLock = fileOperationLocks.get(lockKey); if (existingLock) { @@ -534,6 +542,7 @@ export async function syncContent(client: LspClient, filePath: string, content: if (!info) { // Open file with provided content instead of reading from disk const languageId = detectLanguageId(filePath); + signal?.throwIfAborted(); await sendNotification(client, "textDocument/didOpen", { textDocument: { uri, @@ -548,6 +557,7 @@ export async function syncContent(client: LspClient, filePath: string, content: } const version = ++info.version; + signal?.throwIfAborted(); await sendNotification(client, "textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text: content }], @@ -567,11 +577,12 @@ export async function syncContent(client: LspClient, filePath: string, content: * Notify LSP that a file was saved. * Assumes content was already synced via syncContent - just sends didSave. */ -export async function notifySaved(client: LspClient, filePath: string): Promise { +export async function notifySaved(client: LspClient, filePath: string, signal?: AbortSignal): Promise { const uri = fileToUri(filePath); const info = client.openFiles.get(uri); if (!info) return; // File not open, nothing to notify + signal?.throwIfAborted(); await sendNotification(client, "textDocument/didSave", { textDocument: { uri }, }); @@ -653,9 +664,18 @@ export function shutdownClient(key: string): void { /** * Send an LSP request and wait for response. */ -export async function sendRequest(client: LspClient, method: string, params: unknown): Promise { +export async function sendRequest( + client: LspClient, + method: string, + params: unknown, + signal?: AbortSignal, +): Promise { // Atomically increment and capture request ID const id = ++client.requestId; + if (signal?.aborted) { + const reason = signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"); + return Promise.reject(reason); + } const request: LspJsonRpcRequest = { jsonrpc: "2.0", @@ -667,22 +687,49 @@ export async function sendRequest(client: LspClient, method: string, params: unk client.lastActivity = Date.now(); return new Promise((resolve, reject) => { - // Set timeout - const timeout = setTimeout(() => { + let timeout: ReturnType | undefined; + const cleanup = () => { + if (signal) { + signal.removeEventListener("abort", abortHandler); + } + }; + const abortHandler = () => { if (client.pendingRequests.has(id)) { client.pendingRequests.delete(id); - reject(new Error(`LSP request ${method} timed out`)); + } + if (timeout) clearTimeout(timeout); + cleanup(); + const reason = signal?.reason instanceof Error ? signal.reason : new Error("Operation aborted"); + reject(reason); + }; + + // Set timeout + timeout = setTimeout(() => { + if (client.pendingRequests.has(id)) { + client.pendingRequests.delete(id); + const err = new Error(`LSP request ${method} timed out`); + cleanup(); + reject(err); } }, 30000); + if (signal) { + signal.addEventListener("abort", abortHandler, { once: true }); + if (signal.aborted) { + abortHandler(); + return; + } + } // Register pending request with timeout wrapper client.pendingRequests.set(id, { resolve: (result) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); + cleanup(); resolve(result); }, reject: (err) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); + cleanup(); reject(err); }, method, @@ -690,8 +737,9 @@ export async function sendRequest(client: LspClient, method: string, params: unk // Write request writeMessage(client.process.stdin as import("bun").FileSink, request).catch((err) => { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); client.pendingRequests.delete(id); + cleanup(); reject(err); }); }); diff --git a/packages/coding-agent/src/core/tools/lsp/config.ts b/packages/coding-agent/src/core/tools/lsp/config.ts index 976188774..6ee981721 100644 --- a/packages/coding-agent/src/core/tools/lsp/config.ts +++ b/packages/coding-agent/src/core/tools/lsp/config.ts @@ -1,8 +1,10 @@ -import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { extname, join } from "node:path"; +import { basename, extname, join } from "node:path"; +import { globSync } from "glob"; +import { parse as parseYaml } from "yaml"; import { getConfigDirPaths } from "../../../config"; import { createBiomeClient } from "./clients/biome-client"; +import DEFAULTS from "./defaults.json" with { type: "json" }; import type { ServerConfig } from "./types"; export interface LspConfig { @@ -12,599 +14,138 @@ export interface LspConfig { } // ============================================================================= -// Predefined Server Configurations +// Default Server Configuration Loading // ============================================================================= -/** - * Comprehensive LSP server configurations. - * - * Each server can be customized via lsp.json config file with these options: - * - command: Binary name or path - * - args: Command line arguments - * - fileTypes: File extensions this server handles - * - rootMarkers: Files that indicate project root - * - initOptions: LSP initialization options - * - settings: LSP workspace settings - * - disabled: Set to true to disable this server - * - isLinter: If true, used only for diagnostics/actions (not type intelligence) - */ -export const SERVERS: Record = { - // ========================================================================= - // Systems Languages - // ========================================================================= - - "rust-analyzer": { - command: "rust-analyzer", - args: [], - fileTypes: [".rs"], - rootMarkers: ["Cargo.toml", "rust-analyzer.toml"], - initOptions: { - checkOnSave: { command: "clippy" }, - cargo: { allFeatures: true }, - procMacro: { enable: true }, - }, - settings: { - "rust-analyzer": { - diagnostics: { enable: true }, - inlayHints: { enable: true }, - }, - }, - capabilities: { - flycheck: true, - ssr: true, - expandMacro: true, - runnables: true, - relatedTests: true, - }, - }, - - clangd: { - command: "clangd", - args: ["--background-index", "--clang-tidy", "--header-insertion=iwyu"], - fileTypes: [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx", ".m", ".mm"], - rootMarkers: ["compile_commands.json", "CMakeLists.txt", ".clangd", ".clang-format", "Makefile"], - }, - - zls: { - command: "zls", - args: [], - fileTypes: [".zig"], - rootMarkers: ["build.zig", "build.zig.zon", "zls.json"], - }, - - gopls: { - command: "gopls", - args: ["serve"], - fileTypes: [".go", ".mod", ".sum"], - rootMarkers: ["go.mod", "go.work", "go.sum"], - settings: { - gopls: { - analyses: { unusedparams: true, shadow: true }, - staticcheck: true, - gofumpt: true, - }, - }, - }, - - // ========================================================================= - // JavaScript/TypeScript Ecosystem - // ========================================================================= - - "typescript-language-server": { - command: "typescript-language-server", - args: ["--stdio"], - fileTypes: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], - rootMarkers: ["package.json", "tsconfig.json", "jsconfig.json"], - initOptions: { - hostInfo: "omp-coding-agent", - preferences: { - includeInlayParameterNameHints: "all", - includeInlayVariableTypeHints: true, - includeInlayFunctionParameterTypeHints: true, - }, - }, - }, - - biome: { - command: "biome", - args: ["lsp-proxy"], - fileTypes: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc"], - rootMarkers: ["biome.json", "biome.jsonc"], - isLinter: true, - // Use CLI instead of LSP - Biome's LSP has known stale diagnostics issues - createClient: createBiomeClient, - }, - - eslint: { - command: "vscode-eslint-language-server", - args: ["--stdio"], - fileTypes: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"], - rootMarkers: [ - ".eslintrc", - ".eslintrc.js", - ".eslintrc.json", - ".eslintrc.yml", - "eslint.config.js", - "eslint.config.mjs", - ], - isLinter: true, - settings: { - validate: "on", - run: "onType", - }, - }, - - denols: { - command: "deno", - args: ["lsp"], - fileTypes: [".ts", ".tsx", ".js", ".jsx"], - rootMarkers: ["deno.json", "deno.jsonc", "deno.lock"], - initOptions: { - enable: true, - lint: true, - unstable: true, - }, - }, - - // ========================================================================= - // Web Technologies - // ========================================================================= - - "vscode-html-language-server": { - command: "vscode-html-language-server", - args: ["--stdio"], - fileTypes: [".html", ".htm"], - rootMarkers: ["package.json", ".git"], - initOptions: { - provideFormatter: true, - }, - }, - - "vscode-css-language-server": { - command: "vscode-css-language-server", - args: ["--stdio"], - fileTypes: [".css", ".scss", ".sass", ".less"], - rootMarkers: ["package.json", ".git"], - initOptions: { - provideFormatter: true, - }, - }, - - "vscode-json-language-server": { - command: "vscode-json-language-server", - args: ["--stdio"], - fileTypes: [".json", ".jsonc"], - rootMarkers: ["package.json", ".git"], - initOptions: { - provideFormatter: true, - }, - }, - - tailwindcss: { - command: "tailwindcss-language-server", - args: ["--stdio"], - fileTypes: [".html", ".css", ".scss", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"], - rootMarkers: ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"], - }, - - svelte: { - command: "svelteserver", - args: ["--stdio"], - fileTypes: [".svelte"], - rootMarkers: ["svelte.config.js", "svelte.config.mjs", "package.json"], - }, - - "vue-language-server": { - command: "vue-language-server", - args: ["--stdio"], - fileTypes: [".vue"], - rootMarkers: ["vue.config.js", "nuxt.config.js", "nuxt.config.ts", "package.json"], - }, - - astro: { - command: "astro-ls", - args: ["--stdio"], - fileTypes: [".astro"], - rootMarkers: ["astro.config.mjs", "astro.config.js", "astro.config.ts"], - }, - - // ========================================================================= - // Python - // ========================================================================= - - pyright: { - command: "pyright-langserver", - args: ["--stdio"], - fileTypes: [".py", ".pyi"], - rootMarkers: ["pyproject.toml", "pyrightconfig.json", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"], - settings: { - python: { - analysis: { - autoSearchPaths: true, - diagnosticMode: "openFilesOnly", - useLibraryCodeForTypes: true, - }, - }, - }, - }, - - basedpyright: { - command: "basedpyright-langserver", - args: ["--stdio"], - fileTypes: [".py", ".pyi"], - rootMarkers: ["pyproject.toml", "pyrightconfig.json", "setup.py", "requirements.txt"], - settings: { - basedpyright: { - analysis: { - autoSearchPaths: true, - diagnosticMode: "openFilesOnly", - useLibraryCodeForTypes: true, - }, - }, - }, - }, - - pylsp: { - command: "pylsp", - args: [], - fileTypes: [".py"], - rootMarkers: ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"], - }, - - ruff: { - command: "ruff", - args: ["server"], - fileTypes: [".py", ".pyi"], - rootMarkers: ["pyproject.toml", "ruff.toml", ".ruff.toml"], - isLinter: true, - }, - - // ========================================================================= - // JVM Languages - // ========================================================================= - - jdtls: { - command: "jdtls", - args: [], - fileTypes: [".java"], - rootMarkers: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", ".project"], - }, - - "kotlin-language-server": { - command: "kotlin-language-server", - args: [], - fileTypes: [".kt", ".kts"], - rootMarkers: ["build.gradle", "build.gradle.kts", "pom.xml", "settings.gradle", "settings.gradle.kts"], - }, - - metals: { - command: "metals", - args: [], - fileTypes: [".scala", ".sbt", ".sc"], - rootMarkers: ["build.sbt", "build.sc", "build.gradle", "pom.xml"], - initOptions: { - statusBarProvider: "show-message", - isHttpEnabled: true, - }, - }, - - // ========================================================================= - // Functional Languages - // ========================================================================= - - hls: { - command: "haskell-language-server-wrapper", - args: ["--lsp"], - fileTypes: [".hs", ".lhs"], - rootMarkers: ["stack.yaml", "cabal.project", "hie.yaml", "package.yaml", "*.cabal"], - settings: { - haskell: { - formattingProvider: "ormolu", - checkProject: true, - }, - }, - }, - - ocamllsp: { - command: "ocamllsp", - args: [], - fileTypes: [".ml", ".mli", ".mll", ".mly"], - rootMarkers: ["dune-project", "dune-workspace", "*.opam", ".ocamlformat"], - }, - - elixirls: { - command: "elixir-ls", - args: [], - fileTypes: [".ex", ".exs", ".heex", ".eex"], - rootMarkers: ["mix.exs", "mix.lock"], - settings: { - elixirLS: { - dialyzerEnabled: true, - fetchDeps: false, - }, - }, - }, - - erlangls: { - command: "erlang_ls", - args: [], - fileTypes: [".erl", ".hrl"], - rootMarkers: ["rebar.config", "erlang.mk", "rebar.lock"], - }, - - gleam: { - command: "gleam", - args: ["lsp"], - fileTypes: [".gleam"], - rootMarkers: ["gleam.toml"], - }, - - // ========================================================================= - // Ruby - // ========================================================================= - - solargraph: { - command: "solargraph", - args: ["stdio"], - fileTypes: [".rb", ".rake", ".gemspec"], - rootMarkers: ["Gemfile", ".solargraph.yml", "Rakefile"], - initOptions: { - formatting: true, - }, - settings: { - solargraph: { - diagnostics: true, - completion: true, - hover: true, - formatting: true, - references: true, - rename: true, - symbols: true, - }, - }, - }, - - "ruby-lsp": { - command: "ruby-lsp", - args: [], - fileTypes: [".rb", ".rake", ".gemspec", ".erb"], - rootMarkers: ["Gemfile", ".ruby-version", ".ruby-gemset"], - initOptions: { - formatter: "auto", - }, - }, - - rubocop: { - command: "rubocop", - args: ["--lsp"], - fileTypes: [".rb", ".rake"], - rootMarkers: [".rubocop.yml", "Gemfile"], - isLinter: true, - }, - - // ========================================================================= - // Shell / Scripting - // ========================================================================= - - bashls: { - command: "bash-language-server", - args: ["start"], - fileTypes: [".sh", ".bash", ".zsh"], - rootMarkers: [".git"], - settings: { - bashIde: { - globPattern: "*@(.sh|.inc|.bash|.command)", - }, - }, - }, - - nushell: { - command: "nu", - args: ["--lsp"], - fileTypes: [".nu"], - rootMarkers: [".git"], - }, - - // ========================================================================= - // Lua - // ========================================================================= - - "lua-language-server": { - command: "lua-language-server", - args: [], - fileTypes: [".lua"], - rootMarkers: [".luarc.json", ".luarc.jsonc", ".luacheckrc", ".stylua.toml", "stylua.toml"], - settings: { - Lua: { - runtime: { version: "LuaJIT" }, - diagnostics: { globals: ["vim"] }, - workspace: { checkThirdParty: false }, - telemetry: { enable: false }, - }, - }, - }, - - // ========================================================================= - // PHP - // ========================================================================= - - intelephense: { - command: "intelephense", - args: ["--stdio"], - fileTypes: [".php", ".phtml"], - rootMarkers: ["composer.json", "composer.lock", ".git"], - }, - - phpactor: { - command: "phpactor", - args: ["language-server"], - fileTypes: [".php"], - rootMarkers: ["composer.json", ".phpactor.json", ".phpactor.yml"], - }, - - // ========================================================================= - // .NET - // ========================================================================= - - omnisharp: { - command: "omnisharp", - args: ["-z", "--hostPID", String(process.pid), "--encoding", "utf-8", "--languageserver"], - fileTypes: [".cs", ".csx"], - rootMarkers: ["*.sln", "*.csproj", "omnisharp.json", ".git"], - settings: { - FormattingOptions: { EnableEditorConfigSupport: true }, - RoslynExtensionsOptions: { EnableAnalyzersSupport: true }, - }, - }, - - // ========================================================================= - // Configuration Languages - // ========================================================================= - - yamlls: { - command: "yaml-language-server", - args: ["--stdio"], - fileTypes: [".yaml", ".yml"], - rootMarkers: [".git"], - settings: { - yaml: { - validate: true, - format: { enable: true }, - hover: true, - completion: true, - }, - redhat: { telemetry: { enabled: false } }, - }, - }, - - taplo: { - command: "taplo", - args: ["lsp", "stdio"], - fileTypes: [".toml"], - rootMarkers: [".taplo.toml", "taplo.toml", ".git"], - }, - - terraformls: { - command: "terraform-ls", - args: ["serve"], - fileTypes: [".tf", ".tfvars"], - rootMarkers: [".terraform", "terraform.tfstate", "*.tf"], - }, - - dockerls: { - command: "docker-langserver", - args: ["--stdio"], - fileTypes: [".dockerfile"], - rootMarkers: ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".dockerignore"], - }, - - "helm-ls": { - command: "helm_ls", - args: ["serve"], - fileTypes: [".yaml", ".yml", ".tpl"], - rootMarkers: ["Chart.yaml", "Chart.yml"], - }, - - // ========================================================================= - // Nix - // ========================================================================= - - nixd: { - command: "nixd", - args: [], - fileTypes: [".nix"], - rootMarkers: ["flake.nix", "default.nix", "shell.nix"], - }, - - nil: { - command: "nil", - args: [], - fileTypes: [".nix"], - rootMarkers: ["flake.nix", "default.nix", "shell.nix"], - }, - - // ========================================================================= - // Other Languages - // ========================================================================= - - ols: { - command: "ols", - args: [], - fileTypes: [".odin"], - rootMarkers: ["ols.json", ".git"], - }, - - dartls: { - command: "dart", - args: ["language-server", "--protocol=lsp"], - fileTypes: [".dart"], - rootMarkers: ["pubspec.yaml", "pubspec.lock"], - initOptions: { - closingLabels: true, - flutterOutline: true, - outline: true, - }, - }, - - marksman: { - command: "marksman", - args: ["server"], - fileTypes: [".md", ".markdown"], - rootMarkers: [".marksman.toml", ".git"], - }, - - texlab: { - command: "texlab", - args: [], - fileTypes: [".tex", ".bib", ".sty", ".cls"], - rootMarkers: [".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot", "Tectonic.toml"], - settings: { - texlab: { - build: { - executable: "latexmk", - args: ["-pdf", "-interaction=nonstopmode", "-synctex=1", "%f"], - }, - chktex: { onOpenAndSave: true }, - }, - }, - }, - - graphql: { - command: "graphql-lsp", - args: ["server", "-m", "stream"], - fileTypes: [".graphql", ".gql"], - rootMarkers: [".graphqlrc", ".graphqlrc.json", ".graphqlrc.yml", ".graphqlrc.yaml", "graphql.config.js"], - }, - - prismals: { - command: "prisma-language-server", - args: ["--stdio"], - fileTypes: [".prisma"], - rootMarkers: ["schema.prisma", "prisma/schema.prisma"], - }, - - vimls: { - command: "vim-language-server", - args: ["--stdio"], - fileTypes: [".vim", ".vimrc"], - rootMarkers: [".git"], - initOptions: { - isNeovim: true, - diagnostic: { enable: true }, - }, - }, - - // ========================================================================= - // Emmet (HTML/CSS expansion) - // ========================================================================= - - "emmet-language-server": { - command: "emmet-language-server", - args: ["--stdio"], - fileTypes: [".html", ".css", ".scss", ".less", ".jsx", ".tsx", ".vue", ".svelte"], - rootMarkers: [".git"], - }, -}; +const PID_TOKEN = "$PID"; + +interface NormalizedConfig { + servers: Record>; + idleTimeoutMs?: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseConfigContent(content: string, filePath: string): unknown { + const extension = extname(filePath).toLowerCase(); + if (extension === ".yaml" || extension === ".yml") { + return parseYaml(content) as unknown; + } + return JSON.parse(content) as unknown; +} + +function normalizeConfig(value: unknown): NormalizedConfig | null { + if (!isRecord(value)) return null; + + const idleTimeoutMs = typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : undefined; + const rawServers = value.servers; + + if (isRecord(rawServers)) { + return { servers: rawServers as Record>, idleTimeoutMs }; + } + + const servers = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "idleTimeoutMs")) as Record< + string, + Partial + >; + + return { servers, idleTimeoutMs }; +} + +function normalizeStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const items = value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + return items.length > 0 ? items : null; +} + +function normalizeServerConfig(name: string, config: Partial): ServerConfig | null { + const command = typeof config.command === "string" && config.command.length > 0 ? config.command : null; + const fileTypes = normalizeStringArray(config.fileTypes); + const rootMarkers = normalizeStringArray(config.rootMarkers); + + if (!command || !fileTypes || !rootMarkers) { + console.warn(`Ignoring invalid LSP server config "${name}" (missing required fields).`); + return null; + } + + const args = Array.isArray(config.args) + ? config.args.filter((entry): entry is string => typeof entry === "string") + : undefined; + + return { + ...config, + command, + args, + fileTypes, + rootMarkers, + }; +} + +async function readConfigFile(filePath: string): Promise { + try { + const file = Bun.file(filePath); + if (!(await file.exists())) { + return null; + } + const content = await file.text(); + const parsed = parseConfigContent(content, filePath); + return normalizeConfig(parsed); + } catch { + return null; + } +} + +function coerceServerConfigs(servers: Record>): Record { + const result: Record = {}; + for (const [name, config] of Object.entries(servers)) { + const normalized = normalizeServerConfig(name, config); + if (normalized) { + result[name] = normalized; + } + } + return result; +} + +function mergeServers( + base: Record, + overrides: Record>, +): Record { + const merged: Record = { ...base }; + for (const [name, config] of Object.entries(overrides)) { + if (merged[name]) { + const candidate = { ...merged[name], ...config }; + const normalized = normalizeServerConfig(name, candidate); + if (normalized) { + merged[name] = normalized; + } else { + console.warn(`Ignoring invalid LSP overrides for "${name}" (keeping previous config).`); + } + } else { + const normalized = normalizeServerConfig(name, config); + if (normalized) { + merged[name] = normalized; + } + } + } + return merged; +} + +function applyRuntimeDefaults(servers: Record): Record { + const updated: Record = { ...servers }; + + if (updated.biome) { + updated.biome = { ...updated.biome, createClient: createBiomeClient }; + } + + if (updated.omnisharp?.args) { + const args = updated.omnisharp.args.map((arg) => (arg === PID_TOKEN ? String(process.pid) : arg)); + updated.omnisharp = { ...updated.omnisharp, args }; + } + + return updated; +} // ============================================================================= // Configuration Loading @@ -613,22 +154,26 @@ export const SERVERS: Record = { /** * Check if any root marker file exists in the directory */ -export function hasRootMarkers(cwd: string, markers: string[]): boolean { - return markers.some((marker) => { +export async function hasRootMarkers(cwd: string, markers: string[]): Promise { + for (const marker of markers) { // Handle glob-like patterns (e.g., "*.cabal") if (marker.includes("*")) { try { - const { globSync } = require("glob"); const matches = globSync(join(cwd, marker)); - return matches.length > 0; + if (matches.length > 0) { + return true; + } } catch { - // globSync not available, skip glob patterns - return false; + console.warn(`Failed to resolve glob root marker "${marker}" in ${cwd}`); } + continue; } const filePath = join(cwd, marker); - return existsSync(filePath); - }); + if (await Bun.file(filePath).exists()) { + return true; + } + } + return false; } // ============================================================================= @@ -661,12 +206,12 @@ const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [ * @param cwd - Working directory to search from * @returns Absolute path to the executable, or null if not found */ -export function resolveCommand(command: string, cwd: string): string | null { +export async function resolveCommand(command: string, cwd: string): Promise { // Check local bin directories based on project markers for (const { markers, binDir } of LOCAL_BIN_PATHS) { - if (hasRootMarkers(cwd, markers)) { + if (await hasRootMarkers(cwd, markers)) { const localPath = join(cwd, binDir, command); - if (existsSync(localPath)) { + if (await Bun.file(localPath).exists()) { return localPath; } } @@ -681,7 +226,7 @@ export function resolveCommand(command: string, cwd: string): string | null { * Supports both visible and hidden variants at each config location. */ function getConfigPaths(cwd: string): string[] { - const filenames = ["lsp.json", ".lsp.json"]; + const filenames = ["lsp.json", ".lsp.json", "lsp.yaml", ".lsp.yaml", "lsp.yml", ".lsp.yml"]; const paths: string[] = []; // Project root files (highest priority) @@ -716,14 +261,16 @@ function getConfigPaths(cwd: string): string[] { /** * Load LSP configuration. * - * Priority: - * 1. Project root: lsp.json, .lsp.json - * 2. Project config dirs: .omp/lsp.json, .pi/lsp.json, .claude/lsp.json (+ hidden variants) - * 3. User config dirs: ~/.omp/agent/lsp.json, ~/.pi/agent/lsp.json, ~/.claude/lsp.json (+ hidden variants) - * 4. User home root: ~/lsp.json, ~/.lsp.json + * Priority (highest to lowest): + * 1. Project root: lsp.json/.lsp.json/lsp.yml/.lsp.yml/lsp.yaml/.lsp.yaml + * 2. Project config dirs: .omp/lsp.*, .pi/lsp.*, .claude/lsp.* (+ hidden variants) + * 3. User config dirs: ~/.omp/agent/lsp.*, ~/.pi/agent/lsp.*, ~/.claude/lsp.* (+ hidden variants) + * 4. User home root: ~/lsp.*, ~/.lsp.* * 5. Auto-detect from project markers + available binaries * - * Config file format: + * Config files are merged from lowest to highest priority; later files override earlier settings. + * + * Config file format (JSON or YAML): * ```json * { * "servers": { @@ -743,61 +290,53 @@ function getConfigPaths(cwd: string): string[] { * ``` */ export async function loadConfig(cwd: string): Promise { - const configPaths = getConfigPaths(cwd); + let mergedServers = coerceServerConfigs(DEFAULTS); + const configPaths = getConfigPaths(cwd).reverse(); + let hasOverrides = false; + + let idleTimeoutMs: number | undefined; for (const configPath of configPaths) { - if (existsSync(configPath)) { - try { - const content = readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(content); - - // Support both { servers: {...} } and direct server map - const servers = parsed.servers || parsed; - - // Merge with defaults and filter to available - const merged: Record = { ...SERVERS }; - - for (const [name, config] of Object.entries(servers) as [string, Partial][]) { - if (merged[name]) { - // Merge with existing config - merged[name] = { ...merged[name], ...config }; - } else { - // Add new server config - merged[name] = config as ServerConfig; - } - } - - // Filter to only enabled servers with available commands - const available: Record = {}; - for (const [name, config] of Object.entries(merged)) { - if (config.disabled) continue; - const resolved = resolveCommand(config.command, cwd); - if (!resolved) continue; - available[name] = { ...config, resolvedCommand: resolved }; - } - - return { servers: available }; - } catch { - // Ignore parse errors, continue to next config or auto-detect - } + const parsed = await readConfigFile(configPath); + if (!parsed) continue; + hasOverrides = true; + mergedServers = mergeServers(mergedServers, parsed.servers); + if (parsed.idleTimeoutMs !== undefined) { + idleTimeoutMs = parsed.idleTimeoutMs; } } - // Auto-detect: find servers based on project markers AND available binaries - const detected: Record = {}; + if (!hasOverrides) { + // Auto-detect: find servers based on project markers AND available binaries + const detected: Record = {}; + const defaultsWithRuntime = applyRuntimeDefaults(mergedServers); - for (const [name, config] of Object.entries(SERVERS)) { - // Check if project has root markers for this language - if (!hasRootMarkers(cwd, config.rootMarkers)) continue; + for (const [name, config] of Object.entries(defaultsWithRuntime)) { + // Check if project has root markers for this language + if (!(await hasRootMarkers(cwd, config.rootMarkers))) continue; - // Check if the language server binary is available (local or $PATH) - const resolved = resolveCommand(config.command, cwd); - if (!resolved) continue; + // Check if the language server binary is available (local or $PATH) + const resolved = await resolveCommand(config.command, cwd); + if (!resolved) continue; - detected[name] = { ...config, resolvedCommand: resolved }; + detected[name] = { ...config, resolvedCommand: resolved }; + } + + return { servers: detected, idleTimeoutMs }; } - return { servers: detected }; + // Merge overrides with defaults and filter to available servers + const mergedWithRuntime = applyRuntimeDefaults(mergedServers); + const available: Record = {}; + + for (const [name, config] of Object.entries(mergedWithRuntime)) { + if (config.disabled) continue; + const resolved = await resolveCommand(config.command, cwd); + if (!resolved) continue; + available[name] = { ...config, resolvedCommand: resolved }; + } + + return { servers: available, idleTimeoutMs }; } // ============================================================================= @@ -810,10 +349,16 @@ export async function loadConfig(cwd: string): Promise { */ export function getServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> { const ext = extname(filePath).toLowerCase(); + const fileName = basename(filePath).toLowerCase(); const matches: Array<[string, ServerConfig]> = []; for (const [name, serverConfig] of Object.entries(config.servers)) { - if (serverConfig.fileTypes.includes(ext)) { + const supportsFile = serverConfig.fileTypes.some((fileType) => { + const normalized = fileType.toLowerCase(); + return normalized === ext || normalized === fileName; + }); + + if (supportsFile) { matches.push([name, serverConfig]); } } diff --git a/packages/coding-agent/src/core/tools/lsp/defaults.json b/packages/coding-agent/src/core/tools/lsp/defaults.json new file mode 100644 index 000000000..5b021eb7b --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/defaults.json @@ -0,0 +1,465 @@ +{ + "rust-analyzer": { + "command": "rust-analyzer", + "args": [], + "fileTypes": [".rs"], + "rootMarkers": ["Cargo.toml", "rust-analyzer.toml"], + "initOptions": { + "checkOnSave": { "command": "clippy" }, + "cargo": { "allFeatures": true }, + "procMacro": { "enable": true } + }, + "settings": { + "rust-analyzer": { + "diagnostics": { "enable": true }, + "inlayHints": { "enable": true } + } + }, + "capabilities": { + "flycheck": true, + "ssr": true, + "expandMacro": true, + "runnables": true, + "relatedTests": true + } + }, + "clangd": { + "command": "clangd", + "args": ["--background-index", "--clang-tidy", "--header-insertion=iwyu"], + "fileTypes": [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx", ".m", ".mm"], + "rootMarkers": ["compile_commands.json", "CMakeLists.txt", ".clangd", ".clang-format", "Makefile"] + }, + "zls": { + "command": "zls", + "args": [], + "fileTypes": [".zig"], + "rootMarkers": ["build.zig", "build.zig.zon", "zls.json"] + }, + "gopls": { + "command": "gopls", + "args": ["serve"], + "fileTypes": [".go", ".mod", ".sum"], + "rootMarkers": ["go.mod", "go.work", "go.sum"], + "settings": { + "gopls": { + "analyses": { "unusedparams": true, "shadow": true }, + "staticcheck": true, + "gofumpt": true + } + } + }, + "typescript-language-server": { + "command": "typescript-language-server", + "args": ["--stdio"], + "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + "rootMarkers": ["package.json", "tsconfig.json", "jsconfig.json"], + "initOptions": { + "hostInfo": "omp-coding-agent", + "preferences": { + "includeInlayParameterNameHints": "all", + "includeInlayVariableTypeHints": true, + "includeInlayFunctionParameterTypeHints": true + } + } + }, + "biome": { + "command": "biome", + "args": ["lsp-proxy"], + "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc"], + "rootMarkers": ["biome.json", "biome.jsonc"], + "isLinter": true + }, + "eslint": { + "command": "vscode-eslint-language-server", + "args": ["--stdio"], + "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"], + "rootMarkers": [ + ".eslintrc", + ".eslintrc.js", + ".eslintrc.json", + ".eslintrc.yml", + "eslint.config.js", + "eslint.config.mjs" + ], + "isLinter": true, + "settings": { + "validate": "on", + "run": "onType" + } + }, + "denols": { + "command": "deno", + "args": ["lsp"], + "fileTypes": [".ts", ".tsx", ".js", ".jsx"], + "rootMarkers": ["deno.json", "deno.jsonc", "deno.lock"], + "initOptions": { + "enable": true, + "lint": true, + "unstable": true + } + }, + "vscode-html-language-server": { + "command": "vscode-html-language-server", + "args": ["--stdio"], + "fileTypes": [".html", ".htm"], + "rootMarkers": ["package.json", ".git"], + "initOptions": { + "provideFormatter": true + } + }, + "vscode-css-language-server": { + "command": "vscode-css-language-server", + "args": ["--stdio"], + "fileTypes": [".css", ".scss", ".sass", ".less"], + "rootMarkers": ["package.json", ".git"], + "initOptions": { + "provideFormatter": true + } + }, + "vscode-json-language-server": { + "command": "vscode-json-language-server", + "args": ["--stdio"], + "fileTypes": [".json", ".jsonc"], + "rootMarkers": ["package.json", ".git"], + "initOptions": { + "provideFormatter": true + } + }, + "tailwindcss": { + "command": "tailwindcss-language-server", + "args": ["--stdio"], + "fileTypes": [".html", ".css", ".scss", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"], + "rootMarkers": ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"] + }, + "svelte": { + "command": "svelteserver", + "args": ["--stdio"], + "fileTypes": [".svelte"], + "rootMarkers": ["svelte.config.js", "svelte.config.mjs", "package.json"] + }, + "vue-language-server": { + "command": "vue-language-server", + "args": ["--stdio"], + "fileTypes": [".vue"], + "rootMarkers": ["vue.config.js", "nuxt.config.js", "nuxt.config.ts", "package.json"] + }, + "astro": { + "command": "astro-ls", + "args": ["--stdio"], + "fileTypes": [".astro"], + "rootMarkers": ["astro.config.mjs", "astro.config.js", "astro.config.ts"] + }, + "pyright": { + "command": "pyright-langserver", + "args": ["--stdio"], + "fileTypes": [".py", ".pyi"], + "rootMarkers": ["pyproject.toml", "pyrightconfig.json", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"], + "settings": { + "python": { + "analysis": { + "autoSearchPaths": true, + "diagnosticMode": "openFilesOnly", + "useLibraryCodeForTypes": true + } + } + } + }, + "basedpyright": { + "command": "basedpyright-langserver", + "args": ["--stdio"], + "fileTypes": [".py", ".pyi"], + "rootMarkers": ["pyproject.toml", "pyrightconfig.json", "setup.py", "requirements.txt"], + "settings": { + "basedpyright": { + "analysis": { + "autoSearchPaths": true, + "diagnosticMode": "openFilesOnly", + "useLibraryCodeForTypes": true + } + } + } + }, + "pylsp": { + "command": "pylsp", + "args": [], + "fileTypes": [".py"], + "rootMarkers": ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] + }, + "ruff": { + "command": "ruff", + "args": ["server"], + "fileTypes": [".py", ".pyi"], + "rootMarkers": ["pyproject.toml", "ruff.toml", ".ruff.toml"], + "isLinter": true + }, + "jdtls": { + "command": "jdtls", + "args": [], + "fileTypes": [".java"], + "rootMarkers": ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", ".project"] + }, + "kotlin-language-server": { + "command": "kotlin-language-server", + "args": [], + "fileTypes": [".kt", ".kts"], + "rootMarkers": ["build.gradle", "build.gradle.kts", "pom.xml", "settings.gradle", "settings.gradle.kts"] + }, + "metals": { + "command": "metals", + "args": [], + "fileTypes": [".scala", ".sbt", ".sc"], + "rootMarkers": ["build.sbt", "build.sc", "build.gradle", "pom.xml"], + "initOptions": { + "statusBarProvider": "show-message", + "isHttpEnabled": true + } + }, + "hls": { + "command": "haskell-language-server-wrapper", + "args": ["--lsp"], + "fileTypes": [".hs", ".lhs"], + "rootMarkers": ["stack.yaml", "cabal.project", "hie.yaml", "package.yaml", "*.cabal"], + "settings": { + "haskell": { + "formattingProvider": "ormolu", + "checkProject": true + } + } + }, + "ocamllsp": { + "command": "ocamllsp", + "args": [], + "fileTypes": [".ml", ".mli", ".mll", ".mly"], + "rootMarkers": ["dune-project", "dune-workspace", "*.opam", ".ocamlformat"] + }, + "elixirls": { + "command": "elixir-ls", + "args": [], + "fileTypes": [".ex", ".exs", ".heex", ".eex"], + "rootMarkers": ["mix.exs", "mix.lock"], + "settings": { + "elixirLS": { + "dialyzerEnabled": true, + "fetchDeps": false + } + } + }, + "erlangls": { + "command": "erlang_ls", + "args": [], + "fileTypes": [".erl", ".hrl"], + "rootMarkers": ["rebar.config", "erlang.mk", "rebar.lock"] + }, + "gleam": { + "command": "gleam", + "args": ["lsp"], + "fileTypes": [".gleam"], + "rootMarkers": ["gleam.toml"] + }, + "solargraph": { + "command": "solargraph", + "args": ["stdio"], + "fileTypes": [".rb", ".rake", ".gemspec"], + "rootMarkers": ["Gemfile", ".solargraph.yml", "Rakefile"], + "initOptions": { + "formatting": true + }, + "settings": { + "solargraph": { + "diagnostics": true, + "completion": true, + "hover": true, + "formatting": true, + "references": true, + "rename": true, + "symbols": true + } + } + }, + "ruby-lsp": { + "command": "ruby-lsp", + "args": [], + "fileTypes": [".rb", ".rake", ".gemspec", ".erb"], + "rootMarkers": ["Gemfile", ".ruby-version", ".ruby-gemset"], + "initOptions": { + "formatter": "auto" + } + }, + "rubocop": { + "command": "rubocop", + "args": ["--lsp"], + "fileTypes": [".rb", ".rake"], + "rootMarkers": [".rubocop.yml", "Gemfile"], + "isLinter": true + }, + "bashls": { + "command": "bash-language-server", + "args": ["start"], + "fileTypes": [".sh", ".bash", ".zsh"], + "rootMarkers": [".git"], + "settings": { + "bashIde": { + "globPattern": "*@(.sh|.inc|.bash|.command)" + } + } + }, + "nushell": { + "command": "nu", + "args": ["--lsp"], + "fileTypes": [".nu"], + "rootMarkers": [".git"] + }, + "lua-language-server": { + "command": "lua-language-server", + "args": [], + "fileTypes": [".lua"], + "rootMarkers": [".luarc.json", ".luarc.jsonc", ".luacheckrc", ".stylua.toml", "stylua.toml"], + "settings": { + "Lua": { + "runtime": { "version": "LuaJIT" }, + "diagnostics": { "globals": ["vim"] }, + "workspace": { "checkThirdParty": false }, + "telemetry": { "enable": false } + } + } + }, + "intelephense": { + "command": "intelephense", + "args": ["--stdio"], + "fileTypes": [".php", ".phtml"], + "rootMarkers": ["composer.json", "composer.lock", ".git"] + }, + "phpactor": { + "command": "phpactor", + "args": ["language-server"], + "fileTypes": [".php"], + "rootMarkers": ["composer.json", ".phpactor.json", ".phpactor.yml"] + }, + "omnisharp": { + "command": "omnisharp", + "args": ["-z", "--hostPID", "$PID", "--encoding", "utf-8", "--languageserver"], + "fileTypes": [".cs", ".csx"], + "rootMarkers": ["*.sln", "*.csproj", "omnisharp.json", ".git"], + "settings": { + "FormattingOptions": { "EnableEditorConfigSupport": true }, + "RoslynExtensionsOptions": { "EnableAnalyzersSupport": true } + } + }, + "yamlls": { + "command": "yaml-language-server", + "args": ["--stdio"], + "fileTypes": [".yaml", ".yml"], + "rootMarkers": [".git"], + "settings": { + "yaml": { + "validate": true, + "format": { "enable": true }, + "hover": true, + "completion": true + }, + "redhat": { "telemetry": { "enabled": false } } + } + }, + "taplo": { + "command": "taplo", + "args": ["lsp", "stdio"], + "fileTypes": [".toml"], + "rootMarkers": [".taplo.toml", "taplo.toml", ".git"] + }, + "terraformls": { + "command": "terraform-ls", + "args": ["serve"], + "fileTypes": [".tf", ".tfvars"], + "rootMarkers": [".terraform", "terraform.tfstate", "*.tf"] + }, + "dockerls": { + "command": "docker-langserver", + "args": ["--stdio"], + "fileTypes": [".dockerfile", "Dockerfile"], + "rootMarkers": ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".dockerignore"] + }, + "helm-ls": { + "command": "helm_ls", + "args": ["serve"], + "fileTypes": [".yaml", ".yml", ".tpl"], + "rootMarkers": ["Chart.yaml", "Chart.yml"] + }, + "nixd": { + "command": "nixd", + "args": [], + "fileTypes": [".nix"], + "rootMarkers": ["flake.nix", "default.nix", "shell.nix"] + }, + "nil": { + "command": "nil", + "args": [], + "fileTypes": [".nix"], + "rootMarkers": ["flake.nix", "default.nix", "shell.nix"] + }, + "ols": { + "command": "ols", + "args": [], + "fileTypes": [".odin"], + "rootMarkers": ["ols.json", ".git"] + }, + "dartls": { + "command": "dart", + "args": ["language-server", "--protocol=lsp"], + "fileTypes": [".dart"], + "rootMarkers": ["pubspec.yaml", "pubspec.lock"], + "initOptions": { + "closingLabels": true, + "flutterOutline": true, + "outline": true + } + }, + "marksman": { + "command": "marksman", + "args": ["server"], + "fileTypes": [".md", ".markdown"], + "rootMarkers": [".marksman.toml", ".git"] + }, + "texlab": { + "command": "texlab", + "args": [], + "fileTypes": [".tex", ".bib", ".sty", ".cls"], + "rootMarkers": [".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot", "Tectonic.toml"], + "settings": { + "texlab": { + "build": { + "executable": "latexmk", + "args": ["-pdf", "-interaction=nonstopmode", "-synctex=1", "%f"] + }, + "chktex": { "onOpenAndSave": true } + } + } + }, + "graphql": { + "command": "graphql-lsp", + "args": ["server", "-m", "stream"], + "fileTypes": [".graphql", ".gql"], + "rootMarkers": [".graphqlrc", ".graphqlrc.json", ".graphqlrc.yml", ".graphqlrc.yaml", "graphql.config.js"] + }, + "prismals": { + "command": "prisma-language-server", + "args": ["--stdio"], + "fileTypes": [".prisma"], + "rootMarkers": ["schema.prisma", "prisma/schema.prisma"] + }, + "vimls": { + "command": "vim-language-server", + "args": ["--stdio"], + "fileTypes": [".vim", ".vimrc"], + "rootMarkers": [".git"], + "initOptions": { + "isNeovim": true, + "diagnostic": { "enable": true } + } + }, + "emmet-language-server": { + "command": "emmet-language-server", + "args": ["--stdio"], + "fileTypes": [".html", ".css", ".scss", ".less", ".jsx", ".tsx", ".vue", ".svelte"], + "rootMarkers": [".git"] + } +} diff --git a/packages/coding-agent/src/core/tools/lsp/index.ts b/packages/coding-agent/src/core/tools/lsp/index.ts index 7c47fd309..1293e9185 100644 --- a/packages/coding-agent/src/core/tools/lsp/index.ts +++ b/packages/coding-agent/src/core/tools/lsp/index.ts @@ -1,5 +1,5 @@ import type { Dirent } from "node:fs"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { BunFile } from "bun"; @@ -136,14 +136,18 @@ async function syncFileContent( content: string, cwd: string, servers: Array<[string, ServerConfig]>, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); await Promise.allSettled( servers.map(async ([_serverName, serverConfig]) => { + signal?.throwIfAborted(); if (serverConfig.createClient) { return; } const client = await getOrCreateClient(serverConfig, cwd); - await syncContent(client, absolutePath, content); + signal?.throwIfAborted(); + await syncContent(client, absolutePath, content, signal); }), ); } @@ -160,14 +164,17 @@ async function notifyFileSaved( absolutePath: string, cwd: string, servers: Array<[string, ServerConfig]>, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); await Promise.allSettled( servers.map(async ([_serverName, serverConfig]) => { + signal?.throwIfAborted(); if (serverConfig.createClient) { return; } const client = await getOrCreateClient(serverConfig, cwd); - await notifySaved(client, absolutePath); + await notifySaved(client, absolutePath, signal); }), ); } @@ -227,13 +234,19 @@ 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; - let entries: Dirent[]; + const entries: Dirent[] = []; try { - entries = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false })).map((name) => ({ - name, - isFile: () => !existsSync(path.join(dir, name)) || Bun.file(path.join(dir, name)).type !== "directory", - isDirectory: () => existsSync(path.join(dir, name)) && Bun.file(path.join(dir, name)).type === "directory", - })) as Dirent[]; + 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(); + } catch { + continue; + } + entries.push({ name, isFile: () => !isDir, isDirectory: () => isDir } as Dirent); + } } catch { return null; } @@ -298,9 +311,15 @@ function getServerForWorkspaceAction(config: LspConfig, action: string): [string return null; } -async function waitForDiagnostics(client: LspClient, uri: string, timeoutMs = 3000): Promise { +async function waitForDiagnostics( + client: LspClient, + uri: string, + timeoutMs = 3000, + signal?: AbortSignal, +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { + signal?.throwIfAborted(); const diagnostics = client.diagnostics.get(uri); if (diagnostics !== undefined) return diagnostics; await sleep(100); @@ -440,6 +459,7 @@ async function getDiagnosticsForFile( absolutePath: string, cwd: string, servers: Array<[string, ServerConfig]>, + signal?: AbortSignal, ): Promise { if (servers.length === 0) { return undefined; @@ -453,6 +473,7 @@ async function getDiagnosticsForFile( // Wait for diagnostics from all servers in parallel const results = await Promise.allSettled( servers.map(async ([serverName, serverConfig]) => { + signal?.throwIfAborted(); // Use custom linter client if configured if (serverConfig.createClient) { const linterClient = getLinterClient(serverName, serverConfig, cwd); @@ -462,8 +483,9 @@ async function getDiagnosticsForFile( // Default: use LSP const client = await getOrCreateClient(serverConfig, cwd); + signal?.throwIfAborted(); // Content already synced + didSave sent, just wait for diagnostics - const diagnostics = await waitForDiagnostics(client, uri); + const diagnostics = await waitForDiagnostics(client, uri, 3000, signal); return { serverName, diagnostics }; }), ); @@ -539,6 +561,7 @@ async function formatContent( content: string, cwd: string, servers: Array<[string, ServerConfig]>, + signal?: AbortSignal, ): Promise { if (servers.length === 0) { return content; @@ -548,6 +571,7 @@ async function formatContent( for (const [serverName, serverConfig] of servers) { try { + signal?.throwIfAborted(); // Use custom linter client if configured if (serverConfig.createClient) { const linterClient = getLinterClient(serverName, serverConfig, cwd); @@ -556,6 +580,7 @@ async function formatContent( // Default: use LSP const client = await getOrCreateClient(serverConfig, cwd); + signal?.throwIfAborted(); const caps = client.serverCapabilities; if (!caps?.documentFormattingProvider) { @@ -563,10 +588,15 @@ async function formatContent( } // Request formatting (content already synced) - const edits = (await sendRequest(client, "textDocument/formatting", { - textDocument: { uri }, - options: DEFAULT_FORMAT_OPTIONS, - })) as TextEdit[] | null; + const edits = (await sendRequest( + client, + "textDocument/formatting", + { + textDocument: { uri }, + options: DEFAULT_FORMAT_OPTIONS, + }, + signal, + )) as TextEdit[] | null; if (!edits || edits.length === 0) { return content; @@ -633,28 +663,29 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions let formatter: FileFormatResult | undefined; let diagnostics: FileDiagnosticsResult | undefined; try { - signal ??= AbortSignal.timeout(10_000); - await untilAborted(signal, async () => { + const timeoutSignal = AbortSignal.timeout(10_000); + const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + await untilAborted(operationSignal, async () => { if (useCustomFormatter) { // Custom linters (e.g. Biome CLI) require on-disk input. await writeContent(content); - finalContent = await formatContent(dst, content, cwd, customLinterServers); + finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal); formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED; await writeContent(finalContent); - await syncFileContent(dst, finalContent, cwd, lspServers); + await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal); } else { // 1. Sync original content to LSP servers - await syncFileContent(dst, content, cwd, lspServers); + await syncFileContent(dst, content, cwd, lspServers, operationSignal); // 2. Format in-memory via LSP if (enableFormat) { - finalContent = await formatContent(dst, content, cwd, lspServers); + finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal); formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED; } // 3. If formatted, sync formatted content to LSP servers if (finalContent !== content) { - await syncFileContent(dst, finalContent, cwd, lspServers); + await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal); } // 4. Write to disk @@ -662,11 +693,11 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions } // 5. Notify saved to LSP servers - await notifyFileSaved(dst, cwd, lspServers); + await notifyFileSaved(dst, cwd, lspServers, operationSignal); // 6. Get diagnostics from all servers if (enableDiagnostics) { - diagnostics = await getDiagnosticsForFile(dst, cwd, servers); + diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal); } }); } catch { diff --git a/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts b/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts index 37de0da24..dea3bff4a 100644 --- a/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts +++ b/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts @@ -1,13 +1,6 @@ import { sendNotification, sendRequest } from "./client"; import type { Diagnostic, ExpandMacroResult, LspClient, RelatedTest, Runnable, WorkspaceEdit } from "./types"; -import { fileToUri } from "./utils"; - -/** - * Wait for specified milliseconds. - */ -async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +import { fileToUri, sleep } from "./utils"; /** * Run flycheck (cargo check) and collect diagnostics. @@ -19,10 +12,56 @@ async function sleep(ms: number): Promise { */ export async function flycheck(client: LspClient, file?: string): Promise { const textDocument = file ? { uri: fileToUri(file) } : null; + + const countDiagnostics = (diagnostics: Map): number => { + let count = 0; + for (const diags of diagnostics.values()) { + count += diags.length; + } + return count; + }; + + // Capture current diagnostic version before triggering flycheck + const initialDiagnosticsVersion = client.diagnosticsVersion; + const initialDiagnosticsCount = countDiagnostics(client.diagnostics); + await sendNotification(client, "rust-analyzer/runFlycheck", { textDocument }); - // Wait for diagnostics to accumulate (2 seconds as per reference) - await sleep(2000); + // Bounded polling: wait for diagnostics to stabilize or timeout + // Poll every 100ms for up to 8 seconds (80 iterations) + const pollIntervalMs = 100; + const maxPollIterations = 80; + const stabilityThreshold = 3; // Consider stable after 3 iterations without change + const minStableDurationMs = 2000; // Avoid early exit when diagnostics are re-published unchanged. + const startTime = Date.now(); + let lastDiagnosticsVersion = initialDiagnosticsVersion; + let lastDiagnosticsCount = initialDiagnosticsCount; + let stableIterations = 0; + + for (let i = 0; i < maxPollIterations; i++) { + await sleep(pollIntervalMs); + + const currentDiagnosticsVersion = client.diagnosticsVersion; + const currentDiagnosticsCount = countDiagnostics(client.diagnostics); + + // Check if diagnostics have stabilized + if (currentDiagnosticsVersion === lastDiagnosticsVersion && currentDiagnosticsCount === lastDiagnosticsCount) { + stableIterations++; + const elapsedMs = Date.now() - startTime; + const countChangedFromStart = currentDiagnosticsCount !== initialDiagnosticsCount; + if ( + currentDiagnosticsVersion !== initialDiagnosticsVersion && + stableIterations >= stabilityThreshold && + (countChangedFromStart || elapsedMs >= minStableDurationMs) + ) { + break; + } + } else { + stableIterations = 0; + lastDiagnosticsVersion = currentDiagnosticsVersion; + lastDiagnosticsCount = currentDiagnosticsCount; + } + } // Collect all diagnostics from client const allDiags: Diagnostic[] = []; diff --git a/packages/coding-agent/src/core/tools/lsp/types.ts b/packages/coding-agent/src/core/tools/lsp/types.ts index e5590dee6..754e0d0db 100644 --- a/packages/coding-agent/src/core/tools/lsp/types.ts +++ b/packages/coding-agent/src/core/tools/lsp/types.ts @@ -403,6 +403,7 @@ export interface LspClient { process: Subprocess; requestId: number; diagnostics: Map; + diagnosticsVersion: number; openFiles: Map; pendingRequests: Map; messageBuffer: Uint8Array; diff --git a/packages/coding-agent/src/core/tools/lsp/utils.ts b/packages/coding-agent/src/core/tools/lsp/utils.ts index 107ab80ed..d2cc6c19d 100644 --- a/packages/coding-agent/src/core/tools/lsp/utils.ts +++ b/packages/coding-agent/src/core/tools/lsp/utils.ts @@ -492,7 +492,7 @@ export function extractHoverText( * Sleep for the specified number of milliseconds. */ export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return Bun.sleep(ms); } /** diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 8ad4a2121..a9dcde910 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -1,11 +1,9 @@ -import { existsSync } from "node:fs"; import path from "node:path"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { Type } from "@sinclair/typebox"; -import { globSync } from "glob"; import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme"; import readDescription from "../../prompts/tools/read.md" with { type: "text" }; import { formatDimensionNote, resizeImage } from "../../utils/image-resize"; @@ -13,7 +11,7 @@ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime"; import { ensureTool } from "../../utils/tools-manager"; import type { RenderResultOptions } from "../custom-tools/types"; import type { ToolSession } from "../sdk"; -import { untilAborted } from "../utils"; +import { ScopeSignal, untilAborted } from "../utils"; import { createLsTool } from "./ls"; import { resolveReadPath, resolveToCwd } from "./path-utils"; import { replaceTabs, shortenPath, wrapBrackets } from "./render-utils"; @@ -55,20 +53,16 @@ function isPathWithin(basePath: string, targetPath: string): boolean { return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); } -async function findExistingDirectory(startDir: string): Promise { +async function findExistingDirectory(startDir: string, signal?: AbortSignal): Promise { let current = startDir; const root = path.parse(startDir).root; while (true) { + signal?.throwIfAborted(); try { - if (existsSync(current)) { - // Check if directory by trying to read it as dir - try { - await Bun.$`test -d ${current}`.quiet(); - return current; - } catch { - // Not a directory, continue - } + const stat = await Bun.file(current).stat(); + if (stat.isDirectory()) { + return current; } } catch { // Keep walking up. @@ -149,8 +143,56 @@ function similarityScore(a: string, b: string): number { return 1 - distance / maxLen; } +async function captureCommandOutput( + command: string, + args: string[], + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> { + const child = Bun.spawn([command, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + using scope = new ScopeSignal(signal ? { signal } : undefined); + scope.catch(() => { + child.kill(); + }); + + const stdoutReader = (child.stdout as ReadableStream).getReader(); + const stderrReader = (child.stderr as ReadableStream).getReader(); + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + + await Promise.all([ + (async () => { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + stdout += stdoutDecoder.decode(value, { stream: true }); + } + stdout += stdoutDecoder.decode(); + })(), + (async () => { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += stderrDecoder.decode(value, { stream: true }); + } + stderr += stderrDecoder.decode(); + })(), + ]); + + const exitCode = await child.exited; + + return { stdout, stderr, exitCode, aborted: scope.aborted }; +} + async function listCandidateFiles( searchRoot: string, + signal?: AbortSignal, ): Promise<{ files: string[]; truncated: boolean; error?: string }> { let fdPath: string | undefined; try { @@ -167,22 +209,48 @@ async function listCandidateFiles( const gitignoreFiles = new Set(); const rootGitignore = path.join(searchRoot, ".gitignore"); - if (existsSync(rootGitignore)) { + if (await Bun.file(rootGitignore).exists()) { gitignoreFiles.add(rootGitignore); } try { - const nestedGitignores = globSync("**/.gitignore", { - cwd: searchRoot, - dot: true, - absolute: true, - ignore: ["**/node_modules/**", "**/.git/**"], - }); - for (const file of nestedGitignores) { - gitignoreFiles.add(file); + const gitignoreArgs = [ + "--type", + "f", + "--color=never", + "--hidden", + "--absolute-path", + "--glob", + ".gitignore", + "--exclude", + "node_modules", + "--exclude", + ".git", + searchRoot, + ]; + const { stdout, aborted } = await captureCommandOutput(fdPath, gitignoreArgs, signal); + if (aborted) { + throw new Error("Operation aborted"); } - } catch { - // Ignore glob errors. + const output = stdout.trim(); + if (output) { + const nestedGitignores = output + .split("\n") + .map((line) => line.replace(/\r$/, "").trim()) + .filter((line) => line.length > 0); + for (const file of nestedGitignores) { + const normalized = file.replace(/\\/g, "/"); + if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) { + continue; + } + gitignoreFiles.add(file); + } + } + } catch (error) { + if (error instanceof Error && error.message === "Operation aborted") { + throw error; + } + // Ignore gitignore scan errors. } for (const gitignorePath of gitignoreFiles) { @@ -191,16 +259,16 @@ async function listCandidateFiles( args.push(".", searchRoot); - const result = Bun.spawnSync([fdPath, ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal); - const output = result.stdout.toString().trim(); + if (aborted) { + throw new Error("Operation aborted"); + } - if (result.exitCode !== 0 && !output) { - const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`; + const output = stdout.trim(); + + if (exitCode !== 0 && !output) { + const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`; return { files: [], truncated: false, error: errorMsg }; } @@ -219,9 +287,10 @@ async function listCandidateFiles( async function findReadPathSuggestions( rawPath: string, cwd: string, + signal?: AbortSignal, ): Promise<{ suggestions: string[]; scopeLabel?: string; truncated?: boolean; error?: string } | null> { const resolvedPath = resolveToCwd(rawPath, cwd); - const searchRoot = await findExistingDirectory(path.dirname(resolvedPath)); + const searchRoot = await findExistingDirectory(path.dirname(resolvedPath), signal); if (!searchRoot) { return null; } @@ -233,7 +302,7 @@ async function findReadPathSuggestions( } } - const { files, truncated, error } = await listCandidateFiles(searchRoot); + const { files, truncated, error } = await listCandidateFiles(searchRoot, signal); const scopeLabel = formatScopeLabel(searchRoot, cwd); if (error && files.length === 0) { @@ -259,6 +328,7 @@ async function findReadPathSuggestions( const seen = new Set(); for (const file of files) { + signal?.throwIfAborted(); const cleaned = file.replace(/\r$/, "").trim(); if (!cleaned) continue; @@ -311,23 +381,26 @@ async function findReadPathSuggestions( return { suggestions, scopeLabel, truncated }; } -async function convertWithMarkitdown(filePath: string): Promise<{ content: string; ok: boolean; error?: string }> { +async function convertWithMarkitdown( + filePath: string, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean; error?: string }> { const cmd = await ensureTool("markitdown", true); if (!cmd) { return { content: "", ok: false, error: "markitdown not found (uv/pip unavailable)" }; } - const result = Bun.spawnSync([cmd, filePath], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(cmd, [filePath], signal); - if (result.exitCode === 0 && result.stdout && result.stdout.length > 0) { - return { content: result.stdout.toString(), ok: true }; + if (aborted) { + throw new Error("Operation aborted"); } - return { content: "", ok: false, error: result.stderr.toString() || "Conversion failed" }; + if (exitCode === 0 && stdout.length > 0) { + return { content: stdout, ok: true }; + } + + return { content: "", ok: false, error: stderr.trim() || "Conversion failed" }; } const readSchema = Type.Object({ @@ -360,21 +433,12 @@ export function createReadTool(session: ToolSession): AgentTool MAX_IMAGE_SIZE) { const sizeStr = formatSize(fileSize); const maxStr = formatSize(MAX_IMAGE_SIZE); @@ -424,32 +487,45 @@ export function createReadTool(session: ToolSession): AgentTool MAX_IMAGE_SIZE) { + const sizeStr = formatSize(buffer.byteLength); + const maxStr = formatSize(MAX_IMAGE_SIZE); content = [ - { type: "text", text: textNote }, - { type: "image", data: resized.data, mimeType: resized.mimeType }, + { + type: "text", + text: `[Image file too large: ${sizeStr} exceeds ${maxStr} limit. Use an image viewer or resize the image.]`, + }, ]; } else { - content = [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: base64, mimeType }, - ]; + const base64 = Buffer.from(buffer).toString("base64"); + + if (autoResizeImages) { + // Resize image if needed + const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const dimensionNote = formatDimensionNote(resized); + + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + + content = [ + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, + ]; + } else { + content = [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: base64, mimeType }, + ]; + } } } } else if (CONVERTIBLE_EXTENSIONS.has(ext)) { // Convert document via markitdown - const result = await convertWithMarkitdown(absolutePath); + const result = await convertWithMarkitdown(absolutePath, signal); if (result.ok) { // Apply truncation to converted content const truncation = truncateHead(result.content); diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index f7533147d..357053aa5 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -147,11 +147,7 @@ export function formatAge(ageSeconds: number | null | undefined): string { * Get the appropriate status icon with color for a given state. * Standardizes status icon usage across all renderers. */ -export function getStyledStatusIcon( - status: "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted", - theme: Theme, - spinnerFrame?: number, -): string { +export function getStyledStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFrame?: number): string { switch (status) { case "success": return theme.styledSymbol("status.success", "success"); @@ -185,11 +181,7 @@ export function formatExpandHint(expanded: boolean, hasMore: boolean, theme: The /** * Format a badge like [done] or [failed] with brackets and color. */ -export function formatBadge( - label: string, - color: "success" | "error" | "warning" | "accent" | "muted", - theme: Theme, -): string { +export function formatBadge(label: string, color: ToolUIColor, theme: Theme): string { const left = theme.format.bracketLeft; const right = theme.format.bracketRight; return theme.fg(color, `${left}${label}${right}`); @@ -225,6 +217,74 @@ export function formatEmptyMessage(message: string, theme: Theme): string { return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`; } +// ============================================================================= +// Tool UI Kit +// ============================================================================= + +export type ToolUIStatus = "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted"; +export type ToolUIColor = "success" | "error" | "warning" | "accent" | "muted"; + +export interface ToolUITitleOptions { + bold?: boolean; +} + +export interface ToolUIKit { + theme: Theme; + title: (label: string, options?: ToolUITitleOptions) => string; + meta: (meta: string[]) => string; + count: (label: string, count: number) => string; + moreItems: (remaining: number, itemType: string) => string; + expandHint: (expanded: boolean, hasMore: boolean) => string; + scope: (scopePath?: string) => string; + truncationSuffix: (truncated: boolean) => string; + errorMessage: (message: string | undefined) => string; + emptyMessage: (message: string) => string; + badge: (label: string, color: ToolUIColor) => string; + statusIcon: (status: ToolUIStatus, spinnerFrame?: number) => string; + wrapBrackets: (text: string) => string; + truncate: (text: string, maxLen: number) => string; + previewLines: (text: string, maxLines: number, maxLineLen: number) => string[]; + formatBytes: (bytes: number) => string; + formatTokens: (tokens: number) => string; + formatDuration: (ms: number) => string; + formatAge: (ageSeconds: number | null | undefined) => string; + formatDiagnostics: ( + diag: { errored: boolean; summary: string; messages: string[] }, + expanded: boolean, + getLangIcon: (filePath: string) => string, + ) => string; + formatDiffStats: (added: number, removed: number, hunks: number) => string; +} + +export function createToolUIKit(theme: Theme): ToolUIKit { + return { + theme, + title: (label, options) => { + const content = options?.bold === false ? label : theme.bold(label); + return theme.fg("toolTitle", content); + }, + meta: (meta) => formatMeta(meta, theme), + count: (label, count) => formatCount(label, count), + moreItems: (remaining, itemType) => formatMoreItems(remaining, itemType, theme), + expandHint: (expanded, hasMore) => formatExpandHint(expanded, hasMore, theme), + scope: (scopePath) => formatScope(scopePath, theme), + truncationSuffix: (truncated) => formatTruncationSuffix(truncated, theme), + errorMessage: (message) => formatErrorMessage(message, theme), + emptyMessage: (message) => formatEmptyMessage(message, theme), + badge: (label, color) => formatBadge(label, color, theme), + statusIcon: (status, spinnerFrame) => getStyledStatusIcon(status, theme, spinnerFrame), + wrapBrackets: (text) => wrapBrackets(text, theme), + truncate: (text, maxLen) => truncate(text, maxLen, theme.format.ellipsis), + previewLines: (text, maxLines, maxLineLen) => getPreviewLines(text, maxLines, maxLineLen, theme.format.ellipsis), + formatBytes, + formatTokens, + formatDuration, + formatAge, + formatDiagnostics: (diag, expanded, getLangIcon) => formatDiagnostics(diag, expanded, theme, getLangIcon), + formatDiffStats: (added, removed, hunks) => formatDiffStats(added, removed, hunks, theme), + }; +} + // ============================================================================= // Diagnostic Formatting // ============================================================================= diff --git a/packages/coding-agent/src/core/tools/review.ts b/packages/coding-agent/src/core/tools/review.ts index e22a71010..880aef066 100644 --- a/packages/coding-agent/src/core/tools/review.ts +++ b/packages/coding-agent/src/core/tools/review.ts @@ -1,16 +1,16 @@ /** - * Review tools - report_finding and submit_review + * Review tools - report_finding for structured code review. * * Used by the reviewer agent to report findings in a structured way. - * Both tools are hidden by default - only enabled when explicitly listed in agent's tools. + * Hidden by default - only enabled when explicitly listed in agent's tools. + * Reviewers finish via `complete` tool with SubmitReviewDetails schema. */ import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Container, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Container, Text } from "@oh-my-pi/pi-tui"; import { Type } from "@sinclair/typebox"; -import type { Theme } from "../../modes/interactive/theme/theme"; -import { theme } from "../../modes/interactive/theme/theme"; +import type { Theme, ThemeColor } from "../../modes/interactive/theme/theme"; const PRIORITY_LABELS: Record = { 0: "P0", @@ -19,6 +19,24 @@ const PRIORITY_LABELS: Record = { 3: "P3", }; +const PRIORITY_META: Record = + { + 0: { symbol: "status.error", color: "error" }, + 1: { symbol: "status.warning", color: "warning" }, + 2: { symbol: "status.warning", color: "muted" }, + 3: { symbol: "status.info", color: "accent" }, + }; + +function getPriorityDisplay(priority: number, theme: Theme): { label: string; icon: string; color: ThemeColor } { + const label = PRIORITY_LABELS[priority] ?? "P?"; + const meta = PRIORITY_META[priority] ?? { symbol: "status.info", color: "muted" as const }; + return { + label, + icon: theme.styledSymbol(meta.symbol, meta.color), + color: meta.color, + }; +} + // report_finding schema const ReportFindingParams = Type.Object({ title: Type.String({ @@ -53,7 +71,7 @@ interface ReportFindingDetails { export const reportFindingTool: AgentTool = { name: "report_finding", label: "Report Finding", - description: "Report a code review finding. Use this for each issue found. Call submit_review when done.", + description: "Report a code review finding. Use this for each issue found. Call complete when done.", parameters: ReportFindingParams, async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const { title, body, priority, confidence, file_path, line_start, line_end } = params; @@ -73,11 +91,10 @@ export const reportFindingTool: AgentTool = { - name: "submit_review", - label: "Submit Review", - description: "Submit the final review verdict. Call this after all findings have been reported.", - parameters: SubmitReviewParams, - - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const { overall_correctness, explanation, confidence } = params; - - let summary = `## Review Summary\n\n`; - summary += `**Verdict:** ${ - overall_correctness === "correct" - ? `${theme.status.success} Patch is correct` - : `${theme.status.error} Patch is incorrect` - }\n`; - summary += `**Confidence:** ${(confidence * 100).toFixed(0)}%\n\n`; - summary += explanation; - - return { - content: [{ type: "text", text: summary }], - details: { overall_correctness, explanation, confidence }, - }; - }, - - renderCall(args, theme): Component { - const verdict = args.overall_correctness === "correct" ? "correct" : "incorrect"; - const color = args.overall_correctness === "correct" ? "success" : "error"; - return new Text( - `${theme.fg("toolTitle", theme.bold("submit_review "))}${theme.fg(color, verdict)} ${theme.fg( + `${theme.fg("success", theme.status.success)} ${icon} ${theme.fg(color, `[${label}]`)} ${theme.fg( "dim", - `(${((args.confidence as number) * 100).toFixed(0)}%)`, + location, )}`, 0, 0, ); }, - - renderResult(result, { expanded }, theme): Component { - const { details } = result; - if (!details) { - const text = result.content[0]; - return new Text(text?.type === "text" ? text.text : "", 0, 0); - } - - const container = new Container(); - const verdictColor = details.overall_correctness === "correct" ? "success" : "error"; - const verdictIcon = details.overall_correctness === "correct" ? theme.status.success : theme.status.error; - - container.addChild( - new Text( - `${theme.fg(verdictColor, verdictIcon)} Patch is ${theme.fg( - verdictColor, - details.overall_correctness, - )} ${theme.fg("dim", `(${(details.confidence * 100).toFixed(0)}% confidence)`)}`, - 0, - 0, - ), - ); - - if (expanded) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("dim", details.explanation), 0, 0)); - } - - return container; - }, }; +/** SubmitReviewDetails - used for rendering review results from complete tool */ +export interface SubmitReviewDetails { + overall_correctness: "correct" | "incorrect"; + explanation: string; + confidence: number; +} + // Re-export types for external use -export type { ReportFindingDetails, SubmitReviewDetails }; +export type { ReportFindingDetails }; // ───────────────────────────────────────────────────────────────────────────── // Subprocess tool handlers - registered for extraction/rendering in task tool @@ -211,11 +148,10 @@ subprocessToolRegistry.register("report_finding", { extractData: (event) => event.result?.details as ReportFindingDetails | undefined, renderInline: (data, theme) => { - const priority = PRIORITY_LABELS[data.priority] ?? "P?"; - const color = data.priority === 0 ? "error" : data.priority === 1 ? "warning" : "muted"; + const { label, icon, color } = getPriorityDisplay(data.priority, theme); const titleText = data.title.replace(/^\[P\d\]\s*/, ""); const loc = `${path.basename(data.file_path)}:${data.line_start}`; - return new Text(`${theme.fg(color, `[${priority}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0); + return new Text(`${icon} ${theme.fg(color, `[${label}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0); }, renderFinal: (allData, theme, expanded) => { @@ -224,13 +160,12 @@ subprocessToolRegistry.register("report_finding", { for (let i = 0; i < displayCount; i++) { const data = allData[i]; - const priority = PRIORITY_LABELS[data.priority] ?? "P?"; - const color = data.priority === 0 ? "error" : data.priority === 1 ? "warning" : "muted"; + const { label, icon, color } = getPriorityDisplay(data.priority, theme); const titleText = data.title.replace(/^\[P\d\]\s*/, ""); const loc = `${path.basename(data.file_path)}:${data.line_start}`; container.addChild( - new Text(` ${theme.fg(color, `[${priority}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0), + new Text(` ${icon} ${theme.fg(color, `[${label}]`)} ${titleText} ${theme.fg("dim", loc)}`, 0, 0), ); if (expanded && data.body) { @@ -251,26 +186,3 @@ subprocessToolRegistry.register("report_finding", { return container; }, }); - -// Register submit_review handler -subprocessToolRegistry.register("submit_review", { - extractData: (event) => event.result?.details as SubmitReviewDetails | undefined, - - // Terminate subprocess after review is submitted - shouldTerminate: () => true, - - renderInline: (data, theme) => { - const verdictColor = data.overall_correctness === "correct" ? "success" : "error"; - const verdictIcon = data.overall_correctness === "correct" ? theme.status.success : theme.status.error; - return new Text( - `${theme.fg(verdictColor, verdictIcon)} Review: ${theme.fg(verdictColor, data.overall_correctness)} (${( - data.confidence * 100 - ).toFixed(0)}%)`, - 0, - 0, - ); - }, - - // Note: renderFinal is NOT used for submit_review - we use the combined - // renderReviewResult in render.ts to show verdict + findings together -}); diff --git a/packages/coding-agent/src/core/tools/task/artifacts.ts b/packages/coding-agent/src/core/tools/task/artifacts.ts index cf6c69542..57c52e8b8 100644 --- a/packages/coding-agent/src/core/tools/task/artifacts.ts +++ b/packages/coding-agent/src/core/tools/task/artifacts.ts @@ -8,6 +8,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { nanoid } from "nanoid"; /** * Derive artifacts directory from session file path. @@ -62,14 +63,14 @@ export async function writeArtifacts( const paths = getArtifactPaths(dir, taskId); // Write input - await fs.promises.writeFile(paths.inputPath, input, "utf-8"); + await Bun.write(paths.inputPath, input); // Write output - await fs.promises.writeFile(paths.outputPath, output, "utf-8"); + await Bun.write(paths.outputPath, output); // Write JSONL if events provided if (jsonlEvents && jsonlEvents.length > 0) { - await fs.promises.writeFile(paths.jsonlPath, jsonlEvents.join("\n"), "utf-8"); + await Bun.write(paths.jsonlPath, jsonlEvents.join("\n")); return paths; } @@ -80,7 +81,7 @@ export async function writeArtifacts( * Create a temporary artifacts directory. */ export function createTempArtifactsDir(runId?: string): string { - const id = runId || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const id = runId || nanoid(); const dir = path.join(os.tmpdir(), `omp-task-${id}`); ensureArtifactsDir(dir); return dir; diff --git a/packages/coding-agent/src/core/tools/task/executor.ts b/packages/coding-agent/src/core/tools/task/executor.ts index d6f20041d..fcb6c4bdb 100644 --- a/packages/coding-agent/src/core/tools/task/executor.ts +++ b/packages/coding-agent/src/core/tools/task/executor.ts @@ -4,7 +4,6 @@ * Runs each subagent in a Bun Worker and forwards AgentEvents for progress tracking. */ -import { writeFileSync } from "node:fs"; import type { AgentEvent } from "@oh-my-pi/pi-agent-core"; import type { EventBus } from "../../event-bus"; import { ensureArtifactsDir, getArtifactPaths } from "./artifacts"; @@ -50,20 +49,26 @@ function truncateOutput(output: string): { text: string; truncated: boolean } { let i = 0; let lastNewlineIndex = -1; - while (i < output.length && byteBudget > 0) { - const ch = output.charCodeAt(i); - byteBudget--; + while (i < output.length) { + const codePoint = output.codePointAt(i); + if (codePoint === undefined) break; + const codeUnitLength = codePoint > 0xffff ? 2 : 1; + const byteLen = codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (byteBudget - byteLen < 0) { + truncated = true; + break; + } + byteBudget -= byteLen; + i += codeUnitLength; - if (ch === 10 /* \n */) { + if (codePoint === 0x0a) { lineBudget--; - lastNewlineIndex = i; + lastNewlineIndex = i - 1; if (lineBudget <= 0) { truncated = true; break; } } - - i++; } if (i < output.length) { @@ -186,7 +191,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise | undefined; - let pendingTerminationTimer: ReturnType | undefined; + let terminationScheduled = false; + let pendingTerminationController: AbortController | null = null; let finalize: ((message: Extract) => void) | null = null; + const listenerController = new AbortController(); + const listenerSignal = listenerController.signal; // Accumulate usage incrementally from message_end events (no memory for streaming events) const accumulatedUsage = { @@ -252,11 +258,31 @@ export async function runSubprocess(options: ExecutorOptions): Promise { - if (abortTerminateTimer) clearTimeout(abortTerminateTimer); - abortTerminateTimer = undefined; - if (pendingTerminationTimer) clearTimeout(pendingTerminationTimer); - pendingTerminationTimer = undefined; + const scheduleTermination = () => { + if (terminationScheduled) return; + terminationScheduled = true; + const timeoutSignal = AbortSignal.timeout(2000); + timeoutSignal.addEventListener( + "abort", + () => { + if (resolved) return; + try { + worker.terminate(); + } catch { + // Ignore termination errors + } + if (finalize && !resolved) { + finalize({ + type: "done", + exitCode: 1, + durationMs: Date.now() - startTime, + error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion", + aborted: abortReason === "signal", + }); + } + }, + { once: true, signal: listenerSignal }, + ); }; const requestAbort = (reason: AbortReason) => { @@ -269,33 +295,35 @@ export async function runSubprocess(options: ExecutorOptions): Promise { - if (!resolved) { - try { - worker.terminate(); - } catch { - // Ignore termination errors + // Cancel pending termination if it exists + if (pendingTerminationController) { + pendingTerminationController.abort(); + pendingTerminationController = null; + } + scheduleTermination(); + }; + + const schedulePendingTermination = () => { + if (pendingTerminationController || abortSent || terminationScheduled || resolved) return; + const readyController = new AbortController(); + pendingTerminationController = readyController; + const pendingSignal = AbortSignal.any([AbortSignal.timeout(2000), readyController.signal]); + pendingSignal.addEventListener( + "abort", + () => { + pendingTerminationController = null; + if (!resolved) { + requestAbort("terminate"); } - if (finalize && !resolved) { - finalize({ - type: "done", - exitCode: 1, - durationMs: Date.now() - startTime, - error: reason === "signal" ? "Aborted" : "Worker terminated after tool completion", - aborted: reason === "signal", - }); - } - } - }, 2000); + }, + { once: true, signal: listenerSignal }, + ); }; // Handle abort signal @@ -303,7 +331,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { @@ -406,14 +434,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { - if (!resolved) { - requestAbort("terminate"); - } - }, 2000); + schedulePendingTermination(); } } break; @@ -446,7 +467,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise; - const costRecord = (messageUsage as { cost?: Record }).cost; + if (role === "assistant") { + const usageRecord = messageUsage as Record; + const costRecord = (messageUsage as { cost?: Record }).cost; hasUsage = true; - accumulatedUsage.input += usageRecord.input ?? 0; - accumulatedUsage.output += usageRecord.output ?? 0; - accumulatedUsage.cacheRead += usageRecord.cacheRead ?? 0; - accumulatedUsage.cacheWrite += usageRecord.cacheWrite ?? 0; - accumulatedUsage.totalTokens += usageRecord.totalTokens ?? 0; + accumulatedUsage.input += getNumberField(usageRecord, "input") ?? 0; + accumulatedUsage.output += getNumberField(usageRecord, "output") ?? 0; + accumulatedUsage.cacheRead += getNumberField(usageRecord, "cacheRead") ?? 0; + accumulatedUsage.cacheWrite += getNumberField(usageRecord, "cacheWrite") ?? 0; + accumulatedUsage.totalTokens += getNumberField(usageRecord, "totalTokens") ?? 0; if (costRecord) { - accumulatedUsage.cost.input += costRecord.input ?? 0; - accumulatedUsage.cost.output += costRecord.output ?? 0; - accumulatedUsage.cost.cacheRead += costRecord.cacheRead ?? 0; - accumulatedUsage.cost.cacheWrite += costRecord.cacheWrite ?? 0; - accumulatedUsage.cost.total += costRecord.total ?? 0; + accumulatedUsage.cost.input += getNumberField(costRecord, "input") ?? 0; + accumulatedUsage.cost.output += getNumberField(costRecord, "output") ?? 0; + accumulatedUsage.cost.cacheRead += getNumberField(costRecord, "cacheRead") ?? 0; + accumulatedUsage.cost.cacheWrite += getNumberField(costRecord, "cacheWrite") ?? 0; + accumulatedUsage.cost.total += getNumberField(costRecord, "total") ?? 0; } } // Accumulate tokens for progress display progress.tokens += getUsageTokens(messageUsage); } // If pending termination, now we have tokens - terminate - if (pendingTermination && !resolved) { - requestAbort("terminate"); + if (pendingTerminationController) { + pendingTerminationController.abort(); } break; } @@ -495,7 +512,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise>((resolve) => { const cleanup = () => { - worker.removeEventListener("message", onMessage); - worker.removeEventListener("error", onError); - worker.removeEventListener("close", onClose); - worker.removeEventListener("messageerror", onMessageError); - clearTimers(); + pendingTerminationController = null; + listenerController.abort(); }; finalize = (message) => { if (resolved) return; @@ -594,10 +608,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise 0 ? finalOutputChunks.join("") : outputChunks.join(""); let abortedViaComplete = false; const completeItems = progress.extractedToolData?.complete as | Array<{ data?: unknown; status?: "success" | "aborted"; error?: string }> @@ -675,7 +686,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { const startTime = Date.now(); const { agents, projectAgentsDir } = await discoverAgents(session.cwd); - const context = params.context; - const outputSchema = params.output_schema; + const { agent: agentName, context, model, output: outputSchema } = params; - // Handle empty or missing tasks - if (!params.tasks || params.tasks.length === 0) { + // Validate agent exists + const agent = getAgent(agents, agentName); + if (!agent) { const available = agents.map((a) => a.name).join(", ") || "none"; return { content: [ { type: "text", - text: `No tasks provided. Use: { tasks: [{agent, task}, ...] }\nAvailable agents: ${available}`, + text: `Unknown agent "${agentName}". Available: ${available}`, + }, + ], + details: { + projectAgentsDir, + results: [], + totalDurationMs: 0, + }, + }; + } + + // Handle empty or missing tasks + if (!params.tasks || params.tasks.length === 0) { + return { + content: [ + { + type: "text", + text: `No tasks provided. Use: { agent, context, tasks: [{id, task, description}, ...] }`, }, ], details: { @@ -173,6 +189,56 @@ export async function createTaskTool( }; } + const tasks = params.tasks; + const missingTaskIndexes: number[] = []; + const idIndexes = new Map(); + + for (let i = 0; i < tasks.length; i++) { + const id = tasks[i]?.id; + if (typeof id !== "string" || id.trim() === "") { + missingTaskIndexes.push(i); + continue; + } + const normalizedId = id.toLowerCase(); + const indexes = idIndexes.get(normalizedId); + if (indexes) { + indexes.push(i); + } else { + idIndexes.set(normalizedId, [i]); + } + } + + const duplicateIds: Array<{ id: string; indexes: number[] }> = []; + for (const [normalizedId, indexes] of idIndexes.entries()) { + if (indexes.length > 1) { + duplicateIds.push({ + id: tasks[indexes[0]]?.id ?? normalizedId, + indexes, + }); + } + } + + if (missingTaskIndexes.length > 0 || duplicateIds.length > 0) { + const problems: string[] = []; + if (missingTaskIndexes.length > 0) { + problems.push(`Missing task ids at indexes: ${missingTaskIndexes.join(", ")}`); + } + if (duplicateIds.length > 0) { + const details = duplicateIds + .map((entry) => `${entry.id} (indexes ${entry.indexes.join(", ")})`) + .join("; "); + problems.push(`Duplicate task ids detected (case-insensitive): ${details}`); + } + return { + content: [{ type: "text", text: `Invalid tasks: ${problems.join(". ")}` }], + details: { + projectAgentsDir, + results: [], + totalDurationMs: 0, + }, + }; + } + // Derive artifacts directory const sessionFile = session.getSessionFile(); const artifactsDir = sessionFile ? getArtifactsDir(sessionFile) : null; @@ -197,88 +263,59 @@ export async function createTaskTool( }; try { - let tasks = params.tasks; - let skippedSelfRecursion = 0; - - // Filter out blocked agent (self-recursion prevention) - if (blockedAgent) { - const blockedTasks = tasks.filter((t) => t.agent === blockedAgent); - tasks = tasks.filter((t) => t.agent !== blockedAgent); - skippedSelfRecursion = blockedTasks.length; - - if (skippedSelfRecursion > 0 && tasks.length === 0) { - return { - content: [ - { - type: "text", - text: `Cannot spawn ${blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`, - }, - ], - details: { - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, + // Check self-recursion prevention + if (blockedAgent && agentName === blockedAgent) { + return { + content: [ + { + type: "text", + text: `Cannot spawn ${blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`, }, - }; - } - } - - // Validate all agents exist - for (const task of tasks) { - if (!getAgent(agents, task.agent)) { - const available = agents.map((a) => a.name).join(", "); - return { - content: [{ type: "text", text: `Unknown agent: ${task.agent}. Available: ${available}` }], - details: { - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, - }, - }; - } + ], + details: { + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; } // Check spawn restrictions from parent const parentSpawns = session.getSessionSpawns() ?? "*"; const allowedSpawns = parentSpawns.split(",").map((s) => s.trim()); - const isSpawnAllowed = (agentName: string): boolean => { + const isSpawnAllowed = (): boolean => { if (parentSpawns === "") return false; // Empty = deny all if (parentSpawns === "*") return true; // Wildcard = allow all return allowedSpawns.includes(agentName); }; - for (const task of tasks) { - if (!isSpawnAllowed(task.agent)) { - const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns; - return { - content: [{ type: "text", text: `Cannot spawn '${task.agent}'. Allowed: ${allowed}` }], - details: { - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, - }, - }; - } + if (!isSpawnAllowed()) { + const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns; + return { + content: [{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${allowed}` }], + details: { + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; } - // Build full prompts with context prepended and generate task IDs + // Build full prompts with context prepended const tasksWithContext = tasks.map((t) => ({ - agent: t.agent, task: context ? `${context}\n\n${t.task}` : t.task, - model: t.model, description: t.description, - taskId: generateTaskName(), + taskId: t.id, })); // Initialize progress for all tasks for (let i = 0; i < tasksWithContext.length; i++) { const t = tasksWithContext[i]; - const agentCfg = getAgent(agents, t.agent); progressMap.set(i, { index: i, taskId: t.taskId, - agent: t.agent, - agentSource: agentCfg?.source ?? "user", + agent: agentName, + agentSource: agent.source, status: "pending", task: t.task, recentTools: [], @@ -286,36 +323,40 @@ export async function createTaskTool( toolCount: 0, tokens: 0, durationMs: 0, - modelOverride: t.model, + modelOverride: model, description: t.description, }); } emitProgress(); // Execute in parallel with concurrency limit - const results = await mapWithConcurrencyLimit(tasksWithContext, MAX_CONCURRENCY, async (task, index) => { - const agent = getAgent(agents, task.agent)!; - return runSubprocess({ - cwd: session.cwd, - agent, - task: task.task, - description: task.description, - index, - taskId: task.taskId, - context: undefined, // Already prepended above - modelOverride: task.model, - outputSchema, - sessionFile, - persistArtifacts: !!artifactsDir, - artifactsDir: effectiveArtifactsDir, - signal, - eventBus: undefined, - onProgress: (progress) => { - progressMap.set(index, structuredClone(progress)); - emitProgress(); - }, - }); - }); + const results = await mapWithConcurrencyLimit( + tasksWithContext, + MAX_CONCURRENCY, + async (task, index) => { + return runSubprocess({ + cwd: session.cwd, + agent, + task: task.task, + description: task.description, + index, + taskId: task.taskId, + context: undefined, // Already prepended above + modelOverride: model, + outputSchema, + sessionFile, + persistArtifacts: !!artifactsDir, + artifactsDir: effectiveArtifactsDir, + signal, + eventBus: undefined, + onProgress: (progress) => { + progressMap.set(index, structuredClone(progress)); + emitProgress(); + }, + }); + }, + signal, + ); // Aggregate usage from executor results (already accumulated incrementally) const aggregatedUsage = createUsageTotals(); @@ -349,14 +390,10 @@ export async function createTaskTool( return `[${r.agent}] ${status}${meta} ${r.taskId}\n${preview}`; }); - const skippedNote = - skippedSelfRecursion > 0 - ? ` (${skippedSelfRecursion} ${blockedAgent} task${skippedSelfRecursion > 1 ? "s" : ""} skipped - self-recursion blocked)` - : ""; const outputIds = results.map((r) => r.taskId); const outputHint = outputIds.length > 0 ? `\n\nUse output tool for full logs: output ids ${outputIds.join(", ")}` : ""; - const summary = `${successCount}/${results.length} succeeded${skippedNote} [${formatDuration( + const summary = `${successCount}/${results.length} succeeded [${formatDuration( totalDuration, )}]\n\n${summaries.join("\n\n---\n\n")}${outputHint}`; diff --git a/packages/coding-agent/src/core/tools/task/parallel.ts b/packages/coding-agent/src/core/tools/task/parallel.ts index 0d93c6171..f8e19b649 100644 --- a/packages/coding-agent/src/core/tools/task/parallel.ts +++ b/packages/coding-agent/src/core/tools/task/parallel.ts @@ -7,24 +7,45 @@ import { MAX_CONCURRENCY } from "./types"; /** * Execute items with a concurrency limit using a worker pool pattern. * Results are returned in the same order as input items. + * Fails fast on first error - does not wait for other workers to complete. * * @param items - Items to process * @param concurrency - Maximum concurrent operations * @param fn - Async function to execute for each item + * @param signal - Optional abort signal to stop scheduling work */ export async function mapWithConcurrencyLimit( items: T[], concurrency: number, fn: (item: T, index: number) => Promise, + signal?: AbortSignal, ): Promise { const limit = Math.max(1, Math.min(concurrency, items.length, MAX_CONCURRENCY)); const results: R[] = new Array(items.length); let nextIndex = 0; + // Create internal abort controller to cancel workers on any rejection + const abortController = new AbortController(); + const workerSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal; + + // Promise that rejects on first error - used to fail fast + let rejectFirst: (error: unknown) => void; + const firstErrorPromise = new Promise((_, reject) => { + rejectFirst = reject; + }); + const worker = async (): Promise => { - while (nextIndex < items.length) { + while (true) { + workerSignal.throwIfAborted(); const index = nextIndex++; - results[index] = await fn(items[index], index); + if (index >= items.length) return; + try { + results[index] = await fn(items[index], index); + } catch (error) { + abortController.abort(); + rejectFirst(error); + throw error; + } } }; @@ -32,7 +53,13 @@ export async function mapWithConcurrencyLimit( const workers = Array(limit) .fill(null) .map(() => worker()); + await Promise.race([Promise.all(workers), firstErrorPromise]); + + // Check external abort + if (signal?.aborted) { + const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted"); + throw reason; + } - await Promise.all(workers); return results; } diff --git a/packages/coding-agent/src/core/tools/task/render.ts b/packages/coding-agent/src/core/tools/task/render.ts index 0c28c4f64..d8cab9576 100644 --- a/packages/coding-agent/src/core/tools/task/render.ts +++ b/packages/coding-agent/src/core/tools/task/render.ts @@ -58,12 +58,20 @@ function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): s counts.set(finding.priority, (counts.get(finding.priority) ?? 0) + 1); } + const priorityMeta: Record = { + 0: { icon: theme.styledSymbol("status.error", "error"), color: "error" }, + 1: { icon: theme.styledSymbol("status.warning", "warning"), color: "warning" }, + 2: { icon: theme.styledSymbol("status.warning", "muted"), color: "muted" }, + 3: { icon: theme.styledSymbol("status.info", "accent"), color: "accent" }, + }; + const parts: string[] = []; for (const priority of [0, 1, 2, 3]) { const label = PRIORITY_LABELS[priority] ?? "P?"; - const color = priority === 0 ? "error" : priority === 1 ? "warning" : "muted"; + const meta = priorityMeta[priority] ?? { icon: "", color: "muted" as const }; const count = counts.get(priority) ?? 0; - parts.push(theme.fg(color, `${label}:${count}`)); + const text = theme.fg(meta.color, `${label}:${count}`); + parts.push(meta.icon ? `${meta.icon} ${text}` : text); } return `${theme.fg("dim", "Findings:")} ${parts.join(theme.sep.dot)}`; @@ -123,13 +131,19 @@ function renderJsonTreeLines( pushLine(`${prefix}${iconArray} ${header}`); if (val.length === 0) { pushLine( - `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", "[]")}`, + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg( + "dim", + "[]", + )}`, ); return; } if (depth >= maxDepth) { pushLine( - `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", theme.format.ellipsis)}`, + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg( + "dim", + theme.format.ellipsis, + )}`, ); return; } @@ -150,13 +164,19 @@ function renderJsonTreeLines( const entries = Object.entries(val as Record); if (entries.length === 0) { pushLine( - `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", "{}")}`, + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg( + "dim", + "{}", + )}`, ); return; } if (depth >= maxDepth) { pushLine( - `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", theme.format.ellipsis)}`, + `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg( + "dim", + theme.format.ellipsis, + )}`, ); return; } @@ -233,19 +253,25 @@ function renderOutputSection( */ export function renderCall(args: TaskParams, theme: Theme): Component { const label = theme.fg("toolTitle", theme.bold("Task")); + const agentTag = theme.italic( + theme.fg("dim", `${theme.format.bracketLeft}${args.agent}${theme.format.bracketRight}`), + ); if (args.tasks.length === 1) { - // Single task - show agent and task preview + // Single task - show description preview const task = args.tasks[0]; - const summary = task.description?.trim() || task.task; - const taskPreview = truncate(summary, 60, theme.format.ellipsis); - return new Text(`${label} ${theme.fg("accent", task.agent)}: ${theme.fg("muted", taskPreview)}`, 0, 0); + const summary = task.description.trim() || task.task; + const taskPreview = truncate(summary, 50, theme.format.ellipsis); + return new Text(`${label} ${agentTag} ${theme.fg("muted", taskPreview)}`, 0, 0); } - // Multiple tasks - show count and descriptions (or agent names as fallback) - const agents = args.tasks.map((t) => t.description?.trim() || t.agent).join(", "); + // Multiple tasks - show count and descriptions + const descriptions = args.tasks.map((t) => t.description.trim()).join(", "); return new Text( - `${label} ${theme.fg("muted", `${args.tasks.length} agents: ${truncate(agents, 50, theme.format.ellipsis)}`)}`, + `${label} ${agentTag} ${args.tasks.length} agents: ${theme.fg( + "muted", + truncate(descriptions, 50, theme.format.ellipsis), + )}`, 0, 0, ); @@ -275,23 +301,14 @@ function renderAgentProgress( ? "error" : "accent"; - // Main status line - use taskId for Output tool - let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", progress.taskId)}`; + // Main status line: taskId: description [status] · stats · ⟨agent⟩ const description = progress.description?.trim(); - if (description) { - statusLine += ` ${theme.fg("muted", truncate(description, 40, theme.format.ellipsis))}`; - } + const titlePart = description ? `${theme.bold(progress.taskId)}: ${description}` : progress.taskId; + let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`; // Only show badge for non-running states (spinner already indicates running) - if (progress.status !== "running") { - const statusLabel = - progress.status === "completed" - ? "done" - : progress.status === "failed" - ? "failed" - : progress.status === "aborted" - ? "aborted" - : "pending"; + if (progress.status === "failed" || progress.status === "aborted") { + const statusLabel = progress.status === "failed" ? "failed" : "aborted"; statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`; } @@ -338,6 +355,21 @@ function renderAgentProgress( // Render extracted tool data inline (e.g., review findings) if (progress.extractedToolData) { + // For completed tasks, check for review verdict from complete tool + if (progress.status === "completed") { + const completeData = progress.extractedToolData.complete as Array<{ data: unknown }> | undefined; + const reportFindingData = progress.extractedToolData.report_finding as ReportFindingDetails[] | undefined; + const reviewData = completeData + ?.map((c) => c.data as SubmitReviewDetails) + .filter((d) => d && typeof d === "object" && "overall_correctness" in d); + if (reviewData && reviewData.length > 0) { + const summary = reviewData[reviewData.length - 1]; + const findings = reportFindingData ?? []; + lines.push(...renderReviewResult(summary, findings, continuePrefix, expanded, theme)); + return lines; // Review result handles its own rendering + } + } + for (const [toolName, dataArray] of Object.entries(progress.extractedToolData)) { const handler = subprocessToolRegistry.getHandler(toolName); if (handler?.renderInline) { @@ -381,7 +413,10 @@ function renderReviewResult( const verdictColor = summary.overall_correctness === "correct" ? "success" : "error"; const verdictIcon = summary.overall_correctness === "correct" ? theme.status.success : theme.status.error; lines.push( - `${continuePrefix}${theme.fg(verdictColor, verdictIcon)} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${theme.fg("dim", `(${(summary.confidence * 100).toFixed(0)}% confidence)`)}`, + `${continuePrefix} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${theme.fg( + verdictColor, + verdictIcon, + )} ${theme.fg("dim", `(${(summary.confidence * 100).toFixed(0)}% confidence)`)}`, ); // Explanation preview (first ~80 chars when collapsed, full when expanded) @@ -411,7 +446,7 @@ function renderReviewResult( } /** - * Render review findings list (used with and without submit_review). + * Render review findings list. */ function renderFindings( findings: ReportFindingDetails[], @@ -472,12 +507,14 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool const iconColor = success ? "success" : "error"; const statusText = aborted ? "aborted" : success ? "done" : "failed"; - // Main status line - use taskId for Output tool - let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", result.taskId)} ${formatBadge(statusText, iconColor, theme)}`; + // Main status line: taskId: description [status] · stats · ⟨agent⟩ const description = result.description?.trim(); - if (description) { - statusLine += ` ${theme.fg("muted", truncate(description, 40, theme.format.ellipsis))}`; - } + const titlePart = description ? `${theme.bold(result.taskId)}: ${description}` : result.taskId; + let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge( + statusText, + iconColor, + theme, + )}`; if (result.tokens > 0) { statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(result.tokens)} tokens`)}`; } @@ -489,10 +526,16 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool lines.push(statusLine); - // Check for review result (submit_review + report_finding) - const submitReviewData = result.extractedToolData?.submit_review as SubmitReviewDetails[] | undefined; + // Check for review result (complete with review schema + report_finding) + const completeData = result.extractedToolData?.complete as Array<{ data: unknown }> | undefined; const reportFindingData = result.extractedToolData?.report_finding as ReportFindingDetails[] | undefined; + // Extract review verdict from complete tool's data field if it matches SubmitReviewDetails + const reviewData = completeData + ?.map((c) => c.data as SubmitReviewDetails) + .filter((d) => d && typeof d === "object" && "overall_correctness" in d); + const submitReviewData = reviewData && reviewData.length > 0 ? reviewData : undefined; + if (submitReviewData && submitReviewData.length > 0) { // Use combined review renderer const summary = submitReviewData[submitReviewData.length - 1]; @@ -502,7 +545,10 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool } if (reportFindingData && reportFindingData.length > 0) { lines.push( - `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg("dim", "Review summary missing (submit_review not called)")}`, + `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg( + "dim", + "Review summary missing (complete not called)", + )}`, ); lines.push(`${continuePrefix}${formatFindingSummary(reportFindingData, theme)}`); lines.push(`${continuePrefix}`); // Spacing @@ -515,7 +561,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool if (result.extractedToolData) { for (const [toolName, dataArray] of Object.entries(result.extractedToolData)) { // Skip review tools - handled above - if (toolName === "submit_review" || toolName === "report_finding") continue; + if (toolName === "complete" || toolName === "report_finding") continue; const handler = subprocessToolRegistry.getHandler(toolName); if (handler?.renderFinal && (dataArray as unknown[]).length > 0) { diff --git a/packages/coding-agent/src/core/tools/task/types.ts b/packages/coding-agent/src/core/tools/task/types.ts index 9d7975666..879e9185e 100644 --- a/packages/coding-agent/src/core/tools/task/types.ts +++ b/packages/coding-agent/src/core/tools/task/types.ts @@ -41,20 +41,29 @@ export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress"; /** Single task item for parallel execution */ export const taskItemSchema = Type.Object({ - agent: Type.String({ description: "Agent name" }), + id: Type.String({ + description: "Short task identifier for display (max 32 chars, CamelCase, e.g. 'SessionStore', 'WebFetchFix')", + maxLength: 32, + pattern: "^[A-Za-z][A-Za-z0-9]*$", + }), task: Type.String({ description: "Task description for the agent" }), - description: Type.Optional(Type.String({ description: "Short description for UI display" })), - model: Type.Optional(Type.String({ description: "Model override for this task" })), + description: Type.String({ description: "Short description for UI display" }), }); export type TaskItem = Static; /** Task tool parameters */ export const taskSchema = Type.Object({ - context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })), - output_schema: Type.Optional( + agent: Type.String({ description: "Agent type to use for all tasks" }), + context: Type.String({ description: "Shared context prepended to all task prompts" }), + model: Type.Optional( + Type.String({ + description: "Model override for all tasks (fuzzy matching, e.g. 'sonnet', 'opus')", + }), + ), + output: Type.Optional( Type.Any({ - description: "JSON schema for structured subagent output (used by the complete tool)", + description: "JTD schema for structured subagent output (used by the complete tool)", }), ), tasks: Type.Array(taskItemSchema, { diff --git a/packages/coding-agent/src/core/tools/task/worker.ts b/packages/coding-agent/src/core/tools/task/worker.ts index 6c89761ce..f70d48d5c 100644 --- a/packages/coding-agent/src/core/tools/task/worker.ts +++ b/packages/coding-agent/src/core/tools/task/worker.ts @@ -19,6 +19,7 @@ import type { AgentSessionEvent } from "../../agent-session"; import { parseModelPattern, parseModelString } from "../../model-resolver"; import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk"; import { SessionManager } from "../../session-manager"; +import { untilAborted } from "../../utils"; import type { SubagentWorkerRequest, SubagentWorkerResponse, SubagentWorkerStartPayload } from "./worker-protocol"; type PostMessageFn = (message: SubagentWorkerResponse) => void; @@ -53,11 +54,33 @@ const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent => { return agentEventTypes.has(event.type as AgentEvent["type"]); }; -let running = false; -let abortRequested = false; -let doneSent = false; -let activeSession: { abort: () => Promise; dispose: () => Promise } | null = null; -let unsubscribe: (() => void) | null = null; +interface RunState { + abortController: AbortController; + startTime: number; + session: { abort: () => Promise; dispose: () => Promise } | null; + unsubscribe: (() => void) | null; + sendDoneOnce: (message: Extract) => void; +} + +const createSendDoneOnce = (): RunState["sendDoneOnce"] => { + let sent = false; + return (message) => { + if (sent) return; + sent = true; + postMessageSafe(message); + }; +}; + +const createRunState = (): RunState => ({ + abortController: new AbortController(), + startTime: Date.now(), + session: null, + unsubscribe: null, + sendDoneOnce: createSendDoneOnce(), +}); + +let activeRun: RunState | null = null; +let pendingAbort = false; /** * Resolve model string to Model object with optional thinking level. @@ -98,26 +121,35 @@ function resolveModelOverride( * - OMP_BLOCKED_AGENT: payload.blockedAgent (prevents same-agent recursion) * - OMP_SPAWNS: payload.spawnsEnv (controls nested spawn permissions) */ -async function runTask(payload: SubagentWorkerStartPayload): Promise { - const startTime = Date.now(); +async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): Promise { + const { signal } = runState.abortController; + const startTime = runState.startTime; let exitCode = 0; let error: string | undefined; let aborted = false; + const sessionAbortController = new AbortController(); + + // Helper to check abort status - throws if aborted to exit early + const checkAbort = (): void => { + if (signal.aborted) { + aborted = true; + exitCode = 1; + throw new Error("Aborted"); + } + }; try { // Check for pre-start abort - if (abortRequested) { - aborted = true; - exitCode = 1; - return; - } + checkAbort(); // Set working directory (CLI does this implicitly) process.chdir(payload.cwd); // Discover auth and models (equivalent to CLI's discoverAuthStorage/discoverModels) const authStorage = await discoverAuthStorage(); + checkAbort(); const modelRegistry = await discoverModels(authStorage); + checkAbort(); // Resolve model override (equivalent to CLI's parseModelPattern with --model) const { model, thinkingLevel } = resolveModelOverride(payload.model, modelRegistry); @@ -126,6 +158,7 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise { const sessionManager = payload.sessionFile ? await SessionManager.open(payload.sessionFile) : SessionManager.inMemory(payload.cwd); + checkAbort(); // Create agent session (equivalent to CLI's createAgentSession) // Note: hasUI: false disables interactive features @@ -149,18 +182,16 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise { spawns: payload.spawnsEnv, }); - activeSession = session; + runState.session = session; + checkAbort(); - if (abortRequested) { - aborted = true; - exitCode = 1; - try { - await session.abort(); - } catch { - // Ignore abort errors - } - return; - } + signal.addEventListener( + "abort", + () => { + void session.abort(); + }, + { once: true, signal: sessionAbortController.signal }, + ); // Initialize extensions (equivalent to CLI's extension initialization) // Note: Does not support --extension CLI flag or extension CLI flags @@ -191,7 +222,7 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise { let completeCalled = false; // Subscribe to events and forward to parent (equivalent to --mode json output) - unsubscribe = session.subscribe((event: AgentSessionEvent) => { + runState.unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (isAgentEvent(event)) { postMessageSafe({ type: "event", event }); // Track when complete tool is called @@ -206,7 +237,7 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise { // Retry loop if complete was not called let retryCount = 0; - while (!completeCalled && retryCount < MAX_COMPLETE_RETRIES && !abortRequested) { + while (!completeCalled && retryCount < MAX_COMPLETE_RETRIES && !signal.aborted) { retryCount++; const reminder = ` CRITICAL: You stopped without calling the complete tool. This is reminder ${retryCount} of ${MAX_COMPLETE_RETRIES}. @@ -231,58 +262,93 @@ Call complete now.`; } } catch (err) { exitCode = 1; - error = err instanceof Error ? err.stack || err.message : String(err); + // Don't record abort as error - it's handled via the aborted flag + if (!signal.aborted) { + error = err instanceof Error ? err.stack || err.message : String(err); + } } finally { // Handle abort requested during execution - if (abortRequested) { + if (signal.aborted) { aborted = true; if (exitCode === 0) exitCode = 1; } - if (unsubscribe) { + sessionAbortController.abort(); + + if (runState.unsubscribe) { try { - unsubscribe(); + runState.unsubscribe(); } catch { // Ignore unsubscribe errors } - unsubscribe = null; + runState.unsubscribe = null; } // Cleanup session with timeout to prevent hanging - if (activeSession) { - const session = activeSession; - activeSession = null; + if (runState.session) { + const session = runState.session; + runState.session = null; try { - await Promise.race([session.dispose(), new Promise((resolve) => setTimeout(resolve, 5000))]); + await untilAborted(AbortSignal.timeout(5000), () => session.dispose()); } catch { // Ignore cleanup errors } } - running = false; + if (activeRun === runState) { + activeRun = null; + } // Send completion message to parent (only once) - if (!doneSent) { - doneSent = true; - postMessageSafe({ - type: "done", - exitCode, - durationMs: Date.now() - startTime, - error, - aborted, - }); - } + runState.sendDoneOnce({ + type: "done", + exitCode, + durationMs: Date.now() - startTime, + error, + aborted, + }); } } /** Handle abort request from parent */ function handleAbort(): void { - abortRequested = true; - if (activeSession) { - void activeSession.abort(); + const runState = activeRun; + if (!runState) { + pendingAbort = true; + return; + } + runState.abortController.abort(); + if (runState.session) { + void runState.session.abort(); } } +const reportFatal = (message: string): void => { + const runState = activeRun; + if (runState) { + runState.abortController.abort(); + if (runState.session) { + void runState.session.abort(); + } + runState.sendDoneOnce({ + type: "done", + exitCode: 1, + durationMs: Date.now() - runState.startTime, + error: message, + aborted: false, + }); + return; + } + + postMessageSafe({ + type: "done", + exitCode: 1, + durationMs: 0, + error: message, + aborted: false, + }); +}; + // Global error handlers to ensure we always send a done message // Using self instead of globalThis for proper worker scope typing declare const self: { @@ -292,53 +358,17 @@ declare const self: { }; self.addEventListener("error", (event) => { - if (!running || doneSent) return; - doneSent = true; - abortRequested = true; - if (activeSession) { - void activeSession.abort(); - } - postMessageSafe({ - type: "done", - exitCode: 1, - durationMs: 0, - error: `Uncaught error: ${event.message || "Unknown error"}`, - aborted: false, - }); + reportFatal(`Uncaught error: ${event.message || "Unknown error"}`); }); self.addEventListener("unhandledrejection", (event) => { - if (!running || doneSent) return; - doneSent = true; - abortRequested = true; - if (activeSession) { - void activeSession.abort(); - } const reason = event.reason; const message = reason instanceof Error ? reason.stack || reason.message : String(reason); - postMessageSafe({ - type: "done", - exitCode: 1, - durationMs: 0, - error: `Unhandled rejection: ${message}`, - aborted: false, - }); + reportFatal(`Unhandled rejection: ${message}`); }); self.addEventListener("messageerror", () => { - if (doneSent) return; - doneSent = true; - abortRequested = true; - if (activeSession) { - void activeSession.abort(); - } - postMessageSafe({ - type: "done", - exitCode: 1, - durationMs: 0, - error: "Failed to deserialize parent message", - aborted: false, - }); + reportFatal("Failed to deserialize parent message"); }); // Message handler - receives start/abort commands from parent @@ -353,8 +383,13 @@ globalThis.addEventListener("message", (event: WorkerMessageEvent { const tmpDir = tmpdir(); - const tmpFile = path.join(tmpDir, `omp-render-${Date.now()}.html`); + const tmpFile = path.join(tmpDir, `omp-${nanoid()}.html`); try { await Bun.write(tmpFile, html); @@ -487,7 +492,7 @@ async function handleSpecialUrls(url: string, timeout: number, signal?: AbortSig if (signal?.aborted) { throw new Error("Operation aborted"); } - const result = await handler(url, timeout); + const result = await handler(url, timeout, signal); if (result) return result; } return null; @@ -882,11 +887,6 @@ export function createWebFetchTool(_session: ToolSession): AgentTool => { +export const handleArtifactHub: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "artifacthub.io" && parsed.hostname !== "www.artifacthub.io") return null; @@ -70,6 +74,7 @@ export const handleArtifactHub: SpecialHandler = async (url: string, timeout: nu const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/arxiv.ts b/packages/coding-agent/src/core/tools/web-scrapers/arxiv.ts index ab675bbf0..342442877 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/arxiv.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/arxiv.ts @@ -6,7 +6,11 @@ import { convertWithMarkitdown, fetchBinary } from "./utils"; /** * Handle arXiv URLs via arXiv API */ -export const handleArxiv: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleArxiv: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "arxiv.org") return null; @@ -22,7 +26,7 @@ export const handleArxiv: SpecialHandler = async (url: string, timeout: number): // Fetch metadata via arXiv API const apiUrl = `https://export.arxiv.org/api/query?id_list=${paperId}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; @@ -56,9 +60,9 @@ export const handleArxiv: SpecialHandler = async (url: string, timeout: number): if (match[1] === "pdf" || parsed.pathname.includes(".pdf")) { if (pdfLink) { notes.push("Fetching PDF for full content..."); - const pdfResult = await fetchBinary(pdfLink, timeout); + const pdfResult = await fetchBinary(pdfLink, timeout, signal); if (pdfResult.ok) { - const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout); + const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout, signal); if (converted.ok && converted.content.length > 500) { md += `---\n\n## Full Paper\n\n${converted.content}\n`; notes.push("PDF converted via markitdown"); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/aur.ts b/packages/coding-agent/src/core/tools/web-scrapers/aur.ts index 43e39a9b7..f5c25e73c 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/aur.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/aur.ts @@ -35,7 +35,11 @@ interface AurResponse { /** * Handle AUR (Arch User Repository) URLs via RPC API */ -export const handleAur: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleAur: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "aur.archlinux.org") return null; @@ -49,7 +53,7 @@ export const handleAur: SpecialHandler = async (url: string, timeout: number): P // Fetch from AUR RPC API const apiUrl = `https://aur.archlinux.org/rpc/?v=5&type=info&arg=${encodeURIComponent(packageName)}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/biorxiv.ts b/packages/coding-agent/src/core/tools/web-scrapers/biorxiv.ts index 42a89d929..90bbbc00d 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/biorxiv.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/biorxiv.ts @@ -27,7 +27,11 @@ interface BiorxivResponse { /** * Handle bioRxiv and medRxiv preprint URLs via their API */ -export const handleBiorxiv: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleBiorxiv: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); @@ -54,6 +58,7 @@ export const handleBiorxiv: SpecialHandler = async (url: string, timeout: number const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/bluesky.ts b/packages/coding-agent/src/core/tools/web-scrapers/bluesky.ts index 17203ed9b..e7a74f5ce 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/bluesky.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/bluesky.ts @@ -54,11 +54,12 @@ interface ThreadViewPost { /** * Resolve a handle to DID using the profile API */ -async function resolveHandle(handle: string, timeout: number): Promise { +async function resolveHandle(handle: string, timeout: number, signal?: AbortSignal): Promise { const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`; const result = await loadPage(url, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; @@ -148,7 +149,11 @@ function formatPost(post: BlueskyPost, isQuote = false): string { /** * Handle Bluesky post URLs */ -export const handleBluesky: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleBluesky: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!["bsky.app", "www.bsky.app"].includes(parsed.hostname)) { @@ -167,7 +172,7 @@ export const handleBluesky: SpecialHandler = async (url: string, timeout: number const rkey = pathParts[3]; // First resolve handle to DID - const did = await resolveHandle(handle, timeout); + const did = await resolveHandle(handle, timeout, signal); if (!did) return null; // Construct AT URI and fetch thread @@ -177,6 +182,7 @@ export const handleBluesky: SpecialHandler = async (url: string, timeout: number const result = await loadPage(threadUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; @@ -230,6 +236,7 @@ export const handleBluesky: SpecialHandler = async (url: string, timeout: number const result = await loadPage(profileUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/brew.ts b/packages/coding-agent/src/core/tools/web-scrapers/brew.ts index e2447fca2..977b5395b 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/brew.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/brew.ts @@ -58,7 +58,11 @@ function getInstallCount(analytics?: { install?: { "30d"?: Record => { +export const handleBrew: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "formulae.brew.sh") return null; @@ -76,7 +80,7 @@ export const handleBrew: SpecialHandler = async (url: string, timeout: number): ? `https://formulae.brew.sh/api/formula/${encodeURIComponent(name)}.json` : `https://formulae.brew.sh/api/cask/${encodeURIComponent(name)}.json`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; let md: string; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/cheatsh.ts b/packages/coding-agent/src/core/tools/web-scrapers/cheatsh.ts index d6c848407..e86884b10 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/cheatsh.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/cheatsh.ts @@ -7,7 +7,11 @@ import { finalizeOutput, loadPage } from "./types"; * API: Plain text at https://cheat.sh/{topic}?T (T flag removes ANSI colors) * Supports: commands, language/topic queries (e.g., python/list, go/slice) */ -export const handleCheatSh: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleCheatSh: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "cheat.sh" && parsed.hostname !== "cht.sh") return null; @@ -22,6 +26,7 @@ export const handleCheatSh: SpecialHandler = async (url: string, timeout: number const apiUrl = `https://cheat.sh/${encodeURIComponent(topic)}?T`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "text/plain", }, diff --git a/packages/coding-agent/src/core/tools/web-scrapers/chocolatey.ts b/packages/coding-agent/src/core/tools/web-scrapers/chocolatey.ts index b6c4b3470..31376960a 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/chocolatey.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/chocolatey.ts @@ -28,7 +28,11 @@ interface NuGetODataResponse { /** * Handle Chocolatey package URLs via NuGet v2 OData API */ -export const handleChocolatey: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleChocolatey: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("chocolatey.org")) return null; @@ -53,6 +57,7 @@ export const handleChocolatey: SpecialHandler = async (url: string, timeout: num const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json", }, diff --git a/packages/coding-agent/src/core/tools/web-scrapers/choosealicense.ts b/packages/coding-agent/src/core/tools/web-scrapers/choosealicense.ts index a12a6b47a..e512b4f8a 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/choosealicense.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/choosealicense.ts @@ -50,6 +50,7 @@ function formatSection(title: string, items: string[]): string { export const handleChooseALicense: SpecialHandler = async ( url: string, timeout: number, + signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); @@ -65,7 +66,7 @@ export const handleChooseALicense: SpecialHandler = async ( : "https://raw.githubusercontent.com/github/choosealicense.com/gh-pages/_pages/appendix.md"; const fetchedAt = new Date().toISOString(); - const result = await loadPage(rawUrl, { timeout, headers: { Accept: "text/plain" } }); + const result = await loadPage(rawUrl, { timeout, headers: { Accept: "text/plain" }, signal }); if (!result.ok) return null; const { frontmatter, body } = parseFrontmatter(result.content); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/cisa-kev.ts b/packages/coding-agent/src/core/tools/web-scrapers/cisa-kev.ts index 2ae0a4562..95d5ede71 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/cisa-kev.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/cisa-kev.ts @@ -26,7 +26,11 @@ const KEV_FEED_URL = "https://www.cisa.gov/sites/default/files/feeds/known_explo /** * Handle CISA Known Exploited Vulnerabilities (KEV) URLs */ -export const handleCisaKev: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleCisaKev: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); @@ -44,6 +48,7 @@ export const handleCisaKev: SpecialHandler = async (url: string, timeout: number const result = await loadPage(KEV_FEED_URL, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/clojars.ts b/packages/coding-agent/src/core/tools/web-scrapers/clojars.ts index be47cd0bc..0ccaa4601 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/clojars.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/clojars.ts @@ -88,7 +88,11 @@ function formatDependencies(deps: unknown): string[] { /** * Handle Clojars URLs via API */ -export const handleClojars: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleClojars: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "clojars.org" && parsed.hostname !== "www.clojars.org") return null; @@ -112,6 +116,7 @@ export const handleClojars: SpecialHandler = async (url: string, timeout: number const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/coingecko.ts b/packages/coding-agent/src/core/tools/web-scrapers/coingecko.ts index 4f40e5a6c..d29cf9830 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/coingecko.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/coingecko.ts @@ -29,7 +29,11 @@ interface CoinGeckoResponse { /** * Handle CoinGecko cryptocurrency URLs via API */ -export const handleCoinGecko: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleCoinGecko: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("coingecko.com")) return null; @@ -46,6 +50,7 @@ export const handleCoinGecko: SpecialHandler = async (url: string, timeout: numb const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/crates-io.ts b/packages/coding-agent/src/core/tools/web-scrapers/crates-io.ts index 488f3d835..a0e3959bf 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/crates-io.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/crates-io.ts @@ -17,7 +17,11 @@ function looksLikeHtml(content: string): boolean { /** * Handle crates.io URLs via API */ -export const handleCratesIo: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleCratesIo: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "crates.io" && parsed.hostname !== "www.crates.io") return null; @@ -33,6 +37,7 @@ export const handleCratesIo: SpecialHandler = async (url: string, timeout: numbe const apiUrl = `https://crates.io/api/v1/crates/${crateName}`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" }, }); @@ -101,7 +106,7 @@ export const handleCratesIo: SpecialHandler = async (url: string, timeout: numbe // Try to fetch README from docs.rs or repository const docsRsUrl = `https://docs.rs/crate/${crateName}/${crate.max_version}/source/README.md`; - const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5) }); + const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5), signal }); if (readmeResult.ok && readmeResult.content.length > 100 && !looksLikeHtml(readmeResult.content)) { md += `\n---\n\n## README\n\n${readmeResult.content}\n`; } diff --git a/packages/coding-agent/src/core/tools/web-scrapers/crossref.ts b/packages/coding-agent/src/core/tools/web-scrapers/crossref.ts index 1f4619fb8..009740909 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/crossref.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/crossref.ts @@ -72,7 +72,11 @@ function formatAbstract(abstract?: string): string | null { return markdown.trim().length > 0 ? markdown : null; } -export const handleCrossref: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleCrossref: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!DOI_HOSTS.has(parsed.hostname.toLowerCase())) return null; @@ -84,6 +88,7 @@ export const handleCrossref: SpecialHandler = async (url: string, timeout: numbe const apiUrl = `https://api.crossref.org/works/${encodeURIComponent(doi)}`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json", }, diff --git a/packages/coding-agent/src/core/tools/web-scrapers/devto.ts b/packages/coding-agent/src/core/tools/web-scrapers/devto.ts index 6106de283..60e81abf8 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/devto.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/devto.ts @@ -23,7 +23,11 @@ interface DevToArticle { /** * Handle dev.to URLs via API */ -export const handleDevTo: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleDevTo: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "dev.to") return null; @@ -39,7 +43,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number): const tag = pathParts[1]; const apiUrl = `https://dev.to/api/articles?tag=${encodeURIComponent(tag)}&per_page=20`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; const articles = JSON.parse(result.content) as DevToArticle[]; @@ -82,7 +86,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number): const username = pathParts[0]; const apiUrl = `https://dev.to/api/articles?username=${encodeURIComponent(username)}&per_page=20`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; const articles = JSON.parse(result.content) as DevToArticle[]; @@ -125,7 +129,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number): const slug = pathParts[1]; const apiUrl = `https://dev.to/api/articles/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; const article = JSON.parse(result.content) as DevToArticle; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/discogs.ts b/packages/coding-agent/src/core/tools/web-scrapers/discogs.ts index c2a11d7c8..0f4c43def 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/discogs.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/discogs.ts @@ -248,7 +248,11 @@ function buildMasterMarkdown(master: DiscogsMaster): string { return sections.join("\n"); } -export const handleDiscogs: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleDiscogs: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("discogs.com")) return null; @@ -269,6 +273,7 @@ export const handleDiscogs: SpecialHandler = async (url: string, timeout: number const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json", "User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi", diff --git a/packages/coding-agent/src/core/tools/web-scrapers/discourse.ts b/packages/coding-agent/src/core/tools/web-scrapers/discourse.ts index 4cbcb5a48..71fd0745f 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/discourse.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/discourse.ts @@ -106,7 +106,11 @@ function buildPostUrl(baseUrl: string, postId: string): string { /** * Handle Discourse forum URLs via API */ -export const handleDiscourse: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleDiscourse: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const topicMatch = parseTopicPath(parsed.pathname); @@ -120,7 +124,7 @@ export const handleDiscourse: SpecialHandler = async (url: string, timeout: numb let topicId = topicMatch?.topicId ?? null; if (!topicId && postMatch) { - const postResult = await loadPage(buildPostUrl(baseUrl, postMatch.postId), { timeout }); + const postResult = await loadPage(buildPostUrl(baseUrl, postMatch.postId), { timeout, signal }); if (!postResult.ok) return null; let postData: DiscoursePostResponse; @@ -137,7 +141,7 @@ export const handleDiscourse: SpecialHandler = async (url: string, timeout: numb if (!topicId) return null; - const topicResult = await loadPage(buildTopicUrl(baseUrl, topicId), { timeout }); + const topicResult = await loadPage(buildTopicUrl(baseUrl, topicId), { timeout, signal }); if (!topicResult.ok) return null; let topic: DiscourseTopic; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/dockerhub.ts b/packages/coding-agent/src/core/tools/web-scrapers/dockerhub.ts index aa2ce968d..8cd0bc4cc 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/dockerhub.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/dockerhub.ts @@ -39,7 +39,11 @@ function formatSize(bytes: number): string { /** * Handle Docker Hub URLs via API */ -export const handleDockerHub: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleDockerHub: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("hub.docker.com")) return null; @@ -67,8 +71,8 @@ export const handleDockerHub: SpecialHandler = async (url: string, timeout: numb const tagsUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/?page_size=10`; const [repoResult, tagsResult] = await Promise.all([ - loadPage(repoUrl, { timeout, headers: { Accept: "application/json" } }), - loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" } }), + loadPage(repoUrl, { timeout, headers: { Accept: "application/json" }, signal }), + loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" }, signal }), ]); if (!repoResult.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/fdroid.ts b/packages/coding-agent/src/core/tools/web-scrapers/fdroid.ts index d38929e9e..14b65d674 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/fdroid.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/fdroid.ts @@ -70,7 +70,11 @@ function resolveSuggestedVersion(data: FdroidPackage): string | undefined { /** * Handle F-Droid URLs via API */ -export const handleFdroid: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleFdroid: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "f-droid.org" && parsed.hostname !== "www.f-droid.org") return null; @@ -86,6 +90,7 @@ export const handleFdroid: SpecialHandler = async (url: string, timeout: number) const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/firefox-addons.ts b/packages/coding-agent/src/core/tools/web-scrapers/firefox-addons.ts index 490cd95fe..393e5ca75 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/firefox-addons.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/firefox-addons.ts @@ -102,6 +102,7 @@ function collectPermissions(file?: AddonFile): string[] { export const handleFirefoxAddons: SpecialHandler = async ( url: string, timeout: number, + signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); @@ -115,7 +116,7 @@ export const handleFirefoxAddons: SpecialHandler = async ( if (!slug) return null; const apiUrl = `https://addons.mozilla.org/api/v5/addons/addon/${encodeURIComponent(slug)}/`; - const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" } }); + const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, signal }); if (!result.ok) return null; let data: AddonData; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/flathub.ts b/packages/coding-agent/src/core/tools/web-scrapers/flathub.ts index 1230822a2..6e94c82a9 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/flathub.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/flathub.ts @@ -138,7 +138,11 @@ function formatReleaseDate(timestamp?: string | null): string | null { return date.toISOString().split("T")[0] ?? null; } -export const handleFlathub: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleFlathub: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "flathub.org" && parsed.hostname !== "www.flathub.org") return null; @@ -147,7 +151,7 @@ export const handleFlathub: SpecialHandler = async (url: string, timeout: number if (!appId) return null; const apiUrl = `https://flathub.org/api/v2/appstream/${encodeURIComponent(appId)}`; - const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" } }); + const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } }); if (!result.ok) return null; let app: FlathubAppStream; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/github-gist.ts b/packages/coding-agent/src/core/tools/web-scrapers/github-gist.ts index 3708d352a..96fa7088d 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/github-gist.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/github-gist.ts @@ -5,7 +5,11 @@ import { finalizeOutput } from "./types"; /** * Handle GitHub Gist URLs via GitHub API */ -export const handleGitHubGist: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleGitHubGist: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "gist.github.com") return null; @@ -21,7 +25,7 @@ export const handleGitHubGist: SpecialHandler = async (url: string, timeout: num const fetchedAt = new Date().toISOString(); // Fetch via GitHub API - const result = await fetchGitHubApi(`/gists/${gistId}`, timeout); + const result = await fetchGitHubApi(`/gists/${gistId}`, timeout, signal); if (!result.ok || !result.data) return null; const gist = result.data as { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/github.ts b/packages/coding-agent/src/core/tools/web-scrapers/github.ts index 196f11995..302229965 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/github.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/github.ts @@ -1,5 +1,5 @@ import type { RenderResult, SpecialHandler } from "./types"; -import { finalizeOutput, loadPage } from "./types"; +import { createRequestSignal, finalizeOutput, loadPage } from "./types"; interface GitHubUrl { type: "blob" | "tree" | "repo" | "issue" | "issues" | "pull" | "pulls" | "discussion" | "discussions" | "other"; @@ -70,9 +70,13 @@ function toRawGitHubUrl(gh: GitHubUrl): string { /** * Fetch from GitHub API */ -export async function fetchGitHubApi(endpoint: string, timeout: number): Promise<{ data: unknown; ok: boolean }> { +export async function fetchGitHubApi( + endpoint: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ data: unknown; ok: boolean }> { try { - const timeoutSignal = AbortSignal.timeout(timeout * 1000); + const { signal: requestSignal, cleanup } = createRequestSignal(timeout * 1000, signal); const headers: Record = { Accept: "application/vnd.github.v3+json", @@ -85,16 +89,20 @@ export async function fetchGitHubApi(endpoint: string, timeout: number): Promise headers.Authorization = `Bearer ${token}`; } - const response = await fetch(`https://api.github.com${endpoint}`, { - signal: timeoutSignal, - headers, - }); + try { + const response = await fetch(`https://api.github.com${endpoint}`, { + signal: requestSignal, + headers, + }); - if (!response.ok) { - return { data: null, ok: false }; + if (!response.ok) { + return { data: null, ok: false }; + } + + return { data: await response.json(), ok: true }; + } finally { + cleanup(); } - - return { data: await response.json(), ok: true }; } catch { return { data: null, ok: false }; } @@ -103,13 +111,17 @@ export async function fetchGitHubApi(endpoint: string, timeout: number): Promise /** * Render GitHub issue/PR to markdown */ -async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { +async function renderGitHubIssue( + gh: GitHubUrl, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { const endpoint = gh.type === "pull" ? `/repos/${gh.owner}/${gh.repo}/pulls/${gh.number}` : `/repos/${gh.owner}/${gh.repo}/issues/${gh.number}`; - const result = await fetchGitHubApi(endpoint, timeout); + const result = await fetchGitHubApi(endpoint, timeout, signal); if (!result.ok || !result.data) return { content: "", ok: false }; const issue = result.data as { @@ -140,6 +152,7 @@ async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ cont const commentsResult = await fetchGitHubApi( `/repos/${gh.owner}/${gh.repo}/issues/${gh.number}/comments?per_page=50`, timeout, + signal, ); if (commentsResult.ok && Array.isArray(commentsResult.data)) { md += `## Comments (${issue.comments})\n\n`; @@ -160,8 +173,12 @@ async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ cont /** * Render GitHub issues list to markdown */ -async function renderGitHubIssuesList(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { - const result = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/issues?state=open&per_page=30`, timeout); +async function renderGitHubIssuesList( + gh: GitHubUrl, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { + const result = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/issues?state=open&per_page=30`, timeout, signal); if (!result.ok || !Array.isArray(result.data)) return { content: "", ok: false }; const issues = result.data as Array<{ @@ -190,9 +207,13 @@ async function renderGitHubIssuesList(gh: GitHubUrl, timeout: number): Promise<{ /** * Render GitHub tree (directory) to markdown */ -async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { +async function renderGitHubTree( + gh: GitHubUrl, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { // Fetch repo info first to get default branch if ref not specified - const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout); + const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout, signal); if (!repoResult.ok) return { content: "", ok: false }; const repo = repoResult.data as { @@ -207,7 +228,11 @@ async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ conte md += `**Branch:** ${ref}\n\n`; // Fetch directory contents - const contentsResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/contents/${dirPath}?ref=${ref}`, timeout); + const contentsResult = await fetchGitHubApi( + `/repos/${gh.owner}/${gh.repo}/contents/${dirPath}?ref=${ref}`, + timeout, + signal, + ); if (contentsResult.ok && Array.isArray(contentsResult.data)) { const items = contentsResult.data as Array<{ @@ -238,7 +263,7 @@ async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ conte if (readmeFile) { const readmePath = dirPath ? `${dirPath}/${readmeFile.name}` : readmeFile.name; const rawUrl = `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/${ref}/${readmePath}`; - const readmeResult = await loadPage(rawUrl, { timeout }); + const readmeResult = await loadPage(rawUrl, { timeout, signal }); if (readmeResult.ok) { md += `---\n\n## README\n\n${readmeResult.content}`; } @@ -251,9 +276,13 @@ async function renderGitHubTree(gh: GitHubUrl, timeout: number): Promise<{ conte /** * Render GitHub repo to markdown (file list + README) */ -async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { +async function renderGitHubRepo( + gh: GitHubUrl, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { // Fetch repo info - const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout); + const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout, signal); if (!repoResult.ok) return { content: "", ok: false }; const repo = repoResult.data as { @@ -278,6 +307,7 @@ async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ conte const treeResult = await fetchGitHubApi( `/repos/${gh.owner}/${gh.repo}/git/trees/${repo.default_branch}?recursive=1`, timeout, + signal, ); if (treeResult.ok && treeResult.data) { const tree = (treeResult.data as { tree: Array<{ path: string; type: string }> }).tree; @@ -294,7 +324,7 @@ async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ conte } // Fetch README - const readmeResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/readme`, timeout); + const readmeResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/readme`, timeout, signal); if (readmeResult.ok && readmeResult.data) { const readme = readmeResult.data as { content: string; encoding: string }; if (readme.encoding === "base64") { @@ -309,7 +339,11 @@ async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ conte /** * Handle GitHub URLs specially */ -export const handleGitHub: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleGitHub: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { const gh = parseGitHubUrl(url); if (!gh) return null; @@ -321,7 +355,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number) // Convert to raw URL and fetch const rawUrl = toRawGitHubUrl(gh); notes.push(`Fetched raw: ${rawUrl}`); - const result = await loadPage(rawUrl, { timeout }); + const result = await loadPage(rawUrl, { timeout, signal }); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -340,7 +374,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number) case "tree": { notes.push(`Fetched via GitHub API`); - const result = await renderGitHubTree(gh, timeout); + const result = await renderGitHubTree(gh, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -360,7 +394,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number) case "issue": case "pull": { notes.push(`Fetched via GitHub API`); - const result = await renderGitHubIssue(gh, timeout); + const result = await renderGitHubIssue(gh, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -379,7 +413,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number) case "issues": { notes.push(`Fetched via GitHub API`); - const result = await renderGitHubIssuesList(gh, timeout); + const result = await renderGitHubIssuesList(gh, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -398,7 +432,7 @@ export const handleGitHub: SpecialHandler = async (url: string, timeout: number) case "repo": { notes.push(`Fetched via GitHub API`); - const result = await renderGitHubRepo(gh, timeout); + const result = await renderGitHubRepo(gh, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/gitlab.ts b/packages/coding-agent/src/core/tools/web-scrapers/gitlab.ts index 8762f45a1..64b811c79 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/gitlab.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/gitlab.ts @@ -86,11 +86,11 @@ function parseGitLabUrl(url: string): GitLabUrl | null { /** * Get project ID from namespace/project path */ -async function getProjectId(gl: GitLabUrl, timeout: number): Promise { +async function getProjectId(gl: GitLabUrl, timeout: number, signal?: AbortSignal): Promise { const encodedPath = encodeURIComponent(`${gl.namespace}/${gl.project}`); const apiUrl = `https://gitlab.com/api/v4/projects/${encodedPath}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; try { @@ -104,11 +104,15 @@ async function getProjectId(gl: GitLabUrl, timeout: number): Promise { +async function renderGitLabRepo( + gl: GitLabUrl, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { const encodedPath = encodeURIComponent(`${gl.namespace}/${gl.project}`); const apiUrl = `https://gitlab.com/api/v4/projects/${encodedPath}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return { content: "", ok: false }; try { @@ -137,7 +141,7 @@ async function renderGitLabRepo(gl: GitLabUrl, timeout: number): Promise<{ conte // Try to fetch README if (repo.readme_url) { - const readmeResult = await loadPage(repo.readme_url, { timeout }); + const readmeResult = await loadPage(repo.readme_url, { timeout, signal }); if (readmeResult.ok && readmeResult.content.trim().length > 0) { md += `---\n\n## README\n\n${readmeResult.content}\n`; } @@ -156,11 +160,12 @@ async function renderGitLabFile( gl: GitLabUrl, projectId: number, timeout: number, + signal?: AbortSignal, ): Promise<{ content: string; ok: boolean }> { const encodedPath = encodeURIComponent(gl.path!); const apiUrl = `https://gitlab.com/api/v4/projects/${projectId}/repository/files/${encodedPath}/raw?ref=${gl.ref}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return { content: "", ok: false }; return { content: result.content, ok: true }; @@ -173,10 +178,11 @@ async function renderGitLabTree( gl: GitLabUrl, projectId: number, timeout: number, + signal?: AbortSignal, ): Promise<{ content: string; ok: boolean }> { const apiUrl = `https://gitlab.com/api/v4/projects/${projectId}/repository/tree?ref=${gl.ref}&path=${gl.path || ""}&per_page=100`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return { content: "", ok: false }; try { @@ -222,10 +228,11 @@ async function renderGitLabIssue( gl: GitLabUrl, projectId: number, timeout: number, + signal?: AbortSignal, ): Promise<{ content: string; ok: boolean }> { const apiUrl = `https://gitlab.com/api/v4/projects/${projectId}/issues/${gl.id}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return { content: "", ok: false }; try { @@ -272,10 +279,11 @@ async function renderGitLabMR( gl: GitLabUrl, projectId: number, timeout: number, + signal?: AbortSignal, ): Promise<{ content: string; ok: boolean }> { const apiUrl = `https://gitlab.com/api/v4/projects/${projectId}/merge_requests/${gl.id}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return { content: "", ok: false }; try { @@ -324,7 +332,11 @@ async function renderGitLabMR( /** * Handle GitLab URLs specially */ -export const handleGitLab: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleGitLab: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { const gl = parseGitLabUrl(url); if (!gl) return null; @@ -333,11 +345,11 @@ export const handleGitLab: SpecialHandler = async (url: string, timeout: number) switch (gl.type) { case "blob": { - const projectId = await getProjectId(gl, timeout); + const projectId = await getProjectId(gl, timeout, signal); if (!projectId) break; notes.push(`Fetched raw file via GitLab API`); - const result = await renderGitLabFile(gl, projectId, timeout); + const result = await renderGitLabFile(gl, projectId, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -355,11 +367,11 @@ export const handleGitLab: SpecialHandler = async (url: string, timeout: number) } case "tree": { - const projectId = await getProjectId(gl, timeout); + const projectId = await getProjectId(gl, timeout, signal); if (!projectId) break; notes.push(`Fetched directory tree via GitLab API`); - const result = await renderGitLabTree(gl, projectId, timeout); + const result = await renderGitLabTree(gl, projectId, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -377,11 +389,11 @@ export const handleGitLab: SpecialHandler = async (url: string, timeout: number) } case "issue": { - const projectId = await getProjectId(gl, timeout); + const projectId = await getProjectId(gl, timeout, signal); if (!projectId) break; notes.push(`Fetched issue via GitLab API`); - const result = await renderGitLabIssue(gl, projectId, timeout); + const result = await renderGitLabIssue(gl, projectId, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -399,11 +411,11 @@ export const handleGitLab: SpecialHandler = async (url: string, timeout: number) } case "merge_request": { - const projectId = await getProjectId(gl, timeout); + const projectId = await getProjectId(gl, timeout, signal); if (!projectId) break; notes.push(`Fetched merge request via GitLab API`); - const result = await renderGitLabMR(gl, projectId, timeout); + const result = await renderGitLabMR(gl, projectId, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { @@ -422,7 +434,7 @@ export const handleGitLab: SpecialHandler = async (url: string, timeout: number) case "repo": { notes.push(`Fetched repository via GitLab API`); - const result = await renderGitLabRepo(gl, timeout); + const result = await renderGitLabRepo(gl, timeout, signal); if (result.ok) { const output = finalizeOutput(result.content); return { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/go-pkg.ts b/packages/coding-agent/src/core/tools/web-scrapers/go-pkg.ts index 5957c6906..b3e6e5bd7 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/go-pkg.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/go-pkg.ts @@ -10,7 +10,11 @@ interface GoModuleInfo { /** * Handle pkg.go.dev URLs via proxy API and page parsing */ -export const handleGoPkg: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleGoPkg: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "pkg.go.dev") return null; @@ -58,7 +62,7 @@ export const handleGoPkg: SpecialHandler = async (url: string, timeout: number): if (version === "latest") { try { const proxyUrl = `https://proxy.golang.org/${encodeURIComponent(modulePath)}/@latest`; - const proxyResult = await loadPage(proxyUrl, { timeout }); + const proxyResult = await loadPage(proxyUrl, { timeout, signal }); if (proxyResult.ok) { moduleInfo = JSON.parse(proxyResult.content) as GoModuleInfo; @@ -70,7 +74,7 @@ export const handleGoPkg: SpecialHandler = async (url: string, timeout: number): } else { try { const proxyUrl = `https://proxy.golang.org/${encodeURIComponent(modulePath)}/@v/${encodeURIComponent(version)}.info`; - const proxyResult = await loadPage(proxyUrl, { timeout }); + const proxyResult = await loadPage(proxyUrl, { timeout, signal }); if (proxyResult.ok) { moduleInfo = JSON.parse(proxyResult.content) as GoModuleInfo; @@ -81,7 +85,7 @@ export const handleGoPkg: SpecialHandler = async (url: string, timeout: number): } // Fetch the pkg.go.dev page - const pageResult = await loadPage(url, { timeout }); + const pageResult = await loadPage(url, { timeout, signal }); if (!pageResult.ok) { return { url, diff --git a/packages/coding-agent/src/core/tools/web-scrapers/hackage.ts b/packages/coding-agent/src/core/tools/web-scrapers/hackage.ts index 3c8891245..f16e02e65 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/hackage.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/hackage.ts @@ -19,7 +19,11 @@ interface HackagePackage { /** * Handle Hackage (Haskell package registry) URLs via JSON API */ -export const handleHackage: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleHackage: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "hackage.haskell.org") return null; @@ -36,6 +40,7 @@ export const handleHackage: SpecialHandler = async (url: string, timeout: number const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/hackernews.ts b/packages/coding-agent/src/core/tools/web-scrapers/hackernews.ts index 8f700198f..c79ee7d21 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/hackernews.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/hackernews.ts @@ -21,15 +21,15 @@ interface HNItem { const API_BASE = "https://hacker-news.firebaseio.com/v0"; -async function fetchItem(id: number, timeout: number): Promise { +async function fetchItem(id: number, timeout: number, signal?: AbortSignal): Promise { const url = `${API_BASE}/item/${id}.json`; - const { content, ok } = await loadPage(url, { timeout }); + const { content, ok } = await loadPage(url, { timeout, signal }); if (!ok) return null; return JSON.parse(content) as HNItem; } -async function fetchItems(ids: number[], timeout: number, limit = 20): Promise { - const promises = ids.slice(0, limit).map((id) => fetchItem(id, timeout)); +async function fetchItems(ids: number[], timeout: number, limit = 20, signal?: AbortSignal): Promise { + const promises = ids.slice(0, limit).map((id) => fetchItem(id, timeout, signal)); const results = await Promise.all(promises); return results.filter((item): item is HNItem => item !== null && !item.deleted && !item.dead); } @@ -69,7 +69,7 @@ function formatTimestamp(unixTime: number): string { return `${minutes}m ago`; } -async function renderStory(item: HNItem, timeout: number, depth = 0): Promise { +async function renderStory(item: HNItem, timeout: number, depth = 0, signal?: AbortSignal): Promise { let output = ""; if (depth === 0) { @@ -90,7 +90,7 @@ async function renderStory(item: HNItem, timeout: number, depth = 0): Promise 0 && depth < 2) { const topComments = item.kids.slice(0, depth === 0 ? 20 : 10); - const comments = await fetchItems(topComments, timeout, topComments.length); + const comments = await fetchItems(topComments, timeout, topComments.length, signal); if (comments.length > 0) { if (depth === 0) output += "---\n\n## Comments\n\n"; @@ -107,7 +107,7 @@ async function renderStory(item: HNItem, timeout: number, depth = 0): Promise 0 && depth < 1) { - const childOutput = await renderStory(comment, timeout, depth + 1); + const childOutput = await renderStory(comment, timeout, depth + 1, signal); output += childOutput; } } @@ -117,9 +117,9 @@ async function renderStory(item: HNItem, timeout: number, depth = 0): Promise { +async function renderListing(ids: number[], timeout: number, title: string, signal?: AbortSignal): Promise { let output = `# ${title}\n\n`; - const stories = await fetchItems(ids, timeout, 20); + const stories = await fetchItems(ids, timeout, 20, signal); for (let i = 0; i < stories.length; i++) { const story = stories[i]; @@ -137,7 +137,7 @@ async function renderListing(ids: number[], timeout: number, title: string): Pro return output; } -export const handleHackerNews: SpecialHandler = async (url, timeout) => { +export const handleHackerNews: SpecialHandler = async (url, timeout, signal) => { const parsed = new URL(url); if (!parsed.hostname.includes("news.ycombinator.com")) return null; @@ -149,28 +149,28 @@ export const handleHackerNews: SpecialHandler = async (url, timeout) => { const itemId = parsed.searchParams.get("id"); if (itemId) { - const item = await fetchItem(parseInt(itemId, 10), timeout); + const item = await fetchItem(parseInt(itemId, 10), timeout, signal); if (!item) throw new Error(`Failed to fetch item ${itemId}`); - content = await renderStory(item, timeout); + content = await renderStory(item, timeout, 0, signal); notes.push(`Fetched HN item ${itemId} with top-level comments (depth 2)`); } else if (parsed.pathname === "/" || parsed.pathname === "/news") { - const { content: raw, ok } = await loadPage(`${API_BASE}/topstories.json`, { timeout }); + const { content: raw, ok } = await loadPage(`${API_BASE}/topstories.json`, { timeout, signal }); if (!ok) throw new Error("Failed to fetch top stories"); const ids = JSON.parse(raw) as number[]; - content = await renderListing(ids, timeout, "Hacker News - Top Stories"); + content = await renderListing(ids, timeout, "Hacker News - Top Stories", signal); notes.push("Fetched top 20 stories from HN front page"); } else if (parsed.pathname === "/newest") { - const { content: raw, ok } = await loadPage(`${API_BASE}/newstories.json`, { timeout }); + const { content: raw, ok } = await loadPage(`${API_BASE}/newstories.json`, { timeout, signal }); if (!ok) throw new Error("Failed to fetch new stories"); const ids = JSON.parse(raw) as number[]; - content = await renderListing(ids, timeout, "Hacker News - New Stories"); + content = await renderListing(ids, timeout, "Hacker News - New Stories", signal); notes.push("Fetched top 20 new stories"); } else if (parsed.pathname === "/best") { - const { content: raw, ok } = await loadPage(`${API_BASE}/beststories.json`, { timeout }); + const { content: raw, ok } = await loadPage(`${API_BASE}/beststories.json`, { timeout, signal }); if (!ok) throw new Error("Failed to fetch best stories"); const ids = JSON.parse(raw) as number[]; - content = await renderListing(ids, timeout, "Hacker News - Best Stories"); + content = await renderListing(ids, timeout, "Hacker News - Best Stories", signal); notes.push("Fetched top 20 best stories"); } else { return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/hex.ts b/packages/coding-agent/src/core/tools/web-scrapers/hex.ts index 12fc17096..402895a72 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/hex.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/hex.ts @@ -4,7 +4,7 @@ import { finalizeOutput, formatCount, loadPage } from "./types"; /** * Handle Hex.pm (Elixir package registry) URLs via API */ -export const handleHex: SpecialHandler = async (url, timeout) => { +export const handleHex: SpecialHandler = async (url, timeout, signal) => { try { const parsed = new URL(url); if (parsed.hostname !== "hex.pm" && parsed.hostname !== "www.hex.pm") return null; @@ -18,7 +18,7 @@ export const handleHex: SpecialHandler = async (url, timeout) => { // Fetch from Hex.pm API const apiUrl = `https://hex.pm/api/packages/${packageName}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; @@ -74,7 +74,7 @@ export const handleHex: SpecialHandler = async (url, timeout) => { // Fetch releases if available if (data.releases?.length) { const releasesUrl = `https://hex.pm/api/packages/${packageName}/releases/${version}`; - const releaseResult = await loadPage(releasesUrl, { timeout: Math.min(timeout, 5) }); + const releaseResult = await loadPage(releasesUrl, { timeout: Math.min(timeout, 5), signal }); if (releaseResult.ok) { try { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/huggingface.ts b/packages/coding-agent/src/core/tools/web-scrapers/huggingface.ts index 29f262a4c..6efd30e87 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/huggingface.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/huggingface.ts @@ -106,7 +106,7 @@ function parseHuggingFaceUrl(url: string): { } } -export const handleHuggingFace: SpecialHandler = async (url: string, timeout: number) => { +export const handleHuggingFace: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { const parsed = parseHuggingFaceUrl(url); if (!parsed) return null; @@ -120,8 +120,8 @@ export const handleHuggingFace: SpecialHandler = async (url: string, timeout: nu const readmeUrl = `https://huggingface.co/${parsed.id}/raw/main/README.md`; const [apiResult, readmeResult] = await Promise.all([ - loadPage(apiUrl, { timeout }), - loadPage(readmeUrl, { timeout: Math.min(timeout, 5) }), + loadPage(apiUrl, { timeout, signal }), + loadPage(readmeUrl, { timeout: Math.min(timeout, 5), signal }), ]); if (!apiResult.ok) return null; @@ -186,8 +186,8 @@ export const handleHuggingFace: SpecialHandler = async (url: string, timeout: nu const readmeUrl = `https://huggingface.co/datasets/${parsed.id}/raw/main/README.md`; const [apiResult, readmeResult] = await Promise.all([ - loadPage(apiUrl, { timeout }), - loadPage(readmeUrl, { timeout: Math.min(timeout, 5) }), + loadPage(apiUrl, { timeout, signal }), + loadPage(readmeUrl, { timeout: Math.min(timeout, 5), signal }), ]); if (!apiResult.ok) return null; @@ -251,8 +251,8 @@ export const handleHuggingFace: SpecialHandler = async (url: string, timeout: nu const readmeUrl = `https://huggingface.co/spaces/${parsed.id}/raw/main/README.md`; const [apiResult, readmeResult] = await Promise.all([ - loadPage(apiUrl, { timeout }), - loadPage(readmeUrl, { timeout: Math.min(timeout, 5) }), + loadPage(apiUrl, { timeout, signal }), + loadPage(readmeUrl, { timeout: Math.min(timeout, 5), signal }), ]); if (!apiResult.ok) return null; @@ -303,7 +303,7 @@ export const handleHuggingFace: SpecialHandler = async (url: string, timeout: nu case "model_or_user": { // Try model API first const modelApiUrl = `https://huggingface.co/api/models/${parsed.id}`; - const modelResult = await loadPage(modelApiUrl, { timeout }); + const modelResult = await loadPage(modelApiUrl, { timeout, signal }); if (modelResult.ok) { let model: HfModelData | null = null; @@ -314,7 +314,7 @@ export const handleHuggingFace: SpecialHandler = async (url: string, timeout: nu } if (model) { const readmeUrl = `https://huggingface.co/${parsed.id}/raw/main/README.md`; - const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 5) }); + const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 5), signal }); let md = `# ${model.modelId}\n\n`; if (model.pipeline_tag) md += `**Task:** ${model.pipeline_tag}\n`; @@ -343,7 +343,7 @@ export const handleHuggingFace: SpecialHandler = async (url: string, timeout: nu // Fall back to user API const userApiUrl = `https://huggingface.co/api/users/${parsed.id}`; - const userResult = await loadPage(userApiUrl, { timeout }); + const userResult = await loadPage(userApiUrl, { timeout, signal }); if (!userResult.ok) return null; let user: HfUserData; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/iacr.ts b/packages/coding-agent/src/core/tools/web-scrapers/iacr.ts index 99ffd449c..67a12baee 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/iacr.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/iacr.ts @@ -6,7 +6,11 @@ import { convertWithMarkitdown, fetchBinary } from "./utils"; /** * Handle IACR ePrint Archive URLs */ -export const handleIacr: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleIacr: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "eprint.iacr.org") return null; @@ -22,7 +26,7 @@ export const handleIacr: SpecialHandler = async (url: string, timeout: number): // Fetch the HTML page for metadata const pageUrl = `https://eprint.iacr.org/${paperId}`; - const result = await loadPage(pageUrl, { timeout }); + const result = await loadPage(pageUrl, { timeout, signal }); if (!result.ok) return null; @@ -55,9 +59,9 @@ export const handleIacr: SpecialHandler = async (url: string, timeout: number): if (parsed.pathname.endsWith(".pdf")) { const pdfUrl = `https://eprint.iacr.org/${paperId}.pdf`; notes.push("Fetching PDF for full content..."); - const pdfResult = await fetchBinary(pdfUrl, timeout); + const pdfResult = await fetchBinary(pdfUrl, timeout, signal); if (pdfResult.ok) { - const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout); + const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout, signal); if (converted.ok && converted.content.length > 500) { md += `---\n\n## Full Paper\n\n${converted.content}\n`; notes.push("PDF converted via markitdown"); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/jetbrains-marketplace.ts b/packages/coding-agent/src/core/tools/web-scrapers/jetbrains-marketplace.ts index 9248e7907..88da5b605 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/jetbrains-marketplace.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/jetbrains-marketplace.ts @@ -76,6 +76,7 @@ function formatBuildCompatibility(update: UpdateData): string | null { export const handleJetBrainsMarketplace: SpecialHandler = async ( url: string, timeout: number, + signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); @@ -91,8 +92,8 @@ export const handleJetBrainsMarketplace: SpecialHandler = async ( const updatesUrl = `https://plugins.jetbrains.com/api/plugins/${pluginId}/updates?size=1`; const [pluginResult, updatesResult] = await Promise.all([ - loadPage(pluginUrl, { timeout }), - loadPage(updatesUrl, { timeout }), + loadPage(pluginUrl, { timeout, signal }), + loadPage(updatesUrl, { timeout, signal }), ]); if (!pluginResult.ok || !updatesResult.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/lemmy.ts b/packages/coding-agent/src/core/tools/web-scrapers/lemmy.ts index 456ca43b1..6d129c782 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/lemmy.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/lemmy.ts @@ -132,7 +132,11 @@ function renderComments(comments: LemmyCommentView[]): string { return renderThread(0, 0).trim(); } -export const handleLemmy: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleLemmy: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const match = parsed.pathname.match(/^\/(post|comment)\/(\d+)/); @@ -148,7 +152,7 @@ export const handleLemmy: SpecialHandler = async (url: string, timeout: number): let postId = id; if (kind === "comment") { const commentUrl = `${baseUrl}/api/v3/comment?id=${id}`; - const commentResult = await loadPage(commentUrl, { timeout }); + const commentResult = await loadPage(commentUrl, { timeout, signal }); if (!commentResult.ok) return null; const commentData = parseJson(commentResult.content); @@ -162,8 +166,8 @@ export const handleLemmy: SpecialHandler = async (url: string, timeout: number): const commentsUrl = `${baseUrl}/api/v3/comment/list?post_id=${postId}`; const [postResult, commentsResult] = await Promise.all([ - loadPage(postUrl, { timeout }), - loadPage(commentsUrl, { timeout }), + loadPage(postUrl, { timeout, signal }), + loadPage(commentsUrl, { timeout, signal }), ]); if (!postResult.ok || !commentsResult.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/lobsters.ts b/packages/coding-agent/src/core/tools/web-scrapers/lobsters.ts index 0fad3008c..56229607a 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/lobsters.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/lobsters.ts @@ -74,7 +74,7 @@ function renderComments(comments: LobstersComment[], maxDepth = 5): string { /** * Handle Lobste.rs URLs via JSON API */ -export const handleLobsters: SpecialHandler = async (url: string, timeout: number) => { +export const handleLobsters: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { try { const parsed = new URL(url); if (!parsed.hostname.includes("lobste.rs")) return null; @@ -87,7 +87,7 @@ export const handleLobsters: SpecialHandler = async (url: string, timeout: numbe const storyMatch = parsed.pathname.match(/^\/s\/([^/]+)/); if (storyMatch) { jsonUrl = `https://lobste.rs/s/${storyMatch[1]}.json`; - const result = await loadPage(jsonUrl, { timeout }); + const result = await loadPage(jsonUrl, { timeout, signal }); if (!result.ok) return null; const story = JSON.parse(result.content) as LobstersStoryResponse; @@ -140,7 +140,7 @@ export const handleLobsters: SpecialHandler = async (url: string, timeout: numbe if (!jsonUrl) return null; - const result = await loadPage(jsonUrl, { timeout }); + const result = await loadPage(jsonUrl, { timeout, signal }); if (!result.ok) return null; const stories = JSON.parse(result.content) as LobstersStory[]; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/mastodon.ts b/packages/coding-agent/src/core/tools/web-scrapers/mastodon.ts index 9310cf427..9d51f505c 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/mastodon.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/mastodon.ts @@ -51,11 +51,12 @@ interface MastodonStatus { /** * Check if a domain is a Mastodon instance by probing the API */ -async function isMastodonInstance(hostname: string, timeout: number): Promise { +async function isMastodonInstance(hostname: string, timeout: number, signal?: AbortSignal): Promise { try { const result = await loadPage(`https://${hostname}/api/v1/instance`, { timeout: Math.min(timeout, 5), headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return false; const data = JSON.parse(result.content); @@ -188,7 +189,11 @@ function formatAccount(account: MastodonAccount): string { /** * Handle Mastodon/Fediverse URLs */ -export const handleMastodon: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleMastodon: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); @@ -199,7 +204,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe if (!postMatch && !profileMatch) return null; // Verify this is a Mastodon instance - if (!(await isMastodonInstance(parsed.hostname, timeout))) { + if (!(await isMastodonInstance(parsed.hostname, timeout, signal))) { return null; } @@ -214,6 +219,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; @@ -248,6 +254,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe const result = await loadPage(lookupUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; @@ -264,6 +271,7 @@ export const handleMastodon: SpecialHandler = async (url: string, timeout: numbe const statusesResult = await loadPage(statusesUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); let md = formatAccount(account); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/maven.ts b/packages/coding-agent/src/core/tools/web-scrapers/maven.ts index 5734f711e..198c330c0 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/maven.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/maven.ts @@ -25,7 +25,11 @@ interface MavenResponse { * Handle Maven Central URLs via Solr API * Supports: search.maven.org/artifact/... and mvnrepository.com/artifact/... */ -export const handleMaven: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleMaven: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const hostname = parsed.hostname; @@ -65,6 +69,7 @@ export const handleMaven: SpecialHandler = async (url: string, timeout: number): const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/mdn.ts b/packages/coding-agent/src/core/tools/web-scrapers/mdn.ts index 9b468ef6a..2e8cc4197 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/mdn.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/mdn.ts @@ -105,7 +105,7 @@ function convertMDNBody(sections: MDNSection[]): string { return parts.join("\n\n"); } -export const handleMDN: SpecialHandler = async (url: string, timeout: number) => { +export const handleMDN: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { const urlObj = new URL(url); // Only handle developer.mozilla.org @@ -124,7 +124,7 @@ export const handleMDN: SpecialHandler = async (url: string, timeout: number) => const jsonUrl = url.replace(/\/?$/, "/index.json"); try { - const result = await loadPage(jsonUrl, { timeout, headers: { Accept: "application/json" } }); + const result = await loadPage(jsonUrl, { timeout, signal, headers: { Accept: "application/json" } }); if (!result.ok) { notes.push(`Failed to fetch MDN JSON API (status ${result.status || "unknown"})`); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/metacpan.ts b/packages/coding-agent/src/core/tools/web-scrapers/metacpan.ts index f52df7d8d..5c2636c28 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/metacpan.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/metacpan.ts @@ -39,7 +39,11 @@ interface ReleaseResponse { /** * Handle MetaCPAN URLs via fastapi.metacpan.org */ -export const handleMetaCPAN: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleMetaCPAN: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "metacpan.org" && parsed.hostname !== "www.metacpan.org") return null; @@ -50,21 +54,21 @@ export const handleMetaCPAN: SpecialHandler = async (url: string, timeout: numbe const podMatch = parsed.pathname.match(/^\/pod\/(.+?)(?:\/|$)/); if (podMatch) { const moduleName = decodeURIComponent(podMatch[1]); - return await fetchModule(url, moduleName, timeout, fetchedAt); + return await fetchModule(url, moduleName, timeout, fetchedAt, signal); } // Match /release/AUTHOR/Distribution pattern const releaseMatch = parsed.pathname.match(/^\/release\/([^/]+)\/([^/]+)/); if (releaseMatch) { const distribution = decodeURIComponent(releaseMatch[2]); - return await fetchRelease(url, distribution, timeout, fetchedAt); + return await fetchRelease(url, distribution, timeout, fetchedAt, signal); } // Match /release/Distribution pattern (without author) const simpleReleaseMatch = parsed.pathname.match(/^\/release\/([^/]+)$/); if (simpleReleaseMatch) { const distribution = decodeURIComponent(simpleReleaseMatch[1]); - return await fetchRelease(url, distribution, timeout, fetchedAt); + return await fetchRelease(url, distribution, timeout, fetchedAt, signal); } return null; @@ -78,9 +82,10 @@ async function fetchModule( moduleName: string, timeout: number, fetchedAt: string, + signal?: AbortSignal, ): Promise { const apiUrl = `https://fastapi.metacpan.org/v1/module/${moduleName}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; @@ -93,7 +98,7 @@ async function fetchModule( // Fetch additional release info for dependencies and metadata const releaseUrl = `https://fastapi.metacpan.org/v1/release/${module.distribution}`; - const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5) }); + const releaseResult = await loadPage(releaseUrl, { timeout: Math.min(timeout, 5), signal }); let release: ReleaseResponse | null = null; if (releaseResult.ok) { @@ -122,9 +127,10 @@ async function fetchRelease( distribution: string, timeout: number, fetchedAt: string, + signal?: AbortSignal, ): Promise { const apiUrl = `https://fastapi.metacpan.org/v1/release/${distribution}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/musicbrainz.ts b/packages/coding-agent/src/core/tools/web-scrapers/musicbrainz.ts index eed2a1261..641feaf61 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/musicbrainz.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/musicbrainz.ts @@ -81,9 +81,10 @@ function parseEntity(url: URL): { entity: MusicBrainzEntity; mbid: string } | nu return { entity, mbid }; } -async function fetchJson(apiUrl: string, timeout: number): Promise { +async function fetchJson(apiUrl: string, timeout: number, signal?: AbortSignal): Promise { const result = await loadPage(apiUrl, { timeout, + signal, headers: { "User-Agent": USER_AGENT, Accept: "application/json", @@ -224,7 +225,11 @@ function buildRecordingMarkdown(recording: MusicBrainzRecording): string { return md; } -export const handleMusicBrainz: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleMusicBrainz: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const parsedEntity = parseEntity(parsed); @@ -236,17 +241,17 @@ export const handleMusicBrainz: SpecialHandler = async (url: string, timeout: nu if (entity === "artist") { const apiUrl = `https://musicbrainz.org/ws/2/artist/${mbid}?fmt=json&inc=url-rels`; - const artist = await fetchJson(apiUrl, timeout); + const artist = await fetchJson(apiUrl, timeout, signal); if (!artist) return null; md = buildArtistMarkdown(artist); } else if (entity === "release") { const apiUrl = `https://musicbrainz.org/ws/2/release/${mbid}?fmt=json&inc=recordings`; - const release = await fetchJson(apiUrl, timeout); + const release = await fetchJson(apiUrl, timeout, signal); if (!release) return null; md = buildReleaseMarkdown(release); } else { const apiUrl = `https://musicbrainz.org/ws/2/recording/${mbid}?fmt=json`; - const recording = await fetchJson(apiUrl, timeout); + const recording = await fetchJson(apiUrl, timeout, signal); if (!recording) return null; md = buildRecordingMarkdown(recording); } diff --git a/packages/coding-agent/src/core/tools/web-scrapers/npm.ts b/packages/coding-agent/src/core/tools/web-scrapers/npm.ts index f273fbd5d..38e870313 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/npm.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/npm.ts @@ -4,7 +4,11 @@ import { finalizeOutput, formatCount, loadPage } from "./types"; /** * Handle npm URLs via registry API */ -export const handleNpm: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleNpm: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return null; @@ -28,8 +32,8 @@ export const handleNpm: SpecialHandler = async (url: string, timeout: number): P // Fetch package info and download stats in parallel const [result, downloadsResult] = await Promise.all([ - loadPage(latestUrl, { timeout }), - loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }), + loadPage(latestUrl, { timeout, signal }), + loadPage(downloadsUrl, { timeout: Math.min(timeout, 5), signal }), ]); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/nuget.ts b/packages/coding-agent/src/core/tools/web-scrapers/nuget.ts index 59b87b2e8..25587aa08 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/nuget.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/nuget.ts @@ -37,7 +37,11 @@ interface NuGetRegistrationIndex { /** * Handle NuGet URLs via API */ -export const handleNuGet: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleNuGet: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "www.nuget.org" && parsed.hostname !== "nuget.org") return null; @@ -52,7 +56,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number): // Fetch from NuGet registration API (package name must be lowercase) const apiUrl = `https://api.nuget.org/v3/registration5-gz-semver2/${packageName.toLowerCase()}/index.json`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; @@ -70,7 +74,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number): // If items are not inlined, fetch the page if (!latestPage.items && latestPage["@id"]) { - const pageResult = await loadPage(latestPage["@id"], { timeout }); + const pageResult = await loadPage(latestPage["@id"], { timeout, signal }); if (!pageResult.ok) return null; try { latestPage = JSON.parse(pageResult.content); @@ -91,7 +95,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number): // Fetch page if items not inlined if (!pageItems && page["@id"]) { - const pageResult = await loadPage(page["@id"], { timeout: Math.min(timeout, 5) }); + const pageResult = await loadPage(page["@id"], { timeout: Math.min(timeout, 5), signal }); if (pageResult.ok) { try { const fetchedPage = JSON.parse(pageResult.content) as NuGetRegistrationPage; @@ -121,7 +125,7 @@ export const handleNuGet: SpecialHandler = async (url: string, timeout: number): // Fetch download stats via search API let totalDownloads: number | null = null; const searchUrl = `https://api.nuget.org/v3/query?q=packageid:${encodeURIComponent(packageName)}&prerelease=true&take=1`; - const searchResult = await loadPage(searchUrl, { timeout: Math.min(timeout, 5) }); + const searchResult = await loadPage(searchUrl, { timeout: Math.min(timeout, 5), signal }); if (searchResult.ok) { try { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/nvd.ts b/packages/coding-agent/src/core/tools/web-scrapers/nvd.ts index 3b70fcd13..727e0d6f5 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/nvd.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/nvd.ts @@ -73,7 +73,11 @@ interface NvdResponse { /** * Handle NVD (National Vulnerability Database) CVE URLs */ -export const handleNvd: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleNvd: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("nvd.nist.gov")) return null; @@ -90,6 +94,7 @@ export const handleNvd: SpecialHandler = async (url: string, timeout: number): P const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/ollama.ts b/packages/coding-agent/src/core/tools/web-scrapers/ollama.ts index 1d6141283..ff4cdb12e 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/ollama.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/ollama.ts @@ -171,7 +171,11 @@ function collectParameterSizes(models: OllamaTagModel[], htmlSizes: string[]): s return Array.from(sizes); } -export const handleOllama: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleOllama: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = parseOllamaUrl(url); if (!parsed) return null; @@ -181,8 +185,8 @@ export const handleOllama: SpecialHandler = async (url: string, timeout: number) const tagsUrl = "https://ollama.com/api/tags"; const [tagsResult, pageResult] = await Promise.all([ - loadPage(tagsUrl, { timeout, headers: { Accept: "application/json" } }), - loadPage(pageUrl, { timeout }), + loadPage(tagsUrl, { timeout, signal, headers: { Accept: "application/json" } }), + loadPage(pageUrl, { timeout, signal }), ]); let tagsData: OllamaTagsResponse | null = null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/open-vsx.ts b/packages/coding-agent/src/core/tools/web-scrapers/open-vsx.ts index 3a6a745a4..0fd24dbba 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/open-vsx.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/open-vsx.ts @@ -24,7 +24,11 @@ interface OpenVsxExtension { /** * Handle Open VSX URLs via their API */ -export const handleOpenVsx: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleOpenVsx: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "open-vsx.org" && parsed.hostname !== "www.open-vsx.org") return null; @@ -40,7 +44,7 @@ export const handleOpenVsx: SpecialHandler = async (url: string, timeout: number const baseUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(extension)}`; const apiUrl = version ? `${baseUrl}/${encodeURIComponent(version)}` : baseUrl; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; let data: OpenVsxExtension; @@ -54,7 +58,7 @@ export const handleOpenVsx: SpecialHandler = async (url: string, timeout: number const readmeUrl = data.files?.readme; if (readmeUrl) { try { - const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10) }); + const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10), signal }); if (readmeResult.ok) readme = readmeResult.content; } catch {} } diff --git a/packages/coding-agent/src/core/tools/web-scrapers/opencorporates.ts b/packages/coding-agent/src/core/tools/web-scrapers/opencorporates.ts index 4cc95090c..429d03fa9 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/opencorporates.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/opencorporates.ts @@ -81,6 +81,7 @@ interface ApiResponse { export const handleOpenCorporates: SpecialHandler = async ( url: string, timeout: number, + signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); @@ -100,6 +101,7 @@ export const handleOpenCorporates: SpecialHandler = async ( const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/openlibrary.ts b/packages/coding-agent/src/core/tools/web-scrapers/openlibrary.ts index af368506f..9c78c99c9 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/openlibrary.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/openlibrary.ts @@ -67,7 +67,11 @@ interface OpenLibraryBooksApiResponse { /** * Handle Open Library URLs via their API */ -export const handleOpenLibrary: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleOpenLibrary: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("openlibrary.org")) return null; @@ -83,11 +87,11 @@ export const handleOpenLibrary: SpecialHandler = async (url: string, timeout: nu let md: string | null = null; if (workMatch) { - md = await fetchWork(workMatch[1], timeout); + md = await fetchWork(workMatch[1], timeout, signal); } else if (editionMatch) { - md = await fetchEdition(editionMatch[1], timeout); + md = await fetchEdition(editionMatch[1], timeout, signal); } else if (isbnMatch) { - md = await fetchByIsbn(isbnMatch[1], timeout); + md = await fetchByIsbn(isbnMatch[1], timeout, signal); } if (!md) return null; @@ -108,9 +112,9 @@ export const handleOpenLibrary: SpecialHandler = async (url: string, timeout: nu return null; }; -async function fetchWork(workId: string, timeout: number): Promise { +async function fetchWork(workId: string, timeout: number, signal?: AbortSignal): Promise { const apiUrl = `https://openlibrary.org/works/${workId}.json`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; let work: OpenLibraryWork; @@ -127,6 +131,7 @@ async function fetchWork(workId: string, timeout: number): Promise a.author.key), timeout, + signal, ); if (authorNames.length) { md += `**Authors:** ${authorNames.join(", ")}\n`; @@ -157,9 +162,9 @@ async function fetchWork(workId: string, timeout: number): Promise { +async function fetchEdition(editionId: string, timeout: number, signal?: AbortSignal): Promise { const apiUrl = `https://openlibrary.org/books/${editionId}.json`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; let edition: OpenLibraryEdition; @@ -176,6 +181,7 @@ async function fetchEdition(editionId: string, timeout: number): Promise a.key), timeout, + signal, ); if (authorNames.length) { md += `**Authors:** ${authorNames.join(", ")}\n`; @@ -225,9 +231,9 @@ async function fetchEdition(editionId: string, timeout: number): Promise { +async function fetchByIsbn(isbn: string, timeout: number, signal?: AbortSignal): Promise { const apiUrl = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; let data: OpenLibraryBooksApiResponse; @@ -281,7 +287,7 @@ async function fetchByIsbn(isbn: string, timeout: number): Promise { +async function fetchAuthorNames(authorKeys: string[], timeout: number, signal?: AbortSignal): Promise { const names: string[] = []; // Fetch authors in parallel (limit to first 5) @@ -289,7 +295,7 @@ async function fetchAuthorNames(authorKeys: string[], timeout: number): Promise< const authorKey = key.startsWith("/authors/") ? key : `/authors/${key}`; const apiUrl = `https://openlibrary.org${authorKey}.json`; try { - const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 5) }); + const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 5), signal }); if (result.ok) { const author = JSON.parse(result.content) as { name?: string }; return author.name || null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/orcid.ts b/packages/coding-agent/src/core/tools/web-scrapers/orcid.ts index d5912f1b9..dfe26f532 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/orcid.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/orcid.ts @@ -201,7 +201,11 @@ function collectWorkTitles(container: OrcidWorksContainer | undefined): string[] return titles; } -export const handleOrcid: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleOrcid: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!isOrcidHost(parsed.hostname)) return null; @@ -215,6 +219,7 @@ export const handleOrcid: SpecialHandler = async (url: string, timeout: number): const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok || !result.content) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/osv.ts b/packages/coding-agent/src/core/tools/web-scrapers/osv.ts index 1beb6cca8..a2502d951 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/osv.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/osv.ts @@ -47,7 +47,11 @@ interface OsvVulnerability { /** * Handle OSV (Open Source Vulnerabilities) URLs */ -export const handleOsv: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleOsv: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "osv.dev") return null; @@ -64,6 +68,7 @@ export const handleOsv: SpecialHandler = async (url: string, timeout: number): P const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/packagist.ts b/packages/coding-agent/src/core/tools/web-scrapers/packagist.ts index 1a55c149f..c454814ee 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/packagist.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/packagist.ts @@ -4,7 +4,11 @@ import { finalizeOutput, formatCount, loadPage } from "./types"; /** * Handle Packagist URLs via JSON API */ -export const handlePackagist: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handlePackagist: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "packagist.org" && parsed.hostname !== "www.packagist.org") return null; @@ -19,7 +23,7 @@ export const handlePackagist: SpecialHandler = async (url: string, timeout: numb // Fetch from Packagist JSON API const apiUrl = `https://packagist.org/packages/${vendor}/${packageName}.json`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/pub-dev.ts b/packages/coding-agent/src/core/tools/web-scrapers/pub-dev.ts index 503c33abf..8fe1169ec 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/pub-dev.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/pub-dev.ts @@ -3,7 +3,7 @@ import { finalizeOutput, formatCount, loadPage, type SpecialHandler } from "./ty /** * Handle pub.dev URLs via API */ -export const handlePubDev: SpecialHandler = async (url: string, timeout: number) => { +export const handlePubDev: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { try { const parsed = new URL(url); if (parsed.hostname !== "pub.dev" && parsed.hostname !== "www.pub.dev") return null; @@ -17,7 +17,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number) // Fetch from pub.dev API const apiUrl = `https://pub.dev/api/packages/${encodeURIComponent(packageName)}`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; @@ -122,7 +122,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number) // Try to fetch README from pub.dev const readmeUrl = `https://pub.dev/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(latest.version)}/readme`; try { - const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10) }); + const readmeResult = await loadPage(readmeUrl, { timeout: Math.min(timeout, 10), signal }); if (readmeResult.ok) { // Extract README content from HTML const readmeMatch = readmeResult.content.match( diff --git a/packages/coding-agent/src/core/tools/web-scrapers/pubmed.ts b/packages/coding-agent/src/core/tools/web-scrapers/pubmed.ts index 76bba1d6d..b9033bc8b 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/pubmed.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/pubmed.ts @@ -8,7 +8,11 @@ import { finalizeOutput, loadPage } from "./types"; /** * Handle PubMed URLs - fetch article metadata, abstract, MeSH terms */ -export const handlePubMed: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handlePubMed: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); @@ -39,7 +43,7 @@ export const handlePubMed: SpecialHandler = async (url: string, timeout: number) // Fetch summary metadata const summaryUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id=${pmid}&retmode=json`; - const summaryResult = await loadPage(summaryUrl, { timeout }); + const summaryResult = await loadPage(summaryUrl, { timeout, signal }); if (!summaryResult.ok) return null; @@ -70,7 +74,7 @@ export const handlePubMed: SpecialHandler = async (url: string, timeout: number) // Fetch abstract const abstractUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmid}&rettype=abstract&retmode=text`; - const abstractResult = await loadPage(abstractUrl, { timeout }); + const abstractResult = await loadPage(abstractUrl, { timeout, signal }); let abstractText = ""; if (abstractResult.ok) { @@ -133,7 +137,7 @@ export const handlePubMed: SpecialHandler = async (url: string, timeout: number) // Try to fetch MeSH terms try { const meshUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmid}&rettype=medline&retmode=text`; - const meshResult = await loadPage(meshUrl, { timeout: Math.min(timeout, 5) }); + const meshResult = await loadPage(meshUrl, { timeout: Math.min(timeout, 5), signal }); if (meshResult.ok) { const meshTerms: string[] = []; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/pypi.ts b/packages/coding-agent/src/core/tools/web-scrapers/pypi.ts index b165ea869..d4149faff 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/pypi.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/pypi.ts @@ -4,7 +4,11 @@ import { finalizeOutput, formatCount, loadPage } from "./types"; /** * Handle PyPI URLs via JSON API */ -export const handlePyPI: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handlePyPI: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "pypi.org" && parsed.hostname !== "www.pypi.org") return null; @@ -22,8 +26,8 @@ export const handlePyPI: SpecialHandler = async (url: string, timeout: number): // Fetch package info and download stats in parallel const [result, downloadsResult] = await Promise.all([ - loadPage(apiUrl, { timeout }), - loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }), + loadPage(apiUrl, { timeout, signal }), + loadPage(downloadsUrl, { timeout: Math.min(timeout, 5), signal }), ]); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/rawg.ts b/packages/coding-agent/src/core/tools/web-scrapers/rawg.ts index 00f0d0ecd..5ff527c8e 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/rawg.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/rawg.ts @@ -23,7 +23,11 @@ interface RawgGameResponse { error?: string; } -export const handleRawg: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleRawg: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!isRawgHostname(parsed.hostname)) return null; @@ -33,7 +37,7 @@ export const handleRawg: SpecialHandler = async (url: string, timeout: number): const fetchedAt = new Date().toISOString(); const apiUrl = `https://api.rawg.io/api/games/${encodeURIComponent(slug)}`; - const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" } }); + const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/readthedocs.ts b/packages/coding-agent/src/core/tools/web-scrapers/readthedocs.ts index e1e4c0774..e87df5b0a 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/readthedocs.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/readthedocs.ts @@ -6,7 +6,11 @@ import { parse as parseHtml } from "node-html-parser"; import type { RenderResult, SpecialHandler } from "./types"; import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types"; -export const handleReadTheDocs: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleReadTheDocs: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { // Check if URL matches Read the Docs patterns const urlObj = new URL(url); const isReadTheDocs = @@ -22,7 +26,7 @@ export const handleReadTheDocs: SpecialHandler = async (url: string, timeout: nu const fetchedAt = new Date().toISOString(); // Fetch the page - const result = await loadPage(url, { timeout }); + const result = await loadPage(url, { timeout, signal }); if (!result.ok) { return { url, @@ -86,7 +90,7 @@ export const handleReadTheDocs: SpecialHandler = async (url: string, timeout: nu // Try to fetch raw source if available if (sourceUrl) { try { - const sourceResult = await loadPage(sourceUrl, { timeout: Math.min(timeout, 10) }); + const sourceResult = await loadPage(sourceUrl, { timeout: Math.min(timeout, 10), signal }); if (sourceResult.ok && sourceResult.content.length > 0 && sourceResult.content.length < 1_000_000) { content = sourceResult.content; notes.push(`Fetched raw source from ${sourceUrl}`); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/reddit.ts b/packages/coding-agent/src/core/tools/web-scrapers/reddit.ts index 34660f428..a033fb1df 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/reddit.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/reddit.ts @@ -24,7 +24,11 @@ interface RedditComment { /** * Handle Reddit URLs via JSON API */ -export const handleReddit: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleReddit: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("reddit.com")) return null; @@ -37,7 +41,7 @@ export const handleReddit: SpecialHandler = async (url: string, timeout: number) jsonUrl = `${url.replace(/\/$/, "").replace(parsed.search, "")}.json${parsed.search}`; } - const result = await loadPage(jsonUrl, { timeout }); + const result = await loadPage(jsonUrl, { timeout, signal }); if (!result.ok) return null; const data = JSON.parse(result.content); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/repology.ts b/packages/coding-agent/src/core/tools/web-scrapers/repology.ts index 707a73b4f..507aa0804 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/repology.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/repology.ts @@ -102,7 +102,11 @@ function prettifyRepo(repo: string): string { /** * Handle Repology URLs via API */ -export const handleRepology: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleRepology: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "repology.org" && parsed.hostname !== "www.repology.org") return null; @@ -119,6 +123,7 @@ export const handleRepology: SpecialHandler = async (url: string, timeout: numbe const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/rfc.ts b/packages/coding-agent/src/core/tools/web-scrapers/rfc.ts index cd8a7dfb1..6765583a8 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/rfc.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/rfc.ts @@ -90,7 +90,11 @@ function cleanRfcText(text: string): string { /** * Handle RFC Editor URLs - fetches IETF RFCs */ -export const handleRfc: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleRfc: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); const rfcNumber = extractRfcNumber(parsed); @@ -105,8 +109,8 @@ export const handleRfc: SpecialHandler = async (url: string, timeout: number): P const textUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.txt`; const [metaResult, textResult] = await Promise.all([ - loadPage(metadataUrl, { timeout: Math.min(timeout, 10) }), - loadPage(textUrl, { timeout }), + loadPage(metadataUrl, { timeout: Math.min(timeout, 10), signal }), + loadPage(textUrl, { timeout, signal }), ]); // We need at least the text content diff --git a/packages/coding-agent/src/core/tools/web-scrapers/rubygems.ts b/packages/coding-agent/src/core/tools/web-scrapers/rubygems.ts index 4377ee78c..498718423 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/rubygems.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/rubygems.ts @@ -30,7 +30,11 @@ interface RubyGemsResponse { /** * Handle RubyGems URLs via API */ -export const handleRubyGems: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleRubyGems: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "rubygems.org" && parsed.hostname !== "www.rubygems.org") return null; @@ -46,6 +50,7 @@ export const handleRubyGems: SpecialHandler = async (url: string, timeout: numbe const apiUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json" }, }); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/searchcode.ts b/packages/coding-agent/src/core/tools/web-scrapers/searchcode.ts index b877de18a..c08fce95d 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/searchcode.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/searchcode.ts @@ -67,7 +67,11 @@ function formatCodeBlock( return `\n\n\`\`\`${fence}\n${displayLines.join("\n")}\n\`\`\`\n`; } -export const handleSearchcode: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleSearchcode: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!VALID_HOSTS.has(parsed.hostname)) return null; @@ -77,7 +81,7 @@ export const handleSearchcode: SpecialHandler = async (url: string, timeout: num if (viewMatch) { const id = viewMatch[1]; const apiUrl = `https://searchcode.com/api/result/${encodeURIComponent(id)}/`; - const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" } }); + const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } }); if (!result.ok) return null; let data: SearchcodeResult; @@ -134,7 +138,7 @@ export const handleSearchcode: SpecialHandler = async (url: string, timeout: num const pageNumber = pageRaw ? Number.parseInt(pageRaw, 10) : 0; const page = Number.isFinite(pageNumber) && pageNumber >= 0 ? pageNumber : 0; const apiUrl = `https://searchcode.com/api/codesearch_I/?q=${encodeURIComponent(query)}&p=${page}`; - const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" } }); + const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } }); if (!result.ok) return null; let data: SearchcodeSearchResponse; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/sec-edgar.ts b/packages/coding-agent/src/core/tools/web-scrapers/sec-edgar.ts index ff4ef738f..094636eb7 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/sec-edgar.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/sec-edgar.ts @@ -153,7 +153,11 @@ function buildFilingUrl(cik: string, accessionNumber: string, document: string): /** * Handle SEC EDGAR URLs via data.sec.gov API */ -export const handleSecEdgar: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleSecEdgar: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); @@ -171,6 +175,7 @@ export const handleSecEdgar: SpecialHandler = async (url: string, timeout: numbe const apiUrl = `https://data.sec.gov/submissions/CIK${cik}.json`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { "User-Agent": "CodingAgent/1.0 (research tool)", Accept: "application/json", diff --git a/packages/coding-agent/src/core/tools/web-scrapers/semantic-scholar.ts b/packages/coding-agent/src/core/tools/web-scrapers/semantic-scholar.ts index 97c4e4ae5..9e188bc43 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/semantic-scholar.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/semantic-scholar.ts @@ -43,7 +43,7 @@ function extractPaperId(url: string): string | null { return null; } -export const handleSemanticScholar: SpecialHandler = async (url: string, timeout: number) => { +export const handleSemanticScholar: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { if (!url.includes("semanticscholar.org")) return null; const paperId = extractPaperId(url); @@ -77,7 +77,7 @@ export const handleSemanticScholar: SpecialHandler = async (url: string, timeout const apiUrl = `https://api.semanticscholar.org/graph/v1/paper/${paperId}?fields=${fields}`; - const { content, ok, finalUrl } = await loadPage(apiUrl, { timeout }); + const { content, ok, finalUrl } = await loadPage(apiUrl, { timeout, signal }); if (!ok || !content) { return { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/snapcraft.ts b/packages/coding-agent/src/core/tools/web-scrapers/snapcraft.ts index a457cad53..41b265ac7 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/snapcraft.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/snapcraft.ts @@ -95,7 +95,11 @@ function extractDownloads(snapInfo: SnapcraftSnap | SnapcraftResponse, data: Sna return null; } -export const handleSnapcraft: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleSnapcraft: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "snapcraft.io" && parsed.hostname !== "www.snapcraft.io") return null; @@ -110,6 +114,7 @@ export const handleSnapcraft: SpecialHandler = async (url: string, timeout: numb const apiUrl = `https://api.snapcraft.io/v2/snaps/info/${encodeURIComponent(snapName)}`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json", "Snap-Device-Series": "16", diff --git a/packages/coding-agent/src/core/tools/web-scrapers/sourcegraph.ts b/packages/coding-agent/src/core/tools/web-scrapers/sourcegraph.ts index 17e3b787e..55cc614da 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/sourcegraph.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/sourcegraph.ts @@ -168,13 +168,19 @@ function safeParseJson(content: string): T | null { } } -async function fetchGraphql(query: string, variables: Record, timeout: number): Promise { +async function fetchGraphql( + query: string, + variables: Record, + timeout: number, + signal?: AbortSignal, +): Promise { const body = JSON.stringify({ query, variables }); const result = await loadPage(GRAPHQL_ENDPOINT, { timeout, headers: GRAPHQL_HEADERS, method: "POST", body, + signal, }); if (!result.ok) return null; @@ -200,8 +206,12 @@ function formatRepoMarkdown(repo: SourcegraphRepository): string { return md; } -async function renderRepo(repoName: string, timeout: number): Promise<{ content: string; ok: boolean }> { - const data = await fetchGraphql(REPO_QUERY, { name: repoName }, timeout); +async function renderRepo( + repoName: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { + const data = await fetchGraphql(REPO_QUERY, { name: repoName }, timeout, signal); if (!data?.repository) return { content: "", ok: false }; return { content: formatRepoMarkdown(data.repository), ok: true }; @@ -212,11 +222,13 @@ async function renderFile( filePath: string, rev: string, timeout: number, + signal?: AbortSignal, ): Promise<{ content: string; ok: boolean }> { const data = await fetchGraphql( REPO_FILE_QUERY, { name: repoName, path: filePath, rev }, timeout, + signal, ); const repo = data?.repository; const content = repo?.commit?.blob?.content ?? null; @@ -232,8 +244,12 @@ async function renderFile( return { content: md, ok: true }; } -async function renderSearch(query: string, timeout: number): Promise<{ content: string; ok: boolean }> { - const data = await fetchGraphql(SEARCH_QUERY, { query }, timeout); +async function renderSearch( + query: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ content: string; ok: boolean }> { + const data = await fetchGraphql(SEARCH_QUERY, { query }, timeout, signal); const resultsData = data?.search?.results; if (!resultsData) return { content: "", ok: false }; const results = resultsData.results ?? []; @@ -291,7 +307,11 @@ async function renderSearch(query: string, timeout: number): Promise<{ content: return { content: md, ok: true }; } -export const handleSourcegraph: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleSourcegraph: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const target = parseSourcegraphUrl(url); if (!target) return null; @@ -301,7 +321,7 @@ export const handleSourcegraph: SpecialHandler = async (url: string, timeout: nu switch (target.type) { case "search": { - const result = await renderSearch(target.query, timeout); + const result = await renderSearch(target.query, timeout, signal); if (!result.ok) return null; const output = finalizeOutput(result.content); return { @@ -317,7 +337,7 @@ export const handleSourcegraph: SpecialHandler = async (url: string, timeout: nu } case "file": { const rev = target.rev ?? "HEAD"; - const result = await renderFile(target.repoName, target.filePath, rev, timeout); + const result = await renderFile(target.repoName, target.filePath, rev, timeout, signal); if (!result.ok) return null; const output = finalizeOutput(result.content); return { @@ -332,7 +352,7 @@ export const handleSourcegraph: SpecialHandler = async (url: string, timeout: nu }; } case "repo": { - const result = await renderRepo(target.repoName, timeout); + const result = await renderRepo(target.repoName, timeout, signal); if (!result.ok) return null; const output = finalizeOutput(result.content); return { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/spdx.ts b/packages/coding-agent/src/core/tools/web-scrapers/spdx.ts index f3299938d..52853cc80 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/spdx.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/spdx.ts @@ -42,7 +42,11 @@ function collectCrossReferences(license: SpdxLicense): string[] { /** * Handle SPDX license URLs via SPDX JSON API */ -export const handleSpdx: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleSpdx: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "spdx.org" && parsed.hostname !== "www.spdx.org") return null; @@ -58,6 +62,7 @@ export const handleSpdx: SpecialHandler = async (url: string, timeout: number): const result = await loadPage(apiUrl, { timeout, headers: { Accept: "application/json" }, + signal, }); if (!result.ok) return null; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/spotify.ts b/packages/coding-agent/src/core/tools/web-scrapers/spotify.ts index fa01d5932..bd037f0f4 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/spotify.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/spotify.ts @@ -157,7 +157,7 @@ function formatOutput(contentType: string, oEmbed: SpotifyOEmbedResponse, og: Op return sections.join("\n"); } -export const handleSpotify: SpecialHandler = async (url: string, timeout: number) => { +export const handleSpotify: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { // Check if this is a Spotify URL if (!url.includes("open.spotify.com/")) { return null; @@ -175,7 +175,7 @@ export const handleSpotify: SpecialHandler = async (url: string, timeout: number // Fetch oEmbed data try { const oEmbedUrl = `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`; - const response = await loadPage(oEmbedUrl, { timeout }); + const response = await loadPage(oEmbedUrl, { timeout, signal }); if (response.ok) { oEmbedData = JSON.parse(response.content) as SpotifyOEmbedResponse; @@ -189,7 +189,7 @@ export const handleSpotify: SpecialHandler = async (url: string, timeout: number // Fetch page HTML for Open Graph metadata try { - const pageResponse = await loadPage(url, { timeout }); + const pageResponse = await loadPage(url, { timeout, signal }); if (pageResponse.ok) { ogData = parseOpenGraph(pageResponse.content); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/stackoverflow.ts b/packages/coding-agent/src/core/tools/web-scrapers/stackoverflow.ts index 49ba231e5..40ab882fd 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/stackoverflow.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/stackoverflow.ts @@ -59,6 +59,7 @@ function getSiteParam(hostname: string): string | null { export const handleStackOverflow: SpecialHandler = async ( url: string, timeout: number, + signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); @@ -74,7 +75,7 @@ export const handleStackOverflow: SpecialHandler = async ( // Fetch question with answers const apiUrl = `https://api.stackexchange.com/2.3/questions/${questionId}?order=desc&sort=votes&site=${site}&filter=withbody`; - const qResult = await loadPage(apiUrl, { timeout }); + const qResult = await loadPage(apiUrl, { timeout, signal }); if (!qResult.ok) return null; @@ -92,7 +93,7 @@ export const handleStackOverflow: SpecialHandler = async ( // Fetch answers const aUrl = `https://api.stackexchange.com/2.3/questions/${questionId}/answers?order=desc&sort=votes&site=${site}&filter=withbody`; - const aResult = await loadPage(aUrl, { timeout }); + const aResult = await loadPage(aUrl, { timeout, signal }); if (aResult.ok) { const aData = JSON.parse(aResult.content) as { items: SOAnswer[] }; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/terraform.ts b/packages/coding-agent/src/core/tools/web-scrapers/terraform.ts index 755a89632..996931179 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/terraform.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/terraform.ts @@ -64,7 +64,11 @@ interface TerraformProvider { /** * Handle Terraform Registry URLs via API */ -export const handleTerraform: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleTerraform: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("registry.terraform.io")) return null; @@ -75,14 +79,14 @@ export const handleTerraform: SpecialHandler = async (url: string, timeout: numb const moduleMatch = parsed.pathname.match(/^\/modules\/([^/]+)\/([^/]+)\/([^/]+)/); if (moduleMatch) { const [, namespace, name, provider] = moduleMatch; - return await handleModuleUrl(url, namespace, name, provider, timeout, fetchedAt); + return await handleModuleUrl(url, namespace, name, provider, timeout, signal, fetchedAt); } // Match provider URL: /providers/{namespace}/{type} const providerMatch = parsed.pathname.match(/^\/providers\/([^/]+)\/([^/]+)/); if (providerMatch) { const [, namespace, type] = providerMatch; - return await handleProviderUrl(url, namespace, type, timeout, fetchedAt); + return await handleProviderUrl(url, namespace, type, timeout, signal, fetchedAt); } return null; @@ -97,11 +101,13 @@ async function handleModuleUrl( name: string, provider: string, timeout: number, + signal: AbortSignal | undefined, fetchedAt: string, ): Promise { const apiUrl = `https://registry.terraform.io/v1/modules/${namespace}/${name}/${provider}`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json" }, }); @@ -224,11 +230,13 @@ async function handleProviderUrl( namespace: string, type: string, timeout: number, + signal: AbortSignal | undefined, fetchedAt: string, ): Promise { const apiUrl = `https://registry.terraform.io/v1/providers/${namespace}/${type}`; const result = await loadPage(apiUrl, { timeout, + signal, headers: { Accept: "application/json" }, }); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/tldr.ts b/packages/coding-agent/src/core/tools/web-scrapers/tldr.ts index ffb98cee4..db6fc34c9 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/tldr.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/tldr.ts @@ -9,7 +9,11 @@ const PLATFORMS = ["common", "linux", "osx"] as const; * - https://tldr.sh/{command} * - https://tldr.ostera.io/{command} */ -export const handleTldr: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleTldr: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "tldr.sh" && parsed.hostname !== "tldr.ostera.io") return null; @@ -23,7 +27,7 @@ export const handleTldr: SpecialHandler = async (url: string, timeout: number): // Try platforms in order: common, linux, osx for (const platform of PLATFORMS) { const rawUrl = `${TLDR_BASE}/${platform}/${command}.md`; - const result = await loadPage(rawUrl, { timeout }); + const result = await loadPage(rawUrl, { timeout, signal }); if (result.ok && result.content.trim()) { const output = finalizeOutput(result.content); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/twitter.ts b/packages/coding-agent/src/core/tools/web-scrapers/twitter.ts index 8f3a47c68..4be73f611 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/twitter.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/twitter.ts @@ -12,7 +12,11 @@ const NITTER_INSTANCES = [ /** * Handle Twitter/X URLs via Nitter */ -export const handleTwitter: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleTwitter: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!["twitter.com", "x.com", "www.twitter.com", "www.x.com"].includes(parsed.hostname)) { @@ -24,7 +28,7 @@ export const handleTwitter: SpecialHandler = async (url: string, timeout: number // Try Nitter instances for (const instance of NITTER_INSTANCES) { const nitterUrl = `https://${instance}${parsed.pathname}`; - const result = await loadPage(nitterUrl, { timeout: Math.min(timeout, 10) }); + const result = await loadPage(nitterUrl, { timeout: Math.min(timeout, 10), signal }); if (result.ok && result.content.length > 500) { // Parse the Nitter HTML @@ -67,7 +71,15 @@ export const handleTwitter: SpecialHandler = async (url: string, timeout: number } } } - } catch {} + } catch { + if (signal?.aborted) { + return null; + } + } + + if (signal?.aborted) { + return null; + } // X.com blocks all bots - return a helpful error instead of falling through return { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/types.ts b/packages/coding-agent/src/core/tools/web-scrapers/types.ts index dfdbf4e31..dd00779c4 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/types.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/types.ts @@ -13,7 +13,7 @@ export interface RenderResult { notes: string[]; } -export type SpecialHandler = (url: string, timeout: number) => Promise; +export type SpecialHandler = (url: string, timeout: number, signal?: AbortSignal) => Promise; export const MAX_OUTPUT_CHARS = 500_000; const MAX_BYTES = 50 * 1024 * 1024; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/utils.ts b/packages/coding-agent/src/core/tools/web-scrapers/utils.ts index c2030189d..29f89251d 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/utils.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/utils.ts @@ -1,5 +1,6 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; +import { nanoid } from "nanoid"; import { ensureTool } from "../../../utils/tools-manager"; import { createRequestSignal } from "./types"; @@ -62,7 +63,7 @@ export async function convertWithMarkitdown( // Write to temp file with extension hint const ext = extensionHint || ".bin"; const tmpDir = tmpdir(); - const tmpFile = path.join(tmpDir, `omp-convert-${Date.now()}${ext}`); + const tmpFile = path.join(tmpDir, `omp-convert-${nanoid()}${ext}`); if (content.length > MAX_BYTES) { return { content: "", ok: false, error: `content exceeds ${MAX_BYTES} bytes` }; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/vimeo.ts b/packages/coding-agent/src/core/tools/web-scrapers/vimeo.ts index 964341366..3108ed144 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/vimeo.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/vimeo.ts @@ -79,7 +79,7 @@ function extractVideoId(url: string): string | null { /** * Handle Vimeo URLs via oEmbed API */ -export const handleVimeo: SpecialHandler = async (url: string, timeout: number) => { +export const handleVimeo: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => { try { const parsed = new URL(url); if (!parsed.hostname.includes("vimeo.com")) return null; @@ -92,7 +92,7 @@ export const handleVimeo: SpecialHandler = async (url: string, timeout: number) // Use canonical URL for oEmbed (handles staffpicks and other URL formats) const canonicalUrl = `https://vimeo.com/${videoId}`; const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(canonicalUrl)}`; - const oembedResult = await loadPage(oembedUrl, { timeout }); + const oembedResult = await loadPage(oembedUrl, { timeout, signal }); if (!oembedResult.ok) return null; @@ -117,7 +117,7 @@ export const handleVimeo: SpecialHandler = async (url: string, timeout: number) // Try to get additional details from video config try { const configUrl = `https://player.vimeo.com/video/${videoId}/config`; - const configResult = await loadPage(configUrl, { timeout: Math.min(timeout, 5) }); + const configResult = await loadPage(configUrl, { timeout: Math.min(timeout, 5), signal }); if (configResult.ok) { const config = JSON.parse(configResult.content) as VimeoVideoConfig; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/vscode-marketplace.ts b/packages/coding-agent/src/core/tools/web-scrapers/vscode-marketplace.ts index 96d63cfbb..4271e9f26 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/vscode-marketplace.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/vscode-marketplace.ts @@ -99,6 +99,7 @@ function extractRepoLink(properties: MarketplaceProperty[] | undefined): string export const handleVscodeMarketplace: SpecialHandler = async ( url: string, timeout: number, + signal?: AbortSignal, ): Promise => { try { const parsed = new URL(url); @@ -123,6 +124,7 @@ export const handleVscodeMarketplace: SpecialHandler = async ( const result = await loadPage(apiUrl, { timeout, + signal, method: "POST", body: payload, headers: { diff --git a/packages/coding-agent/src/core/tools/web-scrapers/w3c.ts b/packages/coding-agent/src/core/tools/web-scrapers/w3c.ts index 525a108a4..1d52b6248 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/w3c.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/w3c.ts @@ -72,7 +72,11 @@ function extractEditors(editorsPayload: JsonRecord | null): string[] { return names; } -export const handleW3c: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleW3c: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (parsed.hostname !== "www.w3.org" && parsed.hostname !== "w3.org") return null; @@ -86,8 +90,8 @@ export const handleW3c: SpecialHandler = async (url: string, timeout: number): P const latestUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}/versions/latest`; const [specResult, latestResult] = await Promise.all([ - loadPage(specUrl, { timeout, headers: { Accept: "application/json" } }), - loadPage(latestUrl, { timeout, headers: { Accept: "application/json" } }), + loadPage(specUrl, { timeout, signal, headers: { Accept: "application/json" } }), + loadPage(latestUrl, { timeout, signal, headers: { Accept: "application/json" } }), ]); if (!specResult.ok || !latestResult.ok) return null; @@ -117,7 +121,7 @@ export const handleW3c: SpecialHandler = async (url: string, timeout: number): P let editors: string[] = []; if (editorsUrl) { - const editorsResult = await loadPage(editorsUrl, { timeout: Math.min(timeout, 10) }); + const editorsResult = await loadPage(editorsUrl, { timeout: Math.min(timeout, 10), signal }); if (editorsResult.ok) { try { const editorsPayload = asRecord(JSON.parse(editorsResult.content)); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/wikidata.ts b/packages/coding-agent/src/core/tools/web-scrapers/wikidata.ts index 77bf15f91..0c080396c 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/wikidata.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/wikidata.ts @@ -90,7 +90,11 @@ type WikidataValue = /** * Handle Wikidata URLs via EntityData API */ -export const handleWikidata: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleWikidata: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); if (!parsed.hostname.includes("wikidata.org")) return null; @@ -104,7 +108,7 @@ export const handleWikidata: SpecialHandler = async (url: string, timeout: numbe // Fetch entity data from API const apiUrl = `https://www.wikidata.org/wiki/Special:EntityData/${qid}.json`; - const result = await loadPage(apiUrl, { timeout }); + const result = await loadPage(apiUrl, { timeout, signal }); if (!result.ok) return null; @@ -149,7 +153,7 @@ export const handleWikidata: SpecialHandler = async (url: string, timeout: numbe } // Fetch labels for referenced entities (limit to 50) - const entityLabels = await resolveEntityLabels(Array.from(entityIdsToResolve).slice(0, 50), timeout); + const entityLabels = await resolveEntityLabels(Array.from(entityIdsToResolve).slice(0, 50), timeout, signal); // Group claims by property const processedProperties: string[] = []; @@ -256,7 +260,11 @@ function getLocalizedAliases( /** * Resolve entity IDs to their labels via wbgetentities API */ -async function resolveEntityLabels(entityIds: string[], timeout: number): Promise> { +async function resolveEntityLabels( + entityIds: string[], + timeout: number, + signal?: AbortSignal, +): Promise> { if (entityIds.length === 0) return {}; const labels: Record = {}; @@ -268,7 +276,7 @@ async function resolveEntityLabels(entityIds: string[], timeout: number): Promis const apiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${batch.join("|")}&props=labels&languages=en&format=json`; try { - const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 10) }); + const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 10), signal }); if (result.ok) { const data = JSON.parse(result.content) as { entities: Record }>; diff --git a/packages/coding-agent/src/core/tools/web-scrapers/wikipedia.ts b/packages/coding-agent/src/core/tools/web-scrapers/wikipedia.ts index 031161f18..51789e85d 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/wikipedia.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/wikipedia.ts @@ -5,7 +5,11 @@ import { finalizeOutput, loadPage } from "./types"; /** * Handle Wikipedia URLs via Wikipedia API */ -export const handleWikipedia: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleWikipedia: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { try { const parsed = new URL(url); // Match *.wikipedia.org @@ -21,7 +25,7 @@ export const handleWikipedia: SpecialHandler = async (url: string, timeout: numb // Use Wikipedia API to get plain text extract const apiUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`; - const summaryResult = await loadPage(apiUrl, { timeout }); + const summaryResult = await loadPage(apiUrl, { timeout, signal }); let md = ""; @@ -38,7 +42,7 @@ export const handleWikipedia: SpecialHandler = async (url: string, timeout: numb // Get full article content via mobile-html or parse API const contentUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/mobile-html/${encodeURIComponent(title)}`; - const contentResult = await loadPage(contentUrl, { timeout }); + const contentResult = await loadPage(contentUrl, { timeout, signal }); if (contentResult.ok) { const doc = parseHtml(contentResult.content); diff --git a/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts b/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts index b9ecb923a..fee3ab915 100644 --- a/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts +++ b/packages/coding-agent/src/core/tools/web-scrapers/youtube.ts @@ -1,6 +1,8 @@ import { unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { FileSink } from "bun"; +import { nanoid } from "nanoid"; import { ensureTool } from "../../../utils/tools-manager"; import type { RenderResult, SpecialHandler } from "./types"; import { finalizeOutput } from "./types"; @@ -8,20 +10,44 @@ import { finalizeOutput } from "./types"; /** * Execute a command and return stdout */ -function exec( +async function exec( cmd: string, args: string[], - options?: { timeout?: number; input?: string | Buffer }, -): { stdout: string; stderr: string; ok: boolean } { - const result = Bun.spawnSync([cmd, ...args], { - stdin: options?.input ? (options.input as any) : "ignore", + options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal }, +): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> { + const proc = Bun.spawn([cmd, ...args], { + stdin: options?.input ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe", + timeout: options?.timeout, + signal: options?.signal, }); + + if (options?.input && proc.stdin) { + const stdin = proc.stdin as FileSink; + const payload = typeof options.input === "string" ? new TextEncoder().encode(options.input) : options.input; + stdin.write(payload); + const flushed = stdin.flush(); + if (flushed instanceof Promise) { + await flushed; + } + const ended = stdin.end(); + if (ended instanceof Promise) { + await ended; + } + } + + const [stdout, stderr] = await Promise.all([ + (proc.stdout as ReadableStream).text(), + (proc.stderr as ReadableStream).text(), + ]); + const exitCode = await proc.exited; + return { - stdout: result.stdout?.toString() ?? "", - stderr: result.stderr?.toString() ?? "", - ok: result.exitCode === 0, + stdout, + stderr, + ok: exitCode === 0, + exitCode, }; } @@ -124,12 +150,18 @@ function formatDuration(seconds: number): string { /** * Handle YouTube URLs - fetch metadata and transcript */ -export const handleYouTube: SpecialHandler = async (url: string, timeout: number): Promise => { +export const handleYouTube: SpecialHandler = async ( + url: string, + timeout: number, + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); const yt = parseYouTubeUrl(url); if (!yt) return null; // Ensure yt-dlp is available (auto-download if missing) const ytdlp = await ensureTool("yt-dlp", true); + signal?.throwIfAborted(); if (!ytdlp) { return { url, @@ -148,9 +180,16 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`; // Fetch video metadata - const metaResult = exec(ytdlp, ["--dump-json", "--no-warnings", "--no-playlist", "--skip-download", videoUrl], { - timeout: timeout * 1000, - }); + signal?.throwIfAborted(); + const metaResult = await exec( + ytdlp, + ["--dump-json", "--no-warnings", "--no-playlist", "--skip-download", videoUrl], + { + timeout: timeout * 1000, + signal, + }, + ); + signal?.throwIfAborted(); let title = "YouTube Video"; let channel = ""; @@ -190,21 +229,29 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number let transcriptSource = ""; // First, list available subtitles - const listResult = exec(ytdlp, ["--list-subs", "--no-warnings", "--no-playlist", "--skip-download", videoUrl], { - timeout: timeout * 1000, - }); + signal?.throwIfAborted(); + const listResult = await exec( + ytdlp, + ["--list-subs", "--no-warnings", "--no-playlist", "--skip-download", videoUrl], + { + timeout: timeout * 1000, + signal, + }, + ); + signal?.throwIfAborted(); const hasManualSubs = listResult.stdout.includes("[info] Available subtitles"); const hasAutoSubs = listResult.stdout.includes("[info] Available automatic captions"); // Create temp directory for subtitle download const tmpDir = tmpdir(); - const tmpBase = path.join(tmpDir, `yt-${yt.videoId}-${Date.now()}`); + const tmpBase = path.join(tmpDir, `yt-${yt.videoId}-${nanoid()}`); try { // Try manual subtitles first (English preferred) if (hasManualSubs) { - const subResult = exec( + signal?.throwIfAborted(); + const subResult = await exec( ytdlp, [ "--write-sub", @@ -219,13 +266,15 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number tmpBase, videoUrl, ], - { timeout: timeout * 1000 }, + { timeout: timeout * 1000, signal }, ); if (subResult.ok) { // Find the downloaded subtitle file using glob + signal?.throwIfAborted(); const subFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*.vtt`).scan({ absolute: true })); if (subFiles.length > 0) { + signal?.throwIfAborted(); const vttContent = await Bun.file(subFiles[0]).text(); transcript = cleanVttToText(vttContent); transcriptSource = "manual"; @@ -236,7 +285,8 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number // Fall back to auto-generated captions if (!transcript && hasAutoSubs) { - const autoResult = exec( + signal?.throwIfAborted(); + const autoResult = await exec( ytdlp, [ "--write-auto-sub", @@ -251,12 +301,14 @@ export const handleYouTube: SpecialHandler = async (url: string, timeout: number tmpBase, videoUrl, ], - { timeout: timeout * 1000 }, + { timeout: timeout * 1000, signal }, ); if (autoResult.ok) { + signal?.throwIfAborted(); const subFiles = await Array.fromAsync(new Bun.Glob(`${tmpBase}*.vtt`).scan({ absolute: true })); if (subFiles.length > 0) { + signal?.throwIfAborted(); const vttContent = await Bun.file(subFiles[0]).text(); transcript = cleanVttToText(vttContent); transcriptSource = "auto-generated"; diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 183f73f60..a303252fa 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -6,6 +6,7 @@ import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/inte import writeDescription from "../../prompts/tools/write.md" with { type: "text" }; import type { RenderResultOptions } from "../custom-tools/types"; import type { ToolSession } from "../sdk"; +import { untilAborted } from "../utils"; import { createLspWritethrough, type FileDiagnosticsResult } from "./lsp/index"; import { resolveToCwd } from "./path-utils"; import { formatDiagnostics, replaceTabs, shortenPath } from "./render-utils"; @@ -34,27 +35,29 @@ export function createWriteTool(session: ToolSession): AgentTool { - const absolutePath = resolveToCwd(path, session.cwd); + return untilAborted(signal, async () => { + const absolutePath = resolveToCwd(path, session.cwd); - const diagnostics = await writethrough(absolutePath, content, signal); + const diagnostics = await writethrough(absolutePath, content, signal); - let resultText = `Successfully wrote ${content.length} bytes to ${path}`; - if (!diagnostics) { + let resultText = `Successfully wrote ${content.length} bytes to ${path}`; + if (!diagnostics) { + return { + content: [{ type: "text", text: resultText }], + details: {}, + }; + } + + const messages = diagnostics?.messages; + if (messages && messages.length > 0) { + resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`; + resultText += messages.map((d) => ` ${d}`).join("\n"); + } return { content: [{ type: "text", text: resultText }], - details: {}, + details: { diagnostics }, }; - } - - const messages = diagnostics?.messages; - if (messages && messages.length > 0) { - resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`; - resultText += messages.map((d) => ` ${d}`).join("\n"); - } - return { - content: [{ type: "text", text: resultText }], - details: { diagnostics }, - }; + }); }, }; } diff --git a/packages/coding-agent/src/core/voice.ts b/packages/coding-agent/src/core/voice.ts index 171daa026..2e6919d9e 100644 --- a/packages/coding-agent/src/core/voice.ts +++ b/packages/coding-agent/src/core/voice.ts @@ -2,6 +2,7 @@ import { unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { completeSimple, type Model } from "@mariozechner/pi-ai"; +import { nanoid } from "nanoid"; import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" }; import { logger } from "./logger"; import type { ModelRegistry } from "./model-registry"; @@ -99,7 +100,7 @@ function buildRecordingCommand(filePath: string, sampleRate: number, channels: n export async function startVoiceRecording(_settings: VoiceSettings): Promise { const sampleRate = DEFAULT_SAMPLE_RATE; const channels = DEFAULT_CHANNELS; - const filePath = join(tmpdir(), `omp-voice-${Date.now()}.wav`); + const filePath = join(tmpdir(), `omp-voice-${nanoid()}.wav`); const command = buildRecordingCommand(filePath, sampleRate, channels); if (!command) { throw new Error("No audio recorder found (install sox, arecord, or ffmpeg)."); @@ -233,7 +234,7 @@ function getPlayerCommand(filePath: string, format: VoiceSynthesisResult["format } export async function playAudio(audio: Uint8Array, format: VoiceSynthesisResult["format"]): Promise { - const filePath = join(tmpdir(), `omp-voice-tts-${Date.now()}.${format}`); + const filePath = join(tmpdir(), `omp-tts-${nanoid()}.${format}`); await Bun.write(filePath, audio); const command = getPlayerCommand(filePath, format); diff --git a/packages/coding-agent/src/lib/worktree/collapse.ts b/packages/coding-agent/src/lib/worktree/collapse.ts index 90a6a6c80..65c6b0193 100644 --- a/packages/coding-agent/src/lib/worktree/collapse.ts +++ b/packages/coding-agent/src/lib/worktree/collapse.ts @@ -1,3 +1,4 @@ +import { nanoid } from "nanoid"; import { WorktreeError, WorktreeErrorCode } from "./errors"; import { git, gitWithStdin } from "./git"; import { find, remove, type Worktree } from "./operations"; @@ -89,7 +90,7 @@ async function collapseRebase(src: Worktree, dst: Worktree): Promise { throw new WorktreeError("Failed to resolve HEAD", WorktreeErrorCode.COLLAPSE_FAILED); } const originalHead = headResult.stdout.trim(); - const tempBranch = `wt-collapse-${Date.now()}`; + const tempBranch = `wt-collapse-${nanoid()}`; await requireGitSuccess(await git(["checkout", "-b", tempBranch], src.path), "Failed to create temp branch"); diff --git a/packages/coding-agent/src/lib/worktree/git.ts b/packages/coding-agent/src/lib/worktree/git.ts index 3b10492ee..972750699 100644 --- a/packages/coding-agent/src/lib/worktree/git.ts +++ b/packages/coding-agent/src/lib/worktree/git.ts @@ -17,22 +17,6 @@ type WritableLike = { const textEncoder = new TextEncoder(); -async function readStream(stream: ReadableStream | undefined): Promise { - if (!stream) return ""; - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - return Buffer.concat(chunks).toString(); -} - async function writeStdin(handle: unknown, stdin: string): Promise { if (!handle || typeof handle === "number") return; if (typeof (handle as WritableStream).getWriter === "function") { @@ -77,8 +61,8 @@ export async function gitWithStdin(args: string[], stdin: string, cwd?: string): await writeStdin(proc.stdin, stdin); const [stdout, stderr, exitCode] = await Promise.all([ - readStream(proc.stdout as ReadableStream), - readStream(proc.stderr as ReadableStream), + (proc.stdout as ReadableStream).text(), + (proc.stderr as ReadableStream).text(), proc.exited, ]); diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts index 427eb32a5..22d292b48 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts @@ -7,6 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { nanoid } from "nanoid"; import { getEditorTheme, theme } from "../theme/theme"; import { DynamicBorder } from "./dynamic-border"; @@ -90,7 +91,7 @@ export class HookEditorComponent extends Container { } const currentText = this.editor.getText(); - const tmpFile = path.join(os.tmpdir(), `omp-hook-editor-${Date.now()}.md`); + const tmpFile = path.join(os.tmpdir(), `omp-hook-editor-${nanoid()}.md`); try { fs.writeFileSync(tmpFile, currentText, "utf-8"); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 386eed165..6a7ce14f7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -23,6 +23,7 @@ import { TUI, visibleWidth, } from "@oh-my-pi/pi-tui"; +import { nanoid } from "nanoid"; import { getAuthPath, getDebugLogPath } from "../../config"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session"; import type { ExtensionUIContext } from "../../core/extensions/index"; @@ -2068,7 +2069,7 @@ export class InteractiveMode { } const currentText = this.editor.getText(); - const tmpFile = path.join(os.tmpdir(), `omp-editor-${Date.now()}.omp.md`); + const tmpFile = path.join(os.tmpdir(), `omp-editor-${nanoid()}.omp.md`); try { // Write current content to temp file diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c16054d9f..a01cebdea 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -315,18 +315,18 @@ const UNICODE_SYMBOLS: SymbolMap = { "icon.rewind": "↩", // pick: ⚡ | alt: ✨ ✦ "icon.auto": "⚡", - // pick: SK | alt: 🧠 🎓 - "icon.extensionSkill": "SK", - // pick: TL | alt: 🛠 ⚙ - "icon.extensionTool": "TL", + // pick: ✧ | alt: ⚙ SK 🧠 + "icon.extensionSkill": "✧", + // pick: ⚒ | alt: ⛭ TL 🛠 + "icon.extensionTool": "⚒", // pick: / | alt: ⌘ ⌥ "icon.extensionSlashCommand": "/", - // pick: MCP | alt: 🔌 🧩 - "icon.extensionMcp": "MCP", - // pick: RL | alt: ⚖ 📏 - "icon.extensionRule": "RL", - // pick: HK | alt: 🪝 ⚓ - "icon.extensionHook": "HK", + // pick: ◈ | alt: ⧫ MCP 🔌 + "icon.extensionMcp": "◈", + // pick: § | alt: ⚖ RL 📏 + "icon.extensionRule": "§", + // pick: ↪ | alt: ⚓ HK 🪝 + "icon.extensionHook": "↪", // pick: PR | alt: 💬 ✎ "icon.extensionPrompt": "PR", // pick: CF | alt: 📄 📎 @@ -356,10 +356,10 @@ const UNICODE_SYMBOLS: SymbolMap = { "format.bullet": "•", // pick: – | alt: — ― - "format.dash": "–", - // pick: [ | alt: ⟦ ⟨ - "format.bracketLeft": "[", - // pick: ] | alt: ⟧ ⟩ - "format.bracketRight": "]", + // pick: ⟨ | alt: [ ⟦ + "format.bracketLeft": "⟨", + // pick: ⟩ | alt: ] ⟧ + "format.bracketRight": "⟩", // Markdown-specific // pick: │ | alt: ┃ ║ "md.quoteBorder": "│", @@ -574,15 +574,15 @@ const NERD_SYMBOLS: SymbolMap = { "icon.extensionInstruction": "\uf02d", // Thinking Levels - emoji labels // pick: 🤨 min | alt:  min  min - "thinking.minimal": "🤨 min", + "thinking.minimal": "\u{F0E7} min", // pick: 🤔 low | alt:  low  low - "thinking.low": "🤔 low", + "thinking.low": "\u{F10C} low", // pick: 🤓 med | alt:  med  med - "thinking.medium": "🤓 med", + "thinking.medium": "\u{F192} med", // pick: 🤯 high | alt:  high  high - "thinking.high": "🤯 high", + "thinking.high": "\u{F111} high", // pick: 🧠 xhi | alt:  xhi  xhi - "thinking.xhigh": "🧠 xhi", + "thinking.xhigh": "\u{F06D} xhi", // Checkboxes // pick:  | alt:   "checkbox.checked": "\uf14a", @@ -595,10 +595,10 @@ const NERD_SYMBOLS: SymbolMap = { "format.bullet": "\uf111", // pick: – | alt: — ― - "format.dash": "\u2013", - // pick: [ | alt: ⟦ ⟨ - "format.bracketLeft": "[", - // pick: ] | alt: ⟧ ⟩ - "format.bracketRight": "]", + // pick: ⟨ | alt: [ ⟦ + "format.bracketLeft": "⟨", + // pick: ⟩ | alt: ] ⟧ + "format.bracketRight": "⟩", // Markdown-specific // pick: │ | alt: ┃ ║ "md.quoteBorder": "\u2502", @@ -608,41 +608,41 @@ const NERD_SYMBOLS: SymbolMap = { "md.bullet": "\uf111", // Language icons (nerd font devicons) "lang.default": "", - "lang.typescript": "", - "lang.javascript": "", - "lang.python": "", - "lang.rust": "", - "lang.go": "", - "lang.java": "", - "lang.c": "", - "lang.cpp": "", - "lang.csharp": "", - "lang.ruby": "", - "lang.php": "", - "lang.swift": "", - "lang.kotlin": "", - "lang.shell": "", - "lang.html": "", - "lang.css": "", - "lang.json": "", - "lang.yaml": "", - "lang.markdown": "", - "lang.sql": "", - "lang.docker": "", - "lang.lua": "", - "lang.text": "", - "lang.env": "", - "lang.toml": "", - "lang.xml": "󰗀", - "lang.ini": "", - "lang.conf": "", - "lang.log": "󰌱", - "lang.csv": "󰈛", - "lang.tsv": "󰈛", - "lang.image": "󰈟", - "lang.pdf": "󰈦", - "lang.archive": "", - "lang.binary": "󰆚", + "lang.typescript": "\u{E628}", + "lang.javascript": "\u{E60C}", + "lang.python": "\u{E606}", + "lang.rust": "\u{E7A8}", + "lang.go": "\u{E627}", + "lang.java": "\u{E738}", + "lang.c": "\u{E61E}", + "lang.cpp": "\u{E61D}", + "lang.csharp": "\u{E7BC}", + "lang.ruby": "\u{E791}", + "lang.php": "\u{E608}", + "lang.swift": "\u{E755}", + "lang.kotlin": "\u{E634}", + "lang.shell": "\u{E795}", + "lang.html": "\u{E736}", + "lang.css": "\u{E749}", + "lang.json": "\u{E60B}", + "lang.yaml": "\u{E615}", + "lang.markdown": "\u{E609}", + "lang.sql": "\u{E706}", + "lang.docker": "\u{E7B0}", + "lang.lua": "\u{E620}", + "lang.text": "\u{E612}", + "lang.env": "\u{E615}", + "lang.toml": "\u{E615}", + "lang.xml": "\u{F05C0}", + "lang.ini": "\u{E615}", + "lang.conf": "\u{E615}", + "lang.log": "\u{F0331}", + "lang.csv": "\u{F021B}", + "lang.tsv": "\u{F021B}", + "lang.image": "\u{F021F}", + "lang.pdf": "\u{F0226}", + "lang.archive": "\u{F187}", + "lang.binary": "\u{F019A}", }; const ASCII_SYMBOLS: SymbolMap = { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index a8dd0b1ab..b4652e8d9 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -11,6 +11,7 @@ * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response */ +import { nanoid } from "nanoid"; import type { AgentSession } from "../../core/agent-session"; import type { ExtensionUIContext } from "../../core/extensions/index"; import { theme } from "../interactive/theme/theme"; @@ -66,7 +67,7 @@ export async function runRpcMode(session: AgentSession): Promise { */ const createExtensionUIContext = (): ExtensionUIContext => ({ async select(title: string, options: string[]): Promise { - const id = globalThis.crypto.randomUUID(); + const id = nanoid(); return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { @@ -85,7 +86,7 @@ export async function runRpcMode(session: AgentSession): Promise { }, async confirm(title: string, message: string): Promise { - const id = globalThis.crypto.randomUUID(); + const id = nanoid(); return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { @@ -104,7 +105,7 @@ export async function runRpcMode(session: AgentSession): Promise { }, async input(title: string, placeholder?: string): Promise { - const id = globalThis.crypto.randomUUID(); + const id = nanoid(); return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { @@ -126,7 +127,7 @@ export async function runRpcMode(session: AgentSession): Promise { // Fire and forget - no response needed output({ type: "extension_ui_request", - id: globalThis.crypto.randomUUID(), + id: nanoid(), method: "notify", message, notifyType: type, @@ -137,7 +138,7 @@ export async function runRpcMode(session: AgentSession): Promise { // Fire and forget - no response needed output({ type: "extension_ui_request", - id: globalThis.crypto.randomUUID(), + id: nanoid(), method: "setStatus", statusKey: key, statusText: text, @@ -149,7 +150,7 @@ export async function runRpcMode(session: AgentSession): Promise { if (content === undefined || Array.isArray(content)) { output({ type: "extension_ui_request", - id: globalThis.crypto.randomUUID(), + id: nanoid(), method: "setWidget", widgetKey: key, widgetLines: content as string[] | undefined, @@ -162,7 +163,7 @@ export async function runRpcMode(session: AgentSession): Promise { // Fire and forget - host can implement terminal title control output({ type: "extension_ui_request", - id: globalThis.crypto.randomUUID(), + id: nanoid(), method: "setTitle", title, } as RpcExtensionUIRequest); @@ -177,7 +178,7 @@ export async function runRpcMode(session: AgentSession): Promise { // Fire and forget - host can implement editor control output({ type: "extension_ui_request", - id: globalThis.crypto.randomUUID(), + id: nanoid(), method: "set_editor_text", text, } as RpcExtensionUIRequest); @@ -190,7 +191,7 @@ export async function runRpcMode(session: AgentSession): Promise { }, async editor(title: string, prefill?: string): Promise { - const id = globalThis.crypto.randomUUID(); + const id = nanoid(); return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { diff --git a/packages/coding-agent/src/prompts/reviewer.md b/packages/coding-agent/src/prompts/reviewer.md index af1e698c6..b5af14045 100644 --- a/packages/coding-agent/src/prompts/reviewer.md +++ b/packages/coding-agent/src/prompts/reviewer.md @@ -1,81 +1,77 @@ --- name: reviewer description: Code review specialist for quality and security analysis -tools: read, grep, find, ls, bash, report_finding, submit_review -spawns: explore +tools: read, grep, find, ls, bash, report_finding +spawns: explore, task model: pi/slow, gpt-5.2-codex, gpt-5.2, codex, gpt --- -You are acting as a reviewer for a proposed code change made by another engineer. +You are a senior engineer reviewing a proposed code change. Your goal: identify bugs that the author would want to fix before merging. -Bash is for read-only commands only: `git diff`, `git log`, `git show`, `gh pr diff`. Do NOT modify files or run builds. +# Strategy -# Review Strategy +1. Run `git diff` (or `gh pr diff `) to see the patch +2. Read modified files for full context +3. For large changes, spawn parallel `task` agents (one per module/concern) +4. Call `report_finding` for each issue +5. Call `complete` with your verdict — **review is incomplete until `complete` is called** -1. Run `git diff` (or `gh pr diff `) to see the changes -2. Read the modified files for full context -3. For large changes spanning multiple files/modules, use `task` with `explore` agents in parallel to gather context faster -4. Analyze for bugs, security issues, and code quality problems -5. Use `report_finding` for each issue found -6. Use `submit_review` to provide final verdict - -# Parallelization - -For reviews touching many files, spawn `explore` agents to research in parallel: -- Each agent can investigate a different module or concern -- Example: one explores test coverage, another checks related implementations -- Gather their findings, then synthesize into your review +Bash is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. No file modifications or builds. # What to Flag -Only flag issues where ALL of these apply: +Report an issue only when ALL conditions hold: -1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code -2. The bug is discrete and actionable (not a general issue or combination of multiple issues) -3. Fixing it doesn't demand rigor not present elsewhere in the codebase -4. The bug was introduced in this commit (don't flag pre-existing bugs) -5. The author would likely fix the issue if made aware of it -6. The bug doesn't rely on unstated assumptions about the codebase or author's intent -7. You can identify specific code that is provably affected (speculation is not enough) -8. The issue is clearly not an intentional change by the author +- **Provable impact**: You can show specific code paths affected (no speculation) +- **Actionable**: Discrete fix, not a vague "consider improving X" +- **Unintentional**: Clearly not a deliberate design choice +- **Introduced in this patch**: Don't flag pre-existing bugs +- **No unstated assumptions**: Bug doesn't rely on assumptions about codebase or author's intent +- **Proportionate rigor**: Fix doesn't demand rigor not present elsewhere in the codebase -# Priority Levels +# Priority -- **P0**: Drop everything to fix. Blocking release, operations, or major usage. Only use for universal issues that do not depend on assumptions about inputs. -- **P1**: Urgent. Should be addressed in the next cycle. -- **P2**: Normal. To be fixed eventually. -- **P3**: Low. Nice to have. +| Level | Criteria | Example | +| ----- | ----------------------------------------------------------- | ---------------------------- | +| P0 | Blocks release/operations; universal (no input assumptions) | Data corruption, auth bypass | +| P1 | High; fix next cycle | Race condition under load | +| P2 | Medium; fix eventually | Edge case mishandling | +| P3 | Info; nice to have | Suboptimal but correct | -# Comment Guidelines +# Writing Findings -1. Be clear about WHY the issue is a bug -2. Communicate severity appropriately - don't overstate -3. Keep body to one paragraph max -4. Code snippets should be ≤3 lines, wrapped in markdown code tags -5. Clearly state what conditions are necessary for the bug to arise -6. Tone: matter-of-fact, not accusatory or overly positive -7. Write so the author can immediately grasp the idea without close reading -8. Avoid flattery and phrases like "Great job...", "Thanks for..." -9. Use ```suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block) -10. In every ```suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces) +- **Title**: Imperative, ≤80 chars (e.g., `Handle null response from API`) +- **Body**: One paragraph. State the bug, trigger condition, and impact. Neutral tone. +- **Suggestion blocks**: Only for concrete replacement code. Preserve exact whitespace. No commentary inside. -# CRITICAL + +Validate input length before buffer copy +When `data.length > BUFFER_SIZE`, `memcpy` writes past the buffer boundary. This occurs if the API returns oversized payloads, causing heap corruption. +```suggestion +if (data.length > BUFFER_SIZE) return -EINVAL; +memcpy(buf, data.ptr, data.length); +``` + -You MUST call `submit_review` before ending your response, even if you found no issues. -The review is only considered complete when `submit_review` is called. -Failure to call `submit_review` means the review was not submitted. +# Output Format -# Output +Each `report_finding` requires: -- Use `report_finding` for each issue. Continue until you've listed every qualifying finding. -- Each `report_finding` must include: title (<=80 chars, imperative, prefixed `[P0-P3]`), body (one paragraph), priority (0-3), confidence (0.0-1.0), absolute `file_path`, and `line_start`/`line_end` with a range <=10 lines. -- If there is no finding that a person would definitely want to fix, prefer outputting no findings. -- Every finding must be anchored to a specific diff hunk; the code location must overlap the patch. If you cannot anchor it to the patch, do not report it. -- Ignore trivial style unless it obscures meaning or violates documented standards. -- Use `submit_review` at the end with your overall verdict: - - **correct**: Existing code and tests will not break, patch is free of bugs and blocking issues - - **incorrect**: Has bugs or blocking issues that must be addressed +- `title`: ≤80 chars, imperative +- `body`: One paragraph +- `priority`: 0-3 +- `confidence`: 0.0-1.0 +- `file_path`: Absolute path +- `line_start`, `line_end`: Range ≤10 lines, must overlap the diff -Ignore non-blocking issues (style, formatting, typos, documentation, nits) when determining correctness. +Final `complete` call: -At the end of the review, double-check that every finding is evidence-backed and non-speculative. +- `overall_correctness`: "correct" (no bugs/blockers) or "incorrect" +- `explanation`: 1-3 sentences +- `confidence`: 0.0-1.0 + +Correctness judgment ignores non-blocking issues (style, docs, nits). + +# Critical Reminder + +Every finding must be anchored to the patch and evidence-backed. Before submitting, verify each finding is not speculative. Then call `complete`. diff --git a/packages/coding-agent/src/prompts/tools/output.md b/packages/coding-agent/src/prompts/tools/output.md index 1bd62f1fa..f6f038c59 100644 --- a/packages/coding-agent/src/prompts/tools/output.md +++ b/packages/coding-agent/src/prompts/tools/output.md @@ -16,13 +16,32 @@ Do NOT use when: ## Parameters -- `ids`: Array of output IDs from Task results (e.g., `["reviewer_0", "explore_1"]`) +- `ids`: Array of output IDs from Task results (e.g., `["ApiAudit", "DbAudit"]`) - `format` (optional): - `"raw"` (default): Full output with ANSI codes preserved - `"json"`: Structured object with metadata - `"stripped"`: Plain text with ANSI codes removed for parsing -- `query` (optional): jq-like query for JSON outputs (e.g., `.result.items[0].name`) +- `query` (optional): jq-like query for JSON outputs (e.g., `.endpoints[0].file`) - `offset` (optional): Line number to start reading from (1-indexed) - `limit` (optional): Maximum number of lines to read -Use offset/limit for line ranges to reduce context usage on large outputs. Use `query` for JSON outputs (for example, subagent `complete` results). +Use offset/limit for line ranges to reduce context usage on large outputs. Use `query` for structured agent outputs (agents that call `complete` with `output`). + +## Query Examples + +For agents returning structured data via `complete`, use `query` to extract specific fields: + +``` +# Given output: { properties: { endpoints: { elements: { properties: { file, line, hasAuth } } } } } + +.endpoints # Get all endpoints array +.endpoints[0] # First endpoint object +.endpoints[0].file # First endpoint's file path +.endpoints[0]["hasAuth"] # Bracket notation (equivalent to .hasAuth) +``` + +Query paths: +- `.foo` - property access +- `[0]` - array index +- `.foo.bar[0].baz` - chained access +- `["special-key"]` - properties with special characters diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index 00c98095d..056408f0b 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -16,9 +16,9 @@ The Task tool launches specialized agents (workers) that autonomously handle com ## Usage Notes - Always include a short description of the task in the task parameter -- **Plan-then-execute**: Put shared constraints in `context`, keep each task focused, specify acceptance criteria; use `output_schema` when you need structured output +- **Plan-then-execute**: Put shared constraints in `context`, keep each task focused, specify acceptance criteria; use `output` when you need structured output - **Minimize tool chatter**: Avoid repeating large context; use Output tool with output ids for full logs -- **Structured completion**: If `output_schema` is provided, subagents must call `complete` to finish +- **Structured completion**: If `output` is provided, subagents must call `complete` to finish - **Parallelize**: Launch multiple agents concurrently whenever possible - **Results are intermediate data**: Agent findings provide context for YOU to perform actual work. Do not treat agent reports as "task complete" signals. - **Stateless invocations**: Each agent runs autonomously and returns a single final message. Include all necessary context and specify exactly what information to return. @@ -28,43 +28,42 @@ The Task tool launches specialized agents (workers) that autonomously handle com ## Parameters -- `tasks`: Array of `{agent, task, description?, model?}` - tasks to run in parallel (max {{MAX_PARALLEL_TASKS}}, {{MAX_CONCURRENCY}} concurrent) - - `model`: (optional) Override the agent's default model with fuzzy matching (e.g., "sonnet", "codex", "5.2"). Supports comma-separated fallbacks: "gpt, opus" tries gpt first, then opus. Use "default" for omp's default model -- `context`: (optional) Shared context string prepended to all task prompts - use this to avoid repeating instructions -- `output_schema`: (optional) JSON schema for structured subagent output (used by the complete tool) +- `agent`: Agent type to use for all tasks +- `context`: Shared context string prepended to all task prompts +- `model`: (optional) Model override (fuzzy matching, e.g., "sonnet", "opus") +- `tasks`: Array of `{id, task, description}` - tasks to run in parallel (max {{MAX_PARALLEL_TASKS}}, {{MAX_CONCURRENCY}} concurrent) + - `id`: Short CamelCase identifier for display (max 20 chars, e.g., "SessionStore", "LspRefactor") + - `task`: The task prompt for the agent + - `description`: Short human-readable description of what the task does +- `output`: (optional) JTD schema for structured subagent output (used by the complete tool) -## Examples +## Example -user: "Please write a function that checks if a number is prime" -assistant: Sure let me write a function that checks if a number is prime -assistant: I'm going to use the Write tool to write the following code: - -function isPrime(n) { - if (n <= 1) return false - for (let i = 2; i * i <= n; i++) { - if (n % i === 0) return false - } - return true -} - - -Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code - -assistant: Now let me use the code-reviewer agent to review the code -assistant: Uses the Task tool: { tasks: [{ agent: "code-reviewer", task: "Review the isPrime function" }] } - - - -user: "Find all TODO comments in the codebase" -assistant: I'll use multiple explore agents to search different directories in parallel +user: "Extract all hardcoded strings for i18n" +assistant: I'll scan UI components and return structured string locations for internationalization. assistant: Uses the Task tool: { - "context": "Find all TODO comments. Return file:line:content format.", + "agent": "explore", + "context": "Find hardcoded user-facing strings (labels, messages, errors). Ignore logs, comments, and internal identifiers.", + "output": { + "properties": { + "strings": { + "elements": { + "properties": { + "file": { "type": "string" }, + "line": { "type": "uint32" }, + "text": { "type": "string" }, + "suggestedKey": { "type": "string" } + } + } + } + } + }, "tasks": [ - { "agent": "explore", "task": "Search in src/" }, - { "agent": "explore", "task": "Search in lib/" }, - { "agent": "explore", "task": "Search in tests/" } + { "id": "Forms", "task": "Scan src/components/forms/", "description": "Extract form strings" }, + { "id": "Modals", "task": "Scan src/components/modals/", "description": "Extract modal strings" }, + { "id": "Pages", "task": "Scan src/pages/", "description": "Extract page strings" } ] } diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index bb9b8f66c..056e721e5 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,4 +1,5 @@ import { platform } from "node:os"; +import { nanoid } from "nanoid"; async function spawnWithTimeout(cmd: string[], input: string, timeoutMs: number): Promise { const proc = Bun.spawn(cmd, { stdin: "pipe" }); @@ -199,7 +200,7 @@ async function readImageMacOS(timeout: number): Promise { // Read the actual image data using a temp file approach // osascript can't output binary directly, so we write to a temp file - const tempFile = `/tmp/omp-clipboard-${Date.now()}.${imageType === "png" ? "png" : "jpg"}`; + const tempFile = `/tmp/omp-clipboard-${nanoid()}.${imageType === "png" ? "png" : "jpg"}`; const clipboardClass = imageType === "png" ? "«class PNGf»" : "«class JPEG»"; const readScript = ` diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 157c8d788..65bb5681d 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@mariozechner/pi-ai"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; @@ -28,7 +29,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { beforeEach(() => { // Create temp directory for session files - tempDir = join(tmpdir(), `pi-branching-test-${Date.now()}`); + tempDir = join(tmpdir(), `pi-branching-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); }); diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 711366183..e456b654a 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@mariozechner/pi-ai"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; @@ -29,7 +30,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { beforeEach(() => { // Create temp directory for session files - tempDir = join(tmpdir(), `omp-compaction-test-${Date.now()}`); + tempDir = join(tmpdir(), `omp-compaction-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); // Track events diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 22eeecaa8..6ab218c04 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@mariozechner/pi-ai"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; @@ -53,7 +54,7 @@ describe("AgentSession concurrent prompt guard", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`); + tempDir = join(tmpdir(), `pi-concurrent-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); }); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 23a3df4f3..c332bb477 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@mariozechner/pi-ai"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { @@ -32,7 +33,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { let capturedEvents: SessionEvent[]; beforeEach(() => { - tempDir = join(tmpdir(), `omp-compaction-hooks-test-${Date.now()}`); + tempDir = join(tmpdir(), `omp-compaction-hooks-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); capturedEvents = []; }); diff --git a/packages/coding-agent/test/compaction-thinking-model.test.ts b/packages/coding-agent/test/compaction-thinking-model.test.ts index 812fc72c5..6d80dad93 100644 --- a/packages/coding-agent/test/compaction-thinking-model.test.ts +++ b/packages/coding-agent/test/compaction-thinking-model.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel, type Model } from "@mariozechner/pi-ai"; import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; @@ -31,7 +32,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-thinking-compaction-test-${Date.now()}`); + tempDir = join(tmpdir(), `pi-thinking-compaction-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); }); @@ -144,7 +145,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `pi-thinking-compaction-anthropic-test-${Date.now()}`); + tempDir = join(tmpdir(), `pi-thinking-compaction-anthropic-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index dcf4a0dd2..5be1d22a7 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { nanoid } from "nanoid"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; @@ -11,7 +12,7 @@ describe("ModelRegistry", () => { let authStorage: AuthStorage; beforeEach(() => { - tempDir = join(tmpdir(), `pi-test-model-registry-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDir = join(tmpdir(), `pi-test-model-registry-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); modelsJsonPath = join(tempDir, "models.json"); authStorage = new AuthStorage(join(tempDir, "auth.json")); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 54d9229d4..388ef455c 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentEvent } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { RpcClient } from "../src/modes/rpc/rpc-client"; /** @@ -13,7 +14,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T let sessionDir: string; beforeEach(() => { - sessionDir = join(tmpdir(), `omp-rpc-test-${Date.now()}`); + sessionDir = join(tmpdir(), `omp-rpc-test-${nanoid()}`); client = new RpcClient({ cliPath: join(import.meta.dir, "..", "dist", "cli.js"), cwd: join(import.meta.dir, ".."), @@ -129,7 +130,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T await client.promptAndWait("Say hi"); // Run bash command - const uniqueValue = `test-${Date.now()}`; + const uniqueValue = `test-${nanoid()}`; await client.bash(`echo ${uniqueValue}`); // Wait for file writes @@ -158,7 +159,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T await client.start(); // Run a bash command with a unique value - const uniqueValue = `unique-${Date.now()}`; + const uniqueValue = `unique-${nanoid()}`; await client.bash(`echo ${uniqueValue}`); // Ask the LLM what the output was 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 96820e826..e647996c2 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -2,13 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { nanoid } from "nanoid"; import { findMostRecentSession, loadEntriesFromFile } from "../../src/core/session-manager"; describe("loadEntriesFromFile", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `session-test-${Date.now()}`); + tempDir = join(tmpdir(), `session-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); }); @@ -69,7 +70,7 @@ describe("findMostRecentSession", () => { let tempDir: string; beforeEach(() => { - tempDir = join(tmpdir(), `session-test-${Date.now()}`); + tempDir = join(tmpdir(), `session-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 083a78e71..cfd7c1b1d 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { nanoid } from "nanoid"; import { createBashTool } from "../src/core/tools/bash"; import { createEditTool } from "../src/core/tools/edit"; import { createFindTool } from "../src/core/tools/find"; @@ -43,7 +44,7 @@ describe("Coding Agent Tools", () => { beforeEach(() => { // Create a unique temporary directory for each test - testDir = join(tmpdir(), `coding-agent-test-${Date.now()}`); + testDir = join(tmpdir(), `coding-agent-test-${nanoid()}`); mkdirSync(testDir, { recursive: true }); // Create tools for this test directory @@ -492,7 +493,7 @@ describe("edit tool CRLF handling", () => { let editTool: ReturnType; beforeEach(() => { - testDir = join(tmpdir(), `coding-agent-crlf-test-${Date.now()}`); + testDir = join(tmpdir(), `coding-agent-crlf-test-${nanoid()}`); mkdirSync(testDir, { recursive: true }); editTool = createEditTool(createTestToolSession(testDir)); }); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index d6d7fe7a4..a58db5ed2 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@mariozechner/pi-ai"; import { Agent } from "@oh-my-pi/pi-agent-core"; +import { nanoid } from "nanoid"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; @@ -77,7 +78,7 @@ 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-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const tempDir = join(tmpdir(), `omp-test-${nanoid()}`); mkdirSync(tempDir, { recursive: true }); const toolSession: ToolSession = { diff --git a/packages/git-tool/src/utils.ts b/packages/git-tool/src/utils.ts index b539d3109..ba6a4ef87 100644 --- a/packages/git-tool/src/utils.ts +++ b/packages/git-tool/src/utils.ts @@ -14,22 +14,6 @@ export interface ExecOptions { timeout?: number; } -async function readStream(stream: ReadableStream | undefined): Promise { - if (!stream) return ""; - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - return Buffer.concat(chunks).toString(); -} - export async function exec(command: string, args: string[], options?: ExecOptions): Promise { const cwd = options?.cwd ?? process.cwd(); const proc: Subprocess = Bun.spawn([command, ...args], { @@ -71,8 +55,8 @@ export async function exec(command: string, args: string[], options?: ExecOption } const [stdout, stderr, exitCode] = await Promise.all([ - readStream(proc.stdout as ReadableStream), - readStream(proc.stderr as ReadableStream), + (proc.stdout as ReadableStream).text(), + (proc.stderr as ReadableStream).text(), proc.exited, ]);