diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/config.js b/node_modules/@earendil-works/pi-coding-agent/dist/config.js index 872dd55..edd61db 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/config.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/config.js @@ -399,9 +399,72 @@ catch (e) { const piConfigName = pkg.piConfig?.name; export const PACKAGE_NAME = pkg.name || "@earendil-works/pi-coding-agent"; export const APP_NAME = piConfigName || "pi"; -export const APP_TITLE = piConfigName ? APP_NAME : "π"; +// Privateer patch: this package's own package.json sets piConfig.configDir but not +// piConfig.name, so stock Pi's fallback here is the literal "π" glyph — which is what +// showed up as the OS terminal tab/window title (setTitle(`${APP_TITLE} - session - cwd`) +// in interactive-mode.js) on almost every terminal, even after privateer-brand's own +// setTitle("Privateer") ran, because later stock calls stomp it back to "π". +export const APP_TITLE = piConfigName ? APP_NAME : "⚓ Privateer"; export const CONFIG_DIR_NAME = pkg.piConfig?.configDir || ".pi"; export const VERSION = pkg.version || "0.0.0"; +// --- Privateer patch: dual project config dirs ------------------------------- +// +// Stock Pi resolves EVERY project-scoped path as `/CONFIG_DIR_NAME/...`, where +// CONFIG_DIR_NAME is a build-time constant read from this package's own package.json +// (`piConfig.configDir`, default ".pi"). PI_CODING_AGENT_DIR moves only the USER dir, +// which is why Privateer's global config lives in ~/.privateer/agent while every +// project still had to spell its config `.pi/` — a directory named after a tool the +// user never installed. +// +// We do not rename it (that would orphan every existing `.pi/` folder and break +// interop with stock Pi). Instead BOTH names are live, `.privateer` taking precedence: +// +// read — every project dir that exists is searched, `.privateer` first; on a +// conflict (same settings key, same skill/prompt/theme/extension name, +// SYSTEM.md) the `.privateer` copy wins and the `.pi` one is ignored. +// write — new project config is always created under `.privateer/`. Package +// payloads (npm/, git/) stay in whichever dir already holds them, so an +// existing `.pi/npm` tree is reused rather than re-downloaded. +// +// Precedence order, highest first. `.pi` is filtered out if a fork ever renames +// CONFIG_DIR_NAME to `.privateer`, so the list never holds duplicates. +export const PROJECT_CONFIG_DIR_NAMES = [".privateer", CONFIG_DIR_NAME].filter((name, i, all) => all.indexOf(name) === i); +/** Every candidate project config dir under cwd, highest precedence first (may not exist). */ +export function projectConfigDirCandidates(cwd) { + return PROJECT_CONFIG_DIR_NAMES.map((name) => join(cwd, name)); +} +/** The project config dirs that actually exist under cwd, highest precedence first. */ +export function projectConfigDirs(cwd) { + return projectConfigDirCandidates(cwd).filter((dir) => existsSync(dir)); +} +/** Where NEW project config is written: always `.privateer/`. */ +export function projectConfigWriteDir(cwd) { + return join(cwd, PROJECT_CONFIG_DIR_NAMES[0]); +} +/** + * The highest-precedence project dir that already contains `...rel`, or undefined. + * Use for "one file wins" lookups (settings.json, SYSTEM.md, an npm/ payload root). + */ +export function findProjectConfigPath(cwd, ...rel) { + for (const dir of projectConfigDirCandidates(cwd)) { + const candidate = join(dir, ...rel); + if (existsSync(candidate)) + return candidate; + } + return undefined; +} +/** + * Where a project-scoped `...rel` lives: the existing copy if there is one, else the + * path it would be created at under `.privateer/`. + */ +export function resolveProjectConfigPath(cwd, ...rel) { + return findProjectConfigPath(cwd, ...rel) ?? join(projectConfigWriteDir(cwd), ...rel); +} +/** Human-readable list of the project dir names, for prompts and help text. */ +export function formatProjectConfigDirNames() { + return PROJECT_CONFIG_DIR_NAMES.map((n) => `${n}/`).join(" or "); +} +// --- end Privateer patch ----------------------------------------------------- // e.g., PI_CODING_AGENT_DIR or TAU_CODING_AGENT_DIR export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`; export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`; diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js index 638646d..ba1da98 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js @@ -39,6 +39,129 @@ import { createLocalBashOperations } from "./tools/bash.js"; import { createAllToolDefinitions } from "./tools/index.js"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; import { addUsageToTotals, createUsageTotals } from "./usage-totals.js"; +// ============================================================================ +// Privateer patch: provider errors that aren't API responses +// +// An inference endpoint does not always answer as an API. Put a WAF, a proxy or a +// captive portal in front of one and a rejected request comes back as an HTML page, +// which the provider SDK folds whole into `error.message` — status first, body after. +// +// Measured incident: the account channel's edge WAF answered a turn with a 403 block +// page carrying three inline base64 web fonts, so `errorMessage` was 221 KB. Pi +// printed it into the terminal in full, appended it to the session file on every +// attempt (a 1.8 MB session), and ran isRetryableAssistantError over it — and a +// megabyte of base64 reliably contains "429", "500" and "502", so a permanent 403 +// looked transient and burned the whole retry budget before the user saw anything. +// +// compactProviderError squeezes such a page down to the line a person can act on +// (status, title, visible text — which is where a WAF puts its request id), and +// isHardHttpFailure lets the STATUS decide retryability instead of a substring of the +// body. Mirrors src/engine/errors.ts, which is where these are tested. +const PV_MAX_ERROR_CHARS = 2000; +const PV_MAX_PAGE_TEXT_CHARS = 600; +const PV_HTML_DOC = /]/i; +const PV_NON_PROSE = /<(script|style|svg|head|noscript)\b[\s\S]*?<\/\1\s*>/gi; +const PV_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#x27": "'" }; +function pvPlainText(html) { + return html + .replace(/<[^>]*>/g, " ") + .replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (m, code) => { + const key = code.toLowerCase(); + if (PV_ENTITIES[key] !== undefined) + return PV_ENTITIES[key]; + if (key.startsWith("#x")) + return String.fromCodePoint(parseInt(key.slice(2), 16) || 0) || m; + if (key.startsWith("#")) + return String.fromCodePoint(parseInt(key.slice(1), 10) || 0) || m; + return m; + }) + .replace(/\s+/g, " ") + .trim(); +} +export function compactProviderError(raw) { + const text = typeof raw === "string" ? raw : String(raw ?? ""); + if (!PV_HTML_DOC.test(text)) { + if (text.length <= PV_MAX_ERROR_CHARS) + return text; + return `${text.slice(0, PV_MAX_ERROR_CHARS)}… [dropped ${text.length - PV_MAX_ERROR_CHARS} chars]`; + } + const status = /^\s*(\d{3})\b/.exec(text)?.[1]; + const title = pvPlainText(/]*>([\s\S]*?)<\/title>/i.exec(text)?.[1] ?? ""); + const body = pvPlainText(text.replace(PV_NON_PROSE, " ")); + const visible = [title, body].filter(Boolean).join(" — ").slice(0, PV_MAX_PAGE_TEXT_CHARS); + return (`${status ?? "HTTP error"} — an HTML page, not an API response (something in front of ` + + `the provider answered: a WAF, a proxy, or a captive portal): ` + + `${visible || "(no readable text)"} [dropped ${text.length} chars of HTML]`); +} +// Client-error statuses that CAN clear on their own: a timeout, a lock conflict, an +// early-data replay, a throttle. Every other 4xx is the request itself being wrong. +const PV_TRANSIENT_CLIENT_STATUS = new Set([408, 409, 425, 429]); +export function isHardHttpFailure(text) { + const m = /^\s*(\d{3})\b/.exec(typeof text === "string" ? text : ""); + if (!m) + return false; + const status = Number(m[1]); + return status >= 400 && status < 500 && !PV_TRANSIENT_CLIENT_STATUS.has(status); +} +// Privateer patch: undici's idle-timeout errors. Mirrors isIdleTimeoutError in +// src/engine/errors.ts. The text is exact and stable — undici throws +// BodyTimeoutError / HeadersTimeoutError with message "Body/Headers Timeout Error" +// and code UND_ERR_BODY_TIMEOUT / UND_ERR_HEADERS_TIMEOUT — and it carries NO HTTP +// status. pi's retry regex therefore reads the word "timeout" and silently re-sends +// the whole turn across the full budget: three 5-minute stalls before the user sees +// anything at all. A stalled transport is a fact; a body substring is a guess. +export function isIdleTimeoutError(text) { + const s = typeof text === "string" ? text : ""; + return /(?:^|\b)(?:Body|Headers) Timeout Error\b/i.test(s) || /^UND_ERR_(?:BODY|HEADERS)_TIMEOUT$/.test(s.trim()); +} +// Privateer patch: throttle handling. Mirrors isThrottleFailure / retryAfterMs / +// retryDelayMs in src/engine/errors.ts — see the incident note there. +export function isThrottleFailure(text) { + return /^\s*429\b/.test(typeof text === "string" ? text : ""); +} +const PV_MAX_RETRY_DELAY_MS = 60_000; +const PV_RETRY_AFTER_PATTERNS = [ + /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i, + /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i, + /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i, +]; +export function retryAfterMs(text) { + const s = typeof text === "string" ? text : ""; + for (const re of PV_RETRY_AFTER_PATTERNS) { + const m = re.exec(s); + if (!m) + continue; + const value = Number.parseFloat(m[1]); + if (!Number.isFinite(value) || value <= 0) + continue; + const unit = (m[2] ?? "").toLowerCase(); + const ms = unit === "ms" || /retry-after-ms/i.test(m[0]) + ? value + : unit.startsWith("m") && unit !== "ms" + ? value * 60_000 + : value * 1000; + return Math.min(Math.max(Math.round(ms), 1_000), PV_MAX_RETRY_DELAY_MS); + } + return null; +} +// A server-stated delay wins outright — it is the only number here that is a fact. +// The jitter is the point of the rest: without it every parallel request that tripped +// the same limit wakes at the identical millisecond and trips it again. +// +// Privateer patch: named pvRetryDelayMs, not retryDelayMs — pi-ai now ships its OWN +// `retryDelayMs(policy, attempt)` (plain exponential backoff, no retry-after, no +// jitter), imported by this file below. A same-named top-level function here would be +// a SyntaxError (duplicate declaration) that takes the whole CLI down at launch, +// exactly the failure mode the MIN_ANSWER_TOKENS comment in the pi-ai patch warns +// about. Ours still fully supersedes pi-ai's at the one call site that mattered. +export function pvRetryDelayMs(errorText, attempt, baseDelayMs) { + const stated = retryAfterMs(errorText); + if (stated != null) + return stated; + const n = Math.max(1, Math.floor(attempt)); + const backoff = Math.min(baseDelayMs * 2 ** (n - 1), PV_MAX_RETRY_DELAY_MS); + return Math.round(backoff * (1 - Math.random() * 0.25)); +} /** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. @@ -101,6 +224,8 @@ export class AgentSession { // Retry state _retryAbortController = undefined; _retryAttempt = 0; + // Privateer patch: when the provider last exhausted the retry budget on a 429. + _pvThrottledAt = 0; // Bash execution state _bashAbortControllers = new Set(); _pendingBashMessages = []; @@ -192,6 +317,14 @@ export class AgentSession { } const isOAuth = this._modelRuntime.isUsingOAuth(model.provider); if (isOAuth) { + // Privateer patch: the account channel has no API key that could expire — + // the terminal is simply not signed in yet (a fresh install now boots on + // `privateer/*`, which is the model it will run once logged in). Stock Pi's + // wording describes a state that user was never in, and its "/login + // privateer" bypasses the branded sign-in. auth-guidance owns the words. + if (model.provider === "privateer") { + throw new Error(formatNoApiKeyFoundMessage(model.provider)); + } throw new Error(`Authentication failed for "${model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${model.provider}' to re-authenticate.`); @@ -401,6 +534,16 @@ export class AgentSession { } } } + // Privateer patch: squeeze a non-API error body BEFORE anything reads it. This is + // the one point upstream of all four consumers — extension handlers, UI listeners, + // session persistence, and the _lastAssistantMessage the retry classifier reads — + // so an HTML block page is shortened once, in place, rather than printed, stored + // and pattern-matched at full size. See compactProviderError above. + if (event.type === "message_end" && + event.message?.role === "assistant" && + typeof event.message.errorMessage === "string") { + event.message.errorMessage = compactProviderError(event.message.errorMessage); + } // Emit to extensions first await this._emitExtensionEvent(event); // Notify all listeners @@ -904,6 +1047,33 @@ export class AgentSession { finalError: msg.errorMessage, }); this._retryAttempt = 0; + // Privateer patch: remember that the endpoint is throttling us. The + // pre-prompt compaction in prompt() is an LLM call to this same endpoint, + // and firing it now just spends the summarizer budget on another 429 — + // which is what answered the user's next message with "Auto-compaction + // cancelled" instead of a reply. See the guard in prompt(). + if (isThrottleFailure(msg.errorMessage)) { + this._pvThrottledAt = Date.now(); + } + // Privateer patch: a transient error that survived the full retry budget + // is terminal. Ending the turn here (instead of falling through to + // compaction / queued-message continuation) prevents the agent loop from + // re-entering agent.continue(), hitting the identical error, and — because + // the retry counter was just reset to 0 — starting a fresh burst of 3. + // That re-entry is what produced the endless "retrying 1/3…2/3…3/3" loop + // on a persistently-failing provider/tool. Context-overflow errors never + // reach this branch (_isRetryableError → false), so compaction is untouched. + return false; + } + // Privateer patch: a first-attempt hard 4xx (a WAF 403, a 401, a 404) is also + // terminal. Stock Pi still falls through to compaction here because _retryAttempt + // is 0, and _checkCompaction will summarise a session that already has usage. + // That summary is another LLM call to the SAME blocked endpoint; retryAssistantCall + // classifies the raw HTML body as transient (it contains "500"/"502"), so the + // agent looks hung — retrying a compaction that can never succeed — until the + // summarizer budget burns. Context overflow never matches isHardHttpFailure. + if (msg.stopReason === "error" && isHardHttpFailure(msg.errorMessage)) { + return false; } if (await this._checkCompaction(msg)) { return !this._agentRunAbortRequested; @@ -991,6 +1161,14 @@ export class AgentSession { if (!hasConfiguredAuth) { const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider); if (isOAuth) { + // Privateer patch: the account channel has no API key that could expire — + // the terminal is simply not signed in yet (a fresh install now boots on + // `privateer/*`, which is the model it will run once logged in). Stock Pi's + // wording describes a state that user was never in, and its "/login + // privateer" bypasses the branded sign-in. auth-guidance owns the words. + if (this.model.provider === "privateer") { + throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); + } throw new Error(`Authentication failed for "${this.model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${this.model.provider}' to re-authenticate.`); @@ -1000,7 +1178,13 @@ export class AgentSession { // Check if we need to compact before sending (catches aborted responses). // The user's new prompt is sent below, so do not call agent.continue() here. const lastAssistant = this._findLastAssistantMessage(); - if (lastAssistant) { + // Privateer patch: skip this while the provider is still throttling us. + // Compaction here is another LLM call to the endpoint that just spent our + // whole retry budget on 429s; it cannot succeed, and its failure is reported + // as "Auto-compaction cancelled" — burying the user's actual prompt. One + // rate-limit window of patience, then we try again as normal. The context is + // not lost: the next turn re-checks compaction once the window has passed. + if (lastAssistant && Date.now() - this._pvThrottledAt >= PV_MAX_RETRY_DELAY_MS) { await this._checkCompaction(lastAssistant, false); } // Build messages array (custom message if any, then user message) @@ -2351,6 +2535,34 @@ export class AgentSession { // Context overflow is handled by compaction, not retry. if (isContextOverflow(message, this.model?.contextWindow ?? 0)) return false; + // Privateer patch: an HTTP status is a fact; a substring of the body is a guess. + // pi classifies by regex over the whole error text, so a 403 whose body merely + // CONTAINS "500" — a WAF block page, any proxy interstitial, anything with base64 + // in it — spent the full retry budget on a failure retrying could never clear. + // A 4xx other than 408/409/425/429 is the request being wrong, whatever its body + // says. Provider-agnostic on purpose: an intercepting proxy is not ours to detect. + // Mirrors isHardHttpFailure in src/engine/errors.ts. + if (isHardHttpFailure(message.errorMessage)) + return false; + // Privateer patch: a Privateer ACCOUNT CAP is not a throttle. Daily/monthly + // message or token limits (and an exhausted balance) come back from the account + // channel as a 429 carrying the backend's machine `code` and a ready-to-show + // message. pi classifies anything containing "429" as transient, so the agent + // spent its whole retry budget — with exponential backoff — on a limit that + // cannot clear, and only then showed the user the one message that says what to + // do (upgrade, top up, or /login keys for a BYO key). Scoped to the `privateer` + // provider and to the backend's own cap wording so no other provider's transient + // rate limit is affected. Mirrors isAccountCapCode in src/engine/errors.ts. + if (this.model?.provider === "privateer" && + /"code"\s*:\s*"[A-Z0-9_]*(?:CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP)[A-Z0-9_]*"|limit of [^.]*reached|upgrade or top ?up|usage limit reached/i.test(message.errorMessage ?? "")) + return false; + // Privateer patch: a stalled stream is a transport fact, not a body substring. + // undici's "Body/Headers Timeout Error" contains "timeout", so pi spent the + // whole retry budget re-sending a turn the transport had already given up on — + // a 5-minute stall became three, and only then did the user see a message. + // Mirrors isIdleTimeoutError in src/engine/errors.ts. + if (isIdleTimeoutError(message.errorMessage)) + return false; return isRetryableAssistantError(message); } /** @@ -2408,7 +2620,10 @@ export class AgentSession { this._retryAttempt--; return false; } - const delayMs = retryDelayMs(settings, this._retryAttempt); + // Privateer patch: honour a server-stated `retry-after` and jitter the fallback. + // Stock Pi's own retryDelayMs (imported above, from pi-ai) is a fixed 2s/4s/8s + // ladder with no jitter and no retry-after — see pvRetryDelayMs above. + const delayMs = pvRetryDelayMs(message.errorMessage, this._retryAttempt, settings.baseDelayMs); this._emit({ type: "auto_retry_start", attempt: this._retryAttempt, diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js index 197bccc..bc9ac3f 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js @@ -1,21 +1,33 @@ import { join } from "node:path"; import { getDocsPath } from "../config.js"; const UNKNOWN_PROVIDER = "unknown"; +// Privateer patch: speak Privateer, and stop printing absolute node_modules doc paths. +// +// Stock Pi answered every auth failure with four lines, two of them full paths into +// node_modules/@earendil-works/pi-coding-agent/docs/. On a terminal that isn't signed +// in, that wall repeats on every prompt and buries the one sentence that matters. It +// also told a Privateer user to go find a provider API key when their subscription +// already covers the model. Same information, one actionable line. export function getProviderLoginHelp() { - return [ - "Use /login to log into a provider via OAuth or API key. See:", - ` ${join(getDocsPath(), "providers.md")}`, - ` ${join(getDocsPath(), "models.md")}`, - ].join("\n"); + return "Run /login to connect your Privateer account, or /login keys to use your own provider API key."; } export function formatNoModelsAvailableMessage() { return `No models available. ${getProviderLoginHelp()}`; } export function formatNoModelSelectedMessage() { - return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`; + return `No model selected. ${getProviderLoginHelp()}\n\nThen use /models to select a model.`; } export function formatNoApiKeyFoundMessage(provider) { + // The account channel: there is no API key to find — you're just not signed in (or + // the session didn't arm). Naming the real problem is the whole fix here. + if (provider === "privateer") { + return "This terminal isn't signed in to Privateer, so it can't run your subscription models.\n\nRun /login — it takes one approval in the Privateer app, and no API key."; + } const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider; return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`; } +// Kept so the module's imports stay meaningful for anything that still wants the docs. +export function getProviderDocsPaths() { + return [join(getDocsPath(), "providers.md"), join(getDocsPath(), "models.md")]; +} //# sourceMappingURL=auth-guidance.js.map \ No newline at end of file diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js index be748bf..378f4c7 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.js"; +import { getAgentDir, isBunBinary, isBundledNode, projectConfigDirCandidates } from "../../config.js"; import { resolvePath } from "../../utils/paths.js"; import { createEventBus } from "../event-bus.js"; import { execCommand } from "../exec.js"; @@ -401,12 +401,15 @@ async function loadExtensionModule(extensionPath, cacheToken) { const createJitiImpl = await getCreateJiti(); // Compiled binaries and the bundled Node distribution use embedded modules. // Source TypeScript reuses host modules and root tsconfig paths. Unbundled - // Node builds use dist aliases and do not need the bundled virtual modules. + // Node builds use dist aliases. + // Privateer: reuse the host's Pi/Typebox namespaces in Node builds too. + // Otherwise each extension can reload that graph through Jiti. Aliases + // remain a fallback for non-virtual imports; extension reload is unchanged. const resolutionOptions = usesEmbeddedModules ? { virtualModules: await getVirtualModules(), tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: await getVirtualModules(), tsconfigPaths: true } - : { alias: getAliases() }; + : { virtualModules: await getVirtualModules(), alias: getAliases() }; const jiti = createJitiImpl(import.meta.url, { moduleCache: false, ...resolutionOptions, @@ -607,9 +610,23 @@ export async function discoverAndLoadExtensions(configuredPaths, cwd, agentDir = } } }; - // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ - const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions"); - addPaths(discoverExtensionsInDir(localExtDir)); + // 1. Project-local extensions: cwd/.privateer/extensions/, then cwd/.pi/extensions/ + // + // Privateer patch: both project dirs are discovered, `.privateer` first. Extensions + // are files, so a same-named file in the lower-precedence dir would otherwise load + // as a SECOND copy of the same extension — skip it by basename, which is what makes + // `.privateer` genuinely supersede `.pi` here rather than merely precede it. + const seenLocalNames = new Set(); + for (const projectDir of projectConfigDirCandidates(resolvedCwd)) { + const discovered = discoverExtensionsInDir(path.join(projectDir, "extensions")); + addPaths(discovered.filter((p) => { + const name = path.basename(p); + if (seenLocalNames.has(name)) + return false; + seenLocalNames.add(name); + return true; + })); + } // 2. Global extensions: agentDir/extensions/ const globalExtDir = path.join(resolvedAgentDir, "extensions"); addPaths(discoverExtensionsInDir(globalExtDir)); diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/package-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/package-manager.js index 696e98c..7585930 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/package-manager.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/package-manager.js @@ -24,7 +24,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path"; import ignore from "ignore"; import { minimatch } from "minimatch"; import { gt, maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; -import { CONFIG_DIR_NAME } from "../config.js"; +import { projectConfigDirs, projectConfigWriteDir, resolveProjectConfigPath } from "../config.js"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.js"; import { parseGitUrl } from "../utils/git.js"; import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.js"; @@ -711,23 +711,29 @@ export class DefaultPackageManager { const packageSources = this.dedupePackages(allPackages); await this.resolvePackageSources(packageSources, accumulator, onMissing); const globalBaseDir = this.agentDir; - const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME); + // Privateer patch: relative entries in project settings are resolved against + // every project config dir, `.privateer` first. Paths that don't exist under a + // given dir are skipped silently (collectFilesFromPaths), so a `.pi`-relative + // entry still resolves when `.privateer` exists but doesn't contain it. + const projectBaseDirs = this.projectBaseDirs(); for (const resourceType of RESOURCE_TYPES) { const target = this.getTargetMap(accumulator, resourceType); const globalEntries = (globalSettings[resourceType] ?? []); const projectEntries = (projectSettings[resourceType] ?? []); - this.resolveLocalEntries(projectEntries, resourceType, target, { - source: "local", - scope: "project", - origin: "top-level", - }, projectBaseDir); + for (const projectBaseDir of projectBaseDirs) { + this.resolveLocalEntries(projectEntries, resourceType, target, { + source: "local", + scope: "project", + origin: "top-level", + }, projectBaseDir); + } this.resolveLocalEntries(globalEntries, resourceType, target, { source: "local", scope: "user", origin: "top-level", }, globalBaseDir); } - this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); + this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDirs); return this.toResolvedPaths(accumulator); } async resolveExtensionSources(sources, options) { @@ -1681,13 +1687,30 @@ export class DefaultPackageManager { writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8"); } } + // --- Privateer patch: project config dirs ------------------------------- + /** + * Every project config dir that exists under cwd, `.privateer` before `.pi`. + * Falls back to the write dir so callers always have a base to resolve against. + */ + projectBaseDirs() { + const dirs = projectConfigDirs(this.cwd); + return dirs.length > 0 ? dirs : [projectConfigWriteDir(this.cwd)]; + } + /** An existing project-scoped payload path, else the path to create under `.privateer`. */ + projectPayloadPath(...rel) { + return resolveProjectConfigPath(this.cwd, ...rel); + } + // --- end Privateer patch ------------------------------------------------- getNpmInstallRoot(scope, temporary) { if (temporary) { return this.getTemporaryDir("npm"); } if (scope === "project") { this.assertProjectTrustedForScope(scope); - return join(this.cwd, CONFIG_DIR_NAME, "npm"); + // Privateer patch: reuse an existing `.pi/npm` tree rather than + // re-downloading everything under `.privateer/npm`; only a project with no + // payload tree at all gets one created under `.privateer`. + return this.projectPayloadPath("npm"); } return join(this.agentDir, "npm"); } @@ -1726,7 +1749,9 @@ export class DefaultPackageManager { } if (scope === "project") { this.assertProjectTrustedForScope(scope); - return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); + // Privateer patch: an already-installed package is found in whichever + // project dir holds it; a new one lands in the payload root above. + return this.projectPayloadPath("npm", "node_modules", source.name); } return join(this.agentDir, "npm", "node_modules", source.name); } @@ -1762,7 +1787,7 @@ export class DefaultPackageManager { } if (scope === "project") { this.assertProjectTrustedForScope(scope); - return join(this.cwd, CONFIG_DIR_NAME, "git"); + return this.projectPayloadPath("git"); } return join(this.agentDir, "git"); } @@ -1785,7 +1810,7 @@ export class DefaultPackageManager { getBaseDirForScope(scope) { if (scope === "project") { this.assertProjectTrustedForScope(scope); - return join(this.cwd, CONFIG_DIR_NAME); + return this.projectBaseDirs()[0]; } if (scope === "user") { return this.agentDir; @@ -1796,7 +1821,30 @@ export class DefaultPackageManager { return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true }); } resolvePathFromBase(input, baseDir) { - return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); + const resolved = resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); + // Privateer patch: a relative entry lives relative to the settings file that + // declared it, but the merged project scope has only ONE base — `.privateer` + // once it exists. Without this fallback a `./foo` package or skill declared in + // `.pi/settings.json` resolves to a non-existent `.privateer/foo` and silently + // disappears. Try the other project config dirs and take the first real hit; + // absolute entries and genuine typos are unaffected (both fall through). + if (existsSync(resolved)) { + return resolved; + } + const projectDirs = this.projectBaseDirs(); + if (!projectDirs.some((dir) => resolve(dir) === resolve(baseDir))) { + return resolved; + } + for (const dir of projectDirs) { + if (resolve(dir) === resolve(baseDir)) { + continue; + } + const alternate = resolvePath(input, dir, { homeDir: getHomeDir(), trim: true }); + if (existsSync(alternate)) { + return alternate; + } + } + return resolved; } collectPackageResources(packageRoot, accumulator, filter, metadata) { if (filter) { @@ -1936,19 +1984,24 @@ export class DefaultPackageManager { this.addResource(target, f, metadata, enabledPaths.has(f)); } } - addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir) { + // Privateer patch: `projectBaseDirs` is every project config dir that exists, + // highest precedence first (`.privateer` before `.pi`). Auto-discovery runs once + // per dir; same-named resources collide downstream where the first one wins, so + // `.privateer` supersedes `.pi`. + addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDirs) { const userMetadata = { source: "auto", scope: "user", origin: "top-level", baseDir: globalBaseDir, }; - const projectMetadata = { + const projectMetadataFor = (baseDir) => ({ source: "auto", scope: "project", origin: "top-level", - baseDir: projectBaseDir, - }; + baseDir, + }); + const projectMetadata = projectMetadataFor(projectBaseDirs[0]); const userOverrides = { extensions: (globalSettings.extensions ?? []), skills: (globalSettings.skills ?? []), @@ -1967,12 +2020,12 @@ export class DefaultPackageManager { prompts: join(globalBaseDir, "prompts"), themes: join(globalBaseDir, "themes"), }; - const projectDirs = { - extensions: join(projectBaseDir, "extensions"), - skills: join(projectBaseDir, "skills"), - prompts: join(projectBaseDir, "prompts"), - themes: join(projectBaseDir, "themes"), - }; + const projectDirsFor = (baseDir) => ({ + extensions: join(baseDir, "extensions"), + skills: join(baseDir, "skills"), + prompts: join(baseDir, "prompts"), + themes: join(baseDir, "themes"), + }); const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); const projectTrusted = this.settingsManager.isProjectTrusted(); const projectAgentsSkillDirs = projectTrusted @@ -1986,10 +2039,13 @@ export class DefaultPackageManager { } }; if (projectTrusted) { - // Project extensions from .pi/ - addResources("extensions", collectAutoExtensionEntries(projectDirs.extensions), projectMetadata, projectOverrides.extensions, projectBaseDir); - // Project skills from .pi/ - addResources("skills", collectAutoSkillEntries(projectDirs.skills, "pi"), projectMetadata, projectOverrides.skills, projectBaseDir); + for (const projectBaseDir of projectBaseDirs) { + const projectDirs = projectDirsFor(projectBaseDir); + // Project extensions from .privateer/ then .pi/ + addResources("extensions", collectAutoExtensionEntries(projectDirs.extensions), projectMetadataFor(projectBaseDir), projectOverrides.extensions, projectBaseDir); + // Project skills from .privateer/ then .pi/ + addResources("skills", collectAutoSkillEntries(projectDirs.skills, "pi"), projectMetadataFor(projectBaseDir), projectOverrides.skills, projectBaseDir); + } } // Project skills from .agents/ (each with its own baseDir) for (const agentsSkillsDir of projectAgentsSkillDirs) { @@ -2001,8 +2057,11 @@ export class DefaultPackageManager { addResources("skills", collectAutoSkillEntries(agentsSkillsDir, "agents"), agentsMetadata, projectOverrides.skills, agentsBaseDir); } if (projectTrusted) { - addResources("prompts", collectAutoPromptEntries(projectDirs.prompts), projectMetadata, projectOverrides.prompts, projectBaseDir); - addResources("themes", collectAutoThemeEntries(projectDirs.themes), projectMetadata, projectOverrides.themes, projectBaseDir); + for (const projectBaseDir of projectBaseDirs) { + const projectDirs = projectDirsFor(projectBaseDir); + addResources("prompts", collectAutoPromptEntries(projectDirs.prompts), projectMetadataFor(projectBaseDir), projectOverrides.prompts, projectBaseDir); + addResources("themes", collectAutoThemeEntries(projectDirs.themes), projectMetadataFor(projectBaseDir), projectOverrides.themes, projectBaseDir); + } } // User extensions from ~/.pi/agent/ addResources("extensions", collectAutoExtensionEntries(userDirs.extensions), userMetadata, userOverrides.extensions, globalBaseDir); diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/project-trust.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/project-trust.js index 2f85768..328b27b 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/project-trust.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/project-trust.js @@ -1,8 +1,8 @@ -import { APP_NAME, CONFIG_DIR_NAME } from "../config.js"; +import { APP_NAME, formatProjectConfigDirNames } from "../config.js"; import { emitProjectTrustEvent } from "./extensions/runner.js"; import { getProjectTrustOptions, hasTrustRequiringProjectResources, } from "./trust-manager.js"; function formatProjectTrustPrompt(cwd) { - return `Trust project folder?\n${cwd}\n\nThis allows ${APP_NAME} to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; + return `Trust project folder?\n${cwd}\n\nThis allows ${APP_NAME} to load ${formatProjectConfigDirNames()} settings and resources, install missing project packages, and execute project extensions.`; } async function selectProjectTrustOption(cwd, ctx) { const options = getProjectTrustOptions(cwd, { includeSessionOnly: true }); diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/prompt-templates.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/prompt-templates.js index 3700c08..79cb85e 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/prompt-templates.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/prompt-templates.js @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { basename, dirname, join, resolve, sep } from "path"; -import { CONFIG_DIR_NAME } from "../config.js"; +import { projectConfigDirCandidates } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; import { resolvePath } from "../utils/paths.js"; import { createSyntheticSourceInfo } from "./source-info.js"; @@ -157,7 +157,8 @@ export function loadPromptTemplates(options) { const includeDefaults = options.includeDefaults; const templates = []; const globalPromptsDir = join(resolvedAgentDir, "prompts"); - const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + // Privateer patch: `.privateer/prompts` then `.pi/prompts`. + const projectPromptsDirs = projectConfigDirCandidates(resolvedCwd).map((dir) => resolve(dir, "prompts")); const isUnderPath = (target, root) => { const normalizedRoot = resolve(root); if (target === normalizedRoot) { @@ -174,7 +175,8 @@ export function loadPromptTemplates(options) { baseDir: globalPromptsDir, }); } - if (isUnderPath(resolvedPath, projectPromptsDir)) { + const projectPromptsDir = projectPromptsDirs.find((dir) => isUnderPath(resolvedPath, dir)); + if (projectPromptsDir) { return createSyntheticSourceInfo(resolvedPath, { source: "local", scope: "project", @@ -188,7 +190,9 @@ export function loadPromptTemplates(options) { }; if (includeDefaults) { templates.push(...loadTemplatesFromDir(globalPromptsDir, getSourceInfo)); - templates.push(...loadTemplatesFromDir(projectPromptsDir, getSourceInfo)); + for (const projectPromptsDir of projectPromptsDirs) { + templates.push(...loadTemplatesFromDir(projectPromptsDir, getSourceInfo)); + } } // 3. Load explicit prompt paths for (const rawPath of promptPaths) { diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js index c5ed4d7..d9f50ae 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; import chalk from "chalk"; -import { CONFIG_DIR_NAME } from "../config.js"; +import { findProjectConfigPath, projectConfigDirCandidates, projectConfigDirs } from "../config.js"; import { loadThemeFromPath } from "../modes/interactive/theme/theme.js"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.js"; import { stripBom } from "../utils/text.js"; @@ -625,12 +625,14 @@ export class DefaultResourceLoader { join(this.agentDir, "themes"), join(this.agentDir, "extensions"), ]; - const projectRoots = [ - join(this.cwd, CONFIG_DIR_NAME, "skills"), - join(this.cwd, CONFIG_DIR_NAME, "prompts"), - join(this.cwd, CONFIG_DIR_NAME, "themes"), - join(this.cwd, CONFIG_DIR_NAME, "extensions"), - ]; + // Privateer patch: attribute paths under EITHER project config dir to the + // project scope, so a `.privateer/skills/…` file isn't reported as temporary. + const projectRoots = projectConfigDirCandidates(this.cwd).flatMap((dir) => [ + join(dir, "skills"), + join(dir, "prompts"), + join(dir, "themes"), + join(dir, "extensions"), + ]); for (const root of agentRoots) { if (this.isUnderPath(normalizedPath, root)) { return { path: filePath, source: "local", scope: "user", origin: "top-level", baseDir: root }; @@ -669,7 +671,11 @@ export class DefaultResourceLoader { const themes = []; const diagnostics = []; if (includeDefaults) { - const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")]; + // Privateer patch: `.privateer/themes` first, then `.pi/themes`. + const defaultDirs = [ + join(this.agentDir, "themes"), + ...projectConfigDirs(this.cwd).map((dir) => join(dir, "themes")), + ]; for (const dir of defaultDirs) { this.loadThemesFromDir(dir, themes, diagnostics); } @@ -807,8 +813,9 @@ export class DefaultResourceLoader { return { themes: Array.from(seen.values()), diagnostics }; } discoverSystemPromptFile() { - const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); - if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { + // Privateer patch: `.privateer/SYSTEM.md` wins over `.pi/SYSTEM.md`. + const projectPath = findProjectConfigPath(this.cwd, "SYSTEM.md"); + if (this.settingsManager.isProjectTrusted() && projectPath) { return projectPath; } const globalPath = join(this.agentDir, "SYSTEM.md"); @@ -818,8 +825,9 @@ export class DefaultResourceLoader { return undefined; } discoverAppendSystemPromptFile() { - const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); - if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { + // Privateer patch: `.privateer/APPEND_SYSTEM.md` wins over `.pi/`. + const projectPath = findProjectConfigPath(this.cwd, "APPEND_SYSTEM.md"); + if (this.settingsManager.isProjectTrusted() && projectPath) { return projectPath; } const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js index ed9d8d2..34d344c 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js @@ -3,7 +3,7 @@ import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; -import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { getAgentDir, projectConfigDirCandidates, projectConfigWriteDir } from "../config.js"; import { normalizePath, resolvePath } from "../utils/paths.js"; import { stripBom } from "../utils/text.js"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.js"; @@ -52,14 +52,112 @@ function toSettingsError(scope, error, path) { error: error instanceof Error ? error : new Error(String(error)), }; } +/** + * Privateer patch: drop the keys of `settings` that are byte-identical to `base`. + * + * Project settings are read as `.pi/settings.json` ⊕ `.privateer/settings.json`, but + * written back to `.privateer` alone. Without this reduction every save would copy the + * whole inherited `.pi` file into `.privateer` — two files claiming to own the same + * values, silently diverging the first time one is edited. Reducing keeps `.privateer` + * holding only what it actually overrides. + * + * Known limit (shared with stock Pi's global→project layering): a key can be + * overridden but not un-set — removing it from `.privateer` lets `.pi`'s value show + * through again. + */ +function subtractSettings(settings, base) { + const result = {}; + for (const key of Object.keys(settings)) { + const value = settings[key]; + const baseValue = base[key]; + if (value === undefined) + continue; + if (JSON.stringify(value) === JSON.stringify(baseValue)) + continue; + if (typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue)) { + const nested = subtractSettings(value, baseValue); + if (Object.keys(nested).length > 0) { + result[key] = nested; + } + continue; + } + result[key] = value; + } + return result; +} export class FileSettingsStorage { globalSettingsPath; projectSettingsPath; + // Privateer patch: lower-precedence project settings files (`.pi/settings.json`), + // highest precedence first. Read and merged underneath projectSettingsPath, never + // written to. + projectBaseSettingsPaths; constructor(cwd, agentDir) { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); this.globalSettingsPath = join(resolvedAgentDir, "settings.json"); - this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"); + // Privateer patch: writes always land in `.privateer/settings.json`. + this.projectSettingsPath = join(projectConfigWriteDir(resolvedCwd), "settings.json"); + this.projectBaseSettingsPaths = projectConfigDirCandidates(resolvedCwd) + .map((dir) => join(dir, "settings.json")) + .filter((path) => path !== this.projectSettingsPath); + } + /** + * Privateer patch: the project scope is the MERGE of every project settings file + * that exists — `.pi/settings.json` first, `.privateer/settings.json` layered on + * top — presented to callers as one document so the rest of Pi is unchanged. + * Writes are reduced against the inherited base and land in `.privateer` only. + */ + withProjectLock(fn) { + const path = this.projectSettingsPath; + let base = {}; + let sawBase = false; + // Lowest precedence first, so higher-precedence files overwrite them. + for (const basePath of [...this.projectBaseSettingsPaths].reverse()) { + if (!existsSync(basePath)) + continue; + const raw = readFileSync(basePath, "utf-8"); + if (!raw) + continue; + // A malformed `.pi/settings.json` throws here exactly as it would in stock + // Pi, and is reported by the caller as a project settings load error. + base = deepMergeSettings(base, JSON.parse(raw)); + sawBase = true; + } + const dir = dirname(path); + let release; + try { + const fileExists = existsSync(path); + if (fileExists) { + release = this.acquireLockSyncWithRetry(path); + } + const ownRaw = fileExists ? readFileSync(path, "utf-8") : undefined; + const merged = ownRaw ? deepMergeSettings(base, JSON.parse(ownRaw)) : base; + // `undefined` means "no project settings at all" — the signal stock callers + // use to fall back to an empty document. + const current = ownRaw === undefined && !sawBase ? undefined : JSON.stringify(merged, null, 2); + const next = fn(current); + if (next !== undefined) { + const reduced = subtractSettings(JSON.parse(next), base); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!release) { + release = this.acquireLockSyncWithRetry(path); + } + writeFileSync(path, JSON.stringify(reduced, null, 2), "utf-8"); + } + } + finally { + if (release) { + release(); + } + } } acquireLockSyncWithRetry(path) { const maxAttempts = 10; @@ -86,7 +184,13 @@ export class FileSettingsStorage { throw lastError ?? new Error("Failed to acquire settings lock"); } withLock(scope, fn) { - const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath; + // Privateer patch: the project scope spans two directories, so it needs its own + // read-merge / write-reduce path. + if (scope !== "global") { + this.withProjectLock(fn); + return; + } + const path = this.globalSettingsPath; const dir = dirname(path); let release; try { @@ -164,7 +268,7 @@ export class SettingsManager { const storage = new FileSettingsStorage(resolvedCwd, resolvedAgentDir); return SettingsManager.fromStorageWithPaths(storage, options, { global: join(resolvedAgentDir, "settings.json"), - project: join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"), + project: join(projectConfigWriteDir(resolvedCwd), "settings.json"), }); } /** Create a SettingsManager from an arbitrary storage backend */ @@ -646,7 +750,15 @@ export class SettingsManager { getProviderRetrySettings() { return { timeoutMs: this.settings.retry?.provider?.timeoutMs, - maxRetries: this.settings.retry?.provider?.maxRetries, + // Privateer patch: give the provider-level retry a real default. pi-ai's + // retryProviderRequest is the only layer that reads `retry-after` and jitters, + // but it takes `options.maxRetries ?? 0` and stock Pi hands it `undefined` — so + // it ran ZERO retries and every throttle fell through to the session-level loop + // (3 attempts, fixed 2s/4s/8s, no retry-after), which cannot outlast a 60s rate + // limit window. A bare `429` from the account edge exhausted the budget in ~14s. + // 2 here is deliberately small: it is a *per-attempt* budget that nests inside + // the session retry, and each of its waits already honours the server's number. + maxRetries: this.settings.retry?.provider?.maxRetries ?? 2, maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000, }; } diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js index d101003..540b972 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import ignore from "ignore"; import { basename, dirname, join, relative, resolve, sep } from "path"; -import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { getAgentDir, projectConfigDirCandidates } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; import { canonicalizePath, resolvePath } from "../utils/paths.js"; import { createSyntheticSourceInfo } from "./source-info.js"; @@ -348,10 +348,15 @@ export function loadSkills(options) { } if (includeDefaults) { addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); - addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true)); + // Privateer patch: `.privateer/skills` is added before `.pi/skills`, and the + // first skill to claim a name wins — so `.privateer` supersedes on a collision + // (the loser is reported as a normal collision diagnostic). + for (const projectDir of projectConfigDirCandidates(resolvedCwd)) { + addSkills(loadSkillsFromDirInternal(resolve(projectDir, "skills"), "project", true)); + } } const userSkillsDir = join(resolvedAgentDir, "skills"); - const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"); + const projectSkillsDirs = projectConfigDirCandidates(resolvedCwd).map((dir) => resolve(dir, "skills")); const isUnderPath = (target, root) => { const normalizedRoot = resolve(root); if (target === normalizedRoot) { @@ -364,7 +369,7 @@ export function loadSkills(options) { if (!includeDefaults) { if (isUnderPath(resolvedPath, userSkillsDir)) return "user"; - if (isUnderPath(resolvedPath, projectSkillsDir)) + if (projectSkillsDirs.some((dir) => isUnderPath(resolvedPath, dir))) return "project"; } return "path"; diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js index 7241668..7b79305 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js @@ -10,6 +10,30 @@ function defaultTempFilePath(prefix) { function byteLength(text) { return Buffer.byteLength(text, "utf-8"); } +// --- Privateer patch: per-line cap on the DISPLAY tail -------------------------- +// +// Stock Pi bounds tool output two ways — 2000 lines and 50KB — but never bounds a +// single LINE. One minified line is enough to spend the whole budget: a bare +// `grep -rn .` that wanders into node_modules matches inside +// typescript.js or an Expo web bundle, and those "lines" are megabytes wide. The +// observed shape is a 331MB capture whose entire visible output was 390 lines, +// of which the model saw one — the tail of a single line of minified JS. The +// context window pays 50KB for nothing, and the truncation notice reads as noise +// because nothing about it says "your grep hit a bundle". +// +// So cap what goes into the display tail at one line's worth of characters and +// mark the elision. Accounting (totalDecodedBytes, totalLines, currentLineBytes) +// still measures the REAL stream, so "Showing lines X-Y of Z" stays honest, and +// the temp file still receives every raw byte — `Full output:` means what it +// says. Only the bytes we hand the model change: 25+ real lines instead of one +// blob. +// +// 2000 rather than grep's 500 (GREP_MAX_LINE_LENGTH): a 2000-char line is already +// ~25 terminal rows, so ordinary output — stack traces, long JSON, compiler +// diagnostics — passes through untouched, while the pathological case is capped +// three orders of magnitude below where it hurts. +const DEFAULT_MAX_LINE_CHARS = 2000; +const LINE_ELISION = "... [line truncated]"; /** * Incrementally tracks streaming output with bounded memory. * @@ -32,6 +56,11 @@ export class OutputAccumulator { completedLines = 0; totalLines = 0; currentLineBytes = 0; + // Privateer patch: per-line display cap, carried across chunk boundaries + // because a long line rarely arrives in one read(). + maxLineChars; + lineCharsEmitted = 0; + lineElided = false; hasOpenLine = false; finished = false; tempFilePath; @@ -40,6 +69,8 @@ export class OutputAccumulator { this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES; this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; this.maxRollingBytes = Math.max(this.maxBytes * 2, 1); + // Privateer patch: 0 or less disables the cap. + this.maxLineChars = options.maxLineChars ?? DEFAULT_MAX_LINE_CHARS; this.tempFilePrefix = options.tempFilePrefix ?? "pi-output"; } append(data) { @@ -122,8 +153,11 @@ export class OutputAccumulator { } const bytes = byteLength(text); this.totalDecodedBytes += bytes; - this.tailText += text; - this.tailBytes += bytes; + // Privateer patch: the tail is what the model and the TUI see, so it is the + // only thing capped. Every counter below still sees the full `text`. + const display = this.capLongLines(text); + this.tailText += display; + this.tailBytes += byteLength(display); if (this.tailBytes > this.maxRollingBytes * 2) { this.trimTail(); } @@ -145,6 +179,42 @@ export class OutputAccumulator { } this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); } + /** + * Privateer patch: emit at most `maxLineChars` characters per line, followed by + * one elision marker, dropping the rest of that line until its newline. + * + * Streaming, so the per-line counters live on the instance: a 5MB line arrives + * as hundreds of chunks and every one of them must know the line is already + * spent. + */ + capLongLines(text) { + if (this.maxLineChars <= 0) { + return text; + } + let out = ""; + let i = 0; + while (i < text.length) { + const nl = text.indexOf("\n", i); + const segment = text.slice(i, nl === -1 ? text.length : nl); + const room = Math.max(this.maxLineChars - this.lineCharsEmitted, 0); + if (room > 0) { + out += segment.slice(0, room); + this.lineCharsEmitted += Math.min(segment.length, room); + } + if (segment.length > room && !this.lineElided) { + out += LINE_ELISION; + this.lineElided = true; + } + if (nl === -1) { + break; + } + out += "\n"; + this.lineCharsEmitted = 0; + this.lineElided = false; + i = nl + 1; + } + return out; + } trimTail() { const buffer = Buffer.from(this.tailText, "utf-8"); if (buffer.length <= this.maxRollingBytes) { diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/trust-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/trust-manager.js index 0877ee7..faa2559 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/trust-manager.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/trust-manager.js @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; -import { CONFIG_DIR_NAME } from "../config.js"; +import { projectConfigDirCandidates } from "../config.js"; import { canonicalizePath, resolvePath } from "../utils/paths.js"; import { stripBom } from "../utils/text.js"; const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ @@ -151,9 +151,13 @@ export function hasTrustRequiringProjectResources(cwd) { const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir())); const userAgentsSkillsDir = join(homeDir, ".agents", "skills"); let currentDir = canonicalizePath(resolvePath(cwd)); - const configDir = join(currentDir, CONFIG_DIR_NAME); - if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) { - return true; + // Privateer patch: a project needs a trust decision if EITHER config dir carries + // trust-requiring resources — otherwise `.privateer/extensions` would execute + // without ever prompting. + for (const configDir of projectConfigDirCandidates(currentDir)) { + if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) { + return true; + } } while (true) { const agentsSkillsDir = join(currentDir, ".agents", "skills"); diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts b/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts index 205144d..14080a2 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts +++ b/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts @@ -1,7 +1,7 @@ export { type Args, parseArgs } from "./cli/args.ts"; export { CONFIG_DIR_NAME, getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION, } from "./config.ts"; export { AgentSession, type AgentSessionConfig, type AgentSessionEvent, type AgentSessionEventListener, type ModelCycleResult, type ParsedSkillBlock, type PromptOptions, parseSkillBlock, type SessionStats, } from "./core/agent-session.ts"; -export { readStoredCredential } from "./core/auth-storage.ts"; +export { AuthStorage, readStoredCredential } from "./core/auth-storage.ts"; export type { CacheWarmingDecision, CacheWarmingStatus } from "./core/cache-warmer.ts"; export { type BranchPreparation, type BranchSummaryResult, type CollectEntriesResult, type CompactionResult, type CutPointResult, calculateContextTokens, collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, type FileOperations, findCutPoint, findTurnStartIndex, type GenerateBranchSummaryOptions, generateBranchSummary, generateSummary, generateSummaryWithUsage, getLastAssistantUsage, prepareBranchEntries, serializeConversation, shouldCompact, } from "./core/compaction/index.ts"; export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts"; diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/index.js b/node_modules/@earendil-works/pi-coding-agent/dist/index.js index 01017cd..c2b8722 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/index.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/index.js @@ -3,7 +3,7 @@ export { parseArgs } from "./cli/args.js"; // Config paths export { CONFIG_DIR_NAME, getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION, } from "./config.js"; export { AgentSession, parseSkillBlock, } from "./core/agent-session.js"; -export { readStoredCredential } from "./core/auth-storage.js"; +export { AuthStorage, readStoredCredential } from "./core/auth-storage.js"; // Compaction export { calculateContextTokens, collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, findTurnStartIndex, generateBranchSummary, generateSummary, generateSummaryWithUsage, getLastAssistantUsage, prepareBranchEntries, serializeConversation, shouldCompact, } from "./core/compaction/index.js"; export { createEventBus } from "./core/event-bus.js"; diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/main.js b/node_modules/@earendil-works/pi-coding-agent/dist/main.js index be10307..636fbf4 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/main.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/main.js @@ -802,7 +802,58 @@ export async function main(args, options) { if (exitCode !== 0) { process.exitCode = exitCode; } + // Privateer patch: one-shot runs must EXIT, not merely stop working. + // + // Stock Pi returns here and trusts the event loop to drain. It usually does — + // but a single ref'd resource anywhere in the process pins it open forever, and + // `pi -p` then hangs with the answer already printed, looking for all the world + // like a stuck model call. + // + // Measured on a normal Privateer install: with `-ne`, or an agent dir holding no + // extensions, one-shot runs exit. Load the moat and they never do. Bisecting the + // moat one extension at a time puts it on privateer-gate; the other nine exit + // cleanly. What the gate retains could not be identified from JS, because while + // the process sits there BOTH process._getActiveHandles() and + // process.getActiveResourcesInfo() come back empty — whatever holds libuv's loop + // open is native, so nothing in JS can find it, let alone close it. + // + // It is not only ours to fix, either: extensions are arbitrary third-party code. + // A user package on this same machine (context-mode) spawns MCP stdio servers on + // before_agent_start, which is its own reason a one-shot run may never end. + // + // Exiting here is safe because the work is genuinely finished — runPrintMode's + // finally block has already awaited disposeRuntime() (which runs extension + // session_shutdown handlers, so session indexing and flushes complete) and + // flushRawStdout(). All that remains is the exit itself. We still drain stdout + // and stderr first, because with a pipe (`privateer -p … | tail`) the last write + // may be buffered, and a bare process.exit() would truncate it. + // + // Interactive and RPC modes are untouched: the TUI owns its own teardown, and + // an RPC server is supposed to stay up. + await flushStdioAndExit(process.exitCode ?? 0); return; } } +// Privateer patch: flush stdout/stderr, then exit — even if something in the process +// (native addon, extension, stray timer) would otherwise keep the event loop alive. +// The 2s backstop covers the pathological case where a stream never drains; an +// unref'd timer so it can never itself delay a clean exit. +function flushStdioAndExit(code) { + return new Promise(() => { + let pending = 2; + const done = () => { + if (--pending === 0) + process.exit(code); + }; + const backstop = setTimeout(() => process.exit(code), 2000); + backstop.unref?.(); + try { + process.stdout.write("", done); + process.stderr.write("", done); + } + catch { + process.exit(code); + } + }); +} //# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/migrations.js b/node_modules/@earendil-works/pi-coding-agent/dist/migrations.js index c30d6b8..ad1654a 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/migrations.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/migrations.js @@ -4,7 +4,7 @@ import chalk from "chalk"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.js"; +import { getAgentDir, getBinDir, projectConfigDirs } from "./config.js"; import { migrateKeybindingsConfig } from "./core/keybindings.js"; import { stripBom } from "./utils/text.js"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; @@ -231,14 +231,17 @@ function checkDeprecatedExtensionDirs(baseDir, label) { */ function migrateExtensionSystem(cwd) { const agentDir = getAgentDir(); - const projectDir = join(cwd, CONFIG_DIR_NAME); + // Privateer patch: migrate/scan every project config dir that exists. + const projectDirs = projectConfigDirs(cwd); // Migrate commands/ to prompts/ migrateCommandsToPrompts(agentDir, "Global"); - migrateCommandsToPrompts(projectDir, "Project"); + for (const projectDir of projectDirs) { + migrateCommandsToPrompts(projectDir, "Project"); + } // Check for deprecated directories const warnings = [ ...checkDeprecatedExtensionDirs(agentDir, "Global"), - ...checkDeprecatedExtensionDirs(projectDir, "Project"), + ...projectDirs.flatMap((projectDir) => checkDeprecatedExtensionDirs(projectDir, "Project")), ]; return warnings; } diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js index 6f7ed6a..b0ed0ae 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js @@ -4,7 +4,7 @@ import { homedir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { Container, getKeybindings, Input, matchesKey, Spacer, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import { CONFIG_DIR_NAME } from "../../../config.js"; +import { CONFIG_DIR_NAME, PROJECT_CONFIG_DIR_NAMES, formatProjectConfigDirNames, projectConfigDirs, projectConfigWriteDir } from "../../../config.js"; import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -43,7 +43,7 @@ function getGroupLabel(metadata, agentDir) { ? `User (${formatBaseDir(metadata.baseDir)})` : `Project (${formatBaseDir(metadata.baseDir)})`; } - return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`; + return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${formatProjectConfigDirNames()})`; } return metadata.scope === "user" ? "User settings" : "Project settings"; } @@ -141,7 +141,7 @@ class ConfigSelectorHeader { const hint = switchHint + actionHint + sep + rawKeyHint("esc", "close"); const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint)); const scopeHint = this.writeScope === "project" - ? theme.fg("muted", `${CONFIG_DIR_NAME}/settings.json · inherited global resources are dimmed`) + ? theme.fg("muted", `${PROJECT_CONFIG_DIR_NAMES[0]}/settings.json · inherited global resources are dimmed`) : theme.fg("muted", `~/${CONFIG_DIR_NAME}/agent/settings.json`); return [ truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""), @@ -695,7 +695,12 @@ class ResourceList { return item.metadata.scope === "project" ? "project" : "user"; } getTopLevelBaseDir(scope) { - return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir; + // Privateer patch: patterns are made relative to the highest-precedence project + // dir that exists (`.privateer` when present), else where one would be created. + if (scope !== "project") { + return this.agentDir; + } + return projectConfigDirs(this.cwd)[0] ?? projectConfigWriteDir(this.cwd); } getResourcePattern(item) { const scope = item.metadata.scope; diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js index 0e2811e..c0326e3 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js @@ -1,7 +1,6 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js"; -import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.js"; import { theme } from "../theme/theme.js"; /** * Sanitize text for display in a single-line status. @@ -14,6 +13,43 @@ function sanitizeStatusText(text) { .replace(/ +/g, " ") .trim(); } +/** + * Lay extension statuses out over as many lines as the terminal needs, keeping + * each status whole. Upstream joined them into ONE line and truncated it to the + * width, which silently hid whatever didn't fit — and what didn't fit was often + * the loudest indicator on screen (Privateer's "permission gate OFF" flag). A + * status too wide to fit on a line of its own is wrapped, never clipped. + */ +function packStatusLines(statuses, width) { + if (width <= 0) + return []; + const lines = []; + let current = ""; + let currentWidth = 0; + const flush = () => { + if (current) + lines.push(current); + current = ""; + currentWidth = 0; + }; + for (const status of statuses) { + if (!status) + continue; + const statusWidth = visibleWidth(status); + if (statusWidth > width) { + flush(); + lines.push(...wrapTextWithAnsi(status, width)); + continue; + } + const separator = current ? 1 : 0; + if (currentWidth + separator + statusWidth > width) + flush(); + current = current ? `${current} ${status}` : status; + currentWidth += separator + statusWidth; + } + flush(); + return lines; +} /** * Format token counts for compact footer display. */ @@ -41,8 +77,11 @@ export function formatCwdForFooter(cwd, home) { return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; } /** - * Footer component that shows pwd, token stats, and context usage. - * Computes token/context stats from session, gets git branch and extension statuses from provider. + * Footer component that shows pwd (with git branch) and the active model. + * + * Privateer trimmed this: upstream also drew a second row of cumulative token + * counters, cache-hit rate, cost and a context gauge. Gets git branch and + * extension statuses from provider. */ export class FooterComponent { autoCompactEnabled = true; @@ -74,32 +113,9 @@ export class FooterComponent { } render(width) { const state = this.session.state; - // Calculate cumulative usage from ALL session entries (not just post-compaction messages) - const usageTotals = createUsageTotals(); - let latestCacheHitRate; - for (const entry of this.session.sessionManager.getEntries()) { - if (entry.type === "usage") { - addUsageToTotals(usageTotals, entry.usage); - } - else if (entry.type === "message" && entry.message.role === "assistant") { - addUsageToTotals(usageTotals, entry.message.usage); - const latestPromptTokens = entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; - latestCacheHitRate = - latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; - } - else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { - addUsageToTotals(usageTotals, entry.message.usage); - } - else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { - addUsageToTotals(usageTotals, entry.usage); - } - } - // Calculate context usage from session (handles compaction correctly). - // After compaction, tokens are unknown until the next LLM response. - const contextUsage = this.session.getContextUsage(); - const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0; - const contextPercentValue = contextUsage?.percent ?? 0; - const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; + // Privateer: no token counters, cache-hit rate, cost or context gauge in the + // footer — so the accumulation that fed them is gone too. Upstream walked EVERY + // session entry on EVERY render to build numbers we no longer draw. // Replace home directory with ~ let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE); // Add git branch if available @@ -112,56 +128,24 @@ export class FooterComponent { if (sessionName) { pwd = `${pwd} • ${sessionName}`; } - // Build stats line - const statsParts = []; - if (usageTotals.input) - statsParts.push(`↑${formatTokens(usageTotals.input)}`); - if (usageTotals.output) - statsParts.push(`↓${formatTokens(usageTotals.output)}`); - if (usageTotals.cacheRead) - statsParts.push(`R${formatTokens(usageTotals.cacheRead)}`); - if (usageTotals.cacheWrite) - statsParts.push(`W${formatTokens(usageTotals.cacheWrite)}`); - if ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) { - statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); - } - // Kimi Coding is subscription-backed despite using API-key authentication. - const usingSubscription = state.model - ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingSubscription(state.model.provider) - : false; - if (usageTotals.cost || usingSubscription) { - const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; - statsParts.push(costStr); - } - // Colorize context percentage based on usage - let contextPercentStr; - const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; - const contextPercentDisplay = contextPercent === "?" - ? `?/${formatTokens(contextWindow)}${autoIndicator}` - : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; - if (contextPercentValue > 90) { - contextPercentStr = theme.fg("error", contextPercentDisplay); - } - else if (contextPercentValue > 70) { - contextPercentStr = theme.fg("warning", contextPercentDisplay); - } - else { - contextPercentStr = contextPercentDisplay; - } - statsParts.push(contextPercentStr); - if (areExperimentalFeaturesEnabled()) { - statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`); - } - let statsLeft = statsParts.join(" "); + // Privateer: ONE footer row — the working directory on the left, the model on + // the right. Upstream spent a second row on cumulative token counters, cache-hit + // rate and cost; those are session trivia that never change what you do next, and + // they made the busiest part of the screen the least useful. The `xp` flag stays: + // it warns that experimental features are live, which is not trivia. + const experimentalFlag = areExperimentalFeaturesEnabled() + ? ` ${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}` + : ""; + let left = pwd + experimentalFlag; // Add model name on the right side, plus thinking level if model supports it const modelName = state.model?.id || "no-model"; - let statsLeftWidth = visibleWidth(statsLeft); - // If statsLeft is too wide, truncate it - if (statsLeftWidth > width) { - statsLeft = truncateToWidth(statsLeft, width, "..."); - statsLeftWidth = visibleWidth(statsLeft); + let leftWidth = visibleWidth(left); + // If the left side is too wide, truncate it + if (leftWidth > width) { + left = truncateToWidth(left, width, "..."); + leftWidth = visibleWidth(left); } - // Calculate available space for padding (minimum 2 spaces between stats and model) + // Calculate available space for padding (minimum 2 spaces between cwd and model) const minPadding = 2; // Add thinking level indicator if model supports reasoning let rightSideWithoutProvider = modelName; @@ -174,50 +158,48 @@ export class FooterComponent { let rightSide = rightSideWithoutProvider; if (this.footerData.getAvailableProviderCount() > 1 && state.model) { rightSide = `(${state.model.provider}) ${rightSideWithoutProvider}`; - if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) { + if (leftWidth + minPadding + visibleWidth(rightSide) > width) { // Too wide, fall back rightSide = rightSideWithoutProvider; } } const rightSideWidth = visibleWidth(rightSide); - const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; - let statsLine; + const totalNeeded = leftWidth + minPadding + rightSideWidth; + let footerLine; if (totalNeeded <= width) { // Both fit - add padding to right-align model - const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); - statsLine = statsLeft + padding + rightSide; + const padding = " ".repeat(width - leftWidth - rightSideWidth); + footerLine = left + padding + rightSide; } else { // Need to truncate right side - const availableForRight = width - statsLeftWidth - minPadding; + const availableForRight = width - leftWidth - minPadding; if (availableForRight > 0) { const truncatedRight = truncateToWidth(rightSide, availableForRight, ""); const truncatedRightWidth = visibleWidth(truncatedRight); - const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth)); - statsLine = statsLeft + padding + truncatedRight; + const padding = " ".repeat(Math.max(0, width - leftWidth - truncatedRightWidth)); + footerLine = left + padding + truncatedRight; } else { // Not enough space for right side at all - statsLine = statsLeft; + footerLine = left; } } - // Apply dim to each part separately. statsLeft may contain color codes (for context %) - // that end with a reset, which would clear an outer dim wrapper. So we dim the parts - // before and after the colored section independently. - const dimStatsLeft = theme.fg("dim", statsLeft); - const remainder = statsLine.slice(statsLeft.length); // padding + rightSide - const dimRemainder = theme.fg("dim", remainder); - const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")); - const lines = [pwdLine, dimStatsLeft + dimRemainder]; + // Apply dim to each part separately. `left` may carry the bold/warning-coloured + // xp flag, whose reset would clear an outer dim wrapper. So dim the parts before + // and after the coloured section independently. + const dimLeft = theme.fg("dim", left); + const remainder = footerLine.slice(left.length); // padding + rightSide + const lines = [dimLeft + theme.fg("dim", remainder)]; // Add extension statuses on a single line, sorted by key alphabetically const extensionStatuses = this.footerData.getExtensionStatuses(); if (extensionStatuses.size > 0) { const sortedStatuses = Array.from(extensionStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); - const statusLine = sortedStatuses.join(" "); - // Truncate to terminal width with dim ellipsis for consistency with footer style - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + // Wrap onto extra lines instead of truncating: a status is there because + // something wants to be seen, so none of them may be cut off. + lines.push(...packStatusLines(sortedStatuses, width)); } return lines; } diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js index 6d0356c..ca0aa98 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js @@ -1,9 +1,36 @@ -import { Box, Container, getCapabilities, Image, MouseRegion, Spacer, Text, } from "@earendil-works/pi-tui"; +import { Box, Container, getCapabilities, Image, MouseRegion, Spacer, visibleWidth, Text, } from "@earendil-works/pi-tui"; import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js"; import { convertToPng } from "../../../utils/image-convert.js"; import { theme } from "../theme/theme.js"; import { keyHint } from "./keybinding-hints.js"; const FALLBACK_PREVIEW_LINES = 10; +/** + * Privateer: mark a failed tool call inline on its title line. + * + * Upstream's ONLY failure signal is the full-width red `toolErrorBg` wash behind + * the whole block. We flatten those washes (see dark.json / light.json) because + * large blocks of colour read as noise — but flattening the error one alone would + * make a failure indistinguishable from a success, which is a real regression. + * + * So we put the signal back where it costs nothing: a red glyph prefixed to the + * first line of whatever the tool's own renderCall produced. Wrapping the CALL + * component (not the Box) means Box still owns padding and width, and the marker + * works for every tool without touching seven per-tool renderers. Subsequent + * lines get a blank hanging indent so the title stays visually aligned. + */ +function markCallErrored(component, marker) { + const markerWidth = visibleWidth(marker); + const indent = " ".repeat(markerWidth); + return { + render(width) { + const lines = component.render(Math.max(1, width - markerWidth)); + return lines.map((line, i) => (i === 0 ? marker + line : indent + line)); + }, + invalidate() { + component.invalidate?.(); + }, + }; +} export class ToolExecutionComponent extends Container { contentBox; contentText; @@ -44,8 +71,13 @@ export class ToolExecutionComponent extends Container { // Always create all shell variants. contentBox is used for default renderer-based composition. // selfRenderContainer is used when the tool renders its own framing. // contentText is reserved for generic fallback rendering when no tool definition exists. - this.contentBox = new Box(1, 1, (text) => theme.bg("toolPendingBg", text)); - this.contentText = new Text("", 1, 1, (text) => theme.bg("toolPendingBg", text)); + // Privateer: paddingY is 0 because we flatten the tool-box washes (dark.json / + // light.json). With no background to fill, upstream's paddingY=1 reads as a blank + // line above AND below every call, stacking with the Spacer(1) into three empty + // rows between consecutive tools. 0 leaves exactly the one Spacer row, which also + // matches what the self-render path above emits (a single lines.push("")). + this.contentBox = new Box(1, 0, (text) => theme.bg("toolPendingBg", text)); + this.contentText = new Text("", 1, 0, (text) => theme.bg("toolPendingBg", text)); this.contentTextRegion = this.createResultRegion(this.contentText); this.selfRenderContainer = new Container(); if (this.hasRendererDefinition()) { @@ -90,6 +122,13 @@ export class ToolExecutionComponent extends Container { createCallFallback() { return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0); } + /** Privateer: red-flag a failed call, pass a pending/succeeded one straight through. */ + decorateCall(component) { + if (!this.result?.isError || this.isPartial) { + return component; + } + return markCallErrored(component, theme.fg("error", "\u2717 ")); + } createResultFallback() { const output = this.getTextOutput(); if (!output) { @@ -237,19 +276,22 @@ export class ToolExecutionComponent extends Container { renderContainer.clear(); const callRenderer = this.getCallRenderer(); if (!callRenderer) { - renderContainer.addChild(this.createResultRegion(this.createCallFallback())); + renderContainer.addChild(this.createResultRegion(this.decorateCall(this.createCallFallback()))); hasContent = true; } else { try { const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent)); + // Store the RAW component: it comes back as `lastComponent` on the + // next render and the tool's renderer calls its own methods on it, + // so handing it a wrapper would break every stateful renderer. this.callRendererComponent = component; - renderContainer.addChild(this.createResultRegion(component)); + renderContainer.addChild(this.createResultRegion(this.decorateCall(component))); hasContent = true; } catch { this.callRendererComponent = undefined; - renderContainer.addChild(this.createResultRegion(this.createCallFallback())); + renderContainer.addChild(this.createResultRegion(this.decorateCall(this.createCallFallback()))); hasContent = true; } } @@ -322,7 +364,8 @@ export class ToolExecutionComponent extends Container { return getRenderedTextOutput(this.result, this.showImages); } formatToolExecution() { - let text = theme.fg("toolTitle", theme.bold(this.toolName)); + const errorMark = this.result?.isError && !this.isPartial ? theme.fg("error", "\u2717 ") : ""; + let text = errorMark + theme.fg("toolTitle", theme.bold(this.toolName)); const content = JSON.stringify(this.args, null, 2); if (content) { text += `\n\n${content}`; diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js index d82182c..41426bc 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js @@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui"; import { CombinedAutocompleteProvider, Container, fuzzyFilter, getCapabilities, hyperlink, Markdown, matchesKey, Spacer, setCapabilityOverrides, setKeybindings, Text, TruncatedText, TuiAltScreen, TuiMainScreen, visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { spawn } from "child_process"; -import { APP_NAME, APP_TITLE, CONFIG_DIR_NAME, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, VERSION, } from "../../config.js"; +import { APP_NAME, APP_TITLE, formatProjectConfigDirNames, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, VERSION, } from "../../config.js"; import { parseSkillBlock } from "../../core/agent-session.js"; import { SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; import { CACHE_TTL_MS, collectCacheMisses, computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.js"; @@ -141,7 +141,12 @@ export function formatResumeCommand(sessionManager) { const sessionFile = sessionManager.getSessionFile(); if (!sessionFile || !fs.existsSync(sessionFile)) return undefined; - const args = [APP_NAME]; + // Privateer rename: APP_NAME is "pi", and no `pi` binary exists on a Privateer + // machine — the published bin is `privateer`. So this hint, whose entire job is to + // be copy-pasted, resolved to "bash: pi: command not found". The launcher exports + // PRIVATEER_CMD with the name it was actually invoked under (npm's bin symlink, or + // a user's own symlink of bin/privateer-tui); fall back to the published name. + const args = [process.env.PRIVATEER_CMD || "privateer"]; if (!sessionManager.usesDefaultSessionDir()) { args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir())); } @@ -192,6 +197,64 @@ function formatLoginProviderCompletionDescription(provider) { const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/"); return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`; } +// Privateer patch: describe an error we only have the TEXT of. Mirrors +// describeErrorText in src/engine/errors.ts — see the incident note there. +const PV_RETRY_AFTER_PATTERNS = [ + /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i, + /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i, + /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i, +]; +function pvRetryAfterMs(text) { + const s = typeof text === "string" ? text : ""; + for (const re of PV_RETRY_AFTER_PATTERNS) { + const m = re.exec(s); + if (!m) + continue; + const value = Number.parseFloat(m[1]); + if (!Number.isFinite(value) || value <= 0) + continue; + const unit = (m[2] ?? "").toLowerCase(); + const ms = unit === "ms" || /retry-after-ms/i.test(m[0]) + ? value + : unit.startsWith("m") && unit !== "ms" + ? value * 60_000 + : value * 1000; + return Math.min(Math.max(Math.round(ms), 1_000), 60_000); + } + return null; +} +function pvDescribeErrorText(text) { + const s = typeof text === "string" ? text : ""; + const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN); + if (!Number.isFinite(status)) { + // Privateer patch: no HTTP status, but undici's idle-timeout message is exact + // and is the one error a stalled turn produces — printing it raw leaves the + // user with two words and no next step. Mirrors isIdleTimeoutError / + // describeErrorText in src/engine/errors.ts. + if (/(?:^|\b)(?:Body|Headers) Timeout Error\b/i.test(s)) + return { + message: "The provider stopped responding — the connection went idle.", + hint: "No data arrived for the whole idle-timeout window, so the turn was cut off. Send it again, or run /model to switch providers (a slow model can be given longer in /settings → HTTP idle timeout).", + }; + return null; + } + if (status === 429) { + const stated = pvRetryAfterMs(s); + return { + message: "Rate limited (429).", + hint: stated + ? `The provider asked for ${Math.ceil(stated / 1000)}s. Wait that long and send it again.` + : "Wait a moment and send it again — or run /model to switch to another provider.", + }; + } + if (status === 401 || status === 403) + return { message: s, hint: "Check your credentials — run /login to re-authenticate." }; + if (status === 404) + return { message: s, hint: "Check the model id — run /model to switch." }; + if (status >= 500) + return { message: s, hint: "Usually transient — retry shortly." }; + return null; +} export class InteractiveMode { runtimeHost; renderer; @@ -404,9 +467,15 @@ export class InteractiveMode { } getBuiltInCommandConflictDiagnostics(extensionRunner) { const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); + // Privateer redirect: these built-ins are patched above to dispatch into the + // Privateer extension, so an extension command of the same name is deliberate, + // not a mistake — and it stays live in the modes that have no such redirect + // (rpc, print). Warning about it just tells the user their own shipped auth + // commands are broken when they aren't. + const redirectedBuiltins = new Set(["login", "logout"]); return extensionRunner .getRegisteredCommands() - .filter((command) => builtinNames.has(command.name)) + .filter((command) => builtinNames.has(command.name) && !redirectedBuiltins.has(command.name)) .map((command) => ({ type: "warning", message: command.invocationName === command.name @@ -775,20 +844,18 @@ export class InteractiveMode { this.showNewVersionNotification(newRelease); } }); - // Start package update check asynchronously - this.checkForPackageUpdates() - .then((updates) => { - if (updates.length > 0) { - this.showPackageUpdateNotification(updates); - } - }) - .finally(() => { - // On Windows, npm can overwrite the shared console title while checking - // extension package versions. Restore Pi's title after the startup check. - if (process.platform === "win32" && this.isInitialized) { - this.updateTerminalTitle(); - } - }); + // Privateer owns the package ("tool pack") update surface — see + // extensions/privateer-update.ts. Upstream ran its own check here and drew a + // warning-bordered box telling the user to run `pi update --extensions`: a + // binary that is installed nowhere on a Privateer machine, so the only + // instruction on screen could not be followed. We replace it with a single + // line in the startup banner plus /update, which applies packs in place. The + // CHECK is removed too, not just the box, so this costs no registry round + // trip — the extension does exactly one, on the same startup. + // + // 0.84.1 added a .finally() here restoring the Windows console title after + // npm overwrote it. That repair existed only because of this check, so it + // goes with it — nothing is left to scribble on the title. // Check tmux keyboard setup asynchronously this.checkTmuxKeyboardSetup().then((warning) => { if (warning) { @@ -2419,7 +2486,17 @@ export class InteractiveMode { if (text === "/model" || text.startsWith("/model ")) { const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; this.editor.setText(""); - await this.handleModelCommand(searchTerm); + // Privateer redirect: when the shielded picker extension is loaded + // (registers `/models`), route `/model` to it so users get the + // privacy-posture picker via muscle memory. Falls back to Pi's + // built-in picker when the extension isn't present. + const privateerModels = searchTerm ? `/models ${searchTerm}` : "/models"; + if (this.isExtensionCommand(privateerModels)) { + await this.session.prompt(privateerModels); + } + else { + await this.handleModelCommand(searchTerm); + } return; } if (text === "/thinking" || text.startsWith("/thinking ")) { @@ -2497,12 +2574,42 @@ export class InteractiveMode { if (text === "/login" || text.startsWith("/login ")) { const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined; this.editor.setText(""); - await this.handleLoginCommand(providerRef); + // Privateer redirect: a bare /login IS the account sign-in. Pi's built-in + // opens a two-step menu ("Use a subscription" → a list of 20+ providers) + // that buries the one option a Privateer user wants, and — because it + // only auto-selects a model when the current one is UNKNOWN — a + // successful login through it left the terminal on its launch model and + // the next prompt died on "No API key found". The extension's own flow + // signs in, arms the account channel, and selects the model. + // `/login ` (documented as `/login keys`) still reaches Pi's + // own flow, so BYO provider keys stay reachable; and if the extension + // isn't loaded we fall back to Pi for both. + if (!providerRef && this.isExtensionCommand("/privateer")) { + await this.session.prompt("/privateer login"); + } + else { + await this.handleLoginCommand(providerRef); + } return; } if (text === "/logout") { - this.showOAuthSelector("logout"); this.editor.setText(""); + // Privateer redirect: /logout must actually log you out. Pi's built-in + // only clears Pi's authStorage, which leaves the Privateer machine + // login in ~/.privateer/credentials.json untouched — so on a signed-in + // machine it reported "No stored credentials to remove" and changed + // nothing. Route to the account logout instead, which revokes this + // machine's whole token family (login + every terminal spawned from it) + // and wipes local state. Target is `/privateer logout` rather than the + // extension's own `/logout` so this can never re-enter the branch it + // was dispatched from. Falls back to Pi's selector when the extension + // isn't loaded. + if (this.isExtensionCommand("/privateer")) { + await this.session.prompt("/privateer logout"); + } + else { + this.showOAuthSelector("logout"); + } return; } if (text === "/new") { @@ -2827,9 +2934,19 @@ export class InteractiveMode { if (entries[0]?.type !== "compaction") { throw new Error("Completed compaction is missing from the session context"); } - this.chatContainer.clear(); - // The latest compaction is prepended for model context; append it below at its chronological position. - this.renderSessionEntries(entries.slice(1)); + // Privateer patch: a compaction must never erase what the user can see. + // Stock Pi cleared the whole chat container here and re-rendered only + // buildContextEntries().slice(1) — the MODEL's new context, a summary plus + // the firstKeptEntryId tail. That is the right set of messages to send to + // the model and the wrong set to leave on screen: every prompt, answer and + // tool result above the keep boundary vanished from the transcript the + // instant the summary appeared, so the user watched the thing they typed get + // swallowed and replaced by a summary — worst of all when the compaction was + // triggered by that very prompt. The model context is allowed to shrink; the + // transcript is the record of what actually happened and does not. The kept + // tail is already on screen (it was rendered as it happened), so re-rendering + // it would duplicate it — keep every child and append the summary at its + // chronological position, which is the boundary ("now"). this.addMessageToChat(createCompactionSummaryMessage(event.result.summary, event.result.tokensBefore, new Date().toISOString())); if (event.result.usage) { this.addCompactionCostNotice({ @@ -3253,7 +3370,7 @@ export class InteractiveMode { if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } - this.chatContainer.addChild(new Text(theme.fg("warning", `This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("warning", `This project is not trusted. Project ${formatProjectConfigDirNames()} resources and packages are ignored. Use /trust to save a trust decision, then restart ${process.env.PRIVATEER_CMD || APP_NAME}.`), 1, 0)); } async getUserInput() { const queuedInput = this.pendingUserInputs.shift(); @@ -3597,7 +3714,17 @@ export class InteractiveMode { } showError(errorMessage) { this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), this.outputPad, 0)); + // Privateer patch: by the time an error reaches here it is only a string — the + // structured fields describeError() reads are long gone, so the user was shown + // the SDK's own words ("429 status code (no body)"), which say nothing about + // what to do. The status is still readable at the front of that string; recover + // it and say what describeError would. Mirrors describeErrorText in + // src/engine/errors.ts. Anything without a leading status prints unchanged. + const described = pvDescribeErrorText(errorMessage); + this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${described?.message ?? errorMessage}`), this.outputPad, 0)); + if (described?.hint) { + this.chatContainer.addChild(new Text(theme.fg("muted", described.hint), this.outputPad, 0)); + } this.ui.requestRender(); } showWarning(warningMessage) { diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json index 3f4c698..e0152d0 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json @@ -42,9 +42,9 @@ "customMessageBg": "customMsgBg", "customMessageText": "text", "customMessageLabel": "#9575cd", - "toolPendingBg": "toolPendingBg", - "toolSuccessBg": "toolSuccessBg", - "toolErrorBg": "toolErrorBg", + "toolPendingBg": "", + "toolSuccessBg": "", + "toolErrorBg": "", "toolTitle": "text", "toolOutput": "gray", diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json index 20f6783..49fc151 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json @@ -41,9 +41,9 @@ "customMessageBg": "customMsgBg", "customMessageText": "text", "customMessageLabel": "#7e57c2", - "toolPendingBg": "toolPendingBg", - "toolSuccessBg": "toolSuccessBg", - "toolErrorBg": "toolErrorBg", + "toolPendingBg": "", + "toolSuccessBg": "", + "toolErrorBg": "", "toolTitle": "text", "toolOutput": "mediumGray", diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js b/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js index 3f101e6..51c7f73 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js @@ -5,7 +5,7 @@ import chalk from "chalk"; import lockfile from "proper-lockfile"; import { selectConfig } from "./cli/config-selector.js"; import { createProjectTrustContext } from "./cli/project-trust.js"; -import { APP_NAME, CONFIG_DIR_NAME, detectInstallMethod, getAgentDir, getPackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, PACKAGE_NAME, VERSION, } from "./config.js"; +import { APP_NAME, CONFIG_DIR_NAME, detectInstallMethod, getAgentDir, getPackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, PACKAGE_NAME, PROJECT_CONFIG_DIR_NAMES, VERSION, } from "./config.js"; import { ModelRuntime } from "./core/model-runtime.js"; import { DefaultPackageManager } from "./core/package-manager.js"; import { resolveProjectTrusted } from "./core/project-trust.js"; @@ -221,7 +221,7 @@ Without -l, starts in global settings (~/${CONFIG_DIR_NAME}/agent/settings.json) Press Tab in the TUI to switch between global and project-local modes. Options: - -l, --local Edit project overrides (${CONFIG_DIR_NAME}/settings.json) + -l, --local Edit project overrides (${PROJECT_CONFIG_DIR_NAMES[0]}/settings.json) -a, --approve Trust project-local files for this command with -l -na, --no-approve Ignore project-local files for this command with -l `); @@ -235,7 +235,7 @@ function printPackageCommandHelp(command) { Install a package and add it to settings. Options: - -l, --local Install project-locally (${CONFIG_DIR_NAME}/settings.json) + -l, --local Install project-locally (${PROJECT_CONFIG_DIR_NAMES[0]}/settings.json) -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command @@ -256,7 +256,7 @@ Remove a package and its source from settings. Alias: ${APP_NAME} uninstall [-l] Options: - -l, --local Remove from project settings (${CONFIG_DIR_NAME}/settings.json) + -l, --local Remove from project settings (${PROJECT_CONFIG_DIR_NAMES[0]}/settings.json) -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command