diff --git a/package-lock.json b/package-lock.json index 19e7021a0..06d8c0515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -929,14 +929,6 @@ "node": ">=14.21.3" } }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@earendil-works/pi-agent-core": { "resolved": "packages/agent", "link": true @@ -3645,25 +3637,6 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "license": "MIT" - }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "license": "MIT" @@ -3739,14 +3712,6 @@ "version": "2.0.7", "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260120.1", "dev": true, @@ -3989,16 +3954,6 @@ "version": "2.0.8", "license": "MIT" }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "license": "MIT", @@ -4131,13 +4086,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "license": "BSD-3-Clause" @@ -4541,6 +4489,7 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -4718,24 +4667,6 @@ "version": "3.0.2", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -4791,13 +4722,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", "funding": [ @@ -4819,22 +4743,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/file-type": { - "version": "21.3.4", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -4925,19 +4833,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-tsconfig": { "version": "4.14.0", "dev": true, @@ -5102,6 +4997,7 @@ }, "node_modules/ieee754": { "version": "1.2.1", + "dev": true, "funding": [ { "type": "github", @@ -5863,6 +5759,7 @@ }, "node_modules/once": { "version": "1.4.0", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6024,10 +5921,6 @@ "@napi-rs/canvas": "^0.1.81" } }, - "node_modules/pend": { - "version": "1.2.0", - "license": "MIT" - }, "node_modules/pi-extension-custom-provider-anthropic": { "resolved": "packages/coding-agent/examples/extensions/custom-provider-anthropic", "link": true @@ -6188,6 +6081,7 @@ }, "node_modules/pump": { "version": "3.0.4", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -6621,19 +6515,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-eof": { "version": "1.0.0", "dev": true, @@ -6671,20 +6552,6 @@ ], "license": "MIT" }, - "node_modules/strtok3": { - "version": "10.3.5", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/supports-color": { "version": "8.1.1", "dev": true, @@ -6881,22 +6748,6 @@ "node": ">=8.0" } }, - "node_modules/token-types": { - "version": "6.1.2", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "dev": true, @@ -6965,16 +6816,6 @@ "@webreflection/alien-signals": "^0.3.2" } }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/undici": { "version": "7.25.0", "license": "MIT", @@ -6990,17 +6831,6 @@ "version": "1.0.2", "license": "MIT" }, - "node_modules/uuid": { - "version": "14.0.0", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/vite": { "version": "7.3.3", "dev": true, @@ -7275,6 +7105,7 @@ }, "node_modules/wrappy": { "version": "1.0.2", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -7351,14 +7182,6 @@ "node": ">=10" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/zod": { "version": "4.4.3", "license": "MIT", @@ -7457,18 +7280,14 @@ "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", "typebox": "^1.1.24", "undici": "^7.19.1", - "uuid": "^14.0.0", "yaml": "^2.8.2" }, "bin": { diff --git a/packages/agent/src/harness/session/repo/shared.ts b/packages/agent/src/harness/session/repo/shared.ts index eb359fc2b..2c628ca9f 100644 --- a/packages/agent/src/harness/session/repo/shared.ts +++ b/packages/agent/src/harness/session/repo/shared.ts @@ -1,6 +1,6 @@ -import { v7 as uuidv7 } from "uuid"; import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; import { Session } from "../session.js"; +import { uuidv7 } from "../uuid.js"; export function createSessionId(): string { return uuidv7(); diff --git a/packages/agent/src/harness/session/storage/memory.ts b/packages/agent/src/harness/session/storage/memory.ts index 648ae6b02..652f633f6 100644 --- a/packages/agent/src/harness/session/storage/memory.ts +++ b/packages/agent/src/harness/session/storage/memory.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { v7 as uuidv7 } from "uuid"; import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; +import { uuidv7 } from "../uuid.js"; function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { if (entry.type !== "label") return; diff --git a/packages/agent/src/harness/session/uuid.ts b/packages/agent/src/harness/session/uuid.ts new file mode 100644 index 000000000..27b660aea --- /dev/null +++ b/packages/agent/src/harness/session/uuid.ts @@ -0,0 +1,44 @@ +import { randomBytes } from "node:crypto"; + +let lastTimestamp = -Infinity; +let sequence = 0; + +export function uuidv7(): string { + const random = randomBytes(16); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = (random[6] << 23) | (random[7] << 16) | (random[8] << 8) | random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) | 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence << 2) & 0xff) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f4aa53dae..5f4ab9e5d 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -45,18 +45,14 @@ "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", "typebox": "^1.1.24", "undici": "^7.19.1", - "uuid": "^14.0.0", "yaml": "^2.8.2" }, "overrides": { diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index ee55611e3..c1818dacf 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -10,7 +10,7 @@ import { randomBytes } from "node:crypto"; import { createWriteStream, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import stripAnsi from "strip-ansi"; +import { stripAnsi } from "../utils/ansi.js"; import { sanitizeBinaryOutput } from "../utils/shell.js"; import type { BashOperations } from "./tools/bash.js"; import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js"; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 8a0d286aa..60b111e0c 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -15,8 +15,8 @@ import { } from "fs"; import { readdir, readFile, stat } from "fs/promises"; import { join, resolve } from "path"; -import { v7 as uuidv7 } from "uuid"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; +import { uuidv7 } from "../utils/uuid.js"; import { type BashExecutionMessage, type CustomMessage, diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 8cdfc475f..8c7a6fcda 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -1,7 +1,7 @@ import * as os from "node:os"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; +import { stripAnsi } from "../../utils/ansi.js"; import { sanitizeBinaryOutput } from "../../utils/shell.js"; export function shortenPath(path: unknown): string { diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index bfe733f8a..240cd12d1 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -3,13 +3,13 @@ */ import { Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail, } from "../../../core/tools/truncate.js"; +import { stripAnsi } from "../../../utils/ansi.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; import { keyHint, keyText } from "./keybinding-hints.js"; diff --git a/packages/coding-agent/src/utils/ansi.ts b/packages/coding-agent/src/utils/ansi.ts new file mode 100644 index 000000000..497ab3da5 --- /dev/null +++ b/packages/coding-agent/src/utils/ansi.ts @@ -0,0 +1,87 @@ +export function stripAnsi(value: string): string { + let output = ""; + let index = 0; + + while (index < value.length) { + const code = value.charCodeAt(index); + + if (code === 0x1b) { + const next = value[index + 1]; + + if (next === "[") { + index = skipCsi(value, index + 2); + continue; + } + + if (next === "]" || next === "P" || next === "^" || next === "_") { + index = skipStringControl(value, index + 2); + continue; + } + + if (next && "()*+-./#".includes(next)) { + index = Math.min(index + 3, value.length); + continue; + } + + if (next && isEscFinalByte(next.charCodeAt(0))) { + index += 2; + continue; + } + } else if (code === 0x9b) { + index = skipCsi(value, index + 1); + continue; + } else if (code === 0x9d || code === 0x90 || code === 0x9e || code === 0x9f) { + index = skipStringControl(value, index + 1); + continue; + } + + output += value[index]; + index++; + } + + return output; +} + +function isEscFinalByte(code: number): boolean { + return ( + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x50) || + (code >= 0x52 && code <= 0x54) || + code === 0x5a || + code === 0x63 || + (code >= 0x66 && code <= 0x6e) || + (code >= 0x71 && code <= 0x75) || + code === 0x79 || + code === 0x3d || + code === 0x3e || + code === 0x3c || + code === 0x7e + ); +} + +function skipCsi(value: string, start: number): number { + let index = start; + while (index < value.length) { + const code = value.charCodeAt(index); + index++; + if (code >= 0x40 && code <= 0x7e) { + return index; + } + } + return value.length; +} + +function skipStringControl(value: string, start: number): number { + let index = start; + while (index < value.length) { + const code = value.charCodeAt(index); + if (code === 0x07 || code === 0x9c) { + return index + 1; + } + if (code === 0x1b && value[index + 1] === "\\") { + return index + 2; + } + index++; + } + return value.length; +} diff --git a/packages/coding-agent/src/utils/mime.ts b/packages/coding-agent/src/utils/mime.ts index f9ded46e2..7381d3703 100644 --- a/packages/coding-agent/src/utils/mime.ts +++ b/packages/coding-agent/src/utils/mime.ts @@ -1,30 +1,74 @@ import { open } from "node:fs/promises"; -import { fileTypeFromBuffer } from "file-type"; -const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); +const IMAGE_TYPE_SNIFF_BYTES = 4100; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; -const FILE_TYPE_SNIFF_BYTES = 4100; +export function detectSupportedImageMimeType(buffer: Uint8Array): string | null { + if (startsWith(buffer, [0xff, 0xd8, 0xff])) { + return buffer[3] === 0xf7 ? null : "image/jpeg"; + } + if (startsWith(buffer, PNG_SIGNATURE)) { + return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null; + } + if (startsWithAscii(buffer, 0, "GIF")) { + return "image/gif"; + } + if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) { + return "image/webp"; + } + return null; +} export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise { const fileHandle = await open(filePath, "r"); try { - const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES); - const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0); - if (bytesRead === 0) { - return null; - } - - const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead)); - if (!fileType) { - return null; - } - - if (!IMAGE_MIME_TYPES.has(fileType.mime)) { - return null; - } - - return fileType.mime; + const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0); + return detectSupportedImageMimeType(buffer.subarray(0, bytesRead)); } finally { await fileHandle.close(); } } + +function isPng(buffer: Uint8Array): boolean { + return ( + buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR") + ); +} + +function isAnimatedPng(buffer: Uint8Array): boolean { + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= buffer.length) { + const chunkLength = readUint32BE(buffer, offset); + const chunkTypeOffset = offset + 4; + if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true; + if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false; + + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > buffer.length) return false; + offset = nextOffset; + } + return false; +} + +function readUint32BE(buffer: Uint8Array, offset: number): number { + return ( + (buffer[offset] ?? 0) * 0x1000000 + + ((buffer[offset + 1] ?? 0) << 16) + + ((buffer[offset + 2] ?? 0) << 8) + + (buffer[offset + 3] ?? 0) + ); +} + +function startsWith(buffer: Uint8Array, bytes: number[]): boolean { + if (buffer.length < bytes.length) return false; + return bytes.every((byte, index) => buffer[index] === byte); +} + +function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean { + if (buffer.length < offset + text.length) return false; + for (let index = 0; index < text.length; index++) { + if (buffer[offset + index] !== text.charCodeAt(index)) return false; + } + return true; +} diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index ba60341f0..19df4ea72 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,6 +1,5 @@ import chalk from "chalk"; -import { spawnSync } from "child_process"; -import extractZip from "extract-zip"; +import { type SpawnSyncReturns, spawnSync } from "child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; import { join } from "path"; @@ -159,6 +158,85 @@ function findBinaryRecursively(rootDir: string, binaryFileName: string): string return null; } +function formatSpawnFailure(result: SpawnSyncReturns): string { + if (result.error?.message) { + return result.error.message; + } + const stderr = result.stderr?.toString().trim(); + if (stderr) { + return stderr; + } + const stdout = result.stdout?.toString().trim(); + if (stdout) { + return stdout; + } + return `exit status ${result.status ?? "unknown"}`; +} + +function runExtractionCommand(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { stdio: "pipe" }); + if (!result.error && result.status === 0) { + return null; + } + return `${command}: ${formatSpawnFailure(result)}`; +} + +function extractTarGzArchive(archivePath: string, extractDir: string, assetName: string): void { + const failure = runExtractionCommand("tar", ["xzf", archivePath, "-C", extractDir]); + if (failure) { + throw new Error(`Failed to extract ${assetName}: ${failure}`); + } +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (systemRoot) { + const systemTar = join(systemRoot, "System32", "tar.exe"); + if (existsSync(systemTar)) { + return systemTar; + } + } + return "tar.exe"; +} + +function extractZipArchive(archivePath: string, extractDir: string, assetName: string): void { + const failures: string[] = []; + + if (platform() === "win32") { + // Windows ships bsdtar as tar.exe, which supports zip files. Prefer the + // System32 binary over Git Bash's GNU tar, which does not handle zip archives. + const tarFailure = runExtractionCommand(getWindowsTarCommand(), ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) return; + failures.push(tarFailure); + + const script = + "& { param($archive, $destination) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }"; + const powershellFailure = runExtractionCommand("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + archivePath, + extractDir, + ]); + if (!powershellFailure) return; + failures.push(powershellFailure); + } else { + const unzipFailure = runExtractionCommand("unzip", ["-q", archivePath, "-d", extractDir]); + if (!unzipFailure) return; + failures.push(unzipFailure); + + const tarFailure = runExtractionCommand("tar", ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) return; + failures.push(tarFailure); + } + + throw new Error(`Failed to extract ${assetName}: ${failures.join("; ")}`); +} + // Download and install a tool async function downloadTool(tool: "fd" | "rg"): Promise { const config = TOOLS[tool]; @@ -197,13 +275,9 @@ async function downloadTool(tool: "fd" | "rg"): Promise { try { if (assetName.endsWith(".tar.gz")) { - const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); - if (extractResult.error || extractResult.status !== 0) { - const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error"; - throw new Error(`Failed to extract ${assetName}: ${errMsg}`); - } + extractTarGzArchive(archivePath, extractDir, assetName); } else if (assetName.endsWith(".zip")) { - await extractZip(archivePath, { dir: extractDir }); + extractZipArchive(archivePath, extractDir, assetName); } else { throw new Error(`Unsupported archive format: ${assetName}`); } diff --git a/packages/coding-agent/src/utils/uuid.ts b/packages/coding-agent/src/utils/uuid.ts new file mode 100644 index 000000000..27b660aea --- /dev/null +++ b/packages/coding-agent/src/utils/uuid.ts @@ -0,0 +1,44 @@ +import { randomBytes } from "node:crypto"; + +let lastTimestamp = -Infinity; +let sequence = 0; + +export function uuidv7(): string { + const random = randomBytes(16); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = (random[6] << 23) | (random[7] << 16) | (random[8] << 8) | random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) | 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence << 2) & 0xff) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/packages/coding-agent/test/ansi-utils.test.ts b/packages/coding-agent/test/ansi-utils.test.ts new file mode 100644 index 000000000..2d4aa27f9 --- /dev/null +++ b/packages/coding-agent/test/ansi-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../src/utils/ansi.js"; + +describe("stripAnsi", () => { + it("strips RIS without leaking the final byte", () => { + expect(stripAnsi("\x1bcdone")).toBe("done"); + }); + + it("strips single-byte ESC sequences without leaking final bytes", () => { + for (let code = "g".charCodeAt(0); code <= "m".charCodeAt(0); code++) { + expect(stripAnsi(`\x1b${String.fromCharCode(code)}ok`)).toBe("ok"); + } + for (let code = "r".charCodeAt(0); code <= "t".charCodeAt(0); code++) { + expect(stripAnsi(`\x1b${String.fromCharCode(code)}ok`)).toBe("ok"); + } + }); + + it("strips common ANSI sequences used in tool output", () => { + const input = "a\x1b[31mred\x1b[0m\x1b]8;;https://example.com\x07link\x1b]8;;\x07z"; + expect(stripAnsi(input)).toBe("aredlinkz"); + }); +}); diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts index 9cd02eeb9..26fd993b4 100644 --- a/packages/coding-agent/test/oauth-selector.test.ts +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -1,5 +1,4 @@ import { setKeybindings } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; import { KeybindingsManager } from "../src/core/keybindings.js"; @@ -7,6 +6,7 @@ import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../src/core/provider-display-na import { OAuthSelectorComponent } from "../src/modes/interactive/components/oauth-selector.js"; import { isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { stripAnsi } from "../src/utils/ansi.js"; const originalOpenAiApiKey = process.env.OPENAI_API_KEY; diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index 72df1d95d..e1b7cadd3 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -1,10 +1,10 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { KeybindingsManager } from "../../../src/core/keybindings.js"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.js"; import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.js"; import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; +import { stripAnsi } from "../../../src/utils/ansi.js"; import { createHarness, type Harness } from "../harness.js"; function createFakeTui(): TUI { diff --git a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts index 14f72f7bf..0dcd8a95e 100644 --- a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts +++ b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts @@ -1,13 +1,13 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; import { Container, Text, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { beforeAll, describe, expect, test, vi } from "vitest"; import type { AgentSessionEvent } from "../../../src/core/agent-session.js"; import type { SessionContext } from "../../../src/core/session-manager.js"; import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.js"; import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js"; import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; +import { stripAnsi } from "../../../src/utils/ansi.js"; const TOOL_CALL_ID = "tool-4167"; const TOOL_NAME = "slow_tool"; diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 1e5d4b00e..7013ba2aa 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -1,6 +1,5 @@ import { join, resolve } from "node:path"; import { Text, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { Type } from "typebox"; import { beforeAll, describe, expect, test } from "vitest"; import { getReadmePath } from "../src/config.js"; @@ -10,6 +9,7 @@ import { createReadTool, createReadToolDefinition } from "../src/core/tools/read import { createWriteToolDefinition } from "../src/core/tools/write.js"; import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { stripAnsi } from "../src/utils/ansi.js"; function createBaseToolDefinition(name = "custom_tool"): ToolDefinition { return {