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:
48
third_party/pi/packages/coding-agent/src/utils/abort.ts
vendored
Normal file
48
third_party/pi/packages/coding-agent/src/utils/abort.ts
vendored
Normal 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();
|
||||
});
|
||||
}
|
||||
60
third_party/pi/packages/coding-agent/src/utils/ansi.ts
vendored
Normal file
60
third_party/pi/packages/coding-agent/src/utils/ansi.ts
vendored
Normal 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, "");
|
||||
}
|
||||
196
third_party/pi/packages/coding-agent/src/utils/changelog.ts
vendored
Normal file
196
third_party/pi/packages/coding-agent/src/utils/changelog.ts
vendored
Normal 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";
|
||||
137
third_party/pi/packages/coding-agent/src/utils/child-process.ts
vendored
Normal file
137
third_party/pi/packages/coding-agent/src/utils/child-process.ts
vendored
Normal 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);
|
||||
});
|
||||
}
|
||||
300
third_party/pi/packages/coding-agent/src/utils/clipboard-image.ts
vendored
Normal file
300
third_party/pi/packages/coding-agent/src/utils/clipboard-image.ts
vendored
Normal 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;
|
||||
}
|
||||
33
third_party/pi/packages/coding-agent/src/utils/clipboard-native.ts
vendored
Normal file
33
third_party/pi/packages/coding-agent/src/utils/clipboard-native.ts
vendored
Normal 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 };
|
||||
175
third_party/pi/packages/coding-agent/src/utils/clipboard.ts
vendored
Normal file
175
third_party/pi/packages/coding-agent/src/utils/clipboard.ts
vendored
Normal 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");
|
||||
}
|
||||
}
|
||||
14
third_party/pi/packages/coding-agent/src/utils/deprecation.ts
vendored
Normal file
14
third_party/pi/packages/coding-agent/src/utils/deprecation.ts
vendored
Normal 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();
|
||||
}
|
||||
182
third_party/pi/packages/coding-agent/src/utils/exif-orientation.ts
vendored
Normal file
182
third_party/pi/packages/coding-agent/src/utils/exif-orientation.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
40
third_party/pi/packages/coding-agent/src/utils/frontmatter.ts
vendored
Normal file
40
third_party/pi/packages/coding-agent/src/utils/frontmatter.ts
vendored
Normal 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;
|
||||
30
third_party/pi/packages/coding-agent/src/utils/fs-watch.ts
vendored
Normal file
30
third_party/pi/packages/coding-agent/src/utils/fs-watch.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
226
third_party/pi/packages/coding-agent/src/utils/git.ts
vendored
Normal file
226
third_party/pi/packages/coding-agent/src/utils/git.ts
vendored
Normal 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);
|
||||
}
|
||||
36
third_party/pi/packages/coding-agent/src/utils/highlight-js.d.ts
vendored
Normal file
36
third_party/pi/packages/coding-agent/src/utils/highlight-js.d.ts
vendored
Normal 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;
|
||||
}
|
||||
51
third_party/pi/packages/coding-agent/src/utils/html.ts
vendored
Normal file
51
third_party/pi/packages/coding-agent/src/utils/html.ts
vendored
Normal 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 };
|
||||
}
|
||||
49
third_party/pi/packages/coding-agent/src/utils/image-convert.ts
vendored
Normal file
49
third_party/pi/packages/coding-agent/src/utils/image-convert.ts
vendored
Normal 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",
|
||||
};
|
||||
}
|
||||
119
third_party/pi/packages/coding-agent/src/utils/image-process.ts
vendored
Normal file
119
third_party/pi/packages/coding-agent/src/utils/image-process.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
164
third_party/pi/packages/coding-agent/src/utils/image-resize-core.ts
vendored
Normal file
164
third_party/pi/packages/coding-agent/src/utils/image-resize-core.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
third_party/pi/packages/coding-agent/src/utils/image-resize-worker.ts
vendored
Normal file
42
third_party/pi/packages/coding-agent/src/utils/image-resize-worker.ts
vendored
Normal 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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
123
third_party/pi/packages/coding-agent/src/utils/image-resize.ts
vendored
Normal file
123
third_party/pi/packages/coding-agent/src/utils/image-resize.ts
vendored
Normal 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.]`;
|
||||
}
|
||||
6
third_party/pi/packages/coding-agent/src/utils/json.ts
vendored
Normal file
6
third_party/pi/packages/coding-agent/src/utils/json.ts
vendored
Normal 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 : ""));
|
||||
}
|
||||
78
third_party/pi/packages/coding-agent/src/utils/management-http.ts
vendored
Normal file
78
third_party/pi/packages/coding-agent/src/utils/management-http.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
third_party/pi/packages/coding-agent/src/utils/mime.ts
vendored
Normal file
116
third_party/pi/packages/coding-agent/src/utils/mime.ts
vendored
Normal 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;
|
||||
}
|
||||
24
third_party/pi/packages/coding-agent/src/utils/open-browser.ts
vendored
Normal file
24
third_party/pi/packages/coding-agent/src/utils/open-browser.ts
vendored
Normal 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();
|
||||
}
|
||||
139
third_party/pi/packages/coding-agent/src/utils/paths.ts
vendored
Normal file
139
third_party/pi/packages/coding-agent/src/utils/paths.ts
vendored
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
139
third_party/pi/packages/coding-agent/src/utils/photon.ts
vendored
Normal file
139
third_party/pi/packages/coding-agent/src/utils/photon.ts
vendored
Normal 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;
|
||||
}
|
||||
4
third_party/pi/packages/coding-agent/src/utils/pi-user-agent.ts
vendored
Normal file
4
third_party/pi/packages/coding-agent/src/utils/pi-user-agent.ts
vendored
Normal 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})`;
|
||||
}
|
||||
247
third_party/pi/packages/coding-agent/src/utils/shell.ts
vendored
Normal file
247
third_party/pi/packages/coding-agent/src/utils/shell.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
third_party/pi/packages/coding-agent/src/utils/sleep.ts
vendored
Normal file
18
third_party/pi/packages/coding-agent/src/utils/sleep.ts
vendored
Normal 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"));
|
||||
});
|
||||
});
|
||||
}
|
||||
212
third_party/pi/packages/coding-agent/src/utils/syntax-highlight.ts
vendored
Normal file
212
third_party/pi/packages/coding-agent/src/utils/syntax-highlight.ts
vendored
Normal 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;
|
||||
}
|
||||
9
third_party/pi/packages/coding-agent/src/utils/text.ts
vendored
Normal file
9
third_party/pi/packages/coding-agent/src/utils/text.ts
vendored
Normal 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;
|
||||
}
|
||||
62
third_party/pi/packages/coding-agent/src/utils/tool-result-images.ts
vendored
Normal file
62
third_party/pi/packages/coding-agent/src/utils/tool-result-images.ts
vendored
Normal 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;
|
||||
}
|
||||
400
third_party/pi/packages/coding-agent/src/utils/tools-manager.ts
vendored
Normal file
400
third_party/pi/packages/coding-agent/src/utils/tools-manager.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
109
third_party/pi/packages/coding-agent/src/utils/version-check.ts
vendored
Normal file
109
third_party/pi/packages/coding-agent/src/utils/version-check.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
84
third_party/pi/packages/coding-agent/src/utils/windows-self-update.ts
vendored
Normal file
84
third_party/pi/packages/coding-agent/src/utils/windows-self-update.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user