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/slash-commands.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js index 170a066..b535f72 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js @@ -8,7 +8,7 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, { name: "share", description: "Share session as a secret GitHub gist" }, - { name: "bug", description: "Report a bug to the Pi developers", argumentHint: "" }, + { name: "bug", description: "Export a bug report to email to Privateer support", argumentHint: "" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, 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/bug-report.d.ts b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts index 7eae594..b21fae3 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts @@ -8,7 +8,7 @@ interface BugReportContext { showStatus: (message: string) => void; showError: (message: string) => void; } -/** Run the `/bug` flow: consent, optional summary, then upload or export. */ +/** Run the `/bug` flow: consent, optional summary, then export a zip to email to support. */ export declare function reportBug(context: BugReportContext, initialHint?: string): Promise; export {}; //# sourceMappingURL=bug-report.d.ts.map \ No newline at end of file diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js index 59ae447..68cf842 100644 --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js @@ -1,18 +1,21 @@ import * as path from "node:path"; -import { getAuthCredential } from "../../cli/auth-command.js"; import { BUG_REPORT_CUSTOM_ENTRY_TYPE, bugReportArchiveFileName, collectBugReportDiagnostics, collectBugReportMetadata, writeBugReportArchive, } from "../../core/bug-report.js"; -import { uploadBugReport } from "../../core/bug-report-upload.js"; import { clearCrashLog, readCrashLog } from "../../core/crash-log.js"; -import { getRadiusGatewayUrl, RADIUS_PROVIDER_ID } from "../../core/radius.js"; import { serializeSessionBranch } from "../../core/session-export.js"; import { BorderedLoader } from "./components/bordered-loader.js"; import { ExtensionInputComponent } from "./components/extension-input.js"; import { ExtensionSelectorComponent } from "./components/extension-selector.js"; import { createShareTrailingEntries } from "./session-share.js"; import { theme } from "./theme/theme.js"; -const DISCLAIMER = "This report goes to the Pi developers (Earendil) and is not shared publicly. It includes your pi version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session."; +// Privateer patch: stock Pi's `/bug` uploads this bundle straight to Earendil's Radius +// Gateway (session transcript, settings, environment) with no Privateer-controlled +// endpoint in between — not acceptable for a privacy-first tool. We keep the same +// consent/redaction flow but the only delivery is a local zip the user emails to us +// themselves, so nothing leaves the machine without them explicitly attaching it. +const SUPPORT_EMAIL = "support@privateer.pro"; +const DISCLAIMER = `This report is written to a zip file on your machine only — nothing is uploaded automatically. It includes your Privateer version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session. Email the file to ${SUPPORT_EMAIL} to send it to us.`; const TRANSCRIPT_NOTE = "The transcript contains your messages, model output, tool calls and their results, including file contents and command output read during this session."; -/** Run the `/bug` flow: consent, optional summary, then upload or export. */ +/** Run the `/bug` flow: consent, optional summary, then export a zip for the user to email us. */ export async function reportBug(context, initialHint) { const options = await promptForOptions(context, initialHint); if (!options) { @@ -47,16 +50,6 @@ export async function reportBug(context, initialHint) { context.showError(`Failed to build bug report: ${errorMessage(error)}`); return; } - if (options.delivery === "upload") { - const failure = await upload(context, bundle); - if (failure === undefined) - return; - const fallback = await choose(context, "Upload failed", ["Export as Zip", "Cancel"], `${failure}\n\nExport the report as a zip archive instead?`); - if (fallback !== "Export as Zip") { - context.showStatus("Bug report cancelled"); - return; - } - } await exportZip(context, bundle); } async function promptForOptions(context, initialHint) { @@ -76,14 +69,13 @@ async function promptForOptions(context, initialHint) { includeSummary = summary !== "No"; } const description = hint.trim(); - const delivery = await choose(context, "Bug report", ["Upload Report", "Export as Zip", "Cancel"], `Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nUpload sends the report to ${new URL(getRadiusGatewayUrl()).host}. Export writes a zip archive to the current directory instead.`); - if (!delivery || delivery === "Cancel") + const confirmed = await choose(context, "Bug report", ["Export Report", "Cancel"], `Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nWrites a zip archive to the current directory. Email it to ${SUPPORT_EMAIL}.`); + if (!confirmed || confirmed === "Cancel") return undefined; return { hint: description || undefined, includeSession, includeSummary, - delivery: delivery === "Upload Report" ? "upload" : "zip", }; } function buildBundle(session, options, summary) { @@ -111,28 +103,6 @@ function buildBundle(session, options, summary) { : undefined, }; } -async function upload(context, bundle) { - const loader = showLoader(context, "Uploading bug report..."); - try { - const provider = context.session.modelRuntime.getProvider(RADIUS_PROVIDER_ID); - const token = provider - ? getAuthCredential(await context.session.modelRuntime.getAuth(RADIUS_PROVIDER_ID, { minOAuthValidityMs: 5 * 60_000 })) - : undefined; - const result = await uploadBugReport(bundle, { token, signal: loader.signal }); - restoreEditor(context, loader); - recordInSession(context.session, bundle, { delivery: "upload" }); - context.showStatus(`Bug report uploaded. Report ID: ${result.id}`); - return undefined; - } - catch (error) { - restoreEditor(context, loader); - if (loader.signal.aborted) { - context.showStatus("Bug report cancelled"); - return undefined; - } - return errorMessage(error); - } -} async function exportZip(context, bundle) { const archivePath = path.join(process.cwd(), bugReportArchiveFileName(bundle.metadata.id)); try { @@ -143,7 +113,7 @@ async function exportZip(context, bundle) { return; } recordInSession(context.session, bundle, { delivery: "zip", path: archivePath }); - context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}`); + context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}\n\nEmail this file to ${SUPPORT_EMAIL} to reach support.`); } function recordInSession(session, bundle, delivery) { session.sessionManager.appendCustomEntry(BUG_REPORT_CUSTOM_ENTRY_TYPE, { 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..08bec2d 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,77 @@ 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).", + }; + // Privateer patch: the OpenAI SDK's whole message for a request that never got + // a response. Mirrors isConnectionFailureText in src/engine/errors.ts. + if (/^\s*(?:connection error\.?|fetch failed|other side closed|socket hang up|(?:read |connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b)/i.test(s)) + return { + message: `Couldn't reach the model provider — the request got no response ("${s.trim().slice(0, 80)}").`, + hint: "Check your connection and send it again. If every message fails while a fresh `privateer` works, start a new session with /new.", + }; + 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 { + // "401 status code (no body)" names neither what was refused nor what to do. + message: /\(no body\)\s*$/i.test(s) + ? `The model provider ${status === 401 ? "rejected the credential" : "refused access"} this request was sent with (${status}).` + : 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 +480,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 +857,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) { @@ -1568,7 +1648,7 @@ export class InteractiveMode { if (this.bugReportHintShown) return; this.bugReportHintShown = true; - this.chatContainer.addChild(new Text(theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug sends a report to the developers.`), this.outputPad, 0)); + this.chatContainer.addChild(new Text(theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug exports a report you can email to support@privateer.pro.`), this.outputPad, 0)); this.ui.requestRender(); } renderCurrentSessionState() { @@ -2419,7 +2499,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 +2587,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 +2947,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 +3383,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 +3727,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