diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5c30a20..44ade3a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -188,6 +188,8 @@ All keys below are currently **ACTIVE**. - **Default:** `true` - **Status:** 🟢 ACTIVE - **Description:** On Pi startup, check the npm registry for a newer version of `billion-context-pi` and auto-install it. Set to `false` to avoid all startup network calls. Can also be disabled via the `ACP_AUTO_UPDATE` environment variable (`ACP_AUTO_UPDATE=0` or `ACP_AUTO_UPDATE=false`), which overrides this setting. + - **Read-only install location:** when the copy's install prefix is not writable (e.g. a root-owned `npm i -g` global prefix), auto-update stops retrying that location after the first `EACCES`/permission failure and shows a one-time hint to run `npm i -g billion-context-pi` (or remove the global copy if you rely on pi's bundled install) instead of looping. The check throttle and the stop-retry marker are keyed per install location, so a healthy copy never suppresses a failing one's checks. + - **Two parallel mechanisms:** this extension-side auto-update is independent of pi's own core update banner — both can appear, and disabling one does not disable the other. ### `modelContextLimit` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 32e15d3..412ddbf 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -180,6 +180,8 @@ - **默认值:** `true` - **状态:** 🟢 ACTIVE - **说明:** Pi 启动时检查 npm 是否有更新版本的 `billion-context-pi` 并自动安装。设为 `false` 可避免启动时的所有网络请求。也可通过 `ACP_AUTO_UPDATE` 环境变量(`ACP_AUTO_UPDATE=0` 或 `ACP_AUTO_UPDATE=false`)禁用,该变量优先于此配置。 + - **只读安装位置:** 当该副本的安装前缀不可写(如 root 所有的 `npm i -g` 全局前缀)时,自动更新在首次 `EACCES`/权限失败后停止对该位置的重试,并一次性提示运行 `npm i -g billion-context-pi`(或若依赖 pi 自带安装则移除全局副本),而不是无限循环。检查节流与停止重试标记均按安装位置区分,因此健康副本不会压制失败副本的检查。 + - **两套并行机制:** 此扩展侧自动更新与 pi 核心自身的更新 banner 相互独立——两者可能同时出现,禁用其中一个不影响另一个。 ### `modelContextLimit` diff --git a/src/commands.ts b/src/commands.ts index 08b528d..09edfcd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,6 +5,7 @@ import { defaultCountTokens, parseBlockIdArg, collectBlockContent } from "acp-ke import { getSystemPromptText } from "./compat.js"; import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; import { usageAnchorPredatesCompression } from "./floor-stale.js"; +import { applyOutputHeadroom } from "./overflow-selfheal.js"; import { buildStatusPanel } from "acp-kernel/panel"; import { getDelegateUsage } from "./delegate-tool.js"; import { openFleetInspector } from "./fleet-inspector.js"; @@ -142,7 +143,10 @@ export function makeCommands(runtime: AcpRuntime, pi?: ExtensionAPI): Array<{ na async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext): Promise { const { state, coreMessages, entries } = await runtime.stateFor(ctx); - const config = runtime.configFor(ctx); + // Measure every panel percentage against the SAME real request limit the live + // context transform uses (window − output headroom), not the full window + // (issue #267). + const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model); // Use pi's real context usage (anchored on provider usage) only for the // panel's footer-scale display line; see sentTokens below for arbitration. const realUsage = ctx.getContextUsage?.(); diff --git a/src/index.ts b/src/index.ts index e250387..6f6680c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,7 @@ import { } from "./throttle-retry.js"; import { defaultCountTokens } from "acp-kernel"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; -import { inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js"; +import { applyOutputHeadroom, inspectOverflowMessage } from "./overflow-selfheal.js"; import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js"; @@ -232,6 +232,7 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf }); pi.on("session_shutdown", (_event, ctx) => { runtime.clearDeadCompress(ctx.sessionManager.getSessionId()); + runtime.dropTokenScale(ctx.sessionManager.getSessionId()); delegateStatusWidget.dispose(); closeLogStream(); }); @@ -273,20 +274,13 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // Output headroom: reserve the model's max output budget from the window // so the kernel's nudge/truncate bands sit below (window - maxTokens) — // the context then always leaves room for the model's reply, preventing - // the "context + output > window" overflow on a small window. maxTokens is - // the model's max output capability (ctx.model.maxTokens). Applied to the - // (possibly re-centered) window above; never mutates the shared config. - // Anthropic is exempt — its input limit is enforced independently of - // max_tokens, so reserving would shift every band down by maxTokens with - // no safety gain (see shouldReserveOutputHeadroom). - const maxOutput = (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0; - if (shouldReserveOutputHeadroom((ctx.model as { api?: string } | undefined)?.api)) { - const reservedWindow = reserveOutputHeadroom(config.modelContextLimit, maxOutput); - if (reservedWindow !== config.modelContextLimit) { - const before = config.modelContextLimit; - config = { ...config, modelContextLimit: reservedWindow }; - logInfo("overflow-selfheal", { sid, event: "output-headroom", before, after: reservedWindow, maxOutput }); - } + // the "context + output > window" overflow on a small window. Applied to + // the (possibly re-centered) window above; never mutates the shared + // config. Anthropic is exempt (see applyOutputHeadroom). + const beforeHeadroom = config.modelContextLimit; + config = applyOutputHeadroom(config, ctx.model); + if (config.modelContextLimit !== beforeHeadroom) { + logInfo("overflow-selfheal", { sid, event: "output-headroom", before: beforeHeadroom, after: config.modelContextLimit, maxOutput: (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0 }); } const coveredIds = collectCoveredMessageIds(state); // Nudge arbitration on the SENT-VIEW scale: CJK-aware estimate over the @@ -329,6 +323,26 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf logInfo("turn", { sid, event: "view-recount", prelim: sentTokens, viewTokens: view.viewTokens, tokenCount }); } } + // Growth scale guard (issue #267): the meter switches rulers when the + // anchor flips stale↔not-stale (estimate ↔ provider). A growth delta + // spanning that switch is a false artifact, not real growth, so reset the + // growth baselines on the flip: the T1 growth reference (lastNudgeShownTokens + // / lastPerMessageNudgeTokens) AND the per-tier cadence baselines + // (lastShownByTier, kernel 0.0.55: cadence = tokenCount - lastShownByTier[t] + // >= growthFloor) — an old-scale lastShown subtracted from a new-scale + // tokenCount is exactly the false "+35k growth" artifact from the issue. + // The extension-side re-inject stamps (#269 / PR #316) are reset too: + // growth for the same-turn re-inject is tokenCount - nudgeShownTokensFor(turnKey) + // and an old-scale stamp would fake a full-floor growth after the flip. + // The usage bands above keep the floor-stale behavior untouched — only + // the growth references are re-anchored. + if (runtime.noteTokenScale(sid, !hostFloorActive)) { + state.nudge.lastNudgeShownTokens = 0; + state.nudge.lastPerMessageNudgeTokens = 0; + state.nudge.lastShownByTier = {}; + runtime.clearNudgeTokenStamps(); + logInfo("growth-scale", { sid, event: "scale-flip-reset", anchorStale: !hostFloorActive }); + } debug.event("context-in", { sid, modelId, diff --git a/src/overflow-selfheal.ts b/src/overflow-selfheal.ts index 72f362b..bb749b0 100644 --- a/src/overflow-selfheal.ts +++ b/src/overflow-selfheal.ts @@ -111,6 +111,27 @@ export function shouldReserveOutputHeadroom(api: string | undefined): boolean { return api !== "anthropic-messages"; } +/** + * Apply the output-headroom reservation to a resolved config's modelContextLimit + * (see reserveOutputHeadroom / shouldReserveOutputHeadroom). Returns a NEW config + * (never mutates the input) so the shared resolved config stays untouched. Used + * by BOTH the live context transform and the read-only panel surfaces (/acp, + * acp_status) so every percentage is measured against the SAME real request + * limit — otherwise the panel reports against the full window while the nudge + * bands run against (window − maxOutput) (issue #267). + */ +export function applyOutputHeadroom( + config: T, + model: { maxTokens?: number; api?: string } | undefined, +): T { + const maxOutput = model?.maxTokens ?? 0; + if (shouldReserveOutputHeadroom(model?.api)) { + const reserved = reserveOutputHeadroom(config.modelContextLimit, maxOutput); + if (reserved !== config.modelContextLimit) return { ...config, modelContextLimit: reserved }; + } + return config; +} + // Per-session overflow self-heal state. Keyed by session id so concurrent // sessions in one extension instance cannot share a learned window or an // armed emergency (same rationale as the throttle episode). diff --git a/src/runtime.ts b/src/runtime.ts index e6a5071..cfb7bbd 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -59,6 +59,13 @@ export interface AcpRuntime { * pending kick sleep and releases the map entry so a long-lived process * that cycles through many sessions doesn't accumulate them. */ throttleDrop: (sid: string) => void; + /** Per-session tokenCount scale tracker (estimate vs provider). Returns true + * when the scale just flipped (stale↔not-stale) so the caller can reset the + * growth baseline — a cross-scale delta is a false artifact, not real growth + * (issue #267). The first observation for a session never reports a flip. */ + noteTokenScale: (sid: string, stale: boolean) => boolean; + /** Drop a session's token-scale tracker (session_shutdown). */ + dropTokenScale: (sid: string) => void; store: SessionStateStore; adapter: AdapterConfig; setAdapter(adapter: AdapterConfig): void; @@ -68,6 +75,8 @@ export interface AcpRuntime { nudgeShownFor(turnKey: string): boolean; /** tokenCount at the last actual nudge injection for this turn, for growth-aware re-inject (issue #269). */ nudgeShownTokensFor(turnKey: string): number | undefined; + /** Clears the token-count stamps recorded by markNudgeShown — used on a token-scale flip (issue #267) so the same-turn re-inject floor (#269 / PR #316) is not computed against an old-scale stamp. */ + clearNudgeTokenStamps(): void; /** Process compress toolResults for the CURRENT user turn only (the caller * scopes the list — see collectCompressOutcomes in src/index.ts); idempotent * per toolCallId. Outcome classes: isError or noop (0-block panel) → @@ -295,6 +304,19 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { throttleEpisodes.delete(sid); } + // Per-session tokenCount scale (estimate vs provider). When the anchor flips + // stale↔not-stale the meter switches rulers; a growth delta spanning that + // switch is a false artifact (issue #267), so the caller resets the baseline. + const tokenScaleStale = new Map(); + function noteTokenScale(sid: string, stale: boolean): boolean { + const prev = tokenScaleStale.get(sid); + tokenScaleStale.set(sid, stale); + return prev !== undefined && prev !== stale; + } + function dropTokenScale(sid: string): void { + tokenScaleStale.delete(sid); + } + // Compress-failure tracking (see wireContextTransform): counts FAILED/no-op // compress calls per user turn so the nudge circuit breaker can stop // re-injecting the nudge at a model that answers every nudge with another @@ -435,4 +457,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} diff --git a/src/status-tool.ts b/src/status-tool.ts index a248528..1fc30ad 100644 --- a/src/status-tool.ts +++ b/src/status-tool.ts @@ -4,6 +4,7 @@ import type { AcpRuntime } from "./runtime.js"; import { buildStatusReport, defaultCountTokens, formatRanges, viableRanges } from "acp-kernel"; import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; import { usageAnchorPredatesCompression } from "./floor-stale.js"; +import { applyOutputHeadroom } from "./overflow-selfheal.js"; import { getSystemPromptText } from "./compat.js"; import { logThrow } from "./log.js"; import { getDelegateUsage } from "./delegate-tool.js"; @@ -49,7 +50,9 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition { const { state, coreMessages, entries } = await runtime.stateFor(ctx); - const config = runtime.configFor(ctx); + // Same real request limit (window − output headroom) as the live context + // transform, so the reported percentages match the nudge bands (issue #267). + const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model); // Run the same pipeline (assign-refs → prune → hide-compress-calls → ...) that // the context transform runs, so what acp_status reports matches what the // model actually receives. Without this, consumed/hidden compress calls and diff --git a/src/update.ts b/src/update.ts index 090f33e..0dc67c7 100644 --- a/src/update.ts +++ b/src/update.ts @@ -3,6 +3,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { homedir } from "node:os"; +import { createHash } from "node:crypto"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import { debug, logInfo, logWarn } from "./log.js"; @@ -13,12 +14,31 @@ const registryUrl = (tag: string) => `https://registry.npmjs.org/${PACKAGE_NAME}/${encodeURIComponent(tag)}`; const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/; const CHECK_INTERVAL_MS = 3 * 60 * 1000; +// Short stable key for an install location, used to scope the throttle + +// read-only marker files per copy. Two copies of the extension (e.g. an +// `npm i -g` global install and pi's own npm dir) must not share these files: +// a healthy copy refreshing the throttle would otherwise silently suppress a +// failing copy's checks, and a read-only location's stop-retry marker would +// wrongly apply to a writable one (issue #267). +function locationKey(location: string): string { + return createHash("sha256").update(location).digest("hex").slice(0, 12); +} // Resolved lazily (not at module load) so tests can redirect it via env at any // time. Without this, parallel test processes race on the real file under the // user's home dir: one process stamps the throttle timestamp while another has // just deleted it, making the victim's check skip "npm view" entirely. -const throttleFile = () => - process.env.ACP_UPDATE_THROTTLE_FILE ?? join(homedir(), CONFIG_DIR_NAME, "agent", ".billion-context-pi-update-check"); +// Keyed by the extension dir so each installed copy throttles independently. +const throttleFileFor = (extDir?: string): string => { + if (process.env.ACP_UPDATE_THROTTLE_FILE) return process.env.ACP_UPDATE_THROTTLE_FILE; + const base = join(homedir(), CONFIG_DIR_NAME, "agent"); + return extDir ? join(base, `.billion-context-pi-update-check-${locationKey(extDir)}`) : join(base, ".billion-context-pi-update-check"); +}; +// Persistent marker for an install location that failed with a permission +// error (EACCES/EPERM). Once set, auto-update stops retrying that location — +// a read-only global prefix can never be fixed by re-running npm install, so +// retrying is a pure infinite loop (issue #267). The user must `npm i -g`. +export const readOnlyMarkerFile = (extDir: string): string => + join(homedir(), CONFIG_DIR_NAME, "agent", `.billion-context-pi-readonly-${locationKey(extDir)}`); // Guards against concurrent checks: the context event fires on every LLM call, // so several can race past the throttle read before any writes the timestamp. @@ -176,24 +196,42 @@ function parseSemVer(version: string): { parts: number[]; pre: string[] } | unde }; } -async function readLastCheck(): Promise { +async function readLastCheck(throttle: string): Promise { try { - const data = await readFile(throttleFile(), "utf-8"); + const data = await readFile(throttle, "utf-8"); return parseInt(data.trim(), 10) || 0; } catch { return 0; } } -async function writeLastCheck(timestamp: number): Promise { +async function writeLastCheck(timestamp: number, throttle: string): Promise { try { - await mkdir(dirname(throttleFile()), { recursive: true }); - await writeFile(throttleFile(), String(timestamp), "utf-8"); + await mkdir(dirname(throttle), { recursive: true }); + await writeFile(throttle, String(timestamp), "utf-8"); } catch { // best-effort } } +async function isReadOnlyLocation(extDir: string): Promise { + try { + await access(readOnlyMarkerFile(extDir)); + return true; + } catch { + return false; + } +} + +async function markReadOnlyLocation(extDir: string): Promise { + try { + await mkdir(dirname(readOnlyMarkerFile(extDir)), { recursive: true }); + await writeFile(readOnlyMarkerFile(extDir), String(Date.now()), "utf-8"); + } catch { + // best-effort: if we can't write the marker we'll keep retrying (old behavior) + } +} + type PackageJson = { name?: string; version?: string; @@ -222,7 +260,7 @@ export function findNpmRoot(extDir: string): string | undefined { } } -async function findExtensionDir(): Promise { +export async function findExtensionDir(): Promise { let dir = dirname(fileURLToPath(import.meta.url)); for (;;) { const pkg = await readPackageJson(join(dir, "package.json")); @@ -233,7 +271,13 @@ async function findExtensionDir(): Promise { } } -export type InstallOutcome = "ok" | "failed" | "rolled-back"; +export type InstallOutcome = "ok" | "failed" | "rolled-back" | "read-only"; + +// A permission failure means the install prefix itself is not writable (e.g. an +// `npm i -g` global prefix owned by root). Re-running npm install can never fix +// that, so the caller stops retrying the location and tells the user to run +// `npm i -g` (issue #267). Matched against npm's stderr, not the exit code. +const PERMISSION_ERROR_RE = /EACCES|EPERM|permission denied|operation not permitted|read-only file system|read only file system/i; // The declared entries pi/loaders may touch: pi's own extension entry, the // ESM export, and main. All of them must exist on disk after an install. @@ -321,6 +365,13 @@ export async function autoInstallLatest(latest: string, extDirOverride?: string) npmDir, stderr: stderr.trim().slice(-2000), }); + if (PERMISSION_ERROR_RE.test(stderr)) { + // The install prefix is not writable (e.g. root-owned global prefix). + // Mark it so we stop retrying, and surface a user-visible hint. + await markReadOnlyLocation(extDir); + logWarn("update", { event: "auto-install-read-only", latest, npmDir }); + return "read-only"; + } return "failed"; } const verify = await verifyInstall(npmDir, latest); @@ -405,11 +456,20 @@ export async function checkForUpdate( if (updateInFlight) return; updateInFlight = true; try { + const extDir = await findExtensionDir(); + // A location already marked read-only (EACCES) can never be fixed by + // re-running npm install — skip the whole check (no npm view, no install) + // and remind the user once per process (issue #267). + if (extDir && (await isReadOnlyLocation(extDir))) { + notifyReadOnly(notify); + return; + } + const throttle = throttleFileFor(extDir); const now = Date.now(); - const lastCheck = await readLastCheck(); + const lastCheck = await readLastCheck(throttle); if (now - lastCheck < CHECK_INTERVAL_MS) return; - await writeLastCheck(now); + await writeLastCheck(now, throttle); const runtimeVersion = await getRuntimeVersion(); // Follow the channel the user installed from (dist-tag), not the global @@ -437,8 +497,10 @@ export async function checkForUpdate( logInfo("update", { event: "check", current, latest, hasUpdate }); if (hasUpdate) { - const outcome = await autoInstallLatest(latest); - if (outcome === "ok" && notify) { + const outcome = await autoInstallLatest(latest, extDir); + if (outcome === "read-only" && notify) { + notifyReadOnly(notify); + } else if (outcome === "ok" && notify) { notify( `\x1b[32m\u2714 ACP auto-updated ${current} \u2192 ${latest}. Restart Pi to finish.\x1b[0m`, ); @@ -463,6 +525,21 @@ export async function checkForUpdate( } } +// Emitted at most once per process so a read-only location (checked on every +// context event) does not spam a toast on each LLM call. +let readOnlyNotified = false; +function notifyReadOnly(notify?: (msg: string) => void): void { + if (!notify || readOnlyNotified) return; + readOnlyNotified = true; + notify( + `\x1b[33m\u26a0 ACP auto-update cannot write to this install location (no permission). ` + + `To update run \`npm i -g ${PACKAGE_NAME}\`, or remove the global copy if you rely on pi's bundled install.\x1b[0m`, + ); +} +export function resetUpdateStateForTest(): void { + readOnlyNotified = false; +} + async function getRuntimeVersion(): Promise { const extDir = await findExtensionDir(); if (!extDir) return undefined; diff --git a/tests/growth-scale-flip.test.ts b/tests/growth-scale-flip.test.ts new file mode 100644 index 0000000..cf405a1 --- /dev/null +++ b/tests/growth-scale-flip.test.ts @@ -0,0 +1,135 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile, rm } from "node:fs/promises"; +import { createAcpExtension } from "../src/index.js"; + +// issue #267: the meter switches rulers when the usage anchor flips +// stale↔not-stale (estimate ↔ provider). A growth delta spanning that switch +// is a false artifact. The context transform must re-anchor the growth +// baseline on the flip so growth only accumulates same-source deltas — while +// the usage bands keep the floor-stale behavior untouched. + +const STATE_FILE = "/tmp/pai-acp-growth-scale.session.json"; + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + }; + return { api, handlers }; +} + +function msg(id: string, role: string, text: string, over: Record = {}) { + return { type: "message", id, parentId: null, timestamp: "", message: { role, content: text, timestamp: Date.now(), ...over } }; +} + +const MID = "lorem ".repeat(3000); +const COMPRESS_PANEL = "▣ ACP | 42.3K → 18.9K tokens (~23.4K reclaimed, 3 blocks)"; + +let branchEntries: any[] = []; + +function fakeCtx(tokens: number) { + return { + mode: "rpc" as const, + hasUI: false, + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 180_000 }, + sessionManager: { + getBranch: () => branchEntries as any[], + getSessionId: () => "growth-scale", + getSessionFile: () => STATE_FILE, + }, + getContextUsage: () => ({ tokens, percent: tokens / 180_000, contextWindow: 180_000 }), + }; +} + +const fire = (handlers: Map any)[]>, entries: any[], ctx: any) => + handlers.get("context")![0]!({ type: "context", messages: entries.map((e) => e.message) }, ctx); + +// 20 MID-sized messages (~75-90K estimate) + a 175K provider usage anchor. +function bulkEntries(): any[] { + const entries: any[] = [msg("e0", "user", "start " + MID)]; + for (let i = 1; i <= 18; i++) entries.push(msg(`e${i}`, i % 2 ? "assistant" : "user", `f${i} ` + MID)); + return entries; +} + +test("growth baseline re-anchors on the stale→not-stale scale flip (no cross-scale false growth)", async () => { + await rm(`${STATE_FILE}.acp.json`, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + + // Turn 1 — STALE: the successful compress lands after the 175K usage anchor, + // so the meter runs on the estimate scale (sentTokens), far below 175K. + const staleEntries = [ + ...bulkEntries(), + { type: "message", id: "e19", parentId: null, timestamp: "", message: { role: "assistant", content: "f19 " + MID, timestamp: Date.now(), usage: { input: 175_000, cacheRead: 0, cacheWrite: 0 } } }, + { type: "message", id: "e20", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "c1", content: [{ type: "text", text: COMPRESS_PANEL }], timestamp: Date.now() } }, + ]; + branchEntries = staleEntries; + await fire(handlers, staleEntries, fakeCtx(175_000)); + const afterStale = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + assert.ok(afterStale.nudge.lastPerMessageNudgeTokens < 100_000, "turn 1 baseline on estimate scale"); + + // Turn 2 — NOT-STALE: a fresh 175K usage lands after the compress, so the + // meter switches to the provider scale. Without the scale-flip reset the + // baseline would still be the turn-1 estimate and growth would read as a + // huge false delta (provider − estimate). + const freshEntries = [ + ...staleEntries, + { type: "message", id: "e21", parentId: null, timestamp: "", message: { role: "assistant", content: "f20 " + MID, timestamp: Date.now(), usage: { input: 175_000, cacheRead: 0, cacheWrite: 0 } } }, + ]; + branchEntries = freshEntries; + await fire(handlers, freshEntries, fakeCtx(175_000)); + const afterFresh = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + assert.ok( + afterFresh.nudge.lastPerMessageNudgeTokens >= 170_000, + `baseline re-anchored to provider scale after flip (got ${afterFresh.nudge.lastPerMessageNudgeTokens})`, + ); + // Per-tier cadence baselines (kernel 0.0.55 lastShownByTier) must also live + // on one scale: old-scale lastShown minus new-scale tokenCount is exactly + // the false "+35k growth" cadence bypass from issue #267. + for (const [tier, shownAt] of Object.entries(afterFresh.nudge.lastShownByTier ?? {})) { + assert.ok( + (shownAt as number) >= 170_000, + `tier ${tier} cadence baseline re-anchored after flip (got ${shownAt})`, + ); + } + await rm(`${STATE_FILE}.acp.json`, { force: true }); +}); + +test("no reset when the scale is stable (baseline keeps accumulating same-source growth)", async () => { + await rm(`${STATE_FILE}.acp.json`, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + + // Two consecutive NOT-STALE turns (provider scale, no compress in between): + // the baseline must NOT be reset — it stays anchored so real growth is tracked. + const entriesA = [ + ...bulkEntries(), + { type: "message", id: "e19", parentId: null, timestamp: "", message: { role: "assistant", content: "f19 " + MID, timestamp: Date.now(), usage: { input: 120_000, cacheRead: 0, cacheWrite: 0 } } }, + ]; + branchEntries = entriesA; + await fire(handlers, entriesA, fakeCtx(120_000)); + const afterA = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + const baselineA = afterA.nudge.lastPerMessageNudgeTokens; + assert.ok(baselineA >= 115_000, `turn 1 baseline on provider scale (got ${baselineA})`); + + const entriesB = [ + ...entriesA, + { type: "message", id: "e20", parentId: null, timestamp: "", message: { role: "assistant", content: "f20 " + MID, timestamp: Date.now(), usage: { input: 130_000, cacheRead: 0, cacheWrite: 0 } } }, + ]; + branchEntries = entriesB; + await fire(handlers, entriesB, fakeCtx(130_000)); + const afterB = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + // Same scale (provider) both turns → no flip → baseline unchanged from turn 1. + assert.equal(afterB.nudge.lastPerMessageNudgeTokens, baselineA, "stable scale keeps the baseline (no spurious reset)"); + await rm(`${STATE_FILE}.acp.json`, { force: true }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index 6a5ca8c..c7dd614 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1,8 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { homedir, tmpdir } from "node:os"; -import { mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import type { NpmRunner } from "../src/update.js"; @@ -35,6 +35,9 @@ const { isAutoUpdatableSpec, runNpm, runNode, + findExtensionDir, + readOnlyMarkerFile, + resetUpdateStateForTest, } = await import("../src/update.js"); const THROTTLE = join( @@ -447,3 +450,56 @@ test("autoInstallLatest: npm install failure → failed, no rollback, no verify" assert.equal(nodeCalls, 0); rmSync(fx.root, { recursive: true, force: true }); }); + +// --- read-only (EACCES) handling (issue #267) --- + +test("autoInstallLatest: EACCES install failure → read-only outcome + stop-retry marker written", async () => { + const fx = makeFixture(); + fx.writeInstalled("1.2.3"); + setRunNpmForTest(makeFakeNpm( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "npm error code EACCES\nnpm error syscall open\nnpm error errno -13" }, + ).impl); + setRunNodeForTest(async () => ({ code: 0, stdout: "", stderr: "" })); + const outcome = await autoInstallLatest("9.9.9", fx.extDir); + assert.equal(outcome, "read-only"); + assert.ok(existsSync(readOnlyMarkerFile(fx.extDir)), "stop-retry marker written for the read-only location"); + rmSync(fx.root, { recursive: true, force: true }); +}); + +test("autoInstallLatest: non-permission failure (404) → failed, no stop-retry marker", async () => { + const fx = makeFixture(); + fx.writeInstalled("1.2.3"); + setRunNpmForTest(makeFakeNpm( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "npm error 404 Not Found - GET" }, + ).impl); + setRunNodeForTest(async () => ({ code: 0, stdout: "", stderr: "" })); + const outcome = await autoInstallLatest("9.9.9", fx.extDir); + assert.equal(outcome, "failed"); + assert.ok(!existsSync(readOnlyMarkerFile(fx.extDir)), "no marker for a non-permission failure"); + rmSync(fx.root, { recursive: true, force: true }); +}); + +test("checkForUpdate: read-only marker present → skips npm view entirely + notifies once per process", async () => { + resetUpdateStateForTest(); + const extDir = await findExtensionDir(); + assert.ok(extDir, "extension dir resolvable in test"); + mkdirSync(dirname(readOnlyMarkerFile(extDir)), { recursive: true }); + writeFileSync(readOnlyMarkerFile(extDir), String(Date.now())); + let npmCalls = 0; + setRunNpmForTest(async () => { npmCalls += 1; return { code: 0, stdout: "", stderr: "" }; }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("fetch must not be called for a read-only location"); }) as typeof fetch; + const notes: string[] = []; + try { + await checkForUpdate(true, (m) => notes.push(m)); + await checkForUpdate(true, (m) => notes.push(m)); + } finally { + globalThis.fetch = originalFetch; + rmSync(readOnlyMarkerFile(extDir), { force: true }); + } + assert.equal(npmCalls, 0, "no npm view when the install location is read-only"); + assert.equal(notes.length, 1, "notify emitted once per process, not once per check"); + assert.match(notes[0], /npm i -g billion-context-pi/); +});