From 5b5391911eef878be42e3d278af6c973c771fbdf Mon Sep 17 00:00:00 2001 From: QAyong Date: Sun, 27 Sep 2026 02:20:12 +0800 Subject: [PATCH 01/48] fix: update pi runtime for GPT-6 models --- apps/buddy/package.json | 4 +- .../shared/conversation/modelSelection.ts | 3 + patches/@earendil-works__pi-ai@0.87.1.patch | 35 + ...rendil-works__pi-coding-agent@0.87.1.patch | 774 ++++++++++++++++++ pnpm-lock.yaml | 70 +- pnpm-workspace.yaml | 16 +- 6 files changed, 857 insertions(+), 45 deletions(-) create mode 100644 patches/@earendil-works__pi-ai@0.87.1.patch create mode 100644 patches/@earendil-works__pi-coding-agent@0.87.1.patch diff --git a/apps/buddy/package.json b/apps/buddy/package.json index 76bc7d26..eb62bee4 100644 --- a/apps/buddy/package.json +++ b/apps/buddy/package.json @@ -49,8 +49,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.77", - "@earendil-works/pi-ai": "^0.86.1", - "@earendil-works/pi-coding-agent": "^0.86.1", + "@earendil-works/pi-ai": "^0.87.1", + "@earendil-works/pi-coding-agent": "^0.87.1", "@js-temporal/polyfill": "^0.5.1", "@modelcontextprotocol/client": "2.0.0", "@mozilla/readability": "^0.6.0", diff --git a/apps/buddy/shared/conversation/modelSelection.ts b/apps/buddy/shared/conversation/modelSelection.ts index ae83e484..b9c6b003 100644 --- a/apps/buddy/shared/conversation/modelSelection.ts +++ b/apps/buddy/shared/conversation/modelSelection.ts @@ -27,6 +27,9 @@ const OPENAI_FAST_MODE_MODEL_IDS = new Set([ 'gpt-5.6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', + 'gpt-6-astra', + 'gpt-6-luna', + 'gpt-6-sol', ]) export interface BuddyServiceTierOption { diff --git a/patches/@earendil-works__pi-ai@0.87.1.patch b/patches/@earendil-works__pi-ai@0.87.1.patch new file mode 100644 index 00000000..71d1a7a0 --- /dev/null +++ b/patches/@earendil-works__pi-ai@0.87.1.patch @@ -0,0 +1,35 @@ +diff --git a/dist/api/openai-completions.js b/dist/api/openai-completions.js +--- a/dist/api/openai-completions.js ++++ b/dist/api/openai-completions.js +@@ -1141,6 +1141,14 @@ export function convertMessages(model, context, compat, options) { + } + lastRole = msg.role; + } ++ if (params.some((message) => message.role === "assistant" && ++ typeof message.reasoning_content === "string")) { ++ for (const message of params) { ++ if (message.role === "assistant" && message.reasoning_content === undefined) { ++ message.reasoning_content = ""; ++ } ++ } ++ } + return params; + } + function convertTools(tools, compat) { +diff --git a/dist/api/openai-responses-shared.js b/dist/api/openai-responses-shared.js +index a2049d5a1f633d7a96aa3cf080ded6310df8f97f..cd1f08e0f8300d04cae27662d8815731cd0a7083 100644 +--- a/dist/api/openai-responses-shared.js ++++ b/dist/api/openai-responses-shared.js +@@ -355,7 +355,11 @@ export async function processResponsesStream(openaiStream, output, stream, model + } + if (item.type === "message") { + applyMessagePhaseStopReason(item); +- const block = { type: "text", text: "" }; ++ const block = { ++ type: "text", ++ text: "", ++ textSignature: encodeTextSignatureV1(item.id, item.phase ?? undefined), ++ }; + output.content.push(block); + const slot = { type: "text", block, contentIndex: output.content.length - 1 }; + outputSlots.set(outputIndex, slot); diff --git a/patches/@earendil-works__pi-coding-agent@0.87.1.patch b/patches/@earendil-works__pi-coding-agent@0.87.1.patch new file mode 100644 index 00000000..2c92708c --- /dev/null +++ b/patches/@earendil-works__pi-coding-agent@0.87.1.patch @@ -0,0 +1,774 @@ +diff --git a/dist/core/tools/find-execution.js b/dist/core/tools/find-execution.js +new file mode 100644 +index 0000000000000000000000000000000000000000..dd03780decc4b91299340ba7c6b6742421290949 +--- /dev/null ++++ b/dist/core/tools/find-execution.js +@@ -0,0 +1,67 @@ ++import path from "node:path"; ++import process from "node:process"; ++import { Buffer } from "node:buffer"; ++import { ensureTool } from "../../utils/tools-manager.js"; ++import { pathExists, resolveToCwd } from "./path-utils.js"; ++import { DEFAULT_MAX_BYTES, truncateHead } from "./truncate.js"; ++import { runSearchProcess, searchLimit } from "./search-process.js"; ++ ++export async function executeFind(cwd, input, signal, operations, relativize) { ++ signal?.throwIfAborted(); ++ const searchPath = resolveToCwd(input.path || ".", cwd); ++ const limit = searchLimit(input.limit, 1000, 10_000); ++ if (!(await (operations?.exists ?? pathExists)(searchPath))) throw new Error(`Path not found: ${searchPath}`); ++ const lines = []; ++ let count = 0; ++ let bytes = 0; ++ let budgetReached = false; ++ const collect = (raw) => { ++ count++; ++ if (count > limit) return false; ++ const line = relativize(raw, searchPath); ++ bytes += Buffer.byteLength(line) + 1; ++ if (bytes > DEFAULT_MAX_BYTES - 1024) { ++ budgetReached = true; ++ return false; ++ } ++ lines.push(line); ++ }; ++ if (operations?.glob) { ++ const results = await operations.glob(input.pattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit: limit + 1 }); ++ signal?.throwIfAborted(); ++ for (const result of results) if (collect(result) === false) break; ++ } else { ++ const executable = await ensureTool("fd"); ++ if (!executable) throw new Error("fd is not available and could not be downloaded"); ++ const args = ["--glob", "--color=never", "--hidden", "--print0", "--max-results", String(limit + 1)]; ++ let insideGitRepo = false; ++ for (let current = searchPath;;) { ++ signal?.throwIfAborted(); ++ if (await pathExists(path.join(current, ".git"))) { insideGitRepo = true; break; } ++ const parent = path.dirname(current); ++ if (parent === current) break; ++ current = parent; ++ } ++ if (!insideGitRepo) args.push("--no-require-git"); ++ let pattern = input.pattern; ++ if (pattern.includes("/")) { ++ args.push("--full-path"); ++ if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`; ++ if (process.platform === "win32") pattern = pattern.replaceAll("/", String.raw`[/\\]`); ++ } ++ args.push("--", pattern, searchPath); ++ await runSearchProcess(executable, args, signal, 0, record => collect(new TextDecoder("utf-8", { fatal: true }).decode(record))); ++ } ++ const output = lines.join("\n"); ++ const details = {}; ++ const notices = []; ++ if (count > limit) { details.resultLimitReached = limit; notices.push(`${limit} results limit reached; narrow the path or pattern`); } ++ if (budgetReached) { ++ details.truncation = { ...truncateHead(output), truncated: true, truncatedBy: "bytes" }; ++ notices.push("Search output budget reached; narrow the path or pattern"); ++ } ++ return { ++ content: [{ type: "text", text: (output || (budgetReached ? "Search output omitted" : "No files found matching pattern")) + (notices.length ? `\n\n[${notices.join(". ")}]` : "") }], ++ details: Object.keys(details).length ? details : undefined, ++ }; ++} +diff --git a/dist/core/tools/find.js b/dist/core/tools/find.js +index a3bab76c952c2a03d6534aba80a72f7743a52b94..5a01803160a07a0a10939100876428ecaed4cebf 100644 +--- a/dist/core/tools/find.js ++++ b/dist/core/tools/find.js +@@ -1,12 +1,9 @@ +-import { createInterface } from "node:readline"; +-import { spawn } from "child_process"; + import path from "path"; + import { Type } from "typebox"; +-import { ensureTool } from "../../utils/tools-manager.js"; +-import { pathExists, resolveToCwd } from "./path-utils.js"; ++import { executeFind } from "./find-execution.js"; + import { findRenderers } from "./renderers/find.js"; + import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +-import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js"; ++import { DEFAULT_MAX_BYTES, formatSize } from "./truncate.js"; + /** Relativize a find result against the search root and normalize it to posix separators. */ + export function relativizeFindResultPath(resultPath, searchPath, pathModule = path) { + const hadTrailingSeparator = resultPath.endsWith(pathModule.sep) || (pathModule.sep === "\\" && resultPath.endsWith("/")); +@@ -26,11 +23,6 @@ export const findToolSystemPromptContribution = { + guidelines: [], + }; + const DEFAULT_LIMIT = 1000; +-const defaultFindOperations = { +- exists: pathExists, +- // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. +- glob: () => [], +-}; + export function createFindToolDefinition(cwd, options) { + const customOps = options?.operations; + return { +@@ -39,208 +31,8 @@ export function createFindToolDefinition(cwd, options) { + description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: findToolSystemPromptContribution.snippet, + parameters: findSchema, +- async execute(_toolCallId, { pattern, path: searchDir, limit }, signal, _onUpdate, ctx) { +- return new Promise((resolve, reject) => { +- if (signal?.aborted) { +- reject(new Error("Operation aborted")); +- return; +- } +- let settled = false; +- let stopChild; +- const settle = (fn) => { +- if (settled) +- return; +- settled = true; +- signal?.removeEventListener("abort", onAbort); +- stopChild = undefined; +- fn(); +- }; +- const onAbort = () => { +- stopChild?.(); +- settle(() => reject(new Error("Operation aborted"))); +- }; +- signal?.addEventListener("abort", onAbort, { once: true }); +- (async () => { +- try { +- const searchPath = resolveToCwd(searchDir || ".", ctx?.cwd || cwd); +- const effectiveLimit = limit ?? DEFAULT_LIMIT; +- const ops = customOps ?? defaultFindOperations; +- // If custom operations provide glob(), use that instead of fd. +- if (customOps?.glob) { +- if (!(await ops.exists(searchPath))) { +- settle(() => reject(new Error(`Path not found: ${searchPath}`))); +- return; +- } +- if (signal?.aborted) { +- settle(() => reject(new Error("Operation aborted"))); +- return; +- } +- const results = await ops.glob(pattern, searchPath, { +- ignore: ["**/node_modules/**", "**/.git/**"], +- limit: effectiveLimit, +- }); +- if (signal?.aborted) { +- settle(() => reject(new Error("Operation aborted"))); +- return; +- } +- if (results.length === 0) { +- settle(() => resolve({ +- content: [{ type: "text", text: "No files found matching pattern" }], +- details: undefined, +- })); +- return; +- } +- // Relativize paths against the search root for stable output. +- const relativized = results.map((p) => relativizeFindResultPath(p, searchPath)); +- const resultLimitReached = relativized.length >= effectiveLimit; +- const rawOutput = relativized.join("\n"); +- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); +- let resultOutput = truncation.content; +- const details = {}; +- const notices = []; +- if (resultLimitReached) { +- notices.push(`${effectiveLimit} results limit reached`); +- details.resultLimitReached = effectiveLimit; +- } +- if (truncation.truncated) { +- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); +- details.truncation = truncation; +- } +- if (notices.length > 0) { +- resultOutput += `\n\n[${notices.join(". ")}]`; +- } +- settle(() => resolve({ +- content: [{ type: "text", text: resultOutput }], +- details: Object.keys(details).length > 0 ? details : undefined, +- })); +- return; +- } +- // Default implementation uses fd. +- const fdPath = await ensureTool("fd"); +- if (signal?.aborted) { +- settle(() => reject(new Error("Operation aborted"))); +- return; +- } +- if (!fdPath) { +- settle(() => reject(new Error("fd is not available and could not be downloaded"))); +- return; +- } +- const args = ["--glob", "--color=never", "--hidden"]; +- // fd normally ignores .gitignore outside git repos, so keep --no-require-git +- // there. Inside repos, use fd's default git-aware behavior so parent +- // .gitignore rules stop at nested repo boundaries: +- // https://github.com/earendil-works/pi/issues/5960 +- let insideGitRepo = false; +- for (let current = searchPath;;) { +- if (await pathExists(path.join(current, ".git"))) { +- insideGitRepo = true; +- break; +- } +- const parent = path.dirname(current); +- if (parent === current) +- break; +- current = parent; +- } +- if (!insideGitRepo) +- args.push("--no-require-git"); +- args.push("--max-results", String(effectiveLimit)); +- // fd --glob matches against the basename unless --full-path is set; in --full-path +- // mode it matches against the absolute candidate path, so a path-containing +- // pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything. +- let effectivePattern = pattern; +- if (pattern.includes("/")) { +- args.push("--full-path"); +- if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") { +- effectivePattern = `**/${pattern}`; +- } +- // fd matches full paths using native separators on Windows. +- if (process.platform === "win32") +- effectivePattern = effectivePattern.replaceAll("/", String.raw `[/\\]`); +- } +- args.push("--", effectivePattern, searchPath); +- const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] }); +- const rl = createInterface({ input: child.stdout }); +- let stderr = ""; +- const lines = []; +- stopChild = () => { +- if (!child.killed) { +- child.kill(); +- } +- }; +- const cleanup = () => { +- rl.close(); +- }; +- child.stderr?.on("data", (chunk) => { +- stderr += chunk.toString(); +- }); +- rl.on("line", (line) => { +- lines.push(line); +- }); +- child.on("error", (error) => { +- cleanup(); +- settle(() => reject(new Error(`Failed to run fd: ${error.message}`))); +- }); +- child.on("close", (code) => { +- cleanup(); +- if (signal?.aborted) { +- settle(() => reject(new Error("Operation aborted"))); +- return; +- } +- const output = lines.join("\n"); +- if (code !== 0) { +- const errorMsg = stderr.trim() || `fd exited with code ${code}`; +- if (!output) { +- settle(() => reject(new Error(errorMsg))); +- return; +- } +- } +- if (!output) { +- settle(() => resolve({ +- content: [{ type: "text", text: "No files found matching pattern" }], +- details: undefined, +- })); +- return; +- } +- const relativized = []; +- for (const rawLine of lines) { +- const line = rawLine.replace(/\r$/, "").trim(); +- if (!line) +- continue; +- relativized.push(relativizeFindResultPath(line, searchPath)); +- } +- const resultLimitReached = relativized.length >= effectiveLimit; +- const rawOutput = relativized.join("\n"); +- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); +- let resultOutput = truncation.content; +- const details = {}; +- const notices = []; +- if (resultLimitReached) { +- notices.push(`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`); +- details.resultLimitReached = effectiveLimit; +- } +- if (truncation.truncated) { +- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); +- details.truncation = truncation; +- } +- if (notices.length > 0) { +- resultOutput += `\n\n[${notices.join(". ")}]`; +- } +- settle(() => resolve({ +- content: [{ type: "text", text: resultOutput }], +- details: Object.keys(details).length > 0 ? details : undefined, +- })); +- }); +- } +- catch (e) { +- if (signal?.aborted) { +- settle(() => reject(new Error("Operation aborted"))); +- return; +- } +- const error = e instanceof Error ? e : new Error(String(e)); +- settle(() => reject(error)); +- } +- })(); +- }); ++ async execute(_toolCallId, input, signal, _onUpdate, ctx) { ++ return executeFind(ctx?.cwd || cwd, input, signal, customOps, relativizeFindResultPath); + }, + ...findRenderers, + }; +diff --git a/dist/core/tools/grep-execution.js b/dist/core/tools/grep-execution.js +new file mode 100644 +index 0000000000000000000000000000000000000000..b72872c74e6c615946bc1a0ae0f791c85d160475 +--- /dev/null ++++ b/dist/core/tools/grep-execution.js +@@ -0,0 +1,82 @@ ++import path from "node:path"; ++import { Buffer } from "node:buffer"; ++import { ensureTool } from "../../utils/tools-manager.js"; ++import { resolveToCwd } from "./path-utils.js"; ++import { DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH, truncateHead, truncateLine } from "./truncate.js"; ++import { runSearchProcess, searchLimit } from "./search-process.js"; ++ ++function eventText(value) { ++ if (typeof value?.text === "string") return value.text; ++ if (typeof value?.bytes === "string") { ++ const bytes = Buffer.from(value.bytes, "base64"); ++ return new TextDecoder("utf-8", { fatal: true }).decode(bytes); ++ } ++ throw new Error("Invalid ripgrep text record"); ++} ++ ++export async function executeGrep(cwd, input, signal, isDirectory) { ++ signal?.throwIfAborted(); ++ const executable = await ensureTool("rg"); ++ if (!executable) throw new Error("ripgrep (rg) is not available and could not be downloaded"); ++ const searchPath = resolveToCwd(input.path || ".", cwd); ++ const directory = await isDirectory(searchPath); ++ const limit = searchLimit(input.limit, 100, 10_000); ++ const context = input.context === undefined || input.context === 0 ? 0 : searchLimit(input.context, 0, 100); ++ const args = ["--json", "--no-config", "--line-number", "--color=never", "--hidden"]; ++ if (input.ignoreCase) args.push("--ignore-case"); ++ if (input.literal) args.push("--fixed-strings"); ++ if (input.glob) args.push("--glob", input.glob); ++ if (context) args.push("--context", String(context)); ++ args.push("--", input.pattern, searchPath); ++ const lines = []; ++ let bytes = 0; ++ let matches = 0; ++ let remainingContext = context; ++ let clipped = false; ++ let budgetReached = false; ++ await runSearchProcess(executable, args, signal, 10, (record) => { ++ const event = JSON.parse(record.toString("utf8")); ++ if (event.type === "end" && matches >= limit) return false; ++ if (event.type !== "match" && event.type !== "context") return; ++ const isMatch = event.type === "match"; ++ if (isMatch && matches >= limit) return false; ++ const file = eventText(event.data?.path); ++ const number = event.data?.line_number; ++ if (!Number.isSafeInteger(number) || number < 1) throw new Error("Invalid ripgrep line number"); ++ const shown = directory ? path.relative(searchPath, file).split(path.sep).join("/") : path.basename(file); ++ const value = eventText(event.data?.lines).replace(/\r?\n$/, ""); ++ const { text, wasTruncated } = truncateLine(value); ++ clipped ||= wasTruncated; ++ const line = isMatch ? `${shown}:${number}: ${text}` : `${shown}-${number}- ${text}`; ++ bytes += Buffer.byteLength(line) + 1; ++ if (bytes > DEFAULT_MAX_BYTES - 1024) { ++ budgetReached = true; ++ return false; ++ } ++ lines.push(line); ++ if (isMatch) matches++; ++ if (matches >= limit) { ++ if (!context) return false; ++ if (!isMatch && --remainingContext <= 0) return false; ++ } ++ }, [0, 1]); ++ const details = {}; ++ const notices = []; ++ if (matches >= limit) { ++ details.matchLimitReached = limit; ++ notices.push(`${limit} matches limit reached; narrow the path or pattern`); ++ } ++ if (clipped) { ++ details.linesTruncated = true; ++ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars`); ++ } ++ const output = lines.join("\n"); ++ if (budgetReached) { ++ details.truncation = { ...truncateHead(output), truncated: true, truncatedBy: "bytes" }; ++ notices.push("Search output budget reached; narrow the path or pattern"); ++ } ++ return { ++ content: [{ type: "text", text: (output || (budgetReached ? "Search output omitted" : "No matches found")) + (notices.length ? `\n\n[${notices.join(". ")}]` : "") }], ++ details: Object.keys(details).length ? details : undefined, ++ }; ++} +diff --git a/dist/core/tools/grep.js b/dist/core/tools/grep.js +index d427d071582ead6e0c876121a8b67ad9fe6c45f7..082d3dde3f0985bf968013121cc5db9f65a2ad4f 100644 +--- a/dist/core/tools/grep.js ++++ b/dist/core/tools/grep.js +@@ -1,13 +1,9 @@ + import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; +-import { createInterface } from "node:readline"; +-import { spawn } from "child_process"; +-import path from "path"; + import { Type } from "typebox"; +-import { ensureTool } from "../../utils/tools-manager.js"; +-import { resolveToCwd } from "./path-utils.js"; ++import { executeGrep } from "./grep-execution.js"; + import { grepRenderers } from "./renderers/grep.js"; + import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +-import { DEFAULT_MAX_BYTES, formatSize, GREP_MAX_LINE_LENGTH, truncateHead, truncateLine, } from "./truncate.js"; ++import { DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH, } from "./truncate.js"; + const grepSchema = Type.Object({ + pattern: Type.String({ description: "Search pattern (regex or literal string)" }), + path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })), +@@ -34,214 +30,8 @@ export function createGrepToolDefinition(cwd, options) { + description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, + promptSnippet: grepToolSystemPromptContribution.snippet, + parameters: grepSchema, +- async execute(_toolCallId, { pattern, path: searchDir, glob, ignoreCase, literal, context, limit, }, signal, _onUpdate, ctx) { +- return new Promise((resolve, reject) => { +- if (signal?.aborted) { +- reject(new Error("Operation aborted")); +- return; +- } +- let settled = false; +- const settle = (fn) => { +- if (!settled) { +- settled = true; +- fn(); +- } +- }; +- (async () => { +- try { +- const rgPath = await ensureTool("rg"); +- if (!rgPath) { +- settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded"))); +- return; +- } +- const searchPath = resolveToCwd(searchDir || ".", ctx?.cwd || cwd); +- const ops = customOps ?? defaultGrepOperations; +- let isDirectory; +- try { +- isDirectory = await ops.isDirectory(searchPath); +- } +- catch { +- settle(() => reject(new Error(`Path not found: ${searchPath}`))); +- return; +- } +- const contextValue = context && context > 0 ? context : 0; +- const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); +- const formatPath = (filePath) => { +- if (isDirectory) { +- const relative = path.relative(searchPath, filePath); +- if (relative && !relative.startsWith("..")) { +- return relative.replace(/\\/g, "/"); +- } +- } +- return path.basename(filePath); +- }; +- const fileCache = new Map(); +- const getFileLines = async (filePath) => { +- let lines = fileCache.get(filePath); +- if (!lines) { +- try { +- const content = await ops.readFile(filePath); +- lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +- } +- catch { +- lines = []; +- } +- fileCache.set(filePath, lines); +- } +- return lines; +- }; +- const args = ["--json", "--line-number", "--color=never", "--hidden"]; +- if (ignoreCase) +- args.push("--ignore-case"); +- if (literal) +- args.push("--fixed-strings"); +- if (glob) +- args.push("--glob", glob); +- args.push("--", pattern, searchPath); +- const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] }); +- const rl = createInterface({ input: child.stdout }); +- let stderr = ""; +- let matchCount = 0; +- let matchLimitReached = false; +- let linesTruncated = false; +- let aborted = false; +- let killedDueToLimit = false; +- const outputLines = []; +- const cleanup = () => { +- rl.close(); +- signal?.removeEventListener("abort", onAbort); +- }; +- const stopChild = (dueToLimit = false) => { +- if (!child.killed) { +- killedDueToLimit = dueToLimit; +- child.kill(); +- } +- }; +- const onAbort = () => { +- aborted = true; +- stopChild(); +- }; +- signal?.addEventListener("abort", onAbort, { once: true }); +- child.stderr?.on("data", (chunk) => { +- stderr += chunk.toString(); +- }); +- const formatBlock = async (filePath, lineNumber) => { +- const relativePath = formatPath(filePath); +- const lines = await getFileLines(filePath); +- if (!lines.length) +- return [`${relativePath}:${lineNumber}: (unable to read file)`]; +- const block = []; +- const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber; +- const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber; +- for (let current = start; current <= end; current++) { +- const lineText = lines[current - 1] ?? ""; +- const sanitized = lineText.replace(/\r/g, ""); +- const isMatchLine = current === lineNumber; +- // Truncate long lines so grep output stays compact. +- const { text: truncatedText, wasTruncated } = truncateLine(sanitized); +- if (wasTruncated) +- linesTruncated = true; +- if (isMatchLine) +- block.push(`${relativePath}:${current}: ${truncatedText}`); +- else +- block.push(`${relativePath}-${current}- ${truncatedText}`); +- } +- return block; +- }; +- // Collect matches during streaming, then format them after rg exits. +- const matches = []; +- rl.on("line", (line) => { +- if (!line.trim() || matchCount >= effectiveLimit) +- return; +- let event; +- try { +- event = JSON.parse(line); +- } +- catch { +- return; +- } +- if (event.type === "match") { +- matchCount++; +- const filePath = event.data?.path?.text; +- const lineNumber = event.data?.line_number; +- const lineText = event.data?.lines?.text; +- if (filePath && typeof lineNumber === "number") +- matches.push({ filePath, lineNumber, lineText }); +- if (matchCount >= effectiveLimit) { +- matchLimitReached = true; +- stopChild(true); +- } +- } +- }); +- child.on("error", (error) => { +- cleanup(); +- settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`))); +- }); +- child.on("close", async (code) => { +- cleanup(); +- if (aborted) { +- settle(() => reject(new Error("Operation aborted"))); +- return; +- } +- if (!killedDueToLimit && code !== 0 && code !== 1) { +- const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`; +- settle(() => reject(new Error(errorMsg))); +- return; +- } +- if (matchCount === 0) { +- settle(() => resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined })); +- return; +- } +- // Format matches after streaming finishes so custom readFile() backends can be async. +- for (const match of matches) { +- if (contextValue === 0 && match.lineText !== undefined) { +- const relativePath = formatPath(match.filePath); +- const sanitized = match.lineText +- .replace(/\r\n/g, "\n") +- .replace(/\r/g, "") +- .replace(/\n$/, ""); +- const { text: truncatedText, wasTruncated } = truncateLine(sanitized); +- if (wasTruncated) +- linesTruncated = true; +- outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`); +- } +- else { +- const block = await formatBlock(match.filePath, match.lineNumber); +- outputLines.push(...block); +- } +- } +- const rawOutput = outputLines.join("\n"); +- // Apply byte truncation. There is no line limit here because the match limit already capped rows. +- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); +- let output = truncation.content; +- const details = {}; +- // Build actionable notices for truncation and match limits. +- const notices = []; +- if (matchLimitReached) { +- notices.push(`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`); +- details.matchLimitReached = effectiveLimit; +- } +- if (truncation.truncated) { +- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); +- details.truncation = truncation; +- } +- if (linesTruncated) { +- notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`); +- details.linesTruncated = true; +- } +- if (notices.length > 0) +- output += `\n\n[${notices.join(". ")}]`; +- settle(() => resolve({ +- content: [{ type: "text", text: output }], +- details: Object.keys(details).length > 0 ? details : undefined, +- })); +- }); +- } +- catch (err) { +- settle(() => reject(err)); +- } +- })(); +- }); ++ async execute(_toolCallId, input, signal, _onUpdate, ctx) { ++ return executeGrep(ctx?.cwd || cwd, input, signal, (customOps ?? defaultGrepOperations).isDirectory); + }, + ...grepRenderers, + }; +diff --git a/dist/core/tools/search-process.js b/dist/core/tools/search-process.js +new file mode 100644 +index 0000000000000000000000000000000000000000..a8979704a0a1b9bf1052f3dd4396afb851743d3e +--- /dev/null ++++ b/dist/core/tools/search-process.js +@@ -0,0 +1,74 @@ ++import { spawn } from "node:child_process"; ++import { Buffer } from "node:buffer"; ++ ++const MAX_CAPTURE_BYTES = 8 * 1024 * 1024; ++const MAX_STDERR_BYTES = 16 * 1024; ++ ++export function runSearchProcess(executable, args, signal, separator, onRecord, successCodes = [0]) { ++ return new Promise((resolve, reject) => { ++ signal?.throwIfAborted(); ++ const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); ++ let failure; ++ let stopped = false; ++ let total = 0; ++ let stderr = Buffer.alloc(0); ++ let pending = []; ++ const stop = (error) => { ++ failure ??= error; ++ stopped = true; ++ child.kill("SIGKILL"); ++ }; ++ const abort = () => stop(new Error("Operation aborted", { cause: signal?.reason })); ++ const timer = setTimeout(() => stop(new Error("Search timed out; narrow the search path")), 30_000); ++ signal?.addEventListener("abort", abort, { once: true }); ++ if (signal?.aborted) abort(); ++ const emit = (tail) => { ++ const record = pending.length ? Buffer.concat([...pending, tail]) : tail; ++ pending = []; ++ if (record.length && onRecord(record) === false) stop(); ++ }; ++ child.stdout.on("data", (chunk) => { ++ if (stopped) return; ++ total += chunk.length; ++ if (total > MAX_CAPTURE_BYTES) { ++ stop(new Error("Search output limit exceeded; narrow the search path or pattern")); ++ return; ++ } ++ try { ++ let start = 0; ++ for (let end = chunk.indexOf(separator); end !== -1; end = chunk.indexOf(separator, start)) { ++ emit(chunk.subarray(start, end)); ++ if (stopped) return; ++ start = end + 1; ++ } ++ if (start < chunk.length) pending.push(chunk.subarray(start)); ++ } catch (error) { stop(error); } ++ }); ++ child.stderr.on("data", (chunk) => { ++ if (stopped) return; ++ if (stderr.length + chunk.length > MAX_STDERR_BYTES) { ++ stop(new Error("Search diagnostic output limit exceeded")); ++ return; ++ } ++ stderr = Buffer.concat([stderr, chunk]); ++ }); ++ child.once("error", (error) => { failure ??= error; }); ++ child.once("close", (code) => { ++ clearTimeout(timer); ++ signal?.removeEventListener("abort", abort); ++ if (!failure && !stopped && !successCodes.includes(code)) ++ failure = new Error(stderr.toString("utf8").trim() || `Search exited with code ${code}`); ++ if (!failure && !stopped && pending.length) { ++ try { emit(Buffer.alloc(0)); } catch (error) { failure = error; } ++ } ++ if (failure) reject(failure); ++ else resolve(); ++ }); ++ }); ++} ++ ++export function searchLimit(value, fallback, maximum) { ++ if (value === undefined) return fallback; ++ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Search limits must be non-negative integers"); ++ return Math.min(Math.max(1, value), maximum); ++} +diff --git a/dist/utils/shell.js b/dist/utils/shell.js +index 19605cc7dbd8b6752777e47c5c310c613b35c505..5777711e4ef72900066da6115b183fbac28c09b6 100644 +--- a/dist/utils/shell.js ++++ b/dist/utils/shell.js +@@ -1,5 +1,5 @@ + import { existsSync } from "node:fs"; +-import { delimiter, join } from "node:path"; ++import { delimiter, isAbsolute, join } from "node:path"; + import { spawn, spawnSync } from "child_process"; + import { getBinDir } from "../config.js"; + /** +@@ -106,6 +106,12 @@ export function getPowerShellConfig() { + if (process.platform !== "win32") { + throw new Error("The powershell tool is only available on Windows."); + } ++ const configuredShell = process.env.PI_POWERSHELL_PATH; ++ if (configuredShell !== undefined) { ++ if (!isAbsolute(configuredShell) || !existsSync(configuredShell)) ++ throw new Error("PI_POWERSHELL_PATH must point to an absolute PowerShell executable"); ++ return { shell: configuredShell, args: [...POWERSHELL_ARGS] }; ++ } + 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."); +diff --git a/dist/utils/tools-manager.js b/dist/utils/tools-manager.js +index 39b1f1ae3357925c68127b580340208c5d557ed9..f2747a351c5dfb83b9b25d93dffb78681e1f9022 100644 +--- a/dist/utils/tools-manager.js ++++ b/dist/utils/tools-manager.js +@@ -1,7 +1,7 @@ + import { spawnSync } from "child_process"; + import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; + import { arch, platform } from "os"; +-import { join } from "path"; ++import { isAbsolute, join } from "path"; + import { Readable } from "stream"; + import { pipeline } from "stream/promises"; + import { APP_NAME, getBinDir } from "../config.js"; +@@ -76,6 +76,16 @@ export function getToolPath(tool) { + const config = TOOLS[tool]; + if (!config) + return null; ++ const bundledDirectory = process.env.PI_TOOLS_DIR; ++ if (bundledDirectory !== undefined) { ++ if (!isAbsolute(bundledDirectory)) ++ throw new Error("PI_TOOLS_DIR must be an absolute directory"); ++ const bundledPath = join(bundledDirectory, config.binaryName + (platform() === "win32" ? ".exe" : "")); ++ if (!existsSync(bundledPath)) ++ throw new Error(`Bundled tool is missing: ${bundledPath}`); ++ return bundledPath; ++ } ++ + // Check our tools directory first + const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); + if (existsSync(localPath)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9c8731..d9232e8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,8 +197,8 @@ overrides: patchedDependencies: '@anthropic-ai/sandbox-runtime@0.0.77': b859621ca2a537d1865ef159feeccc66fb80b1470d4aef8748318d6cb84fdc50 - '@earendil-works/pi-ai@0.86.1': 5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb - '@earendil-works/pi-coding-agent@0.86.1': 3a6d09cf229dcb59d4200db8c6f7aecb4bb198c563b0381935fac35b8cfa9794 + '@earendil-works/pi-ai@0.87.1': 5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb + '@earendil-works/pi-coding-agent@0.87.1': aeb1d0976c9bee06023ddc36c53f8c95e67bb7572f94c11d1e37ab24e015b653 importers: @@ -411,11 +411,11 @@ importers: specifier: 0.0.77 version: 0.0.77(patch_hash=b859621ca2a537d1865ef159feeccc66fb80b1470d4aef8748318d6cb84fdc50) '@earendil-works/pi-ai': - specifier: ^0.86.1 - version: 0.86.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) + specifier: ^0.87.1 + version: 0.87.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': - specifier: ^0.86.1 - version: 0.86.1(patch_hash=3a6d09cf229dcb59d4200db8c6f7aecb4bb198c563b0381935fac35b8cfa9794)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) + specifier: ^0.87.1 + version: 0.87.1(patch_hash=aeb1d0976c9bee06023ddc36c53f8c95e67bb7572f94c11d1e37ab24e015b653)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) '@js-temporal/polyfill': specifier: ^0.5.1 version: 0.5.1 @@ -1431,34 +1431,34 @@ packages: oxlint: optional: true - '@earendil-works/chord@0.86.1': - resolution: {integrity: sha512-GzUr5n4tFBHUYxN9CjcRHK8QWo9tbxNrZu6iWPQ+PFiFrLASvSZOKeAVAgh3gHv/t0X5OvUpFlrMQ/nEFfCYpg==} + '@earendil-works/chord@0.87.1': + resolution: {integrity: sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-agent-core@0.86.1': - resolution: {integrity: sha512-8TbBzhYsDeu5V1Zl2NsyrBqJAzX1EiEL3Np3ZjGpy0pSDdGRVOpcyW1qruLqfWmEqGcnxmvgnTMLS/wJNZO2XQ==} + '@earendil-works/pi-agent-core@0.87.1': + resolution: {integrity: sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.86.1': - resolution: {integrity: sha512-1XHhI6D/fyQdsBieHC/E/4zGKVOoGe4yDyX67VXvzoYkFsX/qE7NpZE7E1RC8e6Bz8B9oG/P+MQFXikv2/BGEg==} + '@earendil-works/pi-ai@0.87.1': + resolution: {integrity: sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.86.1': - resolution: {integrity: sha512-vZBuNfJnruxZyemZ3O05V0S/Ylze08ahFTIQ1Mik++gVdOevPl89gt/Uv0U97BPAJaj9cj6Vf9rcIgKtUrd0BA==} + '@earendil-works/pi-coding-agent@0.87.1': + resolution: {integrity: sha512-m8ArJUtVcQMSe1lLE/Ei7vX/JV7O39sWmWBsXV2NOU70F0qCp8GubA24pT3LnwTmM6LL2xV80/h6sQg85n69ew==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-telemetry@0.86.1': - resolution: {integrity: sha512-SOcEqOS3oVGgKeahs2jHB906d8hFjuLP+RBee8xKYMRgw5KAeWHNg+YABfL0ALlp3Bt6tW4b632MLghc3vnTog==} + '@earendil-works/pi-telemetry@0.87.1': + resolution: {integrity: sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ==} engines: {node: '>=22.19.0'} '@earendil-works/pi-tui@0.78.0': resolution: {integrity: sha512-3a705FnsVVUhAyceShNB3kS2rpxcxLcx+hqB0u6MMMpHwQGbW+m++MqA6r7eOzq/8FLx5e3vDh38h/SVTk2qzw==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@0.86.1': - resolution: {integrity: sha512-FU/zU/zG4RWokcZt+BVXXcieWi5ggvYnWP2kkB5XXjMaHRoy5BDhcZJ9JAnLTN9MwrCRoXgPQxOI0bFqwYeZkQ==} + '@earendil-works/pi-tui@0.87.1': + resolution: {integrity: sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ==} engines: {node: '>=22.19.0'} '@electric-sql/pglite-socket@0.1.3': @@ -9996,15 +9996,15 @@ snapshots: optionalDependencies: eslint: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) - '@earendil-works/chord@0.86.1': + '@earendil-works/chord@0.87.1': dependencies: esbuild: 0.28.2 - '@earendil-works/pi-agent-core@0.86.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.87.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/chord': 0.86.1 - '@earendil-works/pi-ai': 0.86.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-telemetry': 0.86.1 + '@earendil-works/chord': 0.87.1 + '@earendil-works/pi-ai': 0.87.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-telemetry': 0.87.1 diff: 8.0.4 ignore: 7.0.8 typebox: 1.3.27 @@ -10018,11 +10018,11 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.86.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.87.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.124.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1127.0 - '@earendil-works/pi-telemetry': 0.86.1 + '@earendil-works/pi-telemetry': 0.87.1 '@google/genai': 2.21.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1) '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0(supports-color@8.1.1) @@ -10039,12 +10039,12 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.86.1(patch_hash=3a6d09cf229dcb59d4200db8c6f7aecb4bb198c563b0381935fac35b8cfa9794)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.87.1(patch_hash=aeb1d0976c9bee06023ddc36c53f8c95e67bb7572f94c11d1e37ab24e015b653)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/chord': 0.86.1 - '@earendil-works/pi-agent-core': 0.86.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.86.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.86.1 + '@earendil-works/chord': 0.87.1 + '@earendil-works/pi-agent-core': 0.87.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.87.1(patch_hash=5a203271dc6590ed6f6b52a3974fc7bf58ec5e17c7e19a543ec5474ae9c516eb)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@8.1.1)(zod@4.4.3))(supports-color@8.1.1)(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.87.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 6.0.0 cross-spawn: 7.0.6 @@ -10069,14 +10069,14 @@ snapshots: - ws - zod - '@earendil-works/pi-telemetry@0.86.1': {} + '@earendil-works/pi-telemetry@0.87.1': {} '@earendil-works/pi-tui@0.78.0': dependencies: get-east-asian-width: 1.6.0 marked: 15.0.12 - '@earendil-works/pi-tui@0.86.1': + '@earendil-works/pi-tui@0.87.1': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.11 @@ -11652,8 +11652,8 @@ snapshots: '@smithy/signature-v4@5.7.0': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@smithy/types@4.14.2': @@ -14509,7 +14509,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8a2adaaf..09425805 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,8 +40,8 @@ allowBuilds: vue-demi: true patchedDependencies: '@anthropic-ai/sandbox-runtime@0.0.77': patches/@anthropic-ai__sandbox-runtime@0.0.77.patch - '@earendil-works/pi-ai@0.86.1': patches/@earendil-works__pi-ai@0.86.1.patch - '@earendil-works/pi-coding-agent@0.86.1': patches/@earendil-works__pi-coding-agent@0.86.1.patch + '@earendil-works/pi-ai@0.87.1': patches/@earendil-works__pi-ai@0.87.1.patch + '@earendil-works/pi-coding-agent@0.87.1': patches/@earendil-works__pi-coding-agent@0.87.1.patch catalog: '@types/node': ^25.6.0 dotenv: ^17.4.2 @@ -52,9 +52,9 @@ catalog: typescript: ^6.0.3 zod: ^4.3.6 minimumReleaseAgeExclude: - - '@earendil-works/chord@0.86.1' - - '@earendil-works/pi-agent-core@0.86.1' - - '@earendil-works/pi-ai@0.86.1' - - '@earendil-works/pi-coding-agent@0.86.1' - - '@earendil-works/pi-telemetry@0.86.1' - - '@earendil-works/pi-tui@0.86.1' + - '@earendil-works/chord@0.87.1' + - '@earendil-works/pi-agent-core@0.87.1' + - '@earendil-works/pi-ai@0.87.1' + - '@earendil-works/pi-coding-agent@0.87.1' + - '@earendil-works/pi-telemetry@0.87.1' + - '@earendil-works/pi-tui@0.87.1' From 09a3bae61828f2e7a0c9a0c7c250fecbb2aaef2b Mon Sep 17 00:00:00 2001 From: QAyong Date: Sun, 27 Sep 2026 02:35:38 +0800 Subject: [PATCH 02/48] feat(desktop): configure close-to-tray behavior --- .../electron/main/__tests__/window.spec.ts | 25 ++++++++++++++++++ .../electron/main/app/DesktopApplication.ts | 2 ++ .../electron/main/app/DesktopWindowHost.ts | 4 +++ .../electron/main/config/LexoraConfigStore.ts | 4 +++ .../__tests__/LexoraConfigStore.spec.ts | 3 +++ apps/buddy/electron/main/window.ts | 11 ++++++-- apps/buddy/electron/shared/desktopApi.ts | 1 + .../electron/shared/desktopApiSchemas.ts | 1 + apps/buddy/src/i18n/locales/en-US/settings.ts | 3 +++ apps/buddy/src/i18n/locales/zh-CN/settings.ts | 3 +++ .../widgets/app/DesktopGeneralSettings.vue | 26 ++++++++++++++++++- 11 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/buddy/electron/main/__tests__/window.spec.ts b/apps/buddy/electron/main/__tests__/window.spec.ts index 41267f31..79bc8fe0 100644 --- a/apps/buddy/electron/main/__tests__/window.spec.ts +++ b/apps/buddy/electron/main/__tests__/window.spec.ts @@ -73,6 +73,29 @@ vi.mock('electron', () => ({ })) describe('createDesktopWindow lifecycle', () => { + it('requests application quit instead of hiding when tray minimization is disabled', () => { + const onCloseToQuit = vi.fn() + const onHidden = vi.fn() + createDesktopWindow({ + appName: 'Lexora Buddy Dev', + iconPath: '/tmp/icon.png', + isQuitting: () => false, + minimizeToTrayOnClose: () => false, + onCloseToQuit, + onHidden, + rendererUrl: null, + }) + const window = electron.FakeBrowserWindow.instances.at(-1)! + const event = { preventDefault: vi.fn() } + + window.emit('close', event) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(onCloseToQuit).toHaveBeenCalledOnce() + expect(onHidden).not.toHaveBeenCalled() + expect(window.hide).not.toHaveBeenCalled() + }) + it('hides to tray on ordinary close and lets a committed quit close the window', () => { let isQuitting = false const onHidden = vi.fn() @@ -80,6 +103,8 @@ describe('createDesktopWindow lifecycle', () => { appName: 'Lexora Buddy Dev', iconPath: '/tmp/icon.png', isQuitting: () => isQuitting, + minimizeToTrayOnClose: () => true, + onCloseToQuit: vi.fn(), onHidden, rendererUrl: null, }) diff --git a/apps/buddy/electron/main/app/DesktopApplication.ts b/apps/buddy/electron/main/app/DesktopApplication.ts index cde80375..cadc1ee2 100644 --- a/apps/buddy/electron/main/app/DesktopApplication.ts +++ b/apps/buddy/electron/main/app/DesktopApplication.ts @@ -115,6 +115,8 @@ class DesktopApplication { return this.#windows.initialize({ onWindowCreated: window => this.#browser.bindWindow(window), isQuitting: () => this.#quit.quitting, + minimizeToTrayOnClose: () => this.#runtime.config?.desktop.minimizeToTrayOnClose ?? true, + onCloseToQuit: () => this.#requestQuit(), onHidden: () => { void showBackgroundCloseNotice(this.#runtime.configStore) }, }) }, ['desktop.integrations']) diff --git a/apps/buddy/electron/main/app/DesktopWindowHost.ts b/apps/buddy/electron/main/app/DesktopWindowHost.ts index 3eca070f..0116c93a 100644 --- a/apps/buddy/electron/main/app/DesktopWindowHost.ts +++ b/apps/buddy/electron/main/app/DesktopWindowHost.ts @@ -12,6 +12,8 @@ import { observeRendererDiagnostics } from './desktopProcessDiagnostics' interface WindowBindings { isQuitting: () => boolean + minimizeToTrayOnClose: () => boolean + onCloseToQuit: () => void onHidden: () => void onWindowCreated: (window: BrowserWindow) => void } @@ -46,6 +48,8 @@ export class DesktopWindowHost { appName: environment.paths.appName, iconPath: environment.desktopIconPath, isQuitting: bindings.isQuitting, + minimizeToTrayOnClose: bindings.minimizeToTrayOnClose, + onCloseToQuit: bindings.onCloseToQuit, onHidden() { if (!handle.window.webContents.isDestroyed()) handle.window.webContents.send(DESKTOP_IPC_CHANNELS.appHidden) diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index ec2d44fa..d1627c52 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -44,6 +44,7 @@ const desktopConfigSchema = z.object({ }).passthrough().prefault({}), context_panel_mode: z.enum(['task', 'independent']).default('task'), context_panel_global: z.boolean().default(false), + minimize_to_tray_on_close: z.boolean().default(true), keybindings: keybindingsSchema.default({}), task_sidebar_pinned_items: z.array(taskSidebarPinnedItemSchema) .max(500) @@ -75,6 +76,7 @@ const desktopConfigSchema = z.object({ }, context_panel_mode: 'task', context_panel_global: false, + minimize_to_tray_on_close: true, keybindings: {}, task_sidebar_pinned_items: [], task_sidebar: { collapsed: false, collapsed_sections: [], collapsed_spaces: [] }, @@ -231,6 +233,7 @@ function decodeConfig(value: unknown): LexoraConfig { }, contextPanelMode: config.desktop.context_panel_mode, contextPanelGlobal: config.desktop.context_panel_global, + minimizeToTrayOnClose: config.desktop.minimize_to_tray_on_close, keybindings: config.desktop.keybindings, taskSidebarPinnedItems: config.desktop.task_sidebar_pinned_items, taskSidebar: { @@ -280,6 +283,7 @@ function encodeConfig(config: LexoraConfig) { }, context_panel_mode: config.desktop.contextPanelMode, context_panel_global: config.desktop.contextPanelGlobal, + minimize_to_tray_on_close: config.desktop.minimizeToTrayOnClose, keybindings: config.desktop.keybindings, task_sidebar_pinned_items: config.desktop.taskSidebarPinnedItems, task_sidebar: { diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index f1a53957..67ed64c2 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -142,6 +142,7 @@ describe('lexoraConfigStore', () => { const updated = await store.update({ desktop: { theme: 'dark', + minimizeToTrayOnClose: false, chat: { welcome: 'writing' }, }, pet: { alwaysOnTop: false, enabled: false, rememberPosition: false }, @@ -150,6 +151,7 @@ describe('lexoraConfigStore', () => { expect(updated.desktop).toEqual({ contextPanelMode: 'task', contextPanelGlobal: false, + minimizeToTrayOnClose: false, keybindings: {}, backgroundCloseNoticeShown: false, pluginAuthor: '', @@ -176,6 +178,7 @@ describe('lexoraConfigStore', () => { }) const content = await readFile(configPath, 'utf8') expect(content).toContain('[desktop]') + expect(content).toContain('minimize_to_tray_on_close = false') expect(content).toContain('[pet]') expect(content).toContain('always_on_top = false') expect(content).toContain('enabled = false') diff --git a/apps/buddy/electron/main/window.ts b/apps/buddy/electron/main/window.ts index b3c815ee..d861c28b 100644 --- a/apps/buddy/electron/main/window.ts +++ b/apps/buddy/electron/main/window.ts @@ -11,6 +11,8 @@ export interface CreateDesktopWindowOptions { appName: string iconPath: string isQuitting: () => boolean + minimizeToTrayOnClose: () => boolean + onCloseToQuit: () => void onHidden?: () => void onPlacementChanged?: (placement: DesktopWindowPlacement) => void placement?: DesktopWindowPlacement | null @@ -104,8 +106,13 @@ export function createDesktopWindow(options: CreateDesktopWindowOptions): Deskto return event.preventDefault() - window.hide() - options.onHidden?.() + if (options.minimizeToTrayOnClose()) { + window.hide() + options.onHidden?.() + } + else { + options.onCloseToQuit() + } }) window.once('ready-to-show', () => { diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index 1c9da186..bb9d6a80 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -159,6 +159,7 @@ export interface LexoraConfig { chat: DesktopChatPreferences contextPanelMode: DesktopContextPanelMode contextPanelGlobal: boolean + minimizeToTrayOnClose: boolean keybindings: Record taskSidebarPinnedItems: DesktopTaskPinnedItem[] taskSidebar: DesktopTaskSidebarPreferences diff --git a/apps/buddy/electron/shared/desktopApiSchemas.ts b/apps/buddy/electron/shared/desktopApiSchemas.ts index 14bb8be9..d184fcd7 100644 --- a/apps/buddy/electron/shared/desktopApiSchemas.ts +++ b/apps/buddy/electron/shared/desktopApiSchemas.ts @@ -69,6 +69,7 @@ export const lexoraConfigPatchSchema: z.ZodType = z.object({ }).strict().optional(), contextPanelMode: z.enum(['task', 'independent']).optional(), contextPanelGlobal: z.boolean().optional(), + minimizeToTrayOnClose: z.boolean().optional(), keybindings: keybindingsSchema.optional(), taskSidebarPinnedItems: taskSidebarPinnedItemsSchema.optional(), taskSidebar: taskSidebarPreferencesSchema.optional(), diff --git a/apps/buddy/src/i18n/locales/en-US/settings.ts b/apps/buddy/src/i18n/locales/en-US/settings.ts index 2cdbcff8..7bc39972 100644 --- a/apps/buddy/src/i18n/locales/en-US/settings.ts +++ b/apps/buddy/src/i18n/locales/en-US/settings.ts @@ -87,6 +87,9 @@ export default { 'desktop.browser.screenshotSaved': 'Screenshot saved', 'desktop.browser.screenshotCopied': 'Screenshot copied to clipboard', 'desktop.browser.screenshotFailed': 'Could not capture the screenshot. Retry or change the destination in browser settings.', + 'desktop.settings.windowBehavior': 'Window behavior', + 'desktop.settings.minimizeToTrayOnClose': 'Minimize to tray when closing the window', + 'desktop.settings.minimizeToTrayOnCloseDescription': 'When enabled, the window hides and Lexora Buddy keeps running in the background. When disabled, closing the window quits the app.', 'desktop.settings.contextPanel': 'Resource panel', 'desktop.settings.contextPanelMode': 'Browsing mode', 'desktop.settings.contextPanelModeDescription': 'Task-linked selects resources when focusing a task. Independent keeps the current selection.', diff --git a/apps/buddy/src/i18n/locales/zh-CN/settings.ts b/apps/buddy/src/i18n/locales/zh-CN/settings.ts index d2f3ac4b..6dd25bad 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/settings.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/settings.ts @@ -85,6 +85,9 @@ export default { 'desktop.browser.screenshotSaved': '截图已保存', 'desktop.browser.screenshotCopied': '截图已复制到剪贴板', 'desktop.browser.screenshotFailed': '截图失败,请重试或在浏览器设置中切换保存方式。', + 'desktop.settings.windowBehavior': '窗口行为', + 'desktop.settings.minimizeToTrayOnClose': '关闭窗口时最小化到托盘', + 'desktop.settings.minimizeToTrayOnCloseDescription': '开启后窗口会隐藏,Lexora Buddy 仍在后台运行;关闭后点击关闭按钮会退出应用。', 'desktop.settings.contextPanel': '资源面板', 'desktop.settings.contextPanelMode': '浏览模式', 'desktop.settings.contextPanelModeDescription': '任务联动在聚焦任务时选择其资源,独立浏览保留当前选择。', diff --git a/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue b/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue index 61bf611d..2436a797 100644 --- a/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue +++ b/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue @@ -5,13 +5,14 @@ import { NSelect, NSpin, NSwitch, useMessage } from 'naive-ui' import { computed, shallowRef, useId } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' -type GeneralSettingField = 'language' | 'contextPanelMode' | 'contextPanelGlobal' +type GeneralSettingField = 'language' | 'contextPanelMode' | 'contextPanelGlobal' | 'minimizeToTrayOnClose' const props = defineProps() const { languageOptions, t } = useBuddyI18n(() => props.language) const message = useMessage() const pendingFields = shallowRef>(new Set()) const globalPanelLabelId = useId() +const minimizeToTrayLabelId = useId() const contextPanelModes = computed(() => [ { label: t('desktop.settings.contextPanelTask'), value: 'task' }, { label: t('desktop.settings.contextPanelIndependent'), value: 'independent' }, @@ -52,6 +53,29 @@ async function updateSetting(field: GeneralSettingField, patch: LexoraConfigPatc +
+

+ {{ t('desktop.settings.windowBehavior') }} +

+
+
+
+ {{ t('desktop.settings.minimizeToTrayOnClose') }} + {{ t('desktop.settings.minimizeToTrayOnCloseDescription') }} +
+
+ +
+
+
+

{{ t('desktop.settings.contextPanel') }} From 54dedd4746018de6a4bbbe1ce1527086cf31ef4a Mon Sep 17 00:00:00 2001 From: QAyong Date: Sun, 27 Sep 2026 03:38:47 +0800 Subject: [PATCH 03/48] feat(buddy): move turn process disclosure to agent header --- .../transcript/BuddyChatAgentIdentity.vue | 5 +- .../widgets/transcript/BuddyChatAgentTurn.vue | 79 +++++++- .../transcript/BuddyChatAgentTurnFlow.vue | 107 ++++++++--- .../__tests__/BuddyChatActivities.spec.ts | 27 ++- .../specs/feature-001-turn-process-folding.md | 175 ++++++++++++++++++ 5 files changed, 361 insertions(+), 32 deletions(-) create mode 100644 docs/specs/feature-001-turn-process-folding.md diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue index 9338011c..f0a50cdc 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue +++ b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue @@ -4,6 +4,7 @@ import { useBuddyI18n } from '@/i18n/buddyI18n' import { BRAND_ASSET_URLS } from '@/shared/branding/brandAssets' const props = defineProps<{ + as?: 'header' | 'span' language: BuddyLocale }>() @@ -11,7 +12,7 @@ const { t } = useBuddyI18n(() => props.language) diff --git a/apps/buddy/src/modules/tasks/widgets/composer/DesktopChatComposer.vue b/apps/buddy/src/modules/tasks/widgets/composer/DesktopChatComposer.vue index ffccad64..abe79a68 100644 --- a/apps/buddy/src/modules/tasks/widgets/composer/DesktopChatComposer.vue +++ b/apps/buddy/src/modules/tasks/widgets/composer/DesktopChatComposer.vue @@ -26,6 +26,7 @@ import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import ChatQuoteStrip from '../quotes/ChatQuoteStrip.vue' import ChatComposerSourceMenu from './ChatComposerSourceMenu.vue' import ChatComposerSourcePicker from './ChatComposerSourcePicker.vue' +import ChatSessionReferenceStrip from './ChatSessionReferenceStrip.vue' import ComposerResourceStrip from './ComposerResourceStrip.vue' const props = defineProps() @@ -66,8 +67,10 @@ const { modelInputIssue, resourceStripResources, quotes, + sessionReferences, addQuote, removeQuote, + removeSessionReference, removeResource, selectPanelSource: selectPanelResource, selectSuggestion, @@ -180,6 +183,13 @@ function captureDraft(): WorkbenchMenuSelection {