chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
function abortReason(signal: AbortSignal): unknown {
if (signal.reason !== undefined) return signal.reason;
const error = new Error("The operation was aborted");
error.name = "AbortError";
return error;
}
/** Normalize an optional public signal without imposing a deadline. */
export function operationSignal(signal?: AbortSignal): AbortSignal {
return signal ?? new AbortController().signal;
}
/** Stop waiting on abort while observing the abandoned operation through settlement. */
export function raceWithAbortSignal<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return operation;
if (signal.aborted) {
void operation.catch(() => {});
return Promise.reject(abortReason(signal));
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const cleanup = () => signal.removeEventListener("abort", onAbort);
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(abortReason(signal));
};
signal.addEventListener("abort", onAbort, { once: true });
void operation.then(
(value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
},
);
if (signal.aborted) onAbort();
});
}

View File

@@ -0,0 +1,60 @@
/*
* Portions of this file are derived from:
* - ansi-regex (https://github.com/chalk/ansi-regex)
* - strip-ansi (https://github.com/chalk/strip-ansi)
*
* MIT License
*
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
function ansiRegex({ onlyFirst = false }: { onlyFirst?: boolean } = {}): RegExp {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
// OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`;
// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte
const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
const pattern = `${osc}|${csi}`;
return new RegExp(pattern, onlyFirst ? undefined : "g");
}
const regex = ansiRegex();
export function stripAnsi(value: string): string {
if (typeof value !== "string") {
throw new TypeError(`Expected a \`string\`, got \`${typeof value}\``);
}
// Fast path: ANSI codes require ESC (7-bit) or CSI (8-bit) introducer
if (!value.includes("\u001B") && !value.includes("\u009B")) {
return value;
}
// Even though the regex is global, we don't need to reset the `.lastIndex`
// because unlike `.exec()` and `.test()`, `.replace()` does it automatically
// and doing it manually has a performance penalty.
return value.replace(regex, "");
}

View File

@@ -0,0 +1,196 @@
import path from "node:path";
import { existsSync, readFileSync } from "fs";
export interface ChangelogEntry {
major: number;
minor: number;
patch: number;
content: string;
}
const GITHUB_REPO = "earendil-works/pi";
const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent";
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
function entryVersion(entry: ChangelogEntry): string {
return `${entry.major}.${entry.minor}.${entry.patch}`;
}
function normalizeTag(version: string | ChangelogEntry): string {
const versionString = typeof version === "string" ? version : entryVersion(version);
return versionString.startsWith("v") ? versionString : `v${versionString}`;
}
function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } {
const hashIndex = target.indexOf("#");
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
const queryIndex = beforeHash.indexOf("?");
if (queryIndex === -1) {
return { fragment, pathPart: beforeHash, query: "" };
}
return {
fragment,
pathPart: beforeHash.slice(0, queryIndex),
query: beforeHash.slice(queryIndex),
};
}
function normalizePathPart(value: string): string {
return value.replaceAll("\\", "/");
}
function resolveRepositoryPath(targetPath: string): string | undefined {
const normalizedTarget = normalizePathPart(targetPath);
const joined = normalizedTarget.startsWith("/")
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
: path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget));
if (joined === "." || joined.startsWith("../") || joined === "..") {
return undefined;
}
return joined;
}
function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean {
if (originalPath.endsWith("/")) {
return true;
}
const basename = path.posix.basename(repositoryPath);
return !basename.includes(".");
}
function normalizeChangelogLinkTarget(target: string, tag: string): string {
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`);
const repoUrl = `https://github.com/${GITHUB_REPO}`;
for (const route of ["blob", "tree"]) {
for (const branch of ["main", "master"]) {
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
if (canonicalTarget.startsWith(floatingRefPrefix)) {
canonicalTarget = `${repoUrl}/${route}/${tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
}
}
}
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
return canonicalTarget;
}
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
if (!pathPart) {
return canonicalTarget;
}
const repositoryPath = resolveRepositoryPath(pathPart);
if (!repositoryPath) {
return canonicalTarget;
}
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
}
export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string {
const tag = normalizeTag(version);
return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => {
return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`;
});
}
/**
* Parse changelog entries from CHANGELOG.md
* Scans for ## lines and collects content until next ## or EOF
*/
export function parseChangelog(changelogPath: string): ChangelogEntry[] {
if (!existsSync(changelogPath)) {
return [];
}
try {
const content = readFileSync(changelogPath, "utf-8");
const lines = content.split("\n");
const entries: ChangelogEntry[] = [];
let currentLines: string[] = [];
let currentVersion: { major: number; minor: number; patch: number } | null = null;
for (const line of lines) {
// Check if this is a version header (## [x.y.z] ...)
if (line.startsWith("## ")) {
// Save previous entry if exists
if (currentVersion && currentLines.length > 0) {
entries.push({
...currentVersion,
content: currentLines.join("\n").trim(),
});
}
// Try to parse version from this line
const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
if (versionMatch) {
currentVersion = {
major: Number.parseInt(versionMatch[1], 10),
minor: Number.parseInt(versionMatch[2], 10),
patch: Number.parseInt(versionMatch[3], 10),
};
currentLines = [line];
} else {
// Reset if we can't parse version
currentVersion = null;
currentLines = [];
}
} else if (currentVersion) {
// Collect lines for current version
currentLines.push(line);
}
}
// Save last entry
if (currentVersion && currentLines.length > 0) {
entries.push({
...currentVersion,
content: currentLines.join("\n").trim(),
});
}
return entries;
} catch (error) {
console.error(`Warning: Could not parse changelog: ${error}`);
return [];
}
}
/**
* Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
*/
export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number {
if (v1.major !== v2.major) return v1.major - v2.major;
if (v1.minor !== v2.minor) return v1.minor - v2.minor;
return v1.patch - v2.patch;
}
/**
* Get entries newer than lastVersion
*/
export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
// Parse lastVersion
const parts = lastVersion.split(".").map(Number);
const last: ChangelogEntry = {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
content: "",
};
return entries.filter((entry) => compareVersions(entry, last) > 0);
}
// Re-export getChangelogPath from paths.ts for convenience
export { getChangelogPath } from "../config.ts";

View File

@@ -0,0 +1,137 @@
import {
type ChildProcess,
type ChildProcessByStdio,
spawn as nodeSpawn,
spawnSync as nodeSpawnSync,
type SpawnOptions,
type SpawnOptionsWithStdioTuple,
type SpawnSyncOptionsWithStringEncoding,
type SpawnSyncReturns,
type StdioNull,
type StdioPipe,
} from "node:child_process";
import type { Readable } from "node:stream";
import crossSpawn from "cross-spawn";
const EXIT_STDIO_GRACE_MS = 100;
export function spawnProcess(
command: string,
args: string[],
options: SpawnOptionsWithStdioTuple<StdioNull, StdioPipe, StdioPipe>,
): ChildProcessByStdio<null, Readable, Readable>;
export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess;
export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess {
return process.platform === "win32" ? crossSpawn(command, args, options) : nodeSpawn(command, args, options);
}
export function spawnProcessSync(
command: string,
args: string[],
options: SpawnSyncOptionsWithStringEncoding,
): SpawnSyncReturns<string> {
return process.platform === "win32"
? crossSpawn.sync(command, args, options)
: nodeSpawnSync(command, args, options);
}
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* A short-lived child can `exit` while a detached descendant keeps its stdout/stderr
* pipe open. We must not resolve and destroy the streams on a fixed deadline measured
* from `exit`, or output still being written past that deadline is silently lost
* (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle:
* the grace timer is re-armed on every chunk, so an actively writing descendant keeps
* us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant
* that never lets `close` fire) still releases us after the grace elapses.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
return new Promise((resolve, reject) => {
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: NodeJS.Timeout | undefined;
let stdoutEnded = child.stdout === null;
let stderrEnded = child.stderr === null;
const cleanup = () => {
if (postExitTimer) {
clearTimeout(postExitTimer);
postExitTimer = undefined;
}
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
child.stdout?.removeListener("data", onData);
child.stderr?.removeListener("data", onData);
};
const finalize = (code: number | null) => {
if (settled) return;
settled = true;
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
resolve(code);
};
const maybeFinalizeAfterExit = () => {
if (!exited || settled) return;
if (stdoutEnded && stderrEnded) {
finalize(exitCode);
}
};
const armIdleTimer = () => {
if (postExitTimer) clearTimeout(postExitTimer);
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
};
const onData = () => {
// Output is still arriving after exit; defer finalizing so we don't
// destroy the stream mid-write and truncate the tail.
if (exited && !settled) armIdleTimer();
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
};
const onStderrEnd = () => {
stderrEnded = true;
maybeFinalizeAfterExit();
};
const onError = (err: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onExit = (code: number | null) => {
exited = true;
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
armIdleTimer();
}
};
const onClose = (code: number | null) => {
finalize(code);
};
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
});
}

View File

@@ -0,0 +1,300 @@
import { spawnSync } from "child_process";
import { randomUUID } from "crypto";
import { readFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { clipboard } from "./clipboard-native.ts";
import { loadPhoton } from "./photon.ts";
export type ClipboardImage = {
bytes: Uint8Array;
mimeType: string;
};
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
const DEFAULT_LIST_TIMEOUT_MS = 1000;
const DEFAULT_READ_TIMEOUT_MS = 3000;
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
}
function baseMimeType(mimeType: string): string {
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
}
export function extensionForImageMimeType(mimeType: string): string | null {
switch (baseMimeType(mimeType)) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
default:
return null;
}
}
function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
const normalized = mimeTypes
.map((t) => t.trim())
.filter(Boolean)
.map((t) => ({ raw: t, base: baseMimeType(t) }));
for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) {
const match = normalized.find((t) => t.base === preferred);
if (match) {
return match.raw;
}
}
const anyImage = normalized.find((t) => t.base.startsWith("image/"));
return anyImage?.raw ?? null;
}
function isSupportedImageMimeType(mimeType: string): boolean {
const base = baseMimeType(mimeType);
return SUPPORTED_IMAGE_MIME_TYPES.some((t) => t === base);
}
/**
* Convert unsupported image formats to PNG using Photon.
* Returns null if conversion is unavailable or fails.
*/
async function convertToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
const photon = await loadPhoton();
if (!photon) {
return null;
}
try {
const image = photon.PhotonImage.new_from_byteslice(bytes);
try {
return image.get_bytes();
} finally {
image.free();
}
} catch {
return null;
}
}
function runCommand(
command: string,
args: string[],
options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv },
): { stdout: Buffer; ok: boolean } {
const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
const result = spawnSync(command, args, {
timeout: timeoutMs,
maxBuffer: maxBufferBytes,
env: options?.env,
});
if (result.error) {
return { ok: false, stdout: Buffer.alloc(0) };
}
if (result.status !== 0) {
return { ok: false, stdout: Buffer.alloc(0) };
}
const stdout = Buffer.isBuffer(result.stdout)
? result.stdout
: Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined);
return { ok: true, stdout };
}
function readClipboardImageViaWlPaste(): ClipboardImage | null {
const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!list.ok) {
return null;
}
const types = list.stdout
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
const selectedType = selectPreferredImageMimeType(types);
if (!selectedType) {
return null;
}
const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
if (!data.ok || data.stdout.length === 0) {
return null;
}
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
}
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
}
/**
* On WSL, the Linux clipboard (Wayland/X11) does not receive image data from
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
* directly, so we use it as a fallback.
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
try {
const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!winPathResult.ok) {
return null;
}
const winPath = winPathResult.stdout.toString("utf-8").trim();
if (!winPath) {
return null;
}
const psQuotedWinPath = winPath.replaceAll("'", "''");
const psScript = [
"Add-Type -AssemblyName System.Windows.Forms",
"Add-Type -AssemblyName System.Drawing",
`$path = '${psQuotedWinPath}'`,
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
].join("; ");
const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
});
if (!result.ok) {
return null;
}
const output = result.stdout.toString("utf-8").trim();
if (output !== "ok") {
return null;
}
const bytes = readFileSync(tmpFile);
if (bytes.length === 0) {
return null;
}
return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
} catch {
return null;
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors.
}
}
}
function readClipboardImageViaXclip(): ClipboardImage | null {
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
let candidateTypes: string[] = [];
if (targets.ok) {
candidateTypes = targets.stdout
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
}
const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null;
const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES];
for (const mimeType of tryTypes) {
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
if (data.ok && data.stdout.length > 0) {
return { bytes: data.stdout, mimeType: baseMimeType(mimeType) };
}
}
return null;
}
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null> {
if (!clipboard || !clipboard.hasImage()) {
return null;
}
const imageData = await clipboard.getImageBinary();
if (!imageData || imageData.length === 0) {
return null;
}
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
return { bytes, mimeType: "image/png" };
}
export async function readClipboardImage(options?: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}): Promise<ClipboardImage | null> {
const env = options?.env ?? process.env;
const platform = options?.platform ?? process.platform;
if (env.TERMUX_VERSION) {
return null;
}
let image: ClipboardImage | null = null;
if (platform === "linux") {
const wsl = isWSL(env);
const wayland = isWaylandSession(env);
if (wayland || wsl) {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
}
if (!image && wsl) {
image = readClipboardImageViaPowerShell();
}
if (!image && !wayland) {
image = (await readClipboardImageViaNativeClipboard()) ?? readClipboardImageViaXclip();
}
} else {
image = await readClipboardImageViaNativeClipboard();
}
if (!image) {
return null;
}
// Convert unsupported formats (e.g., BMP from WSLg) to PNG
if (!isSupportedImageMimeType(image.mimeType)) {
const pngBytes = await convertToPng(image.bytes);
if (!pngBytes) {
return null;
}
return { bytes: pngBytes, mimeType: "image/png" };
}
return image;
}

View File

@@ -0,0 +1,33 @@
import { createRequire } from "module";
import { dirname, join } from "path";
import { pathToFileURL } from "url";
export type ClipboardModule = {
getText: () => Promise<string>;
setText: (text: string) => Promise<void>;
hasImage: () => boolean;
getImageBinary: () => Promise<Array<number>>;
};
type ClipboardRequire = (id: string) => unknown;
const moduleRequire = createRequire(import.meta.url);
const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href);
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
export function loadClipboardNative(
requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire],
): ClipboardModule | null {
for (const requireClipboard of requires) {
try {
return requireClipboard("@mariozechner/clipboard") as ClipboardModule;
} catch {
// Try the next resolution root.
}
}
return null;
}
const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null;
export { clipboard };

View File

@@ -0,0 +1,175 @@
import { type ExecFileSyncOptionsWithStringEncoding, execFileSync, execSync, spawn } from "child_process";
import { platform } from "os";
import { isWaylandSession } from "./clipboard-image.ts";
import { clipboard } from "./clipboard-native.ts";
type NativeClipboardExecOptions = {
input: string;
timeout: number;
stdio: ["pipe", "ignore", "ignore"];
};
function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
try {
execSync("xclip -selection clipboard", options);
} catch {
execSync("xsel --clipboard --input", options);
}
}
const MAX_OSC52_ENCODED_LENGTH = 100_000;
function isRemoteSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
}
function emitOsc52(text: string): boolean {
const encoded = Buffer.from(text).toString("base64");
if (encoded.length > MAX_OSC52_ENCODED_LENGTH) {
return false;
}
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
return true;
}
type ClipboardReadResult = { ok: true; text: string | null } | { ok: false };
const READ_CLIPBOARD_OPTIONS: ExecFileSyncOptionsWithStringEncoding = {
encoding: "utf8",
maxBuffer: 50 * 1024 * 1024,
timeout: 5000,
};
function readWaylandClipboardText(): ClipboardReadResult {
try {
const text = execFileSync("wl-paste", ["--no-newline", "--type", "text"], READ_CLIPBOARD_OPTIONS);
return { ok: true, text: text || null };
} catch {
return { ok: false };
}
}
/** Read plain text from the system clipboard. */
export async function readClipboardText(): Promise<string | null> {
if (platform() === "linux" && isWaylandSession() && process.env.WAYLAND_DISPLAY) {
const result = readWaylandClipboardText();
if (result.ok) {
return result.text;
}
}
if (!clipboard) {
return null;
}
try {
const text = await clipboard.getText();
return text || null;
} catch {
return null;
}
}
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;
const p = platform();
// Prefer direct clipboard writes. Emitting OSC 52 first can make terminals
// write the same native clipboard concurrently with the addon, and very large
// OSC 52 payloads can desynchronize terminal rendering.
//
// On Linux, skip the native addon. The underlying `clipboard-rs` crate is
// X11-only and does not retain selection ownership after `set_text`
// resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even
// some X11 sessions the call resolves successfully without populating the
// clipboard. The platform tools below (wl-copy, xclip, xsel) properly
// daemonize and keep ownership.
try {
if (clipboard && p !== "linux") {
await clipboard.setText(text);
copied = true;
}
} catch {
// Fall through to platform-specific clipboard tools.
}
const remote = isRemoteSession();
if (copied && !remote) {
return;
}
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
if (!copied) {
try {
if (p === "darwin") {
execSync("pbcopy", options);
copied = true;
} else if (p === "win32") {
execSync("clip", options);
copied = true;
} else {
// Linux. Try Termux, Wayland, or X11 clipboard tools.
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", options);
copied = true;
} catch {
// Fall back to Wayland or X11 tools.
}
}
if (!copied) {
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
const isWayland = isWaylandSession();
if (isWayland && hasWaylandDisplay) {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
// Await the exit code and only claim success on a clean exit, so a
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
const wlCopyExit = await new Promise<number>((resolve) => {
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.on("error", () => resolve(1));
proc.on("close", (code) => resolve(code ?? 1));
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
});
if (wlCopyExit === 0) {
copied = true;
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
}
} catch {
// Fall through to OSC 52 fallback.
}
}
if (remote || !copied) {
const osc52Copied = emitOsc52(text);
copied = copied || osc52Copied;
}
if (!copied) {
throw new Error("Failed to copy to clipboard");
}
}

View File

@@ -0,0 +1,14 @@
import chalk from "chalk";
const emittedDeprecationWarnings = new Set<string>();
export function warnDeprecation(message: string): void {
if (emittedDeprecationWarnings.has(message)) return;
emittedDeprecationWarnings.add(message);
console.warn(chalk.yellow(`Deprecation warning: ${message}`));
}
/** Clear deprecation warning state. Exported for tests. */
export function clearDeprecationWarningsForTests(): void {
emittedDeprecationWarnings.clear();
}

View File

@@ -0,0 +1,182 @@
import type { PhotonImageType } from "./photon.ts";
type Photon = typeof import("@silvia-odwyer/photon-node");
function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number {
if (tiffStart + 8 > bytes.length) return 1;
const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
const le = byteOrder === 0x4949;
const read16 = (pos: number): number => {
if (le) return bytes[pos] | (bytes[pos + 1] << 8);
return (bytes[pos] << 8) | bytes[pos + 1];
};
const read32 = (pos: number): number => {
if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
};
const ifdOffset = read32(tiffStart + 4);
const ifdStart = tiffStart + ifdOffset;
if (ifdStart + 2 > bytes.length) return 1;
const entryCount = read16(ifdStart);
for (let i = 0; i < entryCount; i++) {
const entryPos = ifdStart + 2 + i * 12;
if (entryPos + 12 > bytes.length) return 1;
if (read16(entryPos) === 0x0112) {
const value = read16(entryPos + 8);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
function findJpegTiffOffset(bytes: Uint8Array): number {
let offset = 2;
while (offset < bytes.length - 1) {
if (bytes[offset] !== 0xff) return -1;
const marker = bytes[offset + 1];
if (marker === 0xff) {
offset++;
continue;
}
if (marker === 0xe1) {
if (offset + 4 >= bytes.length) return -1;
const segmentStart = offset + 4;
if (segmentStart + 6 > bytes.length) return -1;
if (hasExifHeader(bytes, segmentStart)) return segmentStart + 6;
}
if (offset + 4 > bytes.length) return -1;
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
offset += 2 + length;
}
return -1;
}
function findWebpTiffOffset(bytes: Uint8Array): number {
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
const chunkSize =
bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
const dataStart = offset + 8;
if (chunkId === "EXIF") {
if (dataStart + chunkSize > bytes.length) return -1;
// Some WebP files have "Exif\0\0" prefix before the TIFF header
const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
return tiffStart;
}
// RIFF chunks are padded to even size
offset = dataStart + chunkSize + (chunkSize % 2);
}
return -1;
}
function hasExifHeader(bytes: Uint8Array, offset: number): boolean {
return (
bytes[offset] === 0x45 &&
bytes[offset + 1] === 0x78 &&
bytes[offset + 2] === 0x69 &&
bytes[offset + 3] === 0x66 &&
bytes[offset + 4] === 0x00 &&
bytes[offset + 5] === 0x00
);
}
function getExifOrientation(bytes: Uint8Array): number {
let tiffOffset = -1;
// JPEG: starts with FF D8
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
tiffOffset = findJpegTiffOffset(bytes);
}
// WebP: starts with RIFF....WEBP
else if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
tiffOffset = findWebpTiffOffset(bytes);
}
if (tiffOffset === -1) return 1;
return readOrientationFromTiff(bytes, tiffOffset);
}
type DstIndexFn = (x: number, y: number, w: number, h: number) => number;
function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType {
const w = image.get_width();
const h = image.get_height();
const src = image.get_raw_pixels();
const dst = new Uint8Array(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const srcIdx = (y * w + x) * 4;
const dstIdx = dstIndex(x, y, w, h) * 4;
dst[dstIdx] = src[srcIdx];
dst[dstIdx + 1] = src[srcIdx + 1];
dst[dstIdx + 2] = src[srcIdx + 2];
dst[dstIdx + 3] = src[srcIdx + 3];
}
}
return new photon.PhotonImage(dst, h, w);
}
// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
export function applyExifOrientation(
photon: Photon,
image: PhotonImageType,
originalBytes: Uint8Array,
): PhotonImageType {
const orientation = getExifOrientation(originalBytes);
if (orientation === 1) return image;
switch (orientation) {
case 2:
photon.fliph(image);
return image;
case 3:
photon.fliph(image);
photon.flipv(image);
return image;
case 4:
photon.flipv(image);
return image;
case 5: {
const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
photon.fliph(rotated);
return rotated;
}
case 6:
return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
case 7: {
const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
photon.fliph(rotated);
return rotated;
}
case 8:
return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
default:
return image;
}
}

View File

@@ -0,0 +1,40 @@
import { parse } from "yaml";
import { stripBom } from "./text.ts";
type ParsedFrontmatter<T extends Record<string, unknown>> = {
frontmatter: T;
body: string;
};
const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => {
const normalized = normalizeNewlines(stripBom(content));
if (!normalized.startsWith("---")) {
return { yamlString: null, body: normalized };
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return { yamlString: null, body: normalized };
}
return {
yamlString: normalized.slice(4, endIndex),
body: normalized.slice(endIndex + 4).trim(),
};
};
export const parseFrontmatter = <T extends Record<string, unknown> = Record<string, unknown>>(
content: string,
): ParsedFrontmatter<T> => {
const { yamlString, body } = extractFrontmatter(content);
if (!yamlString) {
return { frontmatter: {} as T, body };
}
const parsed = parse(yamlString);
return { frontmatter: (parsed ?? {}) as T, body };
};
export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body;

View File

@@ -0,0 +1,30 @@
import { type FSWatcher, type WatchListener, watch } from "node:fs";
export const FS_WATCH_RETRY_DELAY_MS = 5000;
export function closeWatcher(watcher: FSWatcher | null | undefined): void {
if (!watcher) {
return;
}
try {
watcher.close();
} catch {
// Ignore watcher close errors
}
}
export function watchWithErrorHandler(
path: string,
listener: WatchListener<string>,
onError: () => void,
): FSWatcher | null {
try {
const watcher = watch(path, listener);
watcher.on("error", onError);
return watcher;
} catch {
onError();
return null;
}
}

View File

@@ -0,0 +1,226 @@
import hostedGitInfo from "hosted-git-info";
/**
* Parsed git URL information.
*/
export type GitSource = {
/** Always "git" for git sources */
type: "git";
/** Clone URL (always valid for git clone, without ref suffix) */
repo: string;
/** Git host domain (e.g., "github.com") */
host: string;
/** Repository path (e.g., "user/repo") */
path: string;
/** Git ref (branch, tag, commit) if specified */
ref?: string;
/** True if ref was specified (package won't be auto-updated) */
pinned: boolean;
};
function splitRef(url: string): { repo: string; ref?: string } {
const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
const pathWithMaybeRef = scpLikeMatch[2] ?? "";
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
return {
repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`,
ref,
};
}
if (url.includes("://")) {
try {
const parsed = new URL(url);
const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, "");
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
parsed.pathname = `/${repoPath}`;
return {
repo: parsed.toString().replace(/\/$/, ""),
ref,
};
} catch {
return { repo: url };
}
}
const slashIndex = url.indexOf("/");
if (slashIndex < 0) {
return { repo: url };
}
const host = url.slice(0, slashIndex);
const pathWithMaybeRef = url.slice(slashIndex + 1);
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) {
return { repo: url };
}
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) {
return { repo: url };
}
return {
repo: `${host}/${repoPath}`,
ref,
};
}
function decodeForValidation(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
function hasUnsafeGitInstallPart(value: string, allowSlash: boolean): boolean {
const decoded = decodeForValidation(value);
if (decoded === null) {
return true;
}
const candidates = [value, decoded];
for (const candidate of candidates) {
if (candidate.includes("\0") || candidate.includes("\\") || candidate.startsWith("/")) {
return true;
}
if (!allowSlash && candidate.includes("/")) {
return true;
}
if (candidate.split("/").includes("..")) {
return true;
}
}
return false;
}
function buildGitSource(args: { repo: string; host: string; path: string; ref?: string }): GitSource | null {
if (args.path.startsWith("/")) {
return null;
}
const normalizedPath = args.path.replace(/\.git$/, "").replace(/^\/+/, "");
if (!args.host || !normalizedPath || normalizedPath.split("/").length < 2) {
return null;
}
if (hasUnsafeGitInstallPart(args.host, false) || hasUnsafeGitInstallPart(normalizedPath, true)) {
return null;
}
return {
type: "git",
repo: args.repo,
host: args.host,
path: normalizedPath,
ref: args.ref,
pinned: Boolean(args.ref),
};
}
function parseGenericGitUrl(url: string): GitSource | null {
const { repo: repoWithoutRef, ref } = splitRef(url);
let repo = repoWithoutRef;
let host = "";
let path = "";
const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
host = scpLikeMatch[1] ?? "";
path = scpLikeMatch[2] ?? "";
} else if (
repoWithoutRef.startsWith("https://") ||
repoWithoutRef.startsWith("http://") ||
repoWithoutRef.startsWith("ssh://") ||
repoWithoutRef.startsWith("git://")
) {
try {
const parsed = new URL(repoWithoutRef);
host = parsed.hostname;
path = parsed.pathname.replace(/^\/+/, "");
} catch {
return null;
}
} else {
const slashIndex = repoWithoutRef.indexOf("/");
if (slashIndex < 0) {
return null;
}
host = repoWithoutRef.slice(0, slashIndex);
path = repoWithoutRef.slice(slashIndex + 1);
if (!host.includes(".") && host !== "localhost") {
return null;
}
repo = `https://${repoWithoutRef}`;
}
return buildGitSource({ repo, host, path, ref });
}
/**
* Parse git source into a GitSource.
*
* Rules:
* - With git: prefix, accept all historical shorthand forms.
* - Without git: prefix, only accept explicit protocol URLs.
*/
export function parseGitUrl(source: string): GitSource | null {
const trimmed = source.trim();
const hasGitPrefix = trimmed.startsWith("git:");
const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) {
return null;
}
const split = splitRef(url);
const hostedCandidates = [split.ref ? `${split.repo}#${split.ref}` : undefined, url].filter(
(value): value is string => Boolean(value),
);
for (const candidate of hostedCandidates) {
const info = hostedGitInfo.fromUrl(candidate);
if (info) {
if (split.ref && info.project?.includes("@")) {
continue;
}
const useHttpsPrefix =
!split.repo.startsWith("http://") &&
!split.repo.startsWith("https://") &&
!split.repo.startsWith("ssh://") &&
!split.repo.startsWith("git://") &&
!split.repo.startsWith("git@");
return buildGitSource({
repo: useHttpsPrefix ? `https://${split.repo}` : split.repo,
host: info.domain || "",
path: `${info.user}/${info.project}`,
ref: info.committish || split.ref || undefined,
});
}
}
const httpsCandidates = [split.ref ? `https://${split.repo}#${split.ref}` : undefined, `https://${url}`].filter(
(value): value is string => Boolean(value),
);
for (const candidate of httpsCandidates) {
const info = hostedGitInfo.fromUrl(candidate);
if (info) {
if (split.ref && info.project?.includes("@")) {
continue;
}
return buildGitSource({
repo: `https://${split.repo}`,
host: info.domain || "",
path: `${info.user}/${info.project}`,
ref: info.committish || split.ref || undefined,
});
}
}
return parseGenericGitUrl(url);
}

View File

@@ -0,0 +1,36 @@
interface HighlightJsResult {
value: string;
}
interface HighlightJsOptions {
language: string;
ignoreIllegals?: boolean;
}
interface HighlightJsLanguageDefinition {
readonly name?: string;
}
type HighlightJsLanguageFactory = (hljs: HighlightJsApi) => HighlightJsLanguageDefinition;
interface HighlightJsApi {
highlight(code: string, options: HighlightJsOptions): HighlightJsResult;
highlightAuto(code: string, languageSubset?: string[]): HighlightJsResult;
getLanguage(name: string): HighlightJsLanguageDefinition | undefined;
registerLanguage(name: string, language: HighlightJsLanguageFactory): void;
}
declare module "highlight.js/lib/core.js" {
const hljs: HighlightJsApi;
export default hljs;
}
declare module "highlight.js/lib/index.js" {
const hljs: HighlightJsApi;
export default hljs;
}
declare module "highlight.js/lib/languages/*.js" {
const language: HighlightJsLanguageFactory;
export default language;
}

View File

@@ -0,0 +1,51 @@
export interface DecodedHtmlEntity {
text: string;
length: number;
}
function decodeCodePoint(codePoint: number): string | undefined {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return undefined;
}
return String.fromCodePoint(codePoint);
}
export function decodeHtmlEntity(entity: string): string | undefined {
switch (entity) {
case "amp":
return "&";
case "lt":
return "<";
case "gt":
return ">";
case "quot":
return '"';
case "apos":
return "'";
}
if (entity.startsWith("#x") || entity.startsWith("#X")) {
return decodeCodePoint(Number.parseInt(entity.slice(2), 16));
}
if (entity.startsWith("#")) {
return decodeCodePoint(Number.parseInt(entity.slice(1), 10));
}
return undefined;
}
export function decodeHtmlEntityAt(html: string, index: number): DecodedHtmlEntity | undefined {
const semicolonIndex = html.indexOf(";", index + 1);
if (semicolonIndex === -1 || semicolonIndex - index > 16) {
return undefined;
}
const entity = html.slice(index + 1, semicolonIndex);
const decoded = decodeHtmlEntity(entity);
if (decoded === undefined) {
return undefined;
}
return { text: decoded, length: semicolonIndex - index + 1 };
}

View File

@@ -0,0 +1,49 @@
import { applyExifOrientation } from "./exif-orientation.ts";
import { loadPhoton } from "./photon.ts";
export async function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
const photon = await loadPhoton();
if (!photon) {
// Photon not available, can't convert
return null;
}
try {
const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
const image = applyExifOrientation(photon, rawImage, bytes);
if (image !== rawImage) rawImage.free();
try {
return new Uint8Array(image.get_bytes());
} finally {
image.free();
}
} catch {
// Conversion failed
return null;
}
}
/**
* Convert image to PNG format for terminal display.
* Kitty graphics protocol requires PNG format (f=100).
*/
export async function convertToPng(
base64Data: string,
mimeType: string,
): Promise<{ data: string; mimeType: string } | null> {
// Already PNG, no conversion needed
if (mimeType === "image/png") {
return { data: base64Data, mimeType };
}
const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
const pngBytes = await convertImageBytesToPng(bytes);
if (!pngBytes) {
return null;
}
return {
data: Buffer.from(pngBytes).toString("base64"),
mimeType: "image/png",
};
}

View File

@@ -0,0 +1,119 @@
import { convertImageBytesToPng } from "./image-convert.ts";
import { formatDimensionNote, type ImageResizeOptions, resizeImage } from "./image-resize.ts";
export interface ProcessImageOptions {
/** Whether to resize images to inline provider limits. Default: true */
autoResizeImages?: boolean;
/** Optional resize overrides. Uses resizeImage defaults when omitted. */
resizeOptions?: ImageResizeOptions;
}
export type ProcessImageResult =
| {
ok: true;
data: string;
mimeType: string;
hints: string[];
}
| {
ok: false;
message: string;
};
interface NormalizedImage {
bytes: Uint8Array;
mimeType: string;
convertedFrom?: string;
}
function baseMimeType(mimeType: string): string {
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
}
function normalizeSupportedImageMimeType(mimeType: string): string | null {
switch (baseMimeType(mimeType)) {
case "image/png":
return "image/png";
case "image/jpeg":
case "image/jpg":
return "image/jpeg";
case "image/gif":
return "image/gif";
case "image/webp":
return "image/webp";
default:
return null;
}
}
async function normalizeImage(bytes: Uint8Array, mimeType: string): Promise<NormalizedImage | null> {
const normalizedMimeType = normalizeSupportedImageMimeType(mimeType);
if (normalizedMimeType) {
return { bytes, mimeType: normalizedMimeType };
}
const pngBytes = await convertImageBytesToPng(bytes);
if (!pngBytes) {
return null;
}
return {
bytes: pngBytes,
mimeType: "image/png",
convertedFrom: baseMimeType(mimeType),
};
}
function conversionHint(from: string | undefined, to: string): string | undefined {
if (!from || from === to) return undefined;
return `[Image converted from ${from} to ${to}.]`;
}
export async function processImage(
bytes: Uint8Array,
mimeType: string,
options?: ProcessImageOptions,
): Promise<ProcessImageResult> {
const autoResizeImages = options?.autoResizeImages ?? true;
const normalized = await normalizeImage(bytes, mimeType);
if (!normalized) {
return {
ok: false,
message: "[Image omitted: could not be converted to a supported inline image format.]",
};
}
if (autoResizeImages) {
const resized = await resizeImage(normalized.bytes, normalized.mimeType, options?.resizeOptions);
if (!resized) {
return {
ok: false,
message: "[Image omitted: could not be resized below the inline image size limit.]",
};
}
const hints: string[] = [];
const convertedHint = conversionHint(normalized.convertedFrom, resized.mimeType);
if (convertedHint) hints.push(convertedHint);
const dimensionNote = formatDimensionNote(resized);
if (dimensionNote) hints.push(dimensionNote);
return {
ok: true,
data: resized.data,
mimeType: resized.mimeType,
hints,
};
}
const hints: string[] = [];
const convertedHint = conversionHint(normalized.convertedFrom, normalized.mimeType);
if (convertedHint) hints.push(convertedHint);
return {
ok: true,
data: Buffer.from(normalized.bytes).toString("base64"),
mimeType: normalized.mimeType,
hints,
};
}

View File

@@ -0,0 +1,164 @@
import { applyExifOrientation } from "./exif-orientation.ts";
import { loadPhoton } from "./photon.ts";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
maxHeight?: number; // Default: 2000
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
jpegQuality?: number; // Default: 80
}
export interface ResizedImage {
data: string; // base64
mimeType: string;
originalWidth: number;
originalHeight: number;
width: number;
height: number;
wasResized: boolean;
}
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
};
interface EncodedCandidate {
data: string;
encodedSize: number;
mimeType: string;
}
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
const data = Buffer.from(buffer).toString("base64");
return {
data,
encodedSize: Buffer.byteLength(data, "utf-8"),
mimeType,
};
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Returns null if the image cannot be resized below maxBytes.
*
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
* returns null.
*
* Strategy for staying under maxBytes:
* 1. First resize to maxWidth/maxHeight
* 2. Try both PNG and JPEG formats, pick the smaller one
* 3. If still too large, try JPEG with decreasing quality
* 4. If still too large, progressively reduce dimensions until 1x1
*/
export async function resizeImageInProcess(
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
const photon = await loadPhoton();
if (!photon) {
return null;
}
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
try {
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
image = applyExifOrientation(photon, rawImage, inputBytes);
if (image !== rawImage) rawImage.free();
const originalWidth = image.get_width();
const originalHeight = image.get_height();
const format = mimeType.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND encoded size)
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
return {
data: Buffer.from(inputBytes).toString("base64"),
mimeType: mimeType || `image/${format}`,
originalWidth,
originalHeight,
width: originalWidth,
height: originalHeight,
wasResized: false,
};
}
// Calculate initial dimensions respecting max limits
let targetWidth = originalWidth;
let targetHeight = originalHeight;
if (targetWidth > opts.maxWidth) {
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
targetWidth = opts.maxWidth;
}
if (targetHeight > opts.maxHeight) {
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
targetHeight = opts.maxHeight;
}
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
try {
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
for (const quality of jpegQualities) {
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
}
return candidates;
} finally {
resized.free();
}
}
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
let currentWidth = targetWidth;
let currentHeight = targetHeight;
while (true) {
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
for (const candidate of candidates) {
if (candidate.encodedSize < opts.maxBytes) {
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: currentWidth,
height: currentHeight,
wasResized: true,
};
}
}
if (currentWidth === 1 && currentHeight === 1) {
break;
}
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
if (nextWidth === currentWidth && nextHeight === currentHeight) {
break;
}
currentWidth = nextWidth;
currentHeight = nextHeight;
}
return null;
} catch {
return null;
} finally {
if (image) {
image.free();
}
}
}

View File

@@ -0,0 +1,42 @@
import { parentPort } from "node:worker_threads";
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
interface ResizeImageWorkerRequest {
inputBytes: Uint8Array;
mimeType: string;
options?: ImageResizeOptions;
}
interface ResizeImageWorkerResponse {
result?: ResizedImage | null;
error?: string;
}
function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
}
const port = parentPort;
if (!port) {
throw new Error("image resize worker requires parentPort");
}
port.once("message", (message: unknown) => {
void (async () => {
try {
if (!isResizeImageWorkerRequest(message)) {
throw new Error("Invalid image resize worker request");
}
const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
const response: ResizeImageWorkerResponse = { result };
port.postMessage(response);
} catch (error) {
const response: ResizeImageWorkerResponse = {
error: error instanceof Error ? error.message : String(error),
};
port.postMessage(response);
}
})();
});

View File

@@ -0,0 +1,123 @@
import { Worker } from "node:worker_threads";
import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
interface ResizeImageWorkerResponse {
result?: ResizedImage | null;
error?: string;
}
function toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
// Transfer detaches the buffer, so transfer a worker-owned copy and leave the
// caller's bytes intact.
return new Uint8Array(input);
}
function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
return value !== null && typeof value === "object";
}
function createResizeWorker(workerSpecifier: string | URL): Worker {
return new Worker(workerSpecifier);
}
async function resizeImageInWorker(
workerSpecifier: string | URL,
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
const worker = createResizeWorker(workerSpecifier);
try {
const inputBytesForWorker = toTransferableBytes(inputBytes);
return await new Promise<ResizedImage | null>((resolve, reject) => {
let settled = false;
const settle = (result: ResizedImage | null): void => {
if (settled) return;
settled = true;
resolve(result);
};
const fail = (error: Error): void => {
if (settled) return;
settled = true;
reject(error);
};
worker.once("message", (message: unknown) => {
if (!isResizeImageWorkerResponse(message)) {
fail(new Error("Invalid image resize worker response"));
return;
}
if (message.error) {
fail(new Error(message.error));
return;
}
settle(message.result ?? null);
});
worker.once("error", fail);
worker.once("exit", (code) => {
if (!settled) {
fail(new Error(`Image resize worker exited with code ${code}`));
}
});
worker.postMessage(
{
inputBytes: inputBytesForWorker,
mimeType,
options,
},
[inputBytesForWorker.buffer],
);
});
} finally {
void worker.terminate().catch(() => undefined);
}
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not
* block the TUI event loop. If the worker cannot be loaded (for example in some
* Bun compiled executable layouts), fall back to in-process resizing so image
* reads still work.
*/
export async function resizeImage(
inputBytes: Uint8Array,
mimeType: string,
options?: ImageResizeOptions,
): Promise<ResizedImage | null> {
const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
const workerUrl = new URL(
isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js",
import.meta.url,
);
// Bun compiled executables resolve worker entrypoints by string path, not via
// new URL(..., import.meta.url). Try the string path first under Bun so the
// release binary uses the embedded worker instead of falling back in-process.
if (typeof process.versions.bun === "string") {
try {
return await resizeImageInWorker("./src/utils/image-resize-worker.ts", inputBytes, mimeType, options);
} catch {}
}
try {
return await resizeImageInWorker(workerUrl, inputBytes, mimeType, options);
} catch {
return resizeImageInProcess(inputBytes, mimeType, options);
}
}
/**
* Format a dimension note for resized images.
* This helps the model understand the coordinate mapping.
*/
export function formatDimensionNote(result: ResizedImage): string | undefined {
if (!result.wasResized) {
return undefined;
}
const scale = result.originalWidth / result.width;
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
}

View File

@@ -0,0 +1,6 @@
/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
export function stripJsonComments(input: string): string {
return input
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
}

View File

@@ -0,0 +1,78 @@
type FetchInput = Parameters<typeof fetch>[0];
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
export interface FetchRetryOptions {
/** Number of additional attempts after the initial request. Defaults to two. */
maxRetries?: number;
/** Retry transient HTTP responses as well as transport failures. Defaults to true. */
retryOnStatus?: boolean;
/** Overall time budget shared by all attempts. */
timeoutMs?: number;
/** Per-attempt timeout. A new timeout is created for every attempt. */
attemptTimeoutMs?: number;
}
/**
* Fetch a management HTTP resource with a bounded immediate retry.
*
* This is intentionally a transport-level helper for idempotent management
* requests (version checks, catalogs, and downloads). It must not be used for
* agent/model operations: those can fail after the HTTP request starts and are
* retried by their semantic caller instead.
*
* Caller cancellation and timeoutMs are terminal. attemptTimeoutMs aborts
* only the current attempt so a hung connection can be retried.
*/
export async function fetchWithRetry(
input: FetchInput,
init: RequestInit | undefined = undefined,
options: FetchRetryOptions = {},
): Promise<Response> {
const maxRetries =
options.maxRetries === undefined || !Number.isFinite(options.maxRetries)
? 2
: Math.max(0, Math.floor(options.maxRetries));
const retryOnStatus = options.retryOnStatus ?? true;
const parentSignal = init?.signal ?? undefined;
const timeoutSignal =
options.timeoutMs !== undefined && options.timeoutMs > 0 ? AbortSignal.timeout(options.timeoutMs) : undefined;
const attemptTimeoutMs =
options.attemptTimeoutMs !== undefined && options.attemptTimeoutMs > 0 ? options.attemptTimeoutMs : undefined;
for (let attempt = 0; ; attempt++) {
parentSignal?.throwIfAborted();
timeoutSignal?.throwIfAborted();
const attemptTimeoutSignal = attemptTimeoutMs ? AbortSignal.timeout(attemptTimeoutMs) : undefined;
const signals = [parentSignal, timeoutSignal, attemptTimeoutSignal].filter(
(signal): signal is AbortSignal => signal !== undefined,
);
const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
try {
const response = await fetch(input, signal ? { ...init, signal } : init);
const shouldRetry = retryOnStatus && RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries;
if (!shouldRetry) return response;
try {
await response.body?.cancel();
} catch {
// The response is being discarded before a retry. There is nothing useful to
// do if cancelling its body also fails.
}
} catch (error) {
const attemptTimedOut =
attemptTimeoutSignal?.aborted === true && !parentSignal?.aborted && !timeoutSignal?.aborted;
if (
parentSignal?.aborted ||
timeoutSignal?.aborted ||
(error instanceof Error &&
error.name === "AbortError" &&
!attemptTimedOut &&
timeoutSignal === undefined) ||
attempt >= maxRetries
) {
throw error;
}
}
}
}

View File

@@ -0,0 +1,116 @@
import { open } from "node:fs/promises";
const IMAGE_TYPE_SNIFF_BYTES = 4100;
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
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";
}
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) {
return "image/bmp";
}
return null;
}
export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null> {
const fileHandle = await open(filePath, "r");
try {
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 isBmp(buffer: Uint8Array): boolean {
if (buffer.length < 26) return false;
const declaredFileSize = readUint32LE(buffer, 2);
const pixelDataOffset = readUint32LE(buffer, 10);
const dibHeaderSize = readUint32LE(buffer, 14);
if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
if (pixelDataOffset < 14 + dibHeaderSize) return false;
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
let colorPlanes: number;
let bitsPerPixel: number;
if (dibHeaderSize === 12) {
colorPlanes = readUint16LE(buffer, 22);
bitsPerPixel = readUint16LE(buffer, 24);
} else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
if (buffer.length < 30) return false;
colorPlanes = readUint16LE(buffer, 26);
bitsPerPixel = readUint16LE(buffer, 28);
} else {
return false;
}
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
}
function readUint16LE(buffer: Uint8Array, offset: number): number {
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
}
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 readUint32LE(buffer: Uint8Array, offset: number): number {
return (
(buffer[offset] ?? 0) +
((buffer[offset + 1] ?? 0) << 8) +
((buffer[offset + 2] ?? 0) << 16) +
(buffer[offset + 3] ?? 0) * 0x1000000
);
}
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;
}

View File

@@ -0,0 +1,24 @@
import { spawn } from "node:child_process";
/**
* Open a URL or file in the platform browser/default handler.
*
* This intentionally never invokes a shell. On Windows, do not use
* `cmd /c start`: cmd.exe re-parses metacharacters (&, |, ^, ...) before
* `start` runs, which would make attacker-controlled URLs injectable.
*/
export function openBrowser(target: string): void {
const [cmd, args]: [string, string[]] =
process.platform === "darwin"
? ["open", [target]]
: process.platform === "win32"
? ["rundll32", ["url.dll,FileProtocolHandler", target]]
: ["xdg-open", [target]];
// spawn reports launcher failures (for example, missing xdg-open) via an
// error event. Browser launch is best-effort: callers still present the target
// to the user, so keep the launcher failure from becoming a process crash.
spawn(cmd, args, { stdio: "ignore", detached: true })
.on("error", () => {})
.unref();
}

View File

@@ -0,0 +1,139 @@
import { realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnProcessSync } from "./child-process.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
export interface PathInputOptions {
/** Trim leading/trailing whitespace before normalization. */
trim?: boolean;
/** Expand leading `~` to a home directory. Defaults to true. */
expandTilde?: boolean;
/** Home directory used for `~` expansion. Defaults to `os.homedir()`. */
homeDir?: string;
/** Strip a leading `@`, used for CLI @file paths. */
stripAtPrefix?: boolean;
/** Normalize unicode space variants to regular spaces. */
normalizeUnicodeSpaces?: boolean;
}
/**
* Resolve a path to its canonical (real) form, following symlinks.
* Falls back to the raw path if resolution fails (e.g. the target does
* not exist yet), so that callers never crash on missing filesystem
* entries.
*/
export function canonicalizePath(path: string): string {
try {
return realpathSync(path);
} catch {
return path;
}
}
export function getFileRevision(path: string): string | undefined {
try {
const stats = statSync(path, { bigint: true });
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
} catch {
return undefined;
}
}
/**
* Returns true if the value is NOT a package source (npm:, git:, etc.)
* or a remote URL protocol. Bare names, relative paths, and file: URLs
* are considered local.
*/
export function isLocalPath(value: string): boolean {
const trimmed = value.trim();
// Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath().
if (
trimmed.startsWith("npm:") ||
trimmed.startsWith("git:") ||
trimmed.startsWith("github:") ||
trimmed.startsWith("http:") ||
trimmed.startsWith("https:") ||
trimmed.startsWith("ssh:")
) {
return false;
}
return true;
}
/** Convert Git Bash, MSYS, Cygwin, and WSL drive paths to a form native Windows APIs accept. */
export function normalizeWindowsShellPath(filePath: string): string {
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\")) return filePath;
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
if (!match) return filePath;
const suffix = match[2]?.replaceAll("/", "\\");
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
}
export function normalizePath(input: string, options: PathInputOptions = {}): string {
let normalized = options.trim ? input.trim() : input;
if (options.normalizeUnicodeSpaces) {
normalized = normalized.replace(UNICODE_SPACES, " ");
}
if (options.stripAtPrefix && normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
if (process.platform === "win32") {
normalized = normalizeWindowsShellPath(normalized);
}
if (options.expandTilde ?? true) {
const home = options.homeDir ?? homedir();
if (normalized === "~") return home;
if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
return join(home, normalized.slice(2));
}
}
if (/^file:\/\//.test(normalized)) {
return fileURLToPath(normalized);
}
return normalized;
}
export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string {
const normalized = normalizePath(input, options);
const normalizedBaseDir = normalizePath(baseDir);
return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);
}
export function getCwdRelativePath(filePath: string, cwd: string): string | undefined {
const resolvedCwd = resolvePath(cwd);
const resolvedPath = resolvePath(filePath, resolvedCwd);
const relativePath = relative(resolvedCwd, resolvedPath);
const isInsideCwd =
relativePath === "" ||
(relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));
return isInsideCwd ? relativePath || "." : undefined;
}
export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {
const absolutePath = resolvePath(filePath, cwd);
return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/");
}
export function markPathIgnoredByCloudSync(path: string): void {
const attrs =
process.platform === "darwin"
? ["com.dropbox.ignored", "com.apple.fileprovider.ignore#P"]
: process.platform === "linux"
? ["user.com.dropbox.ignored"]
: [];
for (const attr of attrs) {
if (process.platform === "darwin") {
spawnProcessSync("xattr", ["-w", attr, "1", path], { encoding: "utf-8", stdio: "ignore" });
} else {
spawnProcessSync("setfattr", ["-n", attr, "-v", "1", path], { encoding: "utf-8", stdio: "ignore" });
}
}
}

View File

@@ -0,0 +1,139 @@
/**
* Photon image processing wrapper.
*
* This module provides a unified interface to @silvia-odwyer/photon-node that works in:
* 1. Node.js (development, npm run build)
* 2. Bun compiled binaries (standalone distribution)
*
* The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
* which bakes the build machine's absolute path into Bun compiled binaries.
*
* Solution:
* 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads
* 2. Copy photon_rs_bg.wasm next to the executable in build:binary
*/
import type { PathOrFileDescriptor } from "fs";
import { createRequire } from "module";
import * as path from "path";
import { fileURLToPath } from "url";
const require = createRequire(import.meta.url);
const fs = require("fs") as typeof import("fs");
// Re-export types from the main package
export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node";
type ReadFileSync = typeof fs.readFileSync;
const WASM_FILENAME = "photon_rs_bg.wasm";
// Lazy-loaded photon module
let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null;
let loadPromise: Promise<typeof import("@silvia-odwyer/photon-node") | null> | null = null;
function pathOrNull(file: PathOrFileDescriptor): string | null {
if (typeof file === "string") {
return file;
}
if (file instanceof URL) {
return fileURLToPath(file);
}
return null;
}
function getFallbackWasmPaths(): string[] {
const execDir = path.dirname(process.execPath);
return [
path.join(execDir, WASM_FILENAME),
path.join(execDir, "photon", WASM_FILENAME),
path.join(process.cwd(), WASM_FILENAME),
];
}
function patchPhotonWasmRead(): () => void {
const originalReadFileSync: ReadFileSync = fs.readFileSync.bind(fs);
const fallbackPaths = getFallbackWasmPaths();
const mutableFs = fs as { readFileSync: ReadFileSync };
const patchedReadFileSync: ReadFileSync = ((...args: Parameters<ReadFileSync>) => {
const [file, options] = args;
const resolvedPath = pathOrNull(file);
if (resolvedPath?.endsWith(WASM_FILENAME)) {
try {
return originalReadFileSync(...args);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err?.code && err.code !== "ENOENT") {
throw error;
}
for (const fallbackPath of fallbackPaths) {
if (!fs.existsSync(fallbackPath)) {
continue;
}
if (options === undefined) {
return originalReadFileSync(fallbackPath);
}
return originalReadFileSync(fallbackPath, options);
}
throw error;
}
}
return originalReadFileSync(...args);
}) as ReadFileSync;
try {
mutableFs.readFileSync = patchedReadFileSync;
} catch {
Object.defineProperty(fs, "readFileSync", {
value: patchedReadFileSync,
writable: true,
configurable: true,
});
}
return () => {
try {
mutableFs.readFileSync = originalReadFileSync;
} catch {
Object.defineProperty(fs, "readFileSync", {
value: originalReadFileSync,
writable: true,
configurable: true,
});
}
};
}
/**
* Load the photon module asynchronously.
* Returns cached module on subsequent calls.
*/
export async function loadPhoton(): Promise<typeof import("@silvia-odwyer/photon-node") | null> {
if (photonModule) {
return photonModule;
}
if (loadPromise) {
return loadPromise;
}
loadPromise = (async () => {
const restoreReadFileSync = patchPhotonWasmRead();
try {
photonModule = await import("@silvia-odwyer/photon-node");
return photonModule;
} catch {
photonModule = null;
return photonModule;
} finally {
restoreReadFileSync();
}
})();
return loadPromise;
}

View File

@@ -0,0 +1,4 @@
export function getPiUserAgent(version: string): string {
const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
return `pi/${version} (${process.platform}; ${runtime}; ${process.arch})`;
}

View File

@@ -0,0 +1,247 @@
import { existsSync } from "node:fs";
import { delimiter, join } from "node:path";
import { spawn, spawnSync } from "child_process";
import { getBinDir } from "../config.ts";
export interface ShellConfig {
shell: string;
args: string[];
commandTransport?: "argv" | "stdin";
}
/**
* Find bash executable on PATH (cross-platform)
*/
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
}
function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}
function findExecutableOnPath(executable: string): string | null {
if (process.platform === "win32") {
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
try {
const result = spawnSync("where", [executable], {
encoding: "utf-8",
timeout: 5000,
windowsHide: true,
});
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch && existsSync(firstMatch)) {
return firstMatch;
}
}
} catch {
// Ignore errors
}
return null;
}
// Unix: Use 'which' and trust its output (handles Termux and special filesystems)
try {
const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch) {
return firstMatch;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Resolve shell configuration based on platform and an optional explicit shell path.
* Resolution order:
* 1. User-specified shellPath
* 2. On Windows: Git Bash in known locations, then bash on PATH
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
*/
export function getShellConfig(customShellPath?: string): ShellConfig {
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
return getBashShellConfig(customShellPath);
}
throw new Error(`Custom shell path not found: ${customShellPath}`);
}
if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
return getBashShellConfig(path);
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findExecutableOnPath("bash.exe");
if (bashOnPath) {
return getBashShellConfig(bashOnPath);
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 3. Set shellPath in settings.json\n\n" +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
// Unix: try /bin/bash, then bash on PATH, then fallback to sh
if (existsSync("/bin/bash")) {
return getBashShellConfig("/bin/bash");
}
const bashOnPath = findExecutableOnPath("bash");
if (bashOnPath) {
return getBashShellConfig(bashOnPath);
}
return { shell: "sh", args: ["-c"] };
}
export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const;
/** Resolve PowerShell on Windows, preferring PowerShell 7 when available. */
export function getPowerShellConfig(): ShellConfig {
if (process.platform !== "win32") {
throw new Error("The powershell tool is only available on Windows.");
}
const shell = findExecutableOnPath("pwsh.exe") ?? findExecutableOnPath("powershell.exe");
if (!shell) {
throw new Error("No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH.");
}
return { shell, args: [...POWERSHELL_ARGS] };
}
export function getShellEnv(): NodeJS.ProcessEnv {
const binDir = getBinDir();
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
const currentPath = process.env[pathKey] ?? "";
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const hasBinDir = pathEntries.includes(binDir);
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
return {
...process.env,
[pathKey]: updatedPath,
};
}
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
* - Characters with undefined code points
*/
export function sanitizeBinaryOutput(str: string): string {
// Use Array.from to properly iterate over code points (not code units)
// This handles surrogate pairs correctly and catches edge cases where
// codePointAt() might return undefined
return Array.from(str)
.filter((char) => {
// Filter out characters that cause string-width to crash
// This includes:
// - Unicode format characters
// - Lone surrogates (already filtered by Array.from)
// - Control chars except \t \n \r
// - Characters with undefined code points
const code = char.codePointAt(0);
// Skip if code point is undefined (edge case with invalid strings)
if (code === undefined) return false;
// Allow tab, newline, carriage return
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
if (code <= 0x1f) return false;
// Filter out Unicode format characters
if (code >= 0xfff9 && code <= 0xfffb) return false;
return true;
})
.join("");
}
/**
* Detached child processes must be tracked so they can be killed on parent
* shutdown signals (SIGHUP/SIGTERM).
*/
const trackedDetachedChildPids = new Set<number>();
export function trackDetachedChildPid(pid: number): void {
trackedDetachedChildPids.add(pid);
}
export function untrackDetachedChildPid(pid: number): void {
trackedDetachedChildPids.delete(pid);
}
export function killTrackedDetachedChildren(): void {
for (const pid of trackedDetachedChildPids) {
killProcessTree(pid);
}
trackedDetachedChildPids.clear();
}
/**
* Kill a process and all its children (cross-platform)
*/
export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use the trusted System32 executable so cleanup does not depend on PATH.
try {
const child = spawn(
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"),
["/F", "/T", "/PID", String(pid)],
{
stdio: "ignore",
detached: true,
windowsHide: true,
},
);
// A failed spawn emits "error" asynchronously; consume it to avoid crashing Node.
child.once("error", () => {});
} catch {
// Ignore errors if taskkill fails.
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* Sleep helper that respects abort signal.
*/
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Aborted"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timeout);
reject(new Error("Aborted"));
});
});
}

View File

@@ -0,0 +1,212 @@
import hljs from "highlight.js/lib/core.js";
import bash from "highlight.js/lib/languages/bash.js";
import c from "highlight.js/lib/languages/c.js";
import cpp from "highlight.js/lib/languages/cpp.js";
import csharp from "highlight.js/lib/languages/csharp.js";
import dart from "highlight.js/lib/languages/dart.js";
import go from "highlight.js/lib/languages/go.js";
import groovy from "highlight.js/lib/languages/groovy.js";
import java from "highlight.js/lib/languages/java.js";
import javascript from "highlight.js/lib/languages/javascript.js";
import kotlin from "highlight.js/lib/languages/kotlin.js";
import lua from "highlight.js/lib/languages/lua.js";
import nix from "highlight.js/lib/languages/nix.js";
import perl from "highlight.js/lib/languages/perl.js";
import php from "highlight.js/lib/languages/php.js";
import python from "highlight.js/lib/languages/python.js";
import ruby from "highlight.js/lib/languages/ruby.js";
import rust from "highlight.js/lib/languages/rust.js";
import scala from "highlight.js/lib/languages/scala.js";
import swift from "highlight.js/lib/languages/swift.js";
import typescript from "highlight.js/lib/languages/typescript.js";
import { decodeHtmlEntityAt } from "./html.ts";
const eagerLanguages = {
python,
java,
go,
javascript,
cpp,
typescript,
php,
ruby,
c,
csharp,
nix,
bash,
rust,
scala,
kotlin,
swift,
dart,
groovy,
perl,
lua,
};
for (const [name, language] of Object.entries(eagerLanguages)) {
hljs.registerLanguage(name, language);
}
let allLanguagesPromise: Promise<void> | undefined;
export function loadAllHighlightLanguages(): Promise<void> {
if (!allLanguagesPromise) {
allLanguagesPromise = new Promise((resolve) => {
setImmediate(() => {
void import("highlight.js/lib/index.js").then(
() => resolve(),
() => {
// Eager languages and plaintext fallback remain available.
resolve();
},
);
});
});
}
return allLanguagesPromise;
}
export type HighlightFormatter = (text: string) => string;
export type HighlightTheme = Partial<Record<string, HighlightFormatter>>;
export interface HighlightOptions {
language?: string;
ignoreIllegals?: boolean;
languageSubset?: string[];
theme?: HighlightTheme;
}
const SPAN_CLOSE = "</span>";
const HIGHLIGHT_CLASS_PREFIX = "hljs-";
function getScopeFromSpanTag(tag: string): string | undefined {
const match = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag);
const classValue = match?.[1] ?? match?.[2];
if (!classValue) {
return undefined;
}
for (const className of classValue.split(/\s+/)) {
if (className.startsWith(HIGHLIGHT_CLASS_PREFIX)) {
return className.slice(HIGHLIGHT_CLASS_PREFIX.length);
}
}
return undefined;
}
function getScopeFormatter(scope: string, theme: HighlightTheme): HighlightFormatter | undefined {
const exact = theme[scope];
if (exact) {
return exact;
}
const dotIndex = scope.indexOf(".");
if (dotIndex !== -1) {
const prefixFormatter = theme[scope.slice(0, dotIndex)];
if (prefixFormatter) {
return prefixFormatter;
}
}
const dashIndex = scope.indexOf("-");
if (dashIndex !== -1) {
const prefixFormatter = theme[scope.slice(0, dashIndex)];
if (prefixFormatter) {
return prefixFormatter;
}
}
return undefined;
}
function getActiveFormatter(scopes: Array<string | undefined>, theme: HighlightTheme): HighlightFormatter | undefined {
for (let i = scopes.length - 1; i >= 0; i--) {
const scope = scopes[i];
if (!scope) {
continue;
}
const formatter = getScopeFormatter(scope, theme);
if (formatter) {
return formatter;
}
}
return theme.default;
}
function isSpanOpenTagStart(html: string, index: number): boolean {
if (!html.startsWith("<span", index)) {
return false;
}
const nextChar = html[index + "<span".length];
return nextChar === ">" || nextChar === " " || nextChar === "\t" || nextChar === "\n" || nextChar === "\r";
}
export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string {
let output = "";
let textBuffer = "";
const scopes: Array<string | undefined> = [];
const flushText = () => {
if (!textBuffer) {
return;
}
const formatter = getActiveFormatter(scopes, theme);
output += formatter ? formatter(textBuffer) : textBuffer;
textBuffer = "";
};
let index = 0;
while (index < html.length) {
if (isSpanOpenTagStart(html, index)) {
const tagEndIndex = html.indexOf(">", index + 5);
if (tagEndIndex !== -1) {
flushText();
const tag = html.slice(index, tagEndIndex + 1);
const scope = getScopeFromSpanTag(tag);
scopes.push(scope);
index = tagEndIndex + 1;
continue;
}
}
if (html.startsWith(SPAN_CLOSE, index)) {
flushText();
if (scopes.length > 0) {
scopes.pop();
}
index += SPAN_CLOSE.length;
continue;
}
if (html[index] === "&") {
const decoded = decodeHtmlEntityAt(html, index);
if (decoded) {
textBuffer += decoded.text;
index += decoded.length;
continue;
}
}
textBuffer += html[index];
index++;
}
flushText();
return output;
}
export function highlight(code: string, options: HighlightOptions = {}): string {
const html = options.language
? hljs.highlight(code, {
language: options.language,
ignoreIllegals: options.ignoreIllegals,
}).value
: hljs.highlightAuto(code, options.languageSubset).value;
return renderHighlightedHtml(html, options.theme);
}
export function supportsLanguage(name: string): boolean {
return hljs.getLanguage(name) !== undefined;
}

View File

@@ -0,0 +1,9 @@
/** Split a leading UTF-8 byte order mark from decoded text. */
export function splitBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
/** Remove a leading UTF-8 byte order mark from decoded text. */
export function stripBom(content: string): string {
return splitBom(content).text;
}

View File

@@ -0,0 +1,62 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { processImage } from "./image-process.ts";
export type ToolResultContent = TextContent | ImageContent;
export interface NormalizeToolResultImagesOptions {
/** Whether oversized images are resized to inline provider limits. Default: true */
autoResizeImages?: boolean;
}
/**
* Normalize image blocks returned by tool results.
*
* The `read` tool and `@file` CLI attachments run their images through `processImage`, but tools
* that produce images themselves (extensions, MCP bridges, screenshot tools) hand back arbitrary
* base64 payloads that go straight into session history and every subsequent provider request.
* Oversized images make the provider reject the whole conversation, not just the offending turn,
* so normalize them once as they enter history.
*
* Returns the original array when nothing changed so callers can skip rewriting the result.
*/
export async function normalizeToolResultImages(
content: ToolResultContent[],
options?: NormalizeToolResultImagesOptions,
): Promise<ToolResultContent[]> {
if (!content.some((block) => block.type === "image")) {
return content;
}
const autoResizeImages = options?.autoResizeImages ?? true;
const normalized: ToolResultContent[] = [];
let changed = false;
for (const block of content) {
if (block.type !== "image") {
normalized.push(block);
continue;
}
const processed = await processImage(Buffer.from(block.data, "base64"), block.mimeType, { autoResizeImages });
if (!processed.ok) {
// Unlike `read`, keep the original block. The tool already produced this image and the
// failure may just be an unavailable image backend, so passing it through preserves the
// behavior tools have today instead of silently deleting their output.
normalized.push(block);
continue;
}
if (processed.data === block.data && processed.mimeType === block.mimeType && processed.hints.length === 0) {
normalized.push(block);
continue;
}
normalized.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
if (processed.hints.length > 0) {
normalized.push({ type: "text", text: processed.hints.join("\n") });
}
changed = true;
}
return changed ? normalized : content;
}

View File

@@ -0,0 +1,400 @@
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";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { APP_NAME, getBinDir } from "../config.ts";
import { fetchWithRetry } from "./management-http.ts";
const TOOLS_DIR = getBinDir();
const NETWORK_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 120_000;
function isOfflineModeEnabled(): boolean {
const value = process.env.PI_OFFLINE;
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
interface ToolConfig {
name: string;
repo: string; // GitHub repo (e.g., "sharkdp/fd")
binaryName: string; // Name of the binary inside the archive
systemBinaryNames?: string[]; // Alternative system command names to try before downloading
tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0)
getAssetName: (version: string, plat: string, architecture: string) => string | null;
}
const TOOLS: Record<string, ToolConfig> = {
fd: {
name: "fd",
repo: "sharkdp/fd",
binaryName: "fd",
systemBinaryNames: ["fd", "fdfind"],
tagPrefix: "v",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-apple-darwin.tar.gz`;
} else if (plat === "linux") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-unknown-linux-musl.tar.gz`;
} else if (plat === "win32") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-pc-windows-msvc.zip`;
}
return null;
},
},
rg: {
name: "ripgrep",
repo: "BurntSushi/ripgrep",
binaryName: "rg",
tagPrefix: "",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
} else if (plat === "linux") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-unknown-linux-musl.tar.gz`;
} else if (plat === "win32") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
}
return null;
},
},
};
// Check if a command exists in PATH by trying to run it
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
// Check for ENOENT error (command not found)
return result.error === undefined || result.error === null;
} catch {
return false;
}
}
// Get the path to a tool (system-wide or in our tools dir)
export function getToolPath(tool: "fd" | "rg"): string | null {
const config = TOOLS[tool];
if (!config) return null;
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
if (existsSync(localPath)) {
return localPath;
}
// Check system PATH - if found, just return the command name (it's in PATH)
const systemBinaryNames = config.systemBinaryNames ?? [config.binaryName];
for (const systemBinaryName of systemBinaryNames) {
if (commandExists(systemBinaryName)) {
return systemBinaryName;
}
}
return null;
}
// Resolve the latest release version from the release page redirect.
// The api.github.com releases endpoint counts against the anonymous API
// quota (60 requests/hour per IP), which is permanently exhausted behind
// shared egress IPs such as corporate proxies and CI runners. The web
// endpoint answers with a redirect to the tagged release at no quota cost
// and lives on the same origin as the binary download itself.
export async function getLatestVersion(repo: string): Promise<string> {
const response = await fetchWithRetry(
`https://github.com/${repo}/releases/latest`,
{
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
redirect: "manual",
},
{ timeoutMs: NETWORK_TIMEOUT_MS },
);
// Only the status and headers matter here. Discard the body so the
// connection can be reused.
try {
await response.body?.cancel();
} catch {
// Discarding the body is best-effort.
}
const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
if (!location) {
throw new Error(`Failed to resolve latest ${repo} release: HTTP ${response.status} without redirect`);
}
const tag = new URL(location, "https://github.com").pathname.split("/").pop();
if (!tag || !location.includes("/releases/tag/")) {
throw new Error(`Failed to resolve latest ${repo} release: unexpected redirect to ${location}`);
}
return decodeURIComponent(tag).replace(/^v/, "");
}
// Download a file from URL
async function downloadFile(url: string, dest: string): Promise<void> {
const response = await fetchWithRetry(url, undefined, { timeoutMs: DOWNLOAD_TIMEOUT_MS });
if (!response.ok) {
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
}
if (!response.body) {
throw new Error("No response body");
}
const fileStream = createWriteStream(dest);
await pipeline(Readable.fromWeb(response.body as any), fileStream);
}
function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {
const stack: string[] = [rootDir];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) continue;
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(currentDir, entry.name);
if (entry.isFile() && entry.name === binaryFileName) {
return fullPath;
}
if (entry.isDirectory()) {
stack.push(fullPath);
}
}
}
return null;
}
function formatSpawnFailure(result: SpawnSyncReturns<Buffer>): 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<string> {
const config = TOOLS[tool];
if (!config) throw new Error(`Unknown tool: ${tool}`);
const plat = platform();
const architecture = arch();
// fd is pinned on darwin/x64, so skip the version lookup there.
const version =
tool === "fd" && plat === "darwin" && architecture === "x64" ? "10.3.0" : await getLatestVersion(config.repo);
// Get asset name for this platform
const assetName = config.getAssetName(version, plat, architecture);
if (!assetName) {
throw new Error(`Unsupported platform: ${plat}/${architecture}`);
}
// Create tools directory
mkdirSync(TOOLS_DIR, { recursive: true });
const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
const archivePath = join(TOOLS_DIR, assetName);
const binaryExt = plat === "win32" ? ".exe" : "";
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
// Download
await downloadFile(downloadUrl, archivePath);
// Extract into a unique temp directory. fd and rg downloads can run concurrently
// during startup, so sharing a fixed directory causes races.
const extractDir = join(
TOOLS_DIR,
`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
);
mkdirSync(extractDir, { recursive: true });
try {
if (assetName.endsWith(".tar.gz")) {
extractTarGzArchive(archivePath, extractDir, assetName);
} else if (assetName.endsWith(".zip")) {
extractZipArchive(archivePath, extractDir, assetName);
} else {
throw new Error(`Unsupported archive format: ${assetName}`);
}
// Find the binary in extracted files. Some archives contain files directly
// at root, others nest under a versioned subdirectory.
const binaryFileName = config.binaryName + binaryExt;
const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, ""));
const extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];
let extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));
if (!extractedBinary) {
extractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;
}
if (extractedBinary) {
renameSync(extractedBinary, binaryPath);
} else {
throw new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);
}
// Make executable (Unix only)
if (plat !== "win32") {
chmodSync(binaryPath, 0o755);
}
} finally {
// Cleanup
rmSync(archivePath, { force: true });
rmSync(extractDir, { recursive: true, force: true });
}
return binaryPath;
}
// Termux package names for tools
const TERMUX_PACKAGES: Record<string, string> = {
fd: "fd",
rg: "ripgrep",
};
export interface ToolStatus {
type: "info" | "warning";
message: string;
}
/**
* Ensure a tool is available, downloading if necessary.
* Reports progress through `onStatus`; status messages are otherwise silent.
* Returns the tool path, or undefined if unavailable.
*/
export async function ensureTool(
tool: "fd" | "rg",
onStatus?: (status: ToolStatus) => void,
): Promise<string | undefined> {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
}
const config = TOOLS[tool];
if (!config) return undefined;
if (isOfflineModeEnabled()) {
onStatus?.({ type: "warning", message: `${config.name} not found. Offline mode enabled, skipping download.` });
return undefined;
}
// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
// Users must install via pkg.
if (platform() === "android") {
const pkgName = TERMUX_PACKAGES[tool] ?? tool;
onStatus?.({ type: "warning", message: `${config.name} not found. Install with: pkg install ${pkgName}` });
return undefined;
}
// Tool not found - download it
onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` });
try {
const path = await downloadTool(tool);
onStatus?.({ type: "info", message: `${config.name} installed to ${path}` });
return path;
} catch (e) {
// Include the error cause chain: fetch failures surface as a bare
// "fetch failed" TypeError with the actionable detail (DNS, TLS,
// timeout) hidden in the cause. Depth-capped to guard against
// circular cause chains.
const messages: string[] = [];
for (
let current: unknown = e, depth = 0;
current instanceof Error && depth < 5;
current = current.cause, depth++
) {
if (!messages.includes(current.message)) messages.push(current.message);
}
onStatus?.({
type: "warning",
message: `Failed to download ${config.name}: ${messages.length > 0 ? messages.join(": ") : String(e)}`,
});
return undefined;
}
}

View File

@@ -0,0 +1,109 @@
import { compare, valid } from "semver";
import { fetchWithRetry } from "./management-http.ts";
import { getPiUserAgent } from "./pi-user-agent.ts";
const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000;
export interface LatestPiRelease {
version: string;
packageName?: string;
note?: string;
}
/** Include useful errno details hidden behind Node's generic "fetch failed" error. */
export function formatVersionCheckError(error: unknown): string {
const rootMessage = error instanceof Error && error.message ? error.message : String(error);
const cause = error instanceof Error ? error.cause : undefined;
const causes = cause instanceof AggregateError ? cause.errors : cause === undefined ? [] : [cause];
const codes = causes
.map((value) =>
typeof value === "object" && value !== null && "code" in value && typeof value.code === "string"
? value.code
: undefined,
)
.filter((code): code is string => code !== undefined);
if (codes.length > 0) return `${rootMessage} (${[...new Set(codes)].join(", ")})`;
const causeMessage = causes.find(
(value): value is Error => value instanceof Error && Boolean(value.message),
)?.message;
return causeMessage ? `${rootMessage} (cause: ${causeMessage})` : rootMessage;
}
export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined {
const left = valid(leftVersion.trim());
const right = valid(rightVersion.trim());
if (!left || !right) {
return undefined;
}
return compare(left, right);
}
export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean {
const comparison = comparePackageVersions(candidateVersion, currentVersion);
if (comparison !== undefined) {
return comparison > 0;
}
return candidateVersion.trim() !== currentVersion.trim();
}
export async function getLatestPiRelease(
currentVersion: string,
options: { timeoutMs?: number; retry?: boolean } = {},
): Promise<LatestPiRelease | undefined> {
if (process.env.PI_OFFLINE) return undefined;
const response = await fetchWithRetry(
LATEST_VERSION_URL,
{
headers: {
"User-Agent": getPiUserAgent(currentVersion),
accept: "application/json",
},
},
{
maxRetries: options.retry ? 2 : 0,
timeoutMs: options.timeoutMs ?? DEFAULT_VERSION_CHECK_TIMEOUT_MS,
},
);
if (!response.ok) return undefined;
const data = (await response.json()) as {
packageName?: unknown;
version?: unknown;
note?: unknown;
};
if (typeof data.version !== "string" || !data.version.trim()) {
return undefined;
}
const packageName =
typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined;
const note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : undefined;
return {
version: data.version.trim(),
packageName,
...(note ? { note } : {}),
};
}
export async function getLatestPiVersion(
currentVersion: string,
options: { timeoutMs?: number; retry?: boolean } = {},
): Promise<string | undefined> {
return (await getLatestPiRelease(currentVersion, options))?.version;
}
export async function checkForNewPiVersion(currentVersion: string): Promise<LatestPiRelease | undefined> {
if (process.env.PI_SKIP_VERSION_CHECK) return undefined;
try {
const latestRelease = await getLatestPiRelease(currentVersion);
if (latestRelease && isNewerPackageVersion(latestRelease.version, currentVersion)) {
return latestRelease;
}
return undefined;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { basename, dirname, join, relative, resolve, toNamespacedPath } from "node:path";
import { getCwdRelativePath } from "./paths.ts";
const QUARANTINE_DIR_NAME = ".pi-native-quarantine";
function normalizePath(path: string): string {
return toNamespacedPath(resolve(path));
}
function getQuarantineRoot(packageDir: string): string | undefined {
let current = resolve(packageDir);
while (true) {
if (basename(current).toLowerCase() === "node_modules") {
return join(current, QUARANTINE_DIR_NAME);
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
function getLoadedSharedObjectsInPackageDir(packageDir: string): string[] {
const sharedObjects = (process.report.getReport() as { sharedObjects?: unknown }).sharedObjects;
if (!Array.isArray(sharedObjects)) {
return [];
}
const root = normalizePath(packageDir).toLowerCase();
const seen = new Set<string>();
const loadedFiles: string[] = [];
for (const value of sharedObjects) {
if (typeof value !== "string") {
continue;
}
const filePath = normalizePath(value);
const comparisonPath = filePath.toLowerCase();
if (getCwdRelativePath(comparisonPath, root) === undefined || seen.has(comparisonPath)) {
continue;
}
seen.add(comparisonPath);
loadedFiles.push(filePath);
}
return loadedFiles;
}
export function cleanupWindowsSelfUpdateQuarantine(packageDir: string): void {
const quarantineRoot = getQuarantineRoot(packageDir);
if (!quarantineRoot) {
return;
}
try {
rmSync(quarantineRoot, { recursive: true, force: true });
} catch {
// A previous pi process may still be exiting and holding a native addon.
}
}
export function quarantineWindowsNativeDependencies(packageDir: string): void {
const resolvedPackageDir = normalizePath(packageDir);
const quarantineRoot = getQuarantineRoot(resolvedPackageDir);
if (!quarantineRoot) {
return;
}
const loadedFiles = getLoadedSharedObjectsInPackageDir(resolvedPackageDir);
if (loadedFiles.length === 0) {
return;
}
const quarantineRunDir = join(quarantineRoot, `${Date.now()}-${process.pid}-${randomUUID()}`);
for (const loadedFile of loadedFiles) {
if (!existsSync(loadedFile)) {
continue;
}
const quarantinePath = join(quarantineRunDir, relative(resolvedPackageDir, loadedFile));
mkdirSync(dirname(quarantinePath), { recursive: true });
renameSync(loadedFile, quarantinePath);
copyFileSync(quarantinePath, loadedFile);
}
}