From 0d3be100f7d7ce97af24186a0a5a65132315c5f3 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Mon, 3 Aug 2026 01:02:47 -0700 Subject: [PATCH 01/97] fix(natives): retain fresh native caches --- packages/natives/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 514d826b5..35c40f99e 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -18,6 +18,9 @@ - Fixed accessibility snapshots incorrectly marking a window root as focused based on its app-local AXFocused attribute when another application held global focus; the root annotation now correctly reflects the global window-roster focus flag. - Improved coordinate-frame error messages for pointer input before capture, out-of-frame coordinates, and between-display points to clearly explain the capture-frame contract and remedy instead of throwing a generic bounds check. - Fixed duplicated characters in AppKit targets on macOS caused by background keyboard events being posted through both CoreGraphics and SkyLight; events are now delivered once via the authenticated SkyLight route. +### Fixed + +- Fixed newer OMP versions deleting a freshly created older native addon cache directory during concurrent startup, which could interrupt extraction with `ENOENT`. ## [17.2.2] - 2026-07-31 From 534bb5787d96e76db8a2c314b4863d368cfb46ed Mon Sep 17 00:00:00 2001 From: Wolfie Date: Mon, 3 Aug 2026 01:02:54 -0700 Subject: [PATCH 02/97] fix(natives): retain fresh native caches --- packages/natives/native/loader-state.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/natives/native/loader-state.js b/packages/natives/native/loader-state.js index e77ff2ea1..db06d5fd6 100644 --- a/packages/natives/native/loader-state.js +++ b/packages/natives/native/loader-state.js @@ -196,6 +196,12 @@ function isOlderReleaseVersion(candidate, current) { return false; } +// A concurrently starting older OMP binary creates or refreshes this directory +// before extracting its addon. Keep fresh directories long enough for that +// startup to finish; a later launch can reclaim them once they are genuinely +// stale. +const NATIVE_CACHE_CLEANUP_GRACE_MS = 10 * 60_000; + /** * Remove version-pinned native cache directories older than the loaded package. * Best-effort by design: permission errors and concurrent processes must not @@ -217,6 +223,8 @@ export function cleanupStaleNativeVersions({ nativesDir, currentVersion }) { if (!entry.isDirectory() || !isOlderReleaseVersion(entry.name, currentVersion)) continue; const targetPath = path.join(nativesDir, entry.name); try { + const stat = fs.statSync(targetPath); + if (Date.now() - stat.mtimeMs < NATIVE_CACHE_CLEANUP_GRACE_MS) continue; fs.rmSync(targetPath, { recursive: true, force: true }); removed.push(targetPath); } catch { From 7b63fc274039653686552fb0cd3c520e66adaf59 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Mon, 3 Aug 2026 01:02:59 -0700 Subject: [PATCH 03/97] test(natives): cover fresh cache cleanup grace --- packages/natives/test/windows-staging.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/natives/test/windows-staging.test.ts b/packages/natives/test/windows-staging.test.ts index 01be98647..144f8ef6a 100644 --- a/packages/natives/test/windows-staging.test.ts +++ b/packages/natives/test/windows-staging.test.ts @@ -195,18 +195,22 @@ describe("windows native addon staging", () => { const nativesDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-natives-cache-")); const currentMajor = Number.parseInt(packageJson.version, 10); const futureVersion = `${currentMajor + 1}.0.0`; + const staleVersion = "15.10.11"; + const freshVersion = "15.10.12"; try { - await fs.mkdir(path.join(nativesDir, "15.10.11")); + await fs.mkdir(path.join(nativesDir, staleVersion)); + await fs.mkdir(path.join(nativesDir, freshVersion)); await fs.mkdir(path.join(nativesDir, packageJson.version)); await fs.mkdir(path.join(nativesDir, futureVersion)); await fs.mkdir(path.join(nativesDir, "not-a-version")); await Bun.write(path.join(nativesDir, "README.txt"), "not a version directory"); + await fs.utimes(path.join(nativesDir, staleVersion), new Date(0), new Date(0)); const removed = cleanupStaleNativeVersions({ nativesDir, currentVersion: packageJson.version }); - expect(removed.map(filePath => path.basename(filePath))).toEqual(["15.10.11"]); + expect(removed.map(filePath => path.basename(filePath))).toEqual([staleVersion]); expect((await fs.readdir(nativesDir)).sort()).toEqual( - ["README.txt", packageJson.version, futureVersion, "not-a-version"].sort(), + ["README.txt", freshVersion, packageJson.version, futureVersion, "not-a-version"].sort(), ); } finally { await fs.rm(nativesDir, { recursive: true, force: true }); From 687f3326b2834c1743d648f6389c6993a0b668f7 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:14:25 +0900 Subject: [PATCH 04/97] fix(task): bound abort cleanup and quarantine late jobs --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/async/job-manager.ts | 26 ++++++ .../src/registry/agent-lifecycle.ts | 24 +++++- packages/coding-agent/src/task/executor.ts | 82 ++++++++++++++----- .../coding-agent/src/task/isolation-runner.ts | 20 ++++- .../src/task/structured-subagent.ts | 19 ++++- .../coding-agent/src/utils/late-cleanup.ts | 17 ++++ .../test/async-job-manager.test.ts | 24 ++++++ .../test/registry/agent-lifecycle.test.ts | 17 ++++ .../task/executor-async-quiescence.test.ts | 37 +++++++++ .../test/task/isolation-runner.test.ts | 57 +++++++++++++ 11 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 packages/coding-agent/src/utils/late-cleanup.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b20591af8..631d117a2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Task cancellation hanging forever when a child ignored abort or stalled during cleanup ([#7483](https://github.com/can1357/oh-my-pi/issues/7483)). + ## [17.2.5] - 2026-08-03 ### Breaking Changes diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index 71d498c01..1fc9e6535 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -94,6 +94,12 @@ export interface AsyncJobDeliveryState { pendingJobIds: string[]; } +export interface AsyncJobReapResult { + settled: boolean; + pendingJobIds: string[]; + completion: Promise; +} + export interface AsyncJobRegisterOptions { id?: string; /** Registry id of the agent that owns this job; used to scope cancelAll. */ @@ -490,6 +496,26 @@ export class AsyncJobManager { } } + /** + * Cancel every job owned by `ownerId`, then wait only until `deadlineAt`. + * The returned completion keeps waiting for actual process settlement when + * the deadline expires, so callers can move that cleanup out of the + * user-visible Task wait without losing ownership of the live work. + */ + async cancelAndReapOwnerJobs(ownerId: string, deadlineAt: number): Promise { + this.cancelAll({ ownerId }); + const timeoutMs = Math.max(0, deadlineAt - Date.now()); + const settled = await this.waitForOwnerJobs(ownerId, { timeoutMs }); + if (settled) { + return { settled: true, pendingJobIds: [], completion: Promise.resolve() }; + } + const pendingJobIds = this.getAllJobs({ ownerId }) + .filter(job => job.status === "running" || job.status === "cancelled") + .map(job => job.id); + const completion = this.waitForOwnerJobs(ownerId).then(() => {}); + return { settled: false, pendingJobIds, completion }; + } + async #waitForAllUntil(deadline: number): Promise { const promises = Array.from(this.#jobs.values()).map(job => job.promise); if (promises.length === 0) return true; diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts index 3864c60f1..525ee5116 100644 --- a/packages/coding-agent/src/registry/agent-lifecycle.ts +++ b/packages/coding-agent/src/registry/agent-lifecycle.ts @@ -20,8 +20,9 @@ * a superseded revive) can never clobber a newer same-id ref. */ -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { type AgentRef, type AgentRefExpectation, @@ -32,6 +33,8 @@ import { export type AgentReviver = (expected: AgentRef) => Promise; +const AGENT_RELEASE_GRACE_MS = 5000; + /** * Builds a reviver for a `parked` ref restored from disk (Agent Hub scan, * collab mirror, resumed process) that carries a sessionFile but no in-memory @@ -402,11 +405,26 @@ export class AgentLifecycleManager { } /** Teardown everything (process exit / main session dispose). */ - async dispose(): Promise { + async dispose(deadlineAt: number = Date.now() + AGENT_RELEASE_GRACE_MS): Promise { this.#unsubscribe?.(); this.#unsubscribe = undefined; const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])]; - await Promise.all(ids.map(id => this.release(id))); + await Promise.all( + ids.map(async id => { + const release = this.release(id).then(() => {}); + try { + await untilAborted(AbortSignal.timeout(Math.max(0, deadlineAt - Date.now())), () => release); + } catch (error) { + if (Date.now() >= deadlineAt) { + trackLateCleanup(release, { id, resource: "adopted-agent" }); + } + logger.warn("Agent cleanup exceeded its deadline", { + id, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); this.#revivals.clear(); this.#parks.clear(); this.#persistedReviverFactory = undefined; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 5538130a0..b6fe3540b 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -54,6 +54,7 @@ import { normalizeSchema } from "../tools/jtd-to-json-schema"; import { buildOutputValidator, summarizeValidationFailure } from "../tools/output-schema-validator"; import { ToolAbortError } from "../tools/tool-errors"; import type { EventBus } from "../utils/event-bus"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { buildNamedToolChoice } from "../utils/tool-choice"; import type { WorkspaceTree } from "../workspace-tree"; import { generateTaskLabel } from "./label"; @@ -79,6 +80,7 @@ import { arrayValuedLabels, assembleYieldResult } from "./yield-assembly"; export type { YieldItem } from "./types"; const MCP_CALL_TIMEOUT_MS = 60_000; +const TASK_ABORT_CLEANUP_GRACE_MS = 10_000; /** * Soft per-agent request budgets (assistant requests per run). Crossing the @@ -455,6 +457,8 @@ export interface ExecutorOptions { * set this false so disposal unregisters them instead of leaving idle peers. */ keepAlive?: boolean; + /** Internal ownership handoff for cleanup that outlives the visible Task result. */ + onCleanupDeferred?: (completion: Promise) => void; } function parseStringifiedJson(value: unknown): unknown { @@ -1964,9 +1968,7 @@ async function driveSessionToYield( // yield: the next iteration's ladder demands a fresh one. } - if (monitor.yieldCalled()) { - await session.waitForIdle(); - } else { + if (!monitor.yieldCalled()) { await awaitAbortable(session.waitForIdle()); } @@ -2346,15 +2348,27 @@ export async function finalizeSubagentLifecycle(args: { isolated: boolean; agentIdleTtlMs: number; reviveSession: AgentReviver | null; + cleanupDeadlineAt?: number; + onCleanupDeferred?: (completion: Promise) => void; }): Promise { const registry = AgentRegistry.global(); const ref = registry.get(args.id); const ownsRef = Boolean(ref && ref.session === args.session); + const cleanupDeadlineAt = args.cleanupDeadlineAt ?? Date.now() + 5000; const disposeSession = async (): Promise => { + const disposal = args.session.dispose(); + const remainingMs = Math.max(0, cleanupDeadlineAt - Date.now()); try { - await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose()); - } catch { - // Ignore cleanup errors + await untilAborted(AbortSignal.timeout(remainingMs), () => disposal); + } catch (error) { + if (Date.now() >= cleanupDeadlineAt) { + args.onCleanupDeferred?.(disposal); + return; + } + logger.warn("Subagent session cleanup failed", { + id: args.id, + error: error instanceof Error ? error.message : String(error), + }); } }; @@ -3186,6 +3200,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise[] = []; + const deferCleanup = (completion: Promise): void => { + lateCleanups.push(completion); + exitCode = 1; + aborted = true; + abortReasonText = `cleanup exceeded ${TASK_ABORT_CLEANUP_GRACE_MS} ms`; + error ??= `Task aborted. Cleanup did not finish within ${TASK_ABORT_CLEANUP_GRACE_MS} ms. No isolated changes were applied.`; + }; if (abortSignal.aborted) { aborted = monitor.isAbortedRun(); if (aborted) { @@ -3194,10 +3217,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise monitor.waitForActiveSessionAbort()); - } catch { - // Ignore abort cleanup timeouts/errors; terminal disposal below is still best-effort. + await untilAborted( + AbortSignal.timeout(Math.max(0, cleanupDeadlineAt - Date.now())), + () => activeSessionAbort, + ); + } catch (cleanupError) { + if (Date.now() >= cleanupDeadlineAt) { + deferCleanup(activeSessionAbort); + } else { + logger.warn("Subagent abort cleanup failed", { + id, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } } if (unsubscribe) { try { @@ -3207,6 +3241,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise 0) { + const completion = Promise.all(lateCleanups).then(() => {}); + trackLateCleanup(completion, { id, resource: "subagent" }); + options.onCleanupDeferred?.(completion); } } diff --git a/packages/coding-agent/src/task/isolation-runner.ts b/packages/coding-agent/src/task/isolation-runner.ts index c6e0a58f7..487d121ed 100644 --- a/packages/coding-agent/src/task/isolation-runner.ts +++ b/packages/coding-agent/src/task/isolation-runner.ts @@ -23,6 +23,7 @@ import type * as natives from "@oh-my-pi/pi-natives"; import type { ToolSession } from "../tools"; import { generateCommitMessage } from "../utils/commit-message-generator"; import * as git from "../utils/git"; +import { trackLateCleanup } from "../utils/late-cleanup"; import type { ExecutorOptions } from "./executor"; import { runSubprocess } from "./executor"; import type { SingleResult } from "./types"; @@ -146,6 +147,7 @@ async function writeIsolationPatch( */ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise { let handle: IsolationHandle | undefined; + let deferredCleanup: Promise | undefined; try { const taskBaseline = structuredClone(opts.context.baseline); handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend); @@ -155,7 +157,12 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise { + deferredCleanup = completion; + opts.baseOptions.onCleanupDeferred?.(completion); + }, }); + if (deferredCleanup) return result; if (opts.mergeMode === "branch" && result.exitCode === 0) { try { const commitResult = await commitToBranch( @@ -213,7 +220,18 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise cleanupIsolation(isolationHandle)), + { + agentId: opts.agentId, + resource: "isolation", + }, + ); + } else { + await cleanupIsolation(isolationHandle); + } } } } diff --git a/packages/coding-agent/src/task/structured-subagent.ts b/packages/coding-agent/src/task/structured-subagent.ts index 0726a102c..36680cbed 100644 --- a/packages/coding-agent/src/task/structured-subagent.ts +++ b/packages/coding-agent/src/task/structured-subagent.ts @@ -20,6 +20,7 @@ import type { TaskEffort } from "../thinking"; import type { ToolSession } from "../tools"; import { isIrcEnabled } from "../tools/hub"; import { buildOutputValidator } from "../tools/output-schema-validator"; +import { trackLateCleanup } from "../utils/late-cleanup"; import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery"; import { type ExecutorOptions, runSubprocess } from "./executor"; import { @@ -540,12 +541,16 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest): let mergeSummary = ""; let requiresRecoveryArtifacts = false; let completedSuccessfully = false; + let deferredCleanup: Promise | undefined; try { const id = await reserveStructuredSubagentId(request.session, { ...request.identity, label: request.identity?.label ?? (request.invocationKind === "eval" ? "EvalAgent" : undefined), }); const baseOptions = buildExecutorOptions(request, policy, lease, id); + baseOptions.onCleanupDeferred = completion => { + deferredCleanup = completion; + }; baseOptions.planReference = await loadPlanReference(request, policy); let isolationContext: IsolationContext | null = null; if (policy.isIsolated) { @@ -639,8 +644,18 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest): (policy.isIsolated && (!policy.applyChanges || changesApplied === false || requiresRecoveryArtifacts)); const shouldCleanup = lease.temporary && !shouldRetainArtifacts; if (shouldCleanup) { - await fs.rm(lease.artifactsDir, { recursive: true, force: true }); - lease.unregister?.(); + const cleanupArtifacts = async (): Promise => { + await fs.rm(lease.artifactsDir, { recursive: true, force: true }); + lease.unregister?.(); + }; + if (deferredCleanup) { + trackLateCleanup(deferredCleanup.then(cleanupArtifacts), { + resource: "artifacts", + artifactsDir: lease.artifactsDir, + }); + } else { + await cleanupArtifacts(); + } } } } diff --git a/packages/coding-agent/src/utils/late-cleanup.ts b/packages/coding-agent/src/utils/late-cleanup.ts new file mode 100644 index 000000000..7d9ae2d8d --- /dev/null +++ b/packages/coding-agent/src/utils/late-cleanup.ts @@ -0,0 +1,17 @@ +import { logger } from "@oh-my-pi/pi-utils"; + +const pendingCleanups = new Set>(); + +/** Keep timed-out cleanup reachable until its resources really settle. */ +export function trackLateCleanup(work: Promise, context: Record): void { + let tracked: Promise; + tracked = work + .catch(error => { + logger.warn("Deferred cleanup failed", { + ...context, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => pendingCleanups.delete(tracked)); + pendingCleanups.add(tracked); +} diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 1a336456a..21fe7529a 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -113,6 +113,30 @@ describe("AsyncJobManager", () => { expect(completions).toHaveLength(0); }); + test("bounds owner-job reap while preserving late settlement", async () => { + const manager = new AsyncJobManager({ onJobComplete: async () => {} }); + const release = Promise.withResolvers(); + const jobId = manager.register( + "task", + "ignores abort", + async () => { + await release.promise; + return "late result"; + }, + { ownerId: "owner" }, + ); + + const reap = await manager.cancelAndReapOwnerJobs("owner", Date.now()); + + expect(reap.settled).toBe(false); + expect(reap.pendingJobIds).toEqual([jobId]); + expect(manager.getJob(jobId)?.status).toBe("cancelled"); + + release.resolve(); + await reap.completion; + expect(manager.getJob(jobId)?.resultText).toBe("late result"); + }); + test("enforces maxRunningJobs cap", () => { const manager = new AsyncJobManager({ maxRunningJobs: 1, diff --git a/packages/coding-agent/test/registry/agent-lifecycle.test.ts b/packages/coding-agent/test/registry/agent-lifecycle.test.ts index 6c3356f66..1de361c77 100644 --- a/packages/coding-agent/test/registry/agent-lifecycle.test.ts +++ b/packages/coding-agent/test/registry/agent-lifecycle.test.ts @@ -314,6 +314,23 @@ describe("AgentLifecycleManager", () => { expect(registry.get("6-Sub")).toBeUndefined(); }); + it("does not let one stuck adopted agent block sibling disposal", async () => { + const gate = deferred(); + const stuck = makeSessionStub(() => gate.promise); + const sibling = makeSessionStub(); + registerIdleSub("stuck-Sub", stuck.session); + registerIdleSub("sibling-Sub", sibling.session); + lifecycle.adopt("stuck-Sub", { idleTtlMs: TTL }); + lifecycle.adopt("sibling-Sub", { idleTtlMs: TTL }); + + await lifecycle.dispose(Date.now()); + + expect(stuck.disposeCalls()).toBe(1); + expect(sibling.disposeCalls()).toBe(1); + gate.resolve(); + await flushAsync(); + }); + it("a delayed release cannot remove or mutate a replacement ref with the same id", async () => { const gate = deferred(); const oldSession = makeSessionStub(() => gate.promise); diff --git a/packages/coding-agent/test/task/executor-async-quiescence.test.ts b/packages/coding-agent/test/task/executor-async-quiescence.test.ts index 69e8e7dc8..29350000a 100644 --- a/packages/coding-agent/test/task/executor-async-quiescence.test.ts +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -250,4 +250,41 @@ describe("runSubprocess async quiescence fresh-yield contract", () => { expect(result.exitCode).toBe(0); expect(result.output).toContain("done"); }); + + it("does not wait on a second idle barrier after a terminal yield", async () => { + const harness = createAsyncSession(({ promptIndex, harness: h }) => { + if (promptIndex === 1) { + h.finishJob(); + h.emitTerminalYield({ report: "done" }); + } + }); + const idleStarted = Promise.withResolvers(); + const releaseIdle = Promise.withResolvers(); + let idleCalls = 0; + harness.session.waitForIdle = async () => { + idleCalls += 1; + idleStarted.resolve(); + await releaseIdle.promise; + }; + mockCreateAgentSession(harness.session); + + const run = runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "quiescence-no-second-idle", + }); + const outcome = await Promise.race([ + run.then(() => "completed" as const), + idleStarted.promise.then(() => "blocked" as const), + ]); + releaseIdle.resolve(); + const result = await run; + + expect(outcome).toBe("completed"); + expect(idleCalls).toBe(0); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("done"); + }); }); diff --git a/packages/coding-agent/test/task/isolation-runner.test.ts b/packages/coding-agent/test/task/isolation-runner.test.ts index 6a9a3c242..706196a18 100644 --- a/packages/coding-agent/test/task/isolation-runner.test.ts +++ b/packages/coding-agent/test/task/isolation-runner.test.ts @@ -139,6 +139,63 @@ describe("runIsolatedSubprocess", () => { expect(deleteSpy).toHaveBeenCalledWith(repoRoot, "omp/task/PreserveBranchFailure"); expect(cleanupSpy).toHaveBeenCalledTimes(1); }); + + it("keeps an isolated worktree until deferred child cleanup settles", async () => { + const cleanupGate = Promise.withResolvers(); + vi.spyOn(worktreeModule, "ensureIsolation").mockResolvedValue({ + mergedDir: "/repo/isolated", + backend: natives.IsoBackendKind.Rcopy, + fellBack: false, + fallbackReason: null, + }); + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + options.onCleanupDeferred?.(cleanupGate.promise); + return result({ exitCode: 1, aborted: true, error: "cleanup exceeded its deadline" }); + }); + const cleanupSpy = vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue(); + + const outcome = await runIsolatedSubprocess({ + baseOptions: { + cwd: "/repo", + agent: { + name: "task", + description: "Task agent", + systemPrompt: "test", + source: "bundled", + }, + task: "Do work", + index: 0, + id: "DeferredCleanup", + }, + context: { + repoRoot: "/repo", + baseline: { + root: { + repoRoot: "/repo", + headCommit: "base", + staged: "", + unstaged: "", + untracked: [], + untrackedPatch: "", + }, + nested: [], + }, + }, + preferredBackend: undefined, + agentId: "DeferredCleanup", + mergeMode: "patch", + artifactsDir: "/artifacts", + buildFailureResult: error => result({ exitCode: 1, error: String(error) }), + }); + + expect(outcome.exitCode).toBe(1); + expect(cleanupSpy).not.toHaveBeenCalled(); + cleanupGate.resolve(); + await cleanupGate.promise; + await Promise.resolve(); + await Promise.resolve(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); }); describe("mergeIsolatedChanges", () => { From cbfbcd865e2d598015734c768d108d49bc2d7f54 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 12:36:36 +0000 Subject: [PATCH 05/97] fix(tool): exempt piped-stdin stages from bash interceptor The 17.2.2 compound-fragment matching splits commands on every unquoted operator including `|`, so a downstream pipe stage like `grep x` in `printf 'x\n' | grep x` became a standalone interception candidate and was routed to the `grep` tool, which searches paths and cannot consume the previous stage's stdout. `extractFlatShellCommandSegments` now flags each segment that receives piped stdin from a single unquoted `|`, and `interceptionCandidates` skips those: a stdin-consuming stage cannot be replaced by a path-based dedicated tool. Standalone (`grep pattern path`), first-stage (`grep x file | wc`), and `&&`/`||`/`;`-sequenced commands still match. Fixes #7496 --- docs/bash-tool-runtime.md | 2 +- docs/tools/bash.md | 4 +- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/tools/bash-interceptor.ts | 12 ++++-- .../coding-agent/src/tools/shell-tokenize.ts | 37 ++++++++++++++++--- .../test/tools/bash-interceptor.test.ts | 35 +++++++++++++++++- 6 files changed, 80 insertions(+), 14 deletions(-) diff --git a/docs/bash-tool-runtime.md b/docs/bash-tool-runtime.md index 642fd96f2..1d8887179 100644 --- a/docs/bash-tool-runtime.md +++ b/docs/bash-tool-runtime.md @@ -32,7 +32,7 @@ There are no structured `head` or `tail` tool parameters in the current schema, ## 2) Optional interception (blocked-command path) -If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. +If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. Fragments that receive piped stdin from a single unquoted `|` are excluded from the fragment candidates, because a stdin-consuming stage cannot be replaced by a path-based dedicated tool. Interception behavior: diff --git a/docs/tools/bash.md b/docs/tools/bash.md index ddea53769..3ef14e670 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -110,7 +110,7 @@ git add file && git commit -m "message" GIT_AUTHOR_NAME=Dev git commit -m "message" ``` -An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. +An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. A stage that consumes another command's stdout through a single unquoted `|` (for example `grep x` in `printf 'x\n' | grep x`) is **not** treated as an interception candidate: it reads piped stdin, which the path-based dedicated tools cannot supply, so only a standalone or first-stage command is matched. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. ### Interaction and selection guide @@ -127,7 +127,7 @@ Choose the setting by the desired outcome: 1. `BashTool.execute()` in `packages/coding-agent/src/tools/bash.ts` reads `command`, normalizes `env`, and defaults `timeout` to `300`. Commands execute exactly as written — there is no pre-execution rewrite pass. 2. If `cwd` is absent, it rewrites a leading `cd && ...` into the structured `cwd` field and strips that prefix from `command`. 3. If `async: true` is requested while `async.enabled` is off, it throws `ToolError` before any execution. -4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. +4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines (excluding stages that consume piped stdin from a single unquoted `|`), followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. 5. `expandInternalUrls()` rewrites supported internal URLs inside `command`, each `env` value, and protocol-looking `cwd` values. Command replacements are shell-escaped; `env` and `cwd` replacements use raw filesystem/string values because they are not interpolated into shell text. 6. `resolveToCwd()` resolves `cwd` against `session.cwd`; `fs.stat()` verifies that the target exists and is a directory. 7. `clampTimeout("bash", requestedTimeoutSec)` enforces `TOOL_TIMEOUTS.bash` (`default: 300`, `min: 1`, `max: 3600`). When clamped, `#buildCompletedResult()` / `#buildBackgroundStartResult()` append a notice line. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b20591af8..6e83dc9c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the bash interceptor blocking `grep`/`cat`/`find` used as a downstream pipeline stage (e.g. `printf 'x\n' | grep x`); a stage consuming piped stdin cannot be replaced by a path-based dedicated tool, so it is no longer matched, while standalone and first-stage searches stay intercepted ([#7496](https://github.com/can1357/oh-my-pi/issues/7496)). + ## [17.2.5] - 2026-08-03 ### Breaking Changes diff --git a/packages/coding-agent/src/tools/bash-interceptor.ts b/packages/coding-agent/src/tools/bash-interceptor.ts index 6d2e83613..baa6cfbdc 100644 --- a/packages/coding-agent/src/tools/bash-interceptor.ts +++ b/packages/coding-agent/src/tools/bash-interceptor.ts @@ -96,10 +96,14 @@ function withoutLeadingEnvironmentAssignments(command: string): string | null { function interceptionCandidates(command: string): string[] { const candidates = [command.trim()]; - const segments = extractFlatShellCommandSegments(command); - candidates.push(...segments.map(segment => segment.trim())); - for (const segment of segments) { - const withoutAssignments = withoutLeadingEnvironmentAssignments(segment); + for (const segment of extractFlatShellCommandSegments(command)) { + // A segment that consumes the previous stage's stdout via `|` reads piped + // stdin, which no path-based dedicated tool (read/grep/glob) — nor any + // other dedicated tool — can replace, so it is not an interception + // candidate. Standalone and first-stage commands still match. + if (segment.pipedStdin) continue; + candidates.push(segment.text); + const withoutAssignments = withoutLeadingEnvironmentAssignments(segment.text); if (withoutAssignments) candidates.push(withoutAssignments); } return candidates; diff --git a/packages/coding-agent/src/tools/shell-tokenize.ts b/packages/coding-agent/src/tools/shell-tokenize.ts index faa50bb72..d1e70e5c5 100644 --- a/packages/coding-agent/src/tools/shell-tokenize.ts +++ b/packages/coding-agent/src/tools/shell-tokenize.ts @@ -83,25 +83,44 @@ export function tokenizeShellSegments(command: string): string[][] { } /** - * Returns the original text of flat shell command segments. Unlike + * A flat shell command segment with the context needed to decide interception. + * + * @see extractFlatShellCommandSegments + */ +export interface FlatShellCommandSegment { + /** Original segment text with quoting and escaping preserved. */ + text: string; + /** + * True when this segment consumes the previous stage's stdout via an + * unquoted `|`. Such a stage reads piped stdin, so path-based dedicated + * tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, `&&`, and + * newlines start an independent command and leave this false. + */ + pipedStdin: boolean; +} + +/** + * Returns the flat shell command segments with the original text of each. Unlike * `tokenizeShellSegments`, this preserves quoting and escaping so the results - * are safe to match against user-configured regular expressions. + * are safe to match against user-configured regular expressions, and flags + * segments that receive piped stdin. * * The extractor deliberately declines to split syntax whose execution context * cannot be determined with this small scanner (heredocs, command substitution, * backticks, grouping, and malformed quoting). Callers must still check the * complete input in that case. */ -export function extractFlatShellCommandSegments(command: string): string[] { - const segments: string[] = []; +export function extractFlatShellCommandSegments(command: string): FlatShellCommandSegment[] { + const segments: FlatShellCommandSegment[] = []; let segmentStart = 0; let inSingle = false; let inDouble = false; let atWordStart = true; + let currentPiped = false; const pushSegment = (end: number) => { const segment = command.slice(segmentStart, end).trim(); - if (segment.length > 0) segments.push(segment); + if (segment.length > 0) segments.push({ text: segment, pipedStdin: currentPiped }); }; for (let i = 0; i < command.length; i++) { @@ -160,6 +179,7 @@ export function extractFlatShellCommandSegments(command: string): string[] { i = newline; segmentStart = newline + 1; atWordStart = true; + currentPiped = false; continue; } const isRedirectionOperatorCharacter = @@ -170,7 +190,12 @@ export function extractFlatShellCommandSegments(command: string): string[] { : false; if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) { pushSegment(i); - if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++; + const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; + if (doubled) i++; + // A single unquoted `|` pipes the previous stage's stdout into the + // next segment; `||`, `;`, `&`, `&&`, and newlines start a fresh + // command whose stdin is not the previous stage's output. + currentPiped = ch === "|" && !doubled; segmentStart = i + 1; atWordStart = true; continue; diff --git a/packages/coding-agent/test/tools/bash-interceptor.test.ts b/packages/coding-agent/test/tools/bash-interceptor.test.ts index 104e53bea..edcfb2074 100644 --- a/packages/coding-agent/test/tools/bash-interceptor.test.ts +++ b/packages/coding-agent/test/tools/bash-interceptor.test.ts @@ -77,13 +77,19 @@ describe("compound command interception", () => { "git add file && git commit -m message", "git add file; git commit -m message", "git add file || git commit -m message", - "git add file | git commit -m message", "git add file & git commit -m message", "git add file\ngit commit -m message", ])("blocks a later command after %s", command => { expect(checkBashInterception(command, ["commit"], rules).block).toBe(true); }); + it("does not intercept a downstream pipe stage that consumes piped stdin", () => { + // `git commit` after a single `|` reads the previous stage's stdout, so + // the dedicated tool cannot replace it. `||` still starts a fresh command. + expect(checkBashInterception("git add file | git commit -m message", ["commit"], rules).block).toBe(false); + expect(checkBashInterception("git add file || git commit -m message", ["commit"], rules).block).toBe(true); + }); + it("removes one or more leading environment assignments before matching", () => { expect( checkBashInterception('GIT_AUTHOR_EMAIL="a@example.com" git commit -m message', ["commit"], rules).block, @@ -203,6 +209,33 @@ describe("default echo/printf redirect rule", () => { }); }); +describe("default grep rule and pipeline stdin", () => { + const tools = ["grep"]; + + it("blocks standalone file searches", () => { + expect(checkBashInterception("grep pattern path", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + expect(checkBashInterception("rg pattern src", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + }); + + it("blocks a first-stage grep that produces pipeline input", () => { + expect(checkBashInterception("grep x file | wc -l", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true); + }); + + it("does not block grep consuming pipeline stdin", () => { + expect(checkBashInterception("printf 'x\\n' | grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); + expect( + checkBashInterception("tr -d '\\r' < input.log | grep -v '^ *foo'", tools, DEFAULT_BASH_INTERCEPTOR_RULES) + .block, + ).toBe(false); + }); + + it("still blocks a standalone grep sequenced after a pipeline", () => { + expect( + checkBashInterception("cat log | tr a b && grep err file", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block, + ).toBe(true); + }); +}); + describe("default hub start rules", () => { const tools = ["hub"]; From d563e25cfe1bf53e1fcef70b4b8e6931ead11746 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:39:33 +0900 Subject: [PATCH 06/97] fix(task): settle all late cleanup (#7488) --- packages/coding-agent/src/task/executor.ts | 39 +++++-- .../task/executor-async-quiescence.test.ts | 106 +++++++++++++++++- 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index b6fe3540b..83c238377 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2142,14 +2142,17 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise { exitCode = 1; } const wasAborted = - runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false)); + runtimeLimitExceeded || Boolean(done.aborted) || abortedViaYield || (!hasYield && Boolean(signal?.aborted)); const finalAbortReason = wasAborted ? runtimeLimitExceeded ? monitor.resolveAbortReasonText() - : abortedViaYield - ? yieldAbortReason - : (done.abortReason ?? - (signal?.aborted ? monitor.resolveSignalAbortReason() : monitor.resolveAbortReasonText())) + : done.aborted + ? (done.abortReason ?? monitor.resolveAbortReasonText()) + : abortedViaYield + ? yieldAbortReason + : signal?.aborted + ? monitor.resolveSignalAbortReason() + : monitor.resolveAbortReasonText() : undefined; progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed"; monitor.scheduleProgress(true); @@ -3202,6 +3205,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise[] = []; + let deferredSessionShutdown: Promise | undefined; const deferCleanup = (completion: Promise): void => { lateCleanups.push(completion); exitCode = 1; @@ -3268,11 +3272,32 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + deferredSessionShutdown = completion; + deferCleanup(completion); + }, }); } + if (jobManager) { + if (deferredSessionShutdown) { + const finalReap = Promise.allSettled([deferredSessionShutdown]).then(async () => { + const reap = await jobManager.cancelAndReapOwnerJobs(id, Date.now()); + await reap.completion; + }); + lateCleanups.push(finalReap); + } else { + const reap = await jobManager.cancelAndReapOwnerJobs(id, cleanupDeadlineAt); + if (!reap.settled) { + deferCleanup(reap.completion); + logger.warn("Subagent async job cleanup exceeded its deadline after session shutdown", { + id, + pendingJobIds: reap.pendingJobIds, + }); + } + } + } if (lateCleanups.length > 0) { - const completion = Promise.all(lateCleanups).then(() => {}); + const completion = Promise.allSettled(lateCleanups).then(() => {}); trackLateCleanup(completion, { id, resource: "subagent" }); options.onCleanupDeferred?.(completion); } diff --git a/packages/coding-agent/test/task/executor-async-quiescence.test.ts b/packages/coding-agent/test/task/executor-async-quiescence.test.ts index 29350000a..fbb893e47 100644 --- a/packages/coding-agent/test/task/executor-async-quiescence.test.ts +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -8,6 +8,7 @@ */ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; @@ -18,7 +19,7 @@ import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; const baseAgent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; -function assistantStopMessage(text: string): AssistantMessage { +function assistantStopMessage(text: string, totalTokens = 0): AssistantMessage { return { role: "assistant", content: [{ type: "text", text }], @@ -27,10 +28,10 @@ function assistantStopMessage(text: string): AssistantMessage { model: "mock", usage: { input: 0, - output: 0, + output: totalTokens, cacheRead: 0, cacheWrite: 0, - totalTokens: 0, + totalTokens, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -44,9 +45,15 @@ interface AsyncQuiescenceHarness { abortCalls: () => number; settleCalls: () => number; emitTerminalYield: (data: unknown) => void; + emitAssistant: (text: string, totalTokens?: number) => void; finishJob: () => void; } +interface AsyncSessionOptions { + abort?: () => Promise; + dispose?: () => Promise; +} + /** * Mock session with the owner-async surface the barrier drives: * `hasPendingAsyncWork` / `getAsyncJobSnapshot` / `settleAsyncWork`. The job @@ -56,6 +63,7 @@ interface AsyncQuiescenceHarness { */ function createAsyncSession( onPrompt: (params: { text: string; promptIndex: number; harness: AsyncQuiescenceHarness }) => void, + options: AsyncSessionOptions = {}, ): AsyncQuiescenceHarness { const listeners: Array<(event: AgentSessionEvent) => void> = []; const state = { messages: [] as AssistantMessage[] }; @@ -103,6 +111,11 @@ function createAsyncSession( state.messages.push(reaction); emit({ type: "message_end", message: reaction } as AgentSessionEvent); }; + const emitAssistant = (text: string, totalTokens = 0) => { + const message = assistantStopMessage(text, totalTokens); + state.messages.push(message); + emit({ type: "message_end", message } as AgentSessionEvent); + }; const harness: AsyncQuiescenceHarness = { session: undefined as unknown as AgentSession, @@ -110,6 +123,7 @@ function createAsyncSession( abortCalls: () => abortCount, settleCalls: () => settleCount, emitTerminalYield, + emitAssistant, finishJob, }; @@ -143,8 +157,9 @@ function createAsyncSession( }, abort: async () => { abortCount += 1; + await options.abort?.(); }, - dispose: async () => {}, + dispose: options.dispose ?? (async () => {}), setIrcWakeTurnObserver: () => {}, }; harness.session = session as unknown as AgentSession; @@ -163,6 +178,7 @@ function mockCreateAgentSession(session: AgentSession) { describe("runSubprocess async quiescence fresh-yield contract", () => { afterEach(() => { vi.restoreAllMocks(); + AsyncJobManager.resetForTests(); }); it("parks a pending yield, injects the result, and completes on the fresh yield", async () => { @@ -287,4 +303,86 @@ describe("runSubprocess async quiescence fresh-yield contract", () => { expect(result.exitCode).toBe(0); expect(result.output).toContain("done"); }); + + it("returns an aborted result after cleanup grace and waits for every late resource", async () => { + const abortStarted = Promise.withResolvers(); + const abortGate = Promise.withResolvers(); + const disposeGate = Promise.withResolvers(); + const lateJobGate = Promise.withResolvers(); + const manager = new AsyncJobManager({}); + AsyncJobManager.setInstance(manager); + let lateJobId: string | undefined; + let deferredCleanup: Promise | undefined; + const harness = createAsyncSession( + ({ promptIndex, harness: h }) => { + if (promptIndex !== 1) return; + h.finishJob(); + h.emitAssistant("captured before cleanup", 7); + h.emitTerminalYield({ report: "yielded output" }); + }, + { + abort: async () => { + abortStarted.resolve(); + await abortGate.promise; + }, + dispose: async () => { + lateJobId = manager.register( + "task", + "shutdown-time job", + async () => { + await lateJobGate.promise; + return "late result"; + }, + { ownerId: "cleanup-timeout" }, + ); + await disposeGate.promise; + }, + }, + ); + mockCreateAgentSession(harness.session); + + const run = runSubprocess({ + cwd: "/tmp", + agent: baseAgent, + task: "do the work", + index: 0, + id: "cleanup-timeout", + keepAlive: false, + onCleanupDeferred: completion => { + deferredCleanup = completion; + }, + }); + await abortStarted.promise; + + const result = await run; + expect(result.exitCode).toBe(1); + expect(result.aborted).toBe(true); + expect(result.abortReason).toBe("cleanup exceeded 10000 ms"); + expect(result.error).toContain("Cleanup did not finish within 10000 ms"); + expect(result.output).toContain("yielded output"); + expect(result.usage?.totalTokens).toBe(7); + expect(lateJobId).toBeDefined(); + expect(deferredCleanup).toBeDefined(); + + let cleanupSettled = false; + const cleanupOutcome = deferredCleanup?.then( + () => { + cleanupSettled = true; + }, + () => { + cleanupSettled = true; + }, + ); + abortGate.resolve(); + disposeGate.reject(new Error("dispose failed")); + for (let attempt = 0; attempt < 10 && manager.getJob(lateJobId ?? "")?.status === "running"; attempt += 1) { + await Promise.resolve(); + } + expect(cleanupSettled).toBe(false); + expect(manager.getJob(lateJobId ?? "")?.status).toBe("cancelled"); + + lateJobGate.resolve(); + await cleanupOutcome; + expect(cleanupSettled).toBe(true); + }, 15_000); }); From 960c21baf9556696bede1e668f2150b0732c31b3 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Mon, 3 Aug 2026 06:49:19 +0000 Subject: [PATCH 07/97] fix(utils): honor current PATH in which lookups --- packages/utils/src/which.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index e1facff59..718e629c0 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -183,8 +183,8 @@ export interface WhichOptions extends Bun.WhichOptions { // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH. // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses. -function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { - const regular = Bun.which(command); +function darwinWhich(command: string, options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command, options); if (regular) return regular; if (isXcodeBin(command)) { return getMacosToolPaths().get(command) ?? null; @@ -214,17 +214,21 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { */ export function $which(command: string, options?: WhichOptions): string | null { const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; + const lookupOptions = + options?.PATH !== undefined || process.env.PATH === undefined + ? options + : { ...options, PATH: process.env.PATH }; let key: CacheKey | undefined; if (cachePolicy !== WhichCachePolicy.Bypass) { - key = cacheKey(command, options); + key = cacheKey(command, lookupOptions); if (cachePolicy !== WhichCachePolicy.Fresh) { const cached = toolCache.get(key); if (cached !== undefined) return cached; } } - const result = whichFresh(command, options); + const result = whichFresh(command, lookupOptions); if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) { toolCache.set(key, result); } From 55a5574bddb91ab2f5ba30b15ed17a0a3f99feeb Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Mon, 3 Aug 2026 12:45:29 +0000 Subject: [PATCH 08/97] Revert "fix(utils): honor current PATH in which lookups" This reverts commit 960c21baf9556696bede1e668f2150b0732c31b3. --- packages/utils/src/which.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index 718e629c0..e1facff59 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -183,8 +183,8 @@ export interface WhichOptions extends Bun.WhichOptions { // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH. // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses. -function darwinWhich(command: string, options?: Bun.WhichOptions): string | null { - const regular = Bun.which(command, options); +function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command); if (regular) return regular; if (isXcodeBin(command)) { return getMacosToolPaths().get(command) ?? null; @@ -214,21 +214,17 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { */ export function $which(command: string, options?: WhichOptions): string | null { const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; - const lookupOptions = - options?.PATH !== undefined || process.env.PATH === undefined - ? options - : { ...options, PATH: process.env.PATH }; let key: CacheKey | undefined; if (cachePolicy !== WhichCachePolicy.Bypass) { - key = cacheKey(command, lookupOptions); + key = cacheKey(command, options); if (cachePolicy !== WhichCachePolicy.Fresh) { const cached = toolCache.get(key); if (cached !== undefined) return cached; } } - const result = whichFresh(command, lookupOptions); + const result = whichFresh(command, options); if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) { toolCache.set(key, result); } From 71a4c69369ed1f63f8fe2d62a4fbc499cae735b3 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Mon, 3 Aug 2026 12:45:43 +0000 Subject: [PATCH 09/97] test(utils): reproduce stale PATH lookup cache --- packages/utils/test/which.test.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/utils/test/which.test.ts diff --git a/packages/utils/test/which.test.ts b/packages/utils/test/which.test.ts new file mode 100644 index 000000000..e054975c2 --- /dev/null +++ b/packages/utils/test/which.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $which } from "../src/which"; + +describe("$which", () => { + const originalPath = process.env.PATH; + const tempDirs: string[] = []; + + afterEach(() => { + process.env.PATH = originalPath; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")("uses the current process PATH for each cached lookup", () => { + const firstDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-which-first-")); + const secondDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-which-second-")); + tempDirs.push(firstDir, secondDir); + + const command = `omp-which-${process.pid}`; + const firstExecutable = path.join(firstDir, command); + const secondExecutable = path.join(secondDir, command); + fs.writeFileSync(firstExecutable, "#!/bin/sh\n"); + fs.writeFileSync(secondExecutable, "#!/bin/sh\n"); + fs.chmodSync(firstExecutable, 0o755); + fs.chmodSync(secondExecutable, 0o755); + + process.env.PATH = firstDir; + expect($which(command)).toBe(firstExecutable); + + process.env.PATH = secondDir; + expect($which(command)).toBe(secondExecutable); + }); +}); From 430e4e386ba1bff9216c816b2dd513fd23573de3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 12:45:43 +0000 Subject: [PATCH 10/97] fix(tool): preserved piped stdin across continuations Retained pending pipeline state across blank and comment-only continuation lines, and parsed Bash's |& operator as a single pipe boundary. Added regression coverage for both forms and aligned the Bash interceptor docs. Fixes #7496 --- docs/bash-tool-runtime.md | 2 +- docs/tools/bash.md | 4 +-- .../coding-agent/src/tools/shell-tokenize.ts | 30 +++++++++++-------- .../test/tools/bash-interceptor.test.ts | 7 +++++ 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/bash-tool-runtime.md b/docs/bash-tool-runtime.md index 1d8887179..235f3e276 100644 --- a/docs/bash-tool-runtime.md +++ b/docs/bash-tool-runtime.md @@ -32,7 +32,7 @@ There are no structured `head` or `tail` tool parameters in the current schema, ## 2) Optional interception (blocked-command path) -If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. Fragments that receive piped stdin from a single unquoted `|` are excluded from the fragment candidates, because a stdin-consuming stage cannot be replaced by a path-based dedicated tool. +If `bashInterceptor.enabled` is true, `BashTool` loads rules from settings (`getBashInterceptorRules()`) and runs `checkBashInterception()` against the command — checking both the original and the cwd-normalized form (after a leading `cd … &&` is extracted) when they differ. Rule syntax is unchanged: each rule checks the complete input first, then raw flat command fragments separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `|&`, `&`, or newlines, then those fragments with leading `NAME=value` assignments removed. Fragments that receive piped stdin from `|` or `|&` are excluded from the fragment candidates, including across blank/comment continuation lines, because a stdin-consuming stage cannot be replaced by a path-based dedicated tool. Interception behavior: diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 3ef14e670..d66850598 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -110,7 +110,7 @@ git add file && git commit -m "message" GIT_AUTHOR_NAME=Dev git commit -m "message" ``` -An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. A stage that consumes another command's stdout through a single unquoted `|` (for example `grep x` in `printf 'x\n' | grep x`) is **not** treated as an interception candidate: it reads piped stdin, which the path-based dedicated tools cannot supply, so only a standalone or first-stage command is matched. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. +An anchored rule such as `^\s*git\s+commit\b` can therefore match the `git commit` command in both examples. A stage that consumes another command's stdout through an unquoted `|` or `|&` (for example `grep x` in `printf 'x\n' | grep x`) is **not** treated as an interception candidate: it reads piped stdin, which the path-based dedicated tools cannot supply, so only a standalone or first-stage command is matched. Blank and comment-only continuation lines after the pipe preserve that context. Quoted, escaped, and commented text is not treated as a command. Heredocs, parameter expansion, command substitution, backticks, grouping, and malformed quoting retain only the complete-command check; the interceptor deliberately does not attempt to become a full shell parser. ### Interaction and selection guide @@ -127,7 +127,7 @@ Choose the setting by the desired outcome: 1. `BashTool.execute()` in `packages/coding-agent/src/tools/bash.ts` reads `command`, normalizes `env`, and defaults `timeout` to `300`. Commands execute exactly as written — there is no pre-execution rewrite pass. 2. If `cwd` is absent, it rewrites a leading `cd && ...` into the structured `cwd` field and strips that prefix from `command`. 3. If `async: true` is requested while `async.enabled` is off, it throws `ToolError` before any execution. -4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `&`, or newlines (excluding stages that consume piped stdin from a single unquoted `|`), followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. +4. If `bashInterceptor.enabled` is on, `checkBashInterception()` runs against both the original command and the `cd`-stripped command. For each form, configured regexes still check the complete input first, then each flat command separated by unquoted/unescaped `&&`, `||`, `;`, `|`, `|&`, `&`, or newlines (excluding stages that consume piped stdin from `|` or `|&`, including across blank/comment continuations), followed by versions of those fragments without leading `NAME=value` assignments. A matching enabled rule throws before URL expansion or execution. 5. `expandInternalUrls()` rewrites supported internal URLs inside `command`, each `env` value, and protocol-looking `cwd` values. Command replacements are shell-escaped; `env` and `cwd` replacements use raw filesystem/string values because they are not interpolated into shell text. 6. `resolveToCwd()` resolves `cwd` against `session.cwd`; `fs.stat()` verifies that the target exists and is a directory. 7. `clampTimeout("bash", requestedTimeoutSec)` enforces `TOOL_TIMEOUTS.bash` (`default: 300`, `min: 1`, `max: 3600`). When clamped, `#buildCompletedResult()` / `#buildBackgroundStartResult()` append a notice line. diff --git a/packages/coding-agent/src/tools/shell-tokenize.ts b/packages/coding-agent/src/tools/shell-tokenize.ts index d1e70e5c5..f93f3e276 100644 --- a/packages/coding-agent/src/tools/shell-tokenize.ts +++ b/packages/coding-agent/src/tools/shell-tokenize.ts @@ -92,9 +92,10 @@ export interface FlatShellCommandSegment { text: string; /** * True when this segment consumes the previous stage's stdout via an - * unquoted `|`. Such a stage reads piped stdin, so path-based dedicated - * tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, `&&`, and - * newlines start an independent command and leave this false. + * unquoted `|` or `|&`. Blank and comment-only continuation lines preserve + * the pending pipe state. Such a stage reads piped stdin, so path-based + * dedicated tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, and + * `&&` start an independent command and leave this false. */ pipedStdin: boolean; } @@ -118,9 +119,11 @@ export function extractFlatShellCommandSegments(command: string): FlatShellComma let atWordStart = true; let currentPiped = false; - const pushSegment = (end: number) => { + const pushSegment = (end: number): boolean => { const segment = command.slice(segmentStart, end).trim(); - if (segment.length > 0) segments.push({ text: segment, pipedStdin: currentPiped }); + if (segment.length === 0) return false; + segments.push({ text: segment, pipedStdin: currentPiped }); + return true; }; for (let i = 0; i < command.length; i++) { @@ -173,13 +176,14 @@ export function extractFlatShellCommandSegments(command: string): FlatShellComma return []; } if (ch === "#" && atWordStart) { - pushSegment(i); + const pushed = pushSegment(i); const newline = command.indexOf("\n", i + 1); if (newline === -1) return segments; i = newline; segmentStart = newline + 1; atWordStart = true; - currentPiped = false; + // Preserve a pending pipe through a comment-only continuation. + if (pushed) currentPiped = false; continue; } const isRedirectionOperatorCharacter = @@ -189,13 +193,13 @@ export function extractFlatShellCommandSegments(command: string): FlatShellComma ? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">" : false; if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) { - pushSegment(i); + const pushed = pushSegment(i); const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; - if (doubled) i++; - // A single unquoted `|` pipes the previous stage's stdout into the - // next segment; `||`, `;`, `&`, `&&`, and newlines start a fresh - // command whose stdin is not the previous stage's output. - currentPiped = ch === "|" && !doubled; + const pipeStderr = ch === "|" && command[i + 1] === "&"; + if (doubled || pipeStderr) i++; + // `|` and `|&` pipe into the next segment. Blank continuation + // lines preserve that pending state; all other operators reset it. + if (pushed || ch !== "\n") currentPiped = ch === "|" && !doubled; segmentStart = i + 1; atWordStart = true; continue; diff --git a/packages/coding-agent/test/tools/bash-interceptor.test.ts b/packages/coding-agent/test/tools/bash-interceptor.test.ts index edcfb2074..924aacd6d 100644 --- a/packages/coding-agent/test/tools/bash-interceptor.test.ts +++ b/packages/coding-agent/test/tools/bash-interceptor.test.ts @@ -227,6 +227,13 @@ describe("default grep rule and pipeline stdin", () => { checkBashInterception("tr -d '\\r' < input.log | grep -v '^ *foo'", tools, DEFAULT_BASH_INTERCEPTOR_RULES) .block, ).toBe(false); + expect(checkBashInterception("printf 'x\\n' |\n grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe( + false, + ); + expect( + checkBashInterception("printf 'x\\n' |\n # filter\n grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block, + ).toBe(false); + expect(checkBashInterception("printf 'x\\n' |& grep x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); }); it("still blocks a standalone grep sequenced after a pipeline", () => { From a5f5f53c357b9a3019ae2445f24e41067c07b968 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Mon, 3 Aug 2026 06:49:19 +0000 Subject: [PATCH 11/97] fix(utils): honor current PATH in which lookups --- packages/utils/src/which.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index e1facff59..718e629c0 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -183,8 +183,8 @@ export interface WhichOptions extends Bun.WhichOptions { // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH. // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses. -function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { - const regular = Bun.which(command); +function darwinWhich(command: string, options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command, options); if (regular) return regular; if (isXcodeBin(command)) { return getMacosToolPaths().get(command) ?? null; @@ -214,17 +214,21 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { */ export function $which(command: string, options?: WhichOptions): string | null { const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; + const lookupOptions = + options?.PATH !== undefined || process.env.PATH === undefined + ? options + : { ...options, PATH: process.env.PATH }; let key: CacheKey | undefined; if (cachePolicy !== WhichCachePolicy.Bypass) { - key = cacheKey(command, options); + key = cacheKey(command, lookupOptions); if (cachePolicy !== WhichCachePolicy.Fresh) { const cached = toolCache.get(key); if (cached !== undefined) return cached; } } - const result = whichFresh(command, options); + const result = whichFresh(command, lookupOptions); if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) { toolCache.set(key, result); } From 1bb5b445fe4319136911567e2f225d3fe2f185b4 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Mon, 3 Aug 2026 13:03:35 +0000 Subject: [PATCH 12/97] style(utils): format effective PATH lookup --- packages/utils/src/which.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index 718e629c0..327c60fb4 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -215,9 +215,7 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { export function $which(command: string, options?: WhichOptions): string | null { const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; const lookupOptions = - options?.PATH !== undefined || process.env.PATH === undefined - ? options - : { ...options, PATH: process.env.PATH }; + options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH }; let key: CacheKey | undefined; if (cachePolicy !== WhichCachePolicy.Bypass) { From e388bc14ce0a8911af9131c187fd4dec1635dc3a Mon Sep 17 00:00:00 2001 From: hancens <2313288888@qq.com> Date: Mon, 3 Aug 2026 21:09:45 +0800 Subject: [PATCH 13/97] perf(agent-hub): bound roster rendering to viewport Agent Hub previously pre-rendered every registry row and resolved each row's observer via getSessions().find, which copy-sorts the full observer map. On large rosters that made open/selection/age-tick O(all rows) and observer lookup near O(N^2 log N). - Add SessionObserverRegistry.getSession(id) for O(1) Map lookup - Lazy-render hub rows around the selection within the terminal line budget - Keep ordering, selection, status counts, overflow, badges, and task lines - Add 10k-row regression covering bounded getSession/render work --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/components/agent-hub.ts | 54 ++++-- .../components/agent-transcript-viewer.ts | 4 +- .../src/modes/session-observer-registry.ts | 5 + .../test/agent-hub-ordering.test.ts | 168 ++++++++++++++---- 5 files changed, 174 insertions(+), 58 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7e3602035..3345946c0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Agent Hub opening and selection becoming O(all rows) on large rosters: row rendering is now lazy around the selected viewport, and observer lookup is O(1) by id instead of copy-sorting every session per row. - Fixed extension slash commands appearing as user prompts after being handled locally. - Preserved explicit session titles when branching from an earlier conversation turn. - Fixed floating rejections from cmux browser guest JavaScript terminating the main process and every active session; attributable rejections now fail the browser run as tool errors while unrelated process rejections retain the fatal path ([#7365](https://github.com/can1357/oh-my-pi/issues/7365)). diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index a5a706c6e..1f895137f 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -183,6 +183,7 @@ export class AgentHubOverlayComponent extends Container { // Table state #rows: AgentRef[] = []; + #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; #selectedRow = 0; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ @@ -368,6 +369,9 @@ export class AgentHubOverlayComponent extends Container { #refreshRows(): void { const selectedId = this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); + const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; + for (const ref of refs) counts[ref.status]++; + this.#statusCounts = counts; if (!this.#rowOrder) { // First refresh (usually the constructor): order by status, then recency. @@ -398,7 +402,7 @@ export class AgentHubOverlayComponent extends Container { } #observableFor(id: string): ObservableSession | undefined { - return this.#observers.getSessions().find(s => s.id === id); + return this.#observers.getSession(id); } // ======================================================================== @@ -418,30 +422,46 @@ export class AgentHubOverlayComponent extends Container { const termHeight = process.stdout.rows || 40; // Chrome: 2 borders + title + notice? + blank + hints + border const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0)); - const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width)); - // Entries are 1-2 lines tall; grow a window around the selection until - // the line budget is spent, so the selected entry stays centered. + // Render outward from the selection and stop once the viewport is full. + // Cache rendered entries so a boundary probe is not paid twice when the + // same index is later accepted into the window. + const rendered = new Map(); + const entryAt = (index: number): string[] => { + const cached = rendered.get(index); + if (cached) return cached; + const ref = this.#rows[index]; + if (!ref) return []; + const entry = this.#renderEntry(ref, index === this.#selectedRow, width); + rendered.set(index, entry); + return entry; + }; let start = this.#selectedRow; let end = this.#selectedRow + 1; - let used = entries[start]?.length ?? 0; + let used = entryAt(start).length; for (let grew = true; grew; ) { grew = false; - if (end < entries.length && used + entries[end].length <= budget) { - used += entries[end].length; - end++; - grew = true; + if (end < this.#rows.length) { + const next = entryAt(end); + if (used + next.length <= budget) { + used += next.length; + end++; + grew = true; + } } - if (start > 0 && used + entries[start - 1].length <= budget) { - start--; - used += entries[start].length; - grew = true; + if (start > 0) { + const previous = entryAt(start - 1); + if (used + previous.length <= budget) { + start--; + used += previous.length; + grew = true; + } } } if (start > 0) { lines.push(` ${theme.fg("dim", `… ${start} more`)}`); } for (let i = start; i < end; i++) { - lines.push(...entries[i]); + lines.push(...entryAt(i)); } if (end < this.#rows.length) { lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); @@ -458,13 +478,9 @@ export class AgentHubOverlayComponent extends Container { } #statusSummary(): string { - const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; - for (const ref of this.#rows) { - counts[ref.status]++; - } const parts: string[] = []; for (const status of ["running", "idle", "parked", "aborted"] as const) { - const count = counts[status]; + const count = this.#statusCounts[status]; if (count > 0) parts.push(`${count} ${status}`); } return parts.join(theme.sep.dot); diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index baabe2bb2..0b8b8c428 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -613,9 +613,7 @@ export class AgentTranscriptViewer implements Component { } #statsLine(): string { - const observed: ObservableSession | undefined = this.deps.observers - ?.getSessions() - .find(s => s.id === this.deps.agentId); + const observed: ObservableSession | undefined = this.deps.observers?.getSession(this.deps.agentId); const progress = observed?.progress; if (!progress) return ""; const stats: string[] = []; diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts index e94ffa9d9..3074f073b 100644 --- a/packages/coding-agent/src/modes/session-observer-registry.ts +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -91,6 +91,11 @@ export class SessionObserverRegistry { this.#notifyListeners("main"); } + /** Return one tracked session without copying or sorting the registry. */ + getSession(id: string): ObservableSession | undefined { + return this.#sessions.get(id); + } + getSessions(): ObservableSession[] { const sessions = [...this.#sessions.values()]; sessions.sort((a, b) => { diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 2588dd3eb..6c834e70b 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -121,6 +121,108 @@ describe("Agent hub row ordering", () => { } }); + it("bounds observer lookups and entry rendering to the viewport on large rosters", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 10_000; i++) { + const id = `Agent-${i.toString().padStart(5, "0")}`; + agents.register({ id, displayName: id, kind: "sub", session: null, status: "parked" }); + } + + const observers = new SessionObserverRegistry(); + const getSessions = vi.spyOn(observers, "getSessions"); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + getSessions.mockClear(); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // rows=12 → line budget 5 for single-line parked rows; plus one failed + // boundary probe past the window. Must not scale with the 10_000 roster. + expect(visibleIds).toHaveLength(5); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + expect(getSession.mock.calls.length).toBeGreaterThan(0); + + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("10000 parked"); + expect(text).toMatch(/… \d+ more/); + + // Moving selection re-renders only the new viewport, not the whole roster. + getSessions.mockClear(); + getSession.mockClear(); + hub.handleInput("j"); + const afterMove = renderedAgentIds(hub); + expect(afterMove).toHaveLength(5); + expect(afterMove).toContain(visibleIds[1]!); + expect(getSessions).not.toHaveBeenCalled(); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(8); + } finally { + hub.dispose(); + } + }); + + it("sizes the lazy viewport by real entry height when rows have a task line", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(12); + const agents = new AgentRegistry(); + for (let i = 0; i < 100; i++) { + const id = `TaskAgent-${i.toString().padStart(3, "0")}`; + agents.register({ + id, + displayName: id, + kind: "sub", + session: null, + status: "parked", + }); + } + + const observers = new SessionObserverRegistry(); + const getSession = vi.spyOn(observers, "getSession"); + const hub = new AgentHubOverlayComponent({ + observers, + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + // Force a second task line via observer metadata so entry height is 2. + getSession.mockImplementation((id: string) => ({ + id, + kind: "subagent", + label: "Subagent", + status: "active", + description: `task for ${id}`, + lastUpdate: Date.now(), + })); + getSession.mockClear(); + const visibleIds = renderedAgentIds(hub); + // Each entry is 2 lines; budget 5 → at most 2 full entries + probes. + expect(visibleIds.length).toBeGreaterThan(0); + expect(visibleIds.length).toBeLessThanOrEqual(3); + expect(getSession.mock.calls.length).toBeLessThanOrEqual(6); + const text = Bun.stripANSI(hub.render(120).join("\n")); + expect(text).toContain("task for"); + expect(text).toContain(visibleIds[0]!); + } finally { + hub.dispose(); + } + }); + it("truncates lines and sanitizes newlines to prevent terminal wrapping", () => { geometry = stubStdoutGeometry(80); const agents = new AgentRegistry(); @@ -133,16 +235,14 @@ describe("Agent hub row ordering", () => { }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "RevAgentStream", - kind: "subagent", - label: "Subagent", - status: "active", - description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", - lastUpdate: Date.now(), - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "RevAgentStream", + kind: "subagent", + label: "Subagent", + status: "active", + description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", + lastUpdate: Date.now(), + }); const hub = new AgentHubOverlayComponent({ observers, @@ -174,19 +274,17 @@ describe("Agent hub row ordering", () => { agents.register({ id: "GuestAgent", displayName: "Guest Agent", kind: "sub", session: null }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "GuestAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "openai/gpt-4o", - resolvedModelIsFallback: true, - } as never, - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "GuestAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "openai/gpt-4o", + resolvedModelIsFallback: true, + } as never, + }); const hub = new AgentHubOverlayComponent({ observers, @@ -216,19 +314,17 @@ describe("Agent hub row ordering", () => { agents.register({ id: "FastAgent", displayName: "Fast Agent", kind: "sub", session }); const observers = new SessionObserverRegistry(); - vi.spyOn(observers, "getSessions").mockReturnValue([ - { - id: "FastAgent", - kind: "subagent", - label: "Subagent", - status: "active", - lastUpdate: Date.now(), - progress: { - resolvedModel: "fireworks/kimi-k2", - resolvedModelIsFallback: true, - } as never, - }, - ]); + vi.spyOn(observers, "getSession").mockReturnValue({ + id: "FastAgent", + kind: "subagent", + label: "Subagent", + status: "active", + lastUpdate: Date.now(), + progress: { + resolvedModel: "fireworks/kimi-k2", + resolvedModelIsFallback: true, + } as never, + }); const hub = new AgentHubOverlayComponent({ observers, From 52dbd8282b8a326526d8e4856d96d62766408bcf Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 13:08:53 +0000 Subject: [PATCH 14/97] fix(agent): classify thinking-only unexpected stops isUnexpectedStopCandidate only counted non-whitespace text blocks, so a stopReason:"stop" turn whose sole content was a signed thinking block bypassed the unexpected-stop guard entirely. #isEmptyAssistantStop treats signed thinking as terminal (not empty), so such stops fell through both recovery handlers and the agent silently stopped mid-task. Count non-whitespace thinking blocks as candidates and feed the thinking text to the classifier when no text block is present. Fixes #7499 --- packages/ai/src/providers/mock.ts | 2 +- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/session/turn-recovery.ts | 13 ++++++-- .../src/session/unexpected-stop-classifier.ts | 13 ++++++-- ...gent-session-unexpected-stop-guard.test.ts | 31 +++++++++++++++++++ .../test/unexpected-stop-classifier.test.ts | 24 ++++++++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/providers/mock.ts b/packages/ai/src/providers/mock.ts index 1e81292d2..a77a4bf95 100644 --- a/packages/ai/src/providers/mock.ts +++ b/packages/ai/src/providers/mock.ts @@ -67,7 +67,7 @@ export type MockApi = typeof MOCK_API; export type MockContent = | string | { type: "text"; text: string } - | { type: "thinking"; thinking: string } + | { type: "thinking"; thinking: string; thinkingSignature?: string } | { type: "toolCall"; /** Optional explicit id; auto-generated when omitted. */ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7e3602035..bdba4f1df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Preserved explicit session titles when branching from an earlier conversation turn. - Fixed floating rejections from cmux browser guest JavaScript terminating the main process and every active session; attributable rejections now fail the browser run as tool errors while unrelated process rejections retain the fatal path ([#7365](https://github.com/can1357/oh-my-pi/issues/7365)). - Fixed the Windows bash tool silently taking down the whole omp process when a command blocked until its timeout: cancelling a timed-out run walked the spawned child's descendant tree from raw `th32ParentProcessID` links, and a recycled pid matching the harness's stale recorded parent pid could enumerate omp as a false descendant and `TerminateProcess` it, killing the session with no `session_exit` record. Run-cancellation sweeps now refuse to signal the harness or any process collected beneath it, while still reaping the timed-out target when it owns a recycled ancestor pid ([#7452](https://github.com/can1357/oh-my-pi/issues/7452)). +- Fixed the unexpected-stop guard (`features.unexpectedStopDetection`) never firing for thinking-only stops: `isUnexpectedStopCandidate` only counted non-whitespace `text` blocks, so a `stopReason: "stop"` turn whose sole content was a signed `thinking` block (a trapped response or a truncated reasoning fragment from reasoning models) bypassed classification and silently ended the turn mid-task. Such stops are now candidates and are classified on their thinking text ([#7499](https://github.com/can1357/oh-my-pi/issues/7499)). ## [17.2.5] - 2026-08-03 diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 629138402..0fb31bcf2 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -14,6 +14,7 @@ import type { Effort, Model, TextContent, + ThinkingContent, ToolChoice, } from "@oh-my-pi/pi-ai"; import { calculateRateLimitBackoffMs, parseRateLimitReason } from "@oh-my-pi/pi-ai"; @@ -591,11 +592,19 @@ export class TurnRecovery { return false; } - const text = assistantMessage.content + let text = assistantMessage.content .filter((content): content is TextContent => content.type === "text") .map(content => content.text) .join("\n"); - if (!/\S/.test(text)) { + // Thinking-only stops carry their signal in the thinking block (a trapped + // response or a truncated fragment); classify on that when there is no text. + if (!hasNonWhitespace(text)) { + text = assistantMessage.content + .filter((content): content is ThinkingContent => content.type === "thinking") + .map(content => content.thinking) + .join("\n"); + } + if (!hasNonWhitespace(text)) { this.#unexpectedStopRetryCount = 0; return false; } diff --git a/packages/coding-agent/src/session/unexpected-stop-classifier.ts b/packages/coding-agent/src/session/unexpected-stop-classifier.ts index 5bf7c3456..685009412 100644 --- a/packages/coding-agent/src/session/unexpected-stop-classifier.ts +++ b/packages/coding-agent/src/session/unexpected-stop-classifier.ts @@ -34,14 +34,21 @@ export interface ClassifyUnexpectedStopDeps { export function isUnexpectedStopCandidate(message: AssistantMessage): boolean { if (message.stopReason !== "stop") return false; - let hasText = false; + let hasContent = false; for (const content of message.content) { if (content.type === "toolCall") return false; if (content.type === "text" && /\S/.test(content.text)) { - hasText = true; + hasContent = true; + } + // A thinking-only stop is still a candidate: reasoning models can trap the + // intended response (or a truncated fragment) in a signed thinking block + // with no text, which #isEmptyAssistantStop treats as terminal rather than + // empty. Feed it to the classifier instead of silently ending the turn. + if (content.type === "thinking" && /\S/.test(content.thinking)) { + hasContent = true; } } - return hasText; + return hasContent; } export async function classifyUnexpectedStop( diff --git a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts index b059004c1..67c2d2781 100644 --- a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts @@ -50,6 +50,13 @@ function unexpectedStop(text: string): MockResponse { }; } +function thinkingOnlyStop(thinking: string): MockResponse { + return { + content: [{ type: "thinking", thinking, thinkingSignature: "reasoning_content" }], + stopReason: "stop", + }; +} + async function createHarness( responses: MockResponse[], settingsOverrides: SettingsOverrides = {}, @@ -170,6 +177,30 @@ describe("AgentSession unexpected stop guard", () => { expect(reminderMessages(session.agent.state.messages)).toHaveLength(1); }); + it("classifies a thinking-only stop on its thinking text and continues", async () => { + let calls = 0; + const spy = vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockImplementation(async () => { + calls++; + return calls === 1; + }); + const { session, mock } = await createHarness( + [thinkingOnlyStop(" 响应"), { content: ["done now"], stopReason: "stop" }], + { + "features.unexpectedStopDetection": true, + "providers.unexpectedStopModel": "online", + }, + ); + + await session.prompt("do the thing"); + await session.waitForIdle(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0]?.[0]).toContain("响应"); + expect(mock.calls).toHaveLength(2); + expect(assistantText(session.agent.state.messages)).toContain("done now"); + expect(reminderMessages(session.agent.state.messages)).toHaveLength(1); + }); + it("does not continue when the classifier returns false", async () => { const spy = vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockResolvedValue(false); const { session, mock } = await createHarness( diff --git a/packages/coding-agent/test/unexpected-stop-classifier.test.ts b/packages/coding-agent/test/unexpected-stop-classifier.test.ts index 26c988f71..8c36cb38d 100644 --- a/packages/coding-agent/test/unexpected-stop-classifier.test.ts +++ b/packages/coding-agent/test/unexpected-stop-classifier.test.ts @@ -76,6 +76,30 @@ describe("isUnexpectedStopCandidate", () => { }); expect(isUnexpectedStopCandidate(message)).toBe(false); }); + + it("returns true for a signed thinking-only stop", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: " 响应", thinkingSignature: "reasoning_content" }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(true); + }); + + it("returns true for an unsigned thinking-only stop with real content", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: "responseAll four reviewers complete." }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(true); + }); + + it("returns false when the thinking block is only whitespace", () => { + const message = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "thinking", thinking: " \n\t ", thinkingSignature: "reasoning_content" }], + }); + expect(isUnexpectedStopCandidate(message)).toBe(false); + }); }); describe("classifyUnexpectedStop", () => { From 50228c36564b25fe1cecc3de3fab63f9790dc815 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 13:18:47 +0000 Subject: [PATCH 15/97] fix(agent): gate thinking-only stops on signature Align isUnexpectedStopCandidate with #isEmptyAssistantStop: only a signed (non-whitespace thinkingSignature) thinking-only stop is a candidate. Unsigned thinking-only stops stay with the empty-stop retry path and its cap, so unexpected-stop classification no longer re-handles a turn the empty-stop cap already gave up on. Fixes #7499 --- .../src/session/unexpected-stop-classifier.ts | 12 +++++++----- .../test/unexpected-stop-classifier.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/session/unexpected-stop-classifier.ts b/packages/coding-agent/src/session/unexpected-stop-classifier.ts index 685009412..bb3525eab 100644 --- a/packages/coding-agent/src/session/unexpected-stop-classifier.ts +++ b/packages/coding-agent/src/session/unexpected-stop-classifier.ts @@ -40,11 +40,13 @@ export function isUnexpectedStopCandidate(message: AssistantMessage): boolean { if (content.type === "text" && /\S/.test(content.text)) { hasContent = true; } - // A thinking-only stop is still a candidate: reasoning models can trap the - // intended response (or a truncated fragment) in a signed thinking block - // with no text, which #isEmptyAssistantStop treats as terminal rather than - // empty. Feed it to the classifier instead of silently ending the turn. - if (content.type === "thinking" && /\S/.test(content.thinking)) { + // A signed thinking-only stop is still a candidate: reasoning models can + // trap the intended response (or a truncated fragment) in a thinking block + // with no text. #isEmptyAssistantStop treats a non-whitespace signature as + // terminal (not empty), so such stops bypass the empty-stop path entirely. + // Match that predicate here — unsigned thinking-only stops stay with the + // empty-stop retry path (and its cap) rather than being re-handled here. + if (content.type === "thinking" && /\S/.test(content.thinking) && /\S/.test(content.thinkingSignature ?? "")) { hasContent = true; } } diff --git a/packages/coding-agent/test/unexpected-stop-classifier.test.ts b/packages/coding-agent/test/unexpected-stop-classifier.test.ts index 8c36cb38d..8024fd30c 100644 --- a/packages/coding-agent/test/unexpected-stop-classifier.test.ts +++ b/packages/coding-agent/test/unexpected-stop-classifier.test.ts @@ -85,12 +85,12 @@ describe("isUnexpectedStopCandidate", () => { expect(isUnexpectedStopCandidate(message)).toBe(true); }); - it("returns true for an unsigned thinking-only stop with real content", () => { + it("returns false for an unsigned thinking-only stop (empty-stop path owns it)", () => { const message = makeAssistantMessage({ stopReason: "stop", content: [{ type: "thinking", thinking: "responseAll four reviewers complete." }], }); - expect(isUnexpectedStopCandidate(message)).toBe(true); + expect(isUnexpectedStopCandidate(message)).toBe(false); }); it("returns false when the thinking block is only whitespace", () => { From e42b95bcaea834444c71b76c4f14a2a8ee602bd4 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:26:24 +0900 Subject: [PATCH 16/97] fix(task): report non-isolated cleanup state (#7488) --- packages/coding-agent/src/task/executor.ts | 6 +++++- .../test/task/executor-async-quiescence.test.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 83c238377..610696357 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -3204,6 +3204,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise[] = []; let deferredSessionShutdown: Promise | undefined; const deferCleanup = (completion: Promise): void => { @@ -3211,7 +3215,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { expect(result.exitCode).toBe(1); expect(result.aborted).toBe(true); expect(result.abortReason).toBe("cleanup exceeded 10000 ms"); - expect(result.error).toContain("Cleanup did not finish within 10000 ms"); + expect(result.error).toBe( + "Task aborted. Cleanup did not finish within 10000 ms. This task was not isolated, so its changes may remain in the working directory.", + ); expect(result.output).toContain("yielded output"); expect(result.usage?.totalTokens).toBe(7); expect(lateJobId).toBeDefined(); From cc66b1d74274747b2c906eb5569e3fc22e4a8f96 Mon Sep 17 00:00:00 2001 From: Muhammad Mustaqeem Date: Mon, 3 Aug 2026 20:06:18 +0500 Subject: [PATCH 17/97] feat(browser): detect Ungoogled Chromium on Linux Add the executable names and absolute paths that are unique to Ungoogled Chromium to the Linux branch of systemChromiumCandidates(), including the system-wide and per-user Flatpak shims for io.github.ungoogled_software.ungoogled_chromium. The entries are appended after the existing ones, so a stock Chrome or Chromium install still wins and PUPPETEER_EXECUTABLE_PATH keeps working exactly as before. Closes #7509 --- .../coding-agent/src/tools/browser/launch.ts | 25 ++++++++- .../test/tools/browser-launch.test.ts | 55 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index c3bb19b05..56c905932 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -199,6 +199,9 @@ function isExecutableFile(p: string): boolean { } } +/** Flatpak application id published by the Ungoogled Chromium project. */ +const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; + function systemChromiumCandidates(): string[] { const home = os.homedir(); const candidates: string[] = []; @@ -217,7 +220,15 @@ function systemChromiumCandidates(): string[] { break; } case "linux": { - const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]; + const names = [ + "google-chrome-stable", + "google-chrome", + "chromium", + "chromium-browser", + "chrome", + "ungoogled-chromium", + "ungoogled-chromium-browser", + ]; for (const name of names) { const found = $which(name); if (found) candidates.push(found); @@ -230,6 +241,13 @@ function systemChromiumCandidates(): string[] { "/snap/bin/chromium", "/var/lib/flatpak/exports/bin/com.google.Chrome", "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + // Ungoogled Chromium. Distro and AUR packages that keep the plain + // `chromium` name are already covered above; these are the paths + // unique to it, including the system and per-user Flatpak shims. + "/usr/bin/ungoogled-chromium", + "/usr/bin/ungoogled-chromium-browser", + `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, + path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID), ); let onNixos = false; try { @@ -866,6 +884,11 @@ export async function applyStealthPatches( await injectStealthScripts(page); } +/** Exposes the resolved executable candidate list for detection tests. */ +export function systemChromiumCandidatesForTest(): string[] { + return systemChromiumCandidates(); +} + export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] { return stealthIgnoreDefaultArgs(executablePath); } diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index 1d677507f..b9bb15528 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { stealthIgnoreDefaultArgsForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; +import { + stealthIgnoreDefaultArgsForTest, + systemChromiumCandidatesForTest, +} from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; const AUTOMATION_FLAG = "--enable-automation"; @@ -33,3 +36,53 @@ describe("browser launch stealth defaults", () => { } }); }); + +const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; + +describe("system Chromium candidates on Linux", () => { + it("offers Ungoogled Chromium executables", () => { + if (process.platform !== "linux") return; + const candidates = systemChromiumCandidatesForTest(); + + expect(candidates).toContain("/usr/bin/ungoogled-chromium"); + expect(candidates).toContain("/usr/bin/ungoogled-chromium-browser"); + expect(candidates).toContain(`/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); + + const perUserFlatpak = candidates.find(candidate => + candidate.endsWith(`/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`), + ); + expect(perUserFlatpak).toBeDefined(); + }); + + it("keeps the previously supported executables", () => { + if (process.platform !== "linux") return; + const candidates = systemChromiumCandidatesForTest(); + + for (const executablePath of [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/var/lib/flatpak/exports/bin/com.google.Chrome", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + ]) { + expect(candidates).toContain(executablePath); + } + }); + + it("ranks Ungoogled Chromium below the stock builds", () => { + if (process.platform !== "linux") return; + const candidates = systemChromiumCandidatesForTest(); + const ungoogled = candidates.indexOf("/usr/bin/ungoogled-chromium"); + + for (const executablePath of [ + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/snap/bin/chromium", + "/var/lib/flatpak/exports/bin/org.chromium.Chromium", + ]) { + expect(ungoogled).toBeGreaterThan(candidates.indexOf(executablePath)); + } + }); +}); From 2cf0a47d6a835740308610972aba022f8a869324 Mon Sep 17 00:00:00 2001 From: Muhammad Mustaqeem Date: Mon, 3 Aug 2026 21:06:17 +0500 Subject: [PATCH 18/97] perf(fuzzy-find): retain only the bounded top-K scored matches fuzzyFind returns at most maxResults matches (100 by default), but it allocated a scored match for every fuzzy hit and full-sorted the complete hit set before truncating. Score every eligible entry exactly once into a bounded worst-first BinaryHeap of at most maxResults candidates, while still counting every nonzero-score hit for the exact totalMatches contract. RankedMatch orders candidates as the exact inverse of the final comparator (score descending, then path_depth ascending, then path ascending), so the heap root is the first candidate to evict and into_sorted_vec yields the final order directly. path_depth is now computed once per retained candidate instead of repeatedly inside the sort comparator. The scoring stage also consumes the walker outcome as an iterator, so the all-entry intermediate Vec is gone and the match path is moved instead of cloned. Walker request construction, cache policy, scoring rules, query normalization, symlink handling, the maxResults == 0 and empty-query early returns, the cancellation heartbeat and error mapping are unchanged. --- crates/pi-natives/src/fd.rs | 237 +++++++++++++++++++++++++++++++++--- 1 file changed, 217 insertions(+), 20 deletions(-) diff --git a/crates/pi-natives/src/fd.rs b/crates/pi-natives/src/fd.rs index b4c262e41..cf4030d86 100644 --- a/crates/pi-natives/src/fd.rs +++ b/crates/pi-natives/src/fd.rs @@ -3,7 +3,7 @@ //! Searches for files and directories whose paths match a query string via //! subsequence scoring. Uses `pi-walker` for directory traversal and caching. -use std::path::Path; +use std::{cmp::Ordering, collections::BinaryHeap, path::Path}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -159,6 +159,98 @@ fn path_depth(path: &str) -> usize { path.trim_end_matches('/').matches('/').count() } +/// A scored match carrying its precomputed depth, ordered worst-first. +/// +/// The ordering is the exact inverse of the final result comparator (score +/// descending, then `path_depth` ascending, then `path` ascending), so the +/// greatest element of a `BinaryHeap` is the candidate that must be +/// evicted first, and `into_sorted_vec` yields the final best-first order. +struct RankedMatch { + depth: usize, + entry: FuzzyFindMatch, +} + +impl RankedMatch { + fn new(entry: FuzzyFindMatch) -> Self { + let depth = path_depth(&entry.path); + Self { depth, entry } + } +} + +impl Ord for RankedMatch { + fn cmp(&self, other: &Self) -> Ordering { + other + .entry + .score + .cmp(&self.entry.score) + .then_with(|| self.depth.cmp(&other.depth)) + .then_with(|| self.entry.path.cmp(&other.entry.path)) + } +} + +impl PartialOrd for RankedMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for RankedMatch { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for RankedMatch {} + +/// Bounded collector retaining at most `capacity` best matches while counting +/// every hit, so `totalMatches` stays exact even when it exceeds `maxResults`. +struct TopMatches { + capacity: usize, + total: u64, + heap: BinaryHeap, +} + +impl TopMatches { + fn new(capacity: usize) -> Self { + Self { capacity, total: 0, heap: BinaryHeap::with_capacity(capacity.min(256)) } + } + + fn push(&mut self, entry: FuzzyFindMatch) { + self.total = self.total.saturating_add(1); + if self.capacity == 0 { + return; + } + let candidate = RankedMatch::new(entry); + if self.heap.len() < self.capacity { + self.heap.push(candidate); + return; + } + // The root is the worst retained candidate; replace it only when the new + // candidate outranks it under the final comparator. + if let Some(worst) = self.heap.peek() + && candidate < *worst + { + self.heap.pop(); + self.heap.push(candidate); + } + } + + /// Exact number of scoring hits, clamped to the `u32` wire type. + fn total_matches(&self) -> u32 { + crate::utils::clamp_u32(self.total) + } + + /// Retained matches ordered by score descending, then shallower paths, then + /// path ascending. + fn into_sorted_matches(self) -> Vec { + self.heap + .into_sorted_vec() + .into_iter() + .map(|ranked| ranked.entry) + .collect() + } +} + struct FuzzyFindConfig { query: String, path: String, @@ -168,14 +260,18 @@ struct FuzzyFindConfig { cache: Option, } -fn score_entries( - entries: &[iofs::GlobMatch], +fn score_entries( + entries: I, query_lower: &str, normalized_query: &str, query_chars: &[char], + max_results: usize, ct: &task::CancelToken, -) -> Result> { - let mut scored = Vec::with_capacity(entries.len().min(256)); +) -> Result +where + I: IntoIterator, +{ + let mut scored = TopMatches::new(max_results); for entry in entries { ct.heartbeat()?; if entry.file_type == iofs::FileType::Symlink { @@ -189,7 +285,7 @@ fn score_entries( continue; } - let mut path = entry.path.clone(); + let mut path = entry.path; if is_directory { path.push('/'); } @@ -229,21 +325,17 @@ fn fuzzy_find_sync(config: FuzzyFindConfig, ct: task::CancelToken) -> Result = outcome - .entries - .into_iter() - .map(iofs::GlobMatch::from) - .collect(); - let mut scored = score_entries(&entries, &query_lower, &normalized_query, &query_chars, &ct)?; + let scored = score_entries( + outcome.entries.into_iter().map(iofs::GlobMatch::from), + &query_lower, + &normalized_query, + &query_chars, + max_results, + &ct, + )?; - scored.sort_by(|a, b| { - b.score - .cmp(&a.score) - .then_with(|| path_depth(&a.path).cmp(&path_depth(&b.path))) - .then_with(|| a.path.cmp(&b.path)) - }); - let total_matches = crate::utils::clamp_u32(scored.len() as u64); - let matches = scored.into_iter().take(max_results).collect(); + let total_matches = scored.total_matches(); + let matches = scored.into_sorted_matches(); Ok(FuzzyFindResult { matches, total_matches }) } @@ -381,4 +473,109 @@ mod tests { "expected cwd-root scripts/ to rank first, got {paths:?}" ); } + + #[cfg(unix)] + #[test] + fn fuzzy_find_reports_exact_total_beyond_max_results() { + let root = TempDirGuard::new(); + for index in 0..12 { + fs::write(root.path().join(format!("needle-{index}.txt")), "needle\n") + .expect("write fixture file"); + } + + let result = fuzzy_find_sync( + FuzzyFindConfig { + query: "needle".to_string(), + path: root.path().to_string_lossy().into_owned(), + hidden: Some(true), + gitignore: Some(false), + max_results: Some(3), + cache: Some(false), + }, + task::CancelToken::default(), + ) + .expect("fuzzy find succeeds"); + + assert_eq!(result.matches.len(), 3, "retained matches must honor maxResults"); + assert_eq!(result.total_matches, 12, "total must count every hit, not the retained ones"); + let paths: Vec<&str> = result + .matches + .iter() + .map(|entry| entry.path.as_str()) + .collect(); + assert_eq!( + paths, + vec!["needle-0.txt", "needle-1.txt", "needle-10.txt"], + "bounded retention must keep the same order as the full sort" + ); + } + + #[test] + fn bounded_retention_matches_reference_ordering_and_total() { + use super::{FuzzyFindMatch, TopMatches, path_depth}; + + // Score ties across depths and directories are the cases where a bounded + // heap can diverge from the full sort, so cover them explicitly. + let candidates = [ + ("packages/ai/scripts/", true, 130u32), + ("scripts/", true, 130), + (".omp/skills/opt/scripts/", true, 130), + ("src/scripts.ts", false, 120), + ("src/deep/nested/scripts.ts", false, 120), + ("a/scripts.ts", false, 120), + ("notes/script-notes.md", false, 80), + ("z.txt", false, 51), + ]; + + let mut reference: Vec<(u32, usize, String)> = candidates + .iter() + .map(|(path, _, score)| (*score, path_depth(path), (*path).to_string())) + .collect(); + reference.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + + for max_results in 1..=candidates.len() + 2 { + let mut bounded = TopMatches::new(max_results); + for (path, is_directory, score) in candidates { + bounded.push(FuzzyFindMatch { path: path.to_string(), is_directory, score }); + } + let total = bounded.total_matches(); + let bounded_paths: Vec = bounded + .into_sorted_matches() + .into_iter() + .map(|entry| entry.path) + .collect(); + let expected_paths: Vec = reference + .iter() + .take(max_results) + .map(|(_, _, path)| path.clone()) + .collect(); + + assert_eq!(total, candidates.len() as u32, "total must count every pushed hit"); + assert_eq!( + bounded_paths, expected_paths, + "bounded order must match the full sort for max_results={max_results}" + ); + } + } + + #[test] + fn bounded_retention_counts_hits_with_zero_capacity() { + use super::{FuzzyFindMatch, TopMatches}; + + let mut bounded = TopMatches::new(0); + for index in 0..5 { + bounded.push(FuzzyFindMatch { + path: format!("file-{index}.txt"), + is_directory: false, + score: 10, + }); + } + + assert_eq!(bounded.total_matches(), 5); + assert!(bounded.into_sorted_matches().is_empty()); + } } From 60187c9ba96bda40f5695889e11c5dfe7fb74166 Mon Sep 17 00:00:00 2001 From: Muhammad Mustaqeem Date: Mon, 3 Aug 2026 21:09:01 +0500 Subject: [PATCH 19/97] refactor(fuzzy-find): release the heap borrow before evicting Comparing against the heap root inside an `if let` binding keeps the shared borrow of `heap` alive across the `pop`/`push` in the same block. Fold the comparison into the condition instead, so the borrow ends with the condition expression and eviction takes a clean mutable borrow. No behavior change. --- crates/pi-natives/src/fd.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/pi-natives/src/fd.rs b/crates/pi-natives/src/fd.rs index cf4030d86..1f9ec8dc9 100644 --- a/crates/pi-natives/src/fd.rs +++ b/crates/pi-natives/src/fd.rs @@ -227,9 +227,7 @@ impl TopMatches { } // The root is the worst retained candidate; replace it only when the new // candidate outranks it under the final comparator. - if let Some(worst) = self.heap.peek() - && candidate < *worst - { + if self.heap.peek().is_some_and(|worst| candidate < *worst) { self.heap.pop(); self.heap.push(candidate); } From db97103c32dae61bf7b5cac9354b254d23a31cbf Mon Sep 17 00:00:00 2001 From: Brent <67750428+eggpeat@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:33:46 +0000 Subject: [PATCH 20/97] fix(coding-agent): complete usage-aware fallback integration --- packages/agent/CHANGELOG.md | 1 + packages/agent/src/agent-loop.ts | 10 +- packages/agent/src/agent.ts | 99 ++- packages/agent/src/types.ts | 6 +- packages/agent/test/agent.test.ts | 118 +++- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth-storage.ts | 13 +- .../test/auth-storage-codex-selection.test.ts | 44 ++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/modes/acp/acp-agent.ts | 3 +- .../controllers/extension-ui-controller.ts | 3 +- packages/coding-agent/src/sdk.ts | 16 +- .../src/session/agent-session-types.ts | 2 + .../coding-agent/src/session/agent-session.ts | 310 ++++----- .../coding-agent/src/session/turn-recovery.ts | 201 +++++- packages/coding-agent/src/task/executor.ts | 17 +- .../test/agent-session-retry-fallback.test.ts | 638 +++++++++++++++++- .../coding-agent/test/issue-905-repro.test.ts | 8 +- .../test/sdk-model-selection.test.ts | 27 + .../test/turn-recovery-replay-unsafe.test.ts | 92 +++ 20 files changed, 1369 insertions(+), 241 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 844d6f388..4e8ae3ebf 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -5,6 +5,7 @@ ## [17.2.6] - 2026-08-03 ### Fixed +- Preserved queued steering and follow-up messages when a continuation is cancelled before or during pre-dequeue hooks, and propagated the caller's cancellation signal through every continuation model-call loop. - Fixed an issue where peer-IRC interrupts (such as subagent messages) incorrectly skipped non-interruptible tool calls queued in the same batch. - Improved interruption messaging to clearly distinguish between parent-agent steering and system-advisory interruptions. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index fbce2f756..9cd8b1671 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1016,7 +1016,7 @@ async function runLoopBody( // Skip when the run is already externally aborted — dequeuing would strand // the messages in a run that is about to die. try { - pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; } catch (error) { stream.push({ type: "turn_start" }); emitInputMessages(stream, messagesToEmit); @@ -1075,7 +1075,7 @@ async function runLoopBody( let gateResult: AgentPreModelCallResult; try { if (config.syncContextBeforeModelCall) { - await config.syncContextBeforeModelCall(currentContext); + await config.syncContextBeforeModelCall(currentContext, signal); } if (!directiveResolvedForTurn) { @@ -1421,7 +1421,7 @@ async function runLoopBody( // instantly aborts — message lands in history, agent never responds. The // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue // still owns every message until this dequeue. - const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; if (hasMoreToolCalls) { // Mid-work: fold any non-interrupting asides into the next turn alongside steering. const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.()); @@ -1450,9 +1450,9 @@ async function runLoopBody( // Re-poll steering too: a steer can land between the stop-boundary dequeue // above and this yield point (e.g. queued while onBeforeYield ran). Without // this poll it would strand in the queue until the next manual prompt. - const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []; + const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || []; const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.()); - const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || []; + const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || []; if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) { // Set as pending so the inner loop processes them before stopping. pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages]; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 8a17735c0..b0a8e5246 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -426,6 +426,8 @@ export class Agent { #asideMessageProvider?: () => AsideMessage[] | Promise; #telemetry?: AgentLoopConfig["telemetry"]; #appendOnlyContext?: AppendOnlyContextManager; + #beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise | void>(); + #beforeModelCallHooks = new Set<(signal?: AbortSignal) => Promise | void>(); /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ #cursorToolResultBuffer: CursorToolResultEntry[] = []; @@ -784,6 +786,40 @@ export class Agent { return () => this.#listeners.delete(fn); } + /** Register an independently removable hook that runs before queued messages are consumed. */ + addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise | void): () => void { + const registration = (signal?: AbortSignal) => hook(signal); + this.#beforeQueuedMessageDequeueHooks.add(registration); + return () => this.#beforeQueuedMessageDequeueHooks.delete(registration); + } + + /** Register an independently removable hook that runs immediately before each model call. */ + addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise | void): () => void { + const registration = (signal?: AbortSignal) => hook(signal); + this.#beforeModelCallHooks.add(registration); + return () => this.#beforeModelCallHooks.delete(registration); + } + + async #runBeforeModelCallHooks(signal?: AbortSignal): Promise { + for (const hook of this.#beforeModelCallHooks) await hook(signal); + } + + async #runBeforeQueuedMessageDequeueHooks(signal?: AbortSignal): Promise { + for (const hook of this.#beforeQueuedMessageDequeueHooks) await hook(signal); + } + + async #dequeueSteeringMessagesAfterHooks(signal?: AbortSignal): Promise { + if (signal?.aborted || this.#steeringQueue.length === 0) return []; + await this.#runBeforeQueuedMessageDequeueHooks(signal); + return signal?.aborted ? [] : this.#dequeueSteeringMessages(); + } + + async #dequeueFollowUpMessagesAfterHooks(signal?: AbortSignal): Promise { + if (signal?.aborted || this.#followUpQueue.length === 0) return []; + await this.#runBeforeQueuedMessageDequeueHooks(signal); + return signal?.aborted ? [] : this.#dequeueFollowUpMessages(); + } + setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void { this.#onResponse = fn; } @@ -1137,11 +1173,26 @@ export class Agent { /** * Continue from current context (used for retries and resuming queued messages). */ - async continue() { + #continuationDequeueSignal(signal?: AbortSignal): AbortSignal | undefined { + if (this.#deadline === undefined) return signal; + const delay = this.#deadline - Date.now(); + let deadlineSignal: AbortSignal; + if (delay <= 0) { + const controller = new AbortController(); + controller.abort(new DOMException("Deadline exceeded", "TimeoutError")); + deadlineSignal = controller.signal; + } else { + deadlineSignal = AbortSignal.timeout(delay); + } + return signal ? AbortSignal.any([signal, deadlineSignal]) : deadlineSignal; + } + + async continue(signal?: AbortSignal) { if (this.#state.isStreaming) { throw new AgentBusyError(); } + const dequeueSignal = this.#continuationDequeueSignal(signal); const messages = this.#state.messages; if (messages.length === 0) { // An empty transcript has nothing to resume, but a queued steer/follow-up @@ -1150,35 +1201,35 @@ export class Agent { // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every // microtask because hasQueuedMessages() never clears, spinning an unbounded // allocation loop until OOM (issue #6344). - const queuedSteering = this.#dequeueSteeringMessages(); + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal); return; } - const queuedFollowUp = this.#dequeueFollowUpMessages(); + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp); + await this.#runLoop(queuedFollowUp, undefined, signal); return; } throw new Error("No messages to continue from"); } if (messages[messages.length - 1].role === "assistant") { - const queuedSteering = this.#dequeueSteeringMessages(); + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal); return; } - const queuedFollowUp = this.#dequeueFollowUpMessages(); + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp); + await this.#runLoop(queuedFollowUp, undefined, signal); return; } throw new Error("Cannot continue from message role: assistant"); } - await this.#runLoop(undefined); + await this.#runLoop(undefined, undefined, signal); } /** @@ -1186,7 +1237,11 @@ export class Agent { * If messages are provided, starts a new conversation turn with those messages. * Otherwise, continues from existing context. */ - async #runLoop(messages?: AgentMessage[], options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }) { + async #runLoop( + messages?: AgentMessage[], + options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }, + continuationSignal?: AbortSignal, + ) { const model = this.#state.model; if (!model) throw new Error("No model configured"); @@ -1197,6 +1252,9 @@ export class Agent { this.#resolveRunningPrompt = resolve; this.#abortController = new AbortController(); + const loopSignal = continuationSignal + ? AbortSignal.any([this.#abortController.signal, continuationSignal]) + : this.#abortController.signal; this.#state.isStreaming = true; this.#state.streamMessage = null; this.#state.error = undefined; @@ -1315,7 +1373,8 @@ export class Agent { onSseEvent: this.#onSseEvent, getApiKey: this.getApiKey, getToolContext: this.#getToolContext, - syncContextBeforeModelCall: async context => { + syncContextBeforeModelCall: async (context, signal) => { + await this.#runBeforeModelCallHooks(signal); if (this.#listeners.size > 0) { await Bun.sleep(0); } @@ -1362,12 +1421,12 @@ export class Agent { getReasoning: () => this.#state.thinkingLevel, getDisableReasoning: () => this.#state.disableReasoning, getServiceTier: this.#serviceTierResolver, - getSteeringMessages: async () => { + getSteeringMessages: async signal => { if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; return []; } - return this.#dequeueSteeringMessages(); + return this.#dequeueSteeringMessagesAfterHooks(signal); }, hasSteeringMessages: () => { if (this.#steeringQueue.length === 0) { @@ -1392,7 +1451,7 @@ export class Agent { }, waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal), hasIrcInterrupts: this.hasIrcInterrupts, - getFollowUpMessages: async () => this.#dequeueFollowUpMessages(), + getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal), getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [], onBeforeYield: () => this.#onBeforeYield?.(), telemetry: this.#telemetry, @@ -1404,8 +1463,8 @@ export class Agent { try { const stream = messages - ? agentLoop(messages, context, config, this.#abortController.signal, this.streamFn) - : agentLoopContinue(context, config, this.#abortController.signal, this.streamFn); + ? agentLoop(messages, context, config, loopSignal, this.streamFn) + : agentLoopContinue(context, config, loopSignal, this.streamFn); for await (const event of stream) { if (event.type === "turn_start") turnOpen = true; @@ -1472,15 +1531,15 @@ export class Agent { if (!onlyEmpty) { this.appendMessage(partial); } else { - if (this.#abortController?.signal.aborted) { + if (loopSignal.aborted) { throw new Error("Request was aborted"); } } } } catch (err) { - const stoppedForAbort = this.#abortController?.signal.aborted === true; + const stoppedForAbort = loopSignal.aborted; const errorMessage = stoppedForAbort - ? abortReasonText(this.#abortController?.signal) + ? abortReasonText(loopSignal) : err instanceof Error ? err.message : String(err); diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index de90a681e..198c9a4a3 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -240,7 +240,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * mid-batch interrupt poll uses {@link hasSteeringMessages} instead and * never consumes the queue. */ - getSteeringMessages?: () => Promise; + getSteeringMessages?: (signal?: AbortSignal) => Promise; /** * Peeks whether steering messages are queued, without consuming them. @@ -285,7 +285,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * If messages are returned, they're added to the context and the agent * continues with another turn. */ - getFollowUpMessages?: () => Promise; + getFollowUpMessages?: (signal?: AbortSignal) => Promise; /** * Returns non-interrupting "aside" messages to inject at a step boundary. * @@ -319,7 +319,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * Mutate the agent context here; use `beforeModelCall` to inspect the * provider-bound context. */ - syncContextBeforeModelCall?: (context: AgentContext) => void | Promise; + syncContextBeforeModelCall?: (context: AgentContext, signal?: AbortSignal) => void | Promise; /** * Asked after the complete provider context has been built, including diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index a887758c8..6155bc2e6 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -240,6 +240,106 @@ describe("Agent", () => { } }); + it("removes duplicate queued-message hooks independently", async () => { + const mock = createMockModel({ responses: [{ content: ["first"] }, { content: ["second"] }] }); + const agent = new Agent({ streamFn: mock.stream }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + let calls = 0; + const signals: Array = []; + const hook = (signal?: AbortSignal) => { + calls++; + signals.push(signal); + }; + const removeFirst = agent.addBeforeQueuedMessageDequeueHook(hook); + const removeSecond = agent.addBeforeQueuedMessageDequeueHook(hook); + + const controller = new AbortController(); + removeFirst(); + agent.followUp({ role: "user", content: "first turn", timestamp: Date.now() }); + await agent.continue(controller.signal); + expect(calls).toBe(1); + expect(signals).toEqual([controller.signal]); + + removeSecond(); + agent.followUp({ role: "user", content: "second turn", timestamp: Date.now() }); + await agent.continue(); + expect(calls).toBe(1); + }); + + it("continue() leaves queued messages owned when its signal is already aborted", async () => { + const agent = new Agent(); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "stay queued", timestamp: Date.now() }); + const controller = new AbortController(); + controller.abort(); + + await expect(agent.continue(controller.signal)).rejects.toThrow("Cannot continue from message role: assistant"); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + it("keeps follow-up ownership when the deadline expires during a dequeue hook", async () => { + const mock = createMockModel({ responses: [{ content: ["done"] }] }); + const agent = new Agent({ streamFn: mock.stream, deadline: Date.now() + 25 }); + let hookSignal: AbortSignal | undefined; + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected the active loop signal"); + hookSignal = signal; + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + agent.followUp({ role: "user", content: "stay queued after deadline", timestamp: Date.now() }); + + await agent.prompt("start"); + + expect(hookSignal?.aborted).toBe(true); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + it("keeps queued work when continue() reaches its deadline inside a dequeue hook", async () => { + const agent = new Agent({ deadline: Date.now() + 25 }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected the deadline-aware dequeue signal"); + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + agent.followUp({ role: "user", content: "stay queued before run loop", timestamp: Date.now() }); + + await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant"); + + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + + it("classifies an in-flight continuation cancellation as aborted", async () => { + const providerStarted = Promise.withResolvers(); + const agent = new Agent({ + streamFn: (_model, _context, options) => { + const signal = options?.signal; + if (!signal) throw new Error("Expected provider abort signal"); + providerStarted.resolve(signal); + const stream = new AssistantMessageEventStream(); + signal.addEventListener("abort", () => stream.fail(new Error("provider aborted")), { once: true }); + return stream; + }, + }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "cancel this continuation", timestamp: Date.now() }); + const controller = new AbortController(); + + const running = agent.continue(controller.signal); + await providerStarted.promise; + controller.abort("caller cancelled"); + await running; + + const finalMessage = agent.state.messages.at(-1); + expect(finalMessage?.role).toBe("assistant"); + if (finalMessage?.role !== "assistant") throw new Error("Expected aborted assistant message"); + expect(finalMessage.stopReason).toBe("aborted"); + expect(finalMessage.errorMessage).toBe("caller cancelled"); + }); + it("continue() should process queued follow-up messages after an assistant turn", async () => { const mock = createMockModel({ responses: [{ content: ["Processed"] }] }); const agent = new Agent({ streamFn: mock.stream }); @@ -276,6 +376,12 @@ describe("Agent", () => { responses: [{ content: ["Processed 1"] }, { content: ["Processed 2"] }], }); const agent = new Agent({ streamFn: mock.stream }); + let dequeueHooks = 0; + const dequeueSignals: Array = []; + agent.addBeforeQueuedMessageDequeueHook(signal => { + dequeueHooks++; + dequeueSignals.push(signal); + }); agent.replaceMessages([ { @@ -297,11 +403,16 @@ describe("Agent", () => { timestamp: Date.now() + 1, }); - await expect(agent.continue()).resolves.toBeUndefined(); + const controller = new AbortController(); + await expect(agent.continue(controller.signal)).resolves.toBeUndefined(); const recentMessages = agent.state.messages.slice(-4); expect(recentMessages.map(m => m.role)).toEqual(["user", "assistant", "user", "assistant"]); expect(mock.calls.length).toBe(2); + expect(dequeueHooks).toBe(2); + expect(dequeueSignals).toHaveLength(2); + controller.abort(); + expect(dequeueSignals.every(signal => signal?.aborted === true)).toBe(true); }); it("delivers a steer that lands at the yield boundary instead of stranding it", async () => { @@ -856,6 +967,10 @@ describe("Agent", () => { }, streamFn: mock.stream, }); + let beforeModelCalls = 0; + agent.addBeforeModelCallHook(() => { + beforeModelCalls++; + }); const unsubscribe = agent.subscribe(event => { if (event.type === "message_end" && event.message.role === "toolResult") { @@ -875,6 +990,7 @@ describe("Agent", () => { { systemPrompt: "prompt-one", toolNames: ["alpha"] }, { systemPrompt: "prompt-two", toolNames: ["alpha", "beta"] }, ]); + expect(beforeModelCalls).toBe(2); }); it("prompt() drops stale forced toolChoice after same-turn tool refresh", async () => { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index abfe680ff..e1ba32b4e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ - Added profile-aware Bedrock Mantle region selection, authenticated model discovery, bearer-token or SigV4 authentication, and credential refresh handling for OpenAI Responses models. ### Fixed +- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. - Fixed an issue where Ollama requests without a user-role message would fail to generate output or silently fail with a misleading error. diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e1c76bda2..ac4e6dba8 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3856,13 +3856,15 @@ export class AuthStorage { : undefined; const rankingContext: CredentialRankingContext = { modelId: options.modelId }; + const planRequirement = resolveOpenAICodexPlanRequirement(provider, options.modelId); + const planEligibilityByCredential = new Map(); const blockScope = strategy.blockScope?.(rankingContext); const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []); const reserveFraction = Number.isFinite(options.reserveFraction) ? Math.max(0, Math.min(1, options.reserveFraction)) : 0; const nowMs = Date.now(); - const accounts = await Promise.all( + let accounts = await Promise.all( pool.map(async ({ entry, index }): Promise => { const credentialType = entry.credential.type; const providerKey = this.#getProviderTypeKey(provider, credentialType); @@ -3890,6 +3892,9 @@ export class AuthStorage { if (options.signal?.aborted) throw error; report = null; } + if (planRequirement !== "none") { + planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement)); + } if (provider === "openai-codex") { blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes); @@ -3942,6 +3947,12 @@ export class AuthStorage { }; }), ); + if ( + planRequirement !== "none" && + accounts.some(account => planEligibilityByCredential.get(account.credentialId) === true) + ) { + accounts = accounts.filter(account => planEligibilityByCredential.get(account.credentialId) === true); + } if (selectedCredentialId !== undefined) { const selectedAccount = accounts.find(account => account.credentialId === selectedCredentialId); if (selectedAccount) selectedAccount.selected = true; diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 365e7da0b..b363c4f17 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -2027,6 +2027,50 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-pro"); }); + test("ignores plan-ineligible headroom when reporting Spark model health", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-pro", "pro@example.com") }, + ]); + usageByAccount.set( + "acct-free", + addSparkUsage( + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + 0.05, + 0.05, + ), + ); + usageByAccount.set( + "acct-pro", + addSparkUsage( + createCodexUsageReport({ + accountId: "acct-pro", + primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "pro", email: "pro@example.com", limitReached: true }, + }), + 1, + 1, + ), + ); + + const health = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.3-codex-spark", + reserveFraction: 0.1, + }); + + expect(health.state).toBe("depleted"); + expect(health.accounts).toHaveLength(1); + expect(health.accounts[0]?.state).toBe("depleted"); + }); + test("routes codex spark to a single Plus account when no Pro is connected", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 46d565c01..5bf34f4fe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ - Added the `/reset` slash command to reset the conversation context in place: it drops the live messages, queued turns, and pending tool calls (and cancels the turn's async jobs, post-prompt continuations, and checkpoint/plan runtime state) while keeping the session id, title, cwd, model, and on-disk transcript. It records a durable reset boundary so the live transcript stays cleared across rebuilds (theme change, focus attach, `/shake`, resume) instead of resurrecting the pre-reset messages, while the full pre-reset history stays on disk ([#3580](https://github.com/can1357/oh-my-pi/issues/3580)). ### Fixed +- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. - Fixed extension slash commands appearing as user prompts after being handled locally. - Preserved explicit session titles when branching from an earlier conversation turn. diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 8ee0db5ee..d3d480564 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -2312,7 +2312,7 @@ export class AcpAgent implements Agent { this.#clientCapabilities, ); if (this.#clientCapabilities?.elicitation?.form != null) { - record.session.setUsageFallbackConfirmer(confirmation => { + record.session.setUsageFallbackConfirmer((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" @@ -2320,6 +2320,7 @@ export class AcpAgent implements Agent { return uiContext.confirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, + { signal }, ); }); } diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 750bdd193..942b03da8 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -128,7 +128,7 @@ export class ExtensionUiController { }; this.ctx.setToolUIContext(uiContext, true); this.#toolUIContext = uiContext; - this.ctx.session.setUsageFallbackConfirmer?.(confirmation => { + this.ctx.session.setUsageFallbackConfirmer?.((confirmation, signal) => { const reserve = confirmation.remainingPercent === undefined ? "inside the configured reserve margin" @@ -136,6 +136,7 @@ export class ExtensionUiController { return this.showHookConfirm( "Coding-plan reserve reached", `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`, + { signal }, ); }); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4ee9a24c5..3f3ae2bcc 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2283,9 +2283,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro } } const usageReservePolicy = settings.get("retry.usageReservePolicy"); + const modelFallbackEnabled = settings.get("retry.modelFallback"); if ( - (hasUsageFallbackCandidate || usageReservePolicy === "fail-closed") && - settings.get("retry.modelFallback") && + ((modelFallbackEnabled && (hasUsageFallbackCandidate || usageFallbackTriggered)) || + usageReservePolicy === "fail-closed") && settings.get("retry.usageAwareFallback") ) { let usageHealth: ModelUsageHealth | undefined; @@ -2308,8 +2309,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro `Usage depleted for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - usageFallbackTriggered = true; - continue; + if (modelFallbackEnabled) { + usageFallbackTriggered = true; + continue; + } } if (usageHealth?.state === "reserve") { if (usageReservePolicy === "fail-closed") { @@ -2317,7 +2320,10 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro `Usage reserve reached for ${primary.model.provider}/${primary.model.id}; reserve policy is fail-closed.`, ); } - if (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) { + if ( + modelFallbackEnabled && + (usageReservePolicy === "auto" || (!options.hasUI && !options.deferUsageReserveConfirmation)) + ) { usageFallbackTriggered = true; continue; } diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index db1d56f12..2fe0f0f91 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -92,6 +92,8 @@ export interface UsageFallbackConfirmation { remainingPercent: number | undefined; } +export type UsageFallbackConfirmer = (confirmation: UsageFallbackConfirmation, signal: AbortSignal) => Promise; + /** Identifies a retry fallback chain already entered during startup model resolution. */ export interface InitialRetryFallbackState { /** Role whose configured primary was unavailable. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2f6523b18..c116a3587 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -58,7 +58,6 @@ import type { ImageContent, Message, Model, - ModelUsageHealth, OAuthAccountIdentity, ProviderSessionState, ResetCreditAccountStatus, @@ -101,7 +100,7 @@ import { type AdvisorConfig, type AdvisorRuntimeStatus, loadAdvisorTranscriptCos import { type AsyncJob, AsyncJobManager } from "../async"; import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode"; import type { ModelRegistry } from "../config/model-registry"; -import { type ResolvedModelRoleValue, resolveModelOverride } from "../config/model-resolver"; +import type { ResolvedModelRoleValue } from "../config/model-resolver"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import { buildServiceTierByFamily } from "../config/service-tier"; import type { Settings, SkillsSettings } from "../config/settings"; @@ -230,7 +229,7 @@ import type { SessionHandoffOptions, SessionOAuthAccountList, SessionStats, - UsageFallbackConfirmation, + UsageFallbackConfirmer, } from "./agent-session-types"; import { ASYNC_INLINE_RESULT_MAX_CHARS, @@ -312,7 +311,6 @@ import { queueChipText, toRestoredQueuedMessage, } from "./queued-messages"; -import { formatRetryFallbackSelector, type RetryFallbackSelector } from "./retry-fallback-chains"; import { type AdvisorStats, SessionAdvisors, type SessionAdvisorsHost } from "./session-advisors"; import type { BuildSessionContextOptions, SessionContext } from "./session-context"; import { getRestorableSessionModels } from "./session-context"; @@ -558,9 +556,13 @@ export class AgentSession { // Model registry for API key resolution #modelRegistry: ModelRegistry; - #usageFallbackConfirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined; - #usageReserveApprovedSelector: string | undefined; + #usageFallbackConfirmer: UsageFallbackConfirmer | undefined; #usagePreflightAbortControllers = new Set(); + #queuedMessageDrainBlocked = false; + #usagePreflightReadyForNextModelCall = false; + #usagePreflightReadyModel: Model | undefined; + #detachUsageBeforeQueueDequeue: (() => void) | undefined; + #detachUsageBeforeModelCall: (() => void) | undefined; #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; #onPayload: SimpleStreamOptions["onPayload"] | undefined; @@ -768,8 +770,10 @@ export class AgentSession { !this.#canAutoContinueForFollowUp() ? [...this.agent.peekFollowUpQueue()] : []; + const parkedQueueDrainBlocked = parkedFollowUps.length > 0 && this.#queuedMessageDrainBlocked; if (parkedFollowUps.length > 0) { this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []); + if (parkedQueueDrainBlocked) this.#queuedMessageDrainBlocked = false; } let finishObservation: ((error?: unknown) => void | Promise) | undefined; try { @@ -805,6 +809,7 @@ export class AgentSession { [...this.agent.peekSteeringQueue()], [...parkedFollowUps, ...this.agent.peekFollowUpQueue()], ); + this.#queuedMessageDrainBlocked ||= parkedQueueDrainBlocked; } this.#endInFlight(async () => { try { @@ -830,6 +835,7 @@ export class AgentSession { steering.filter(m => !isAdvisorCard(m)), followUp.filter(m => !isAdvisorCard(m)), ); + this.#reconcileQueuedMessageDrain(); return cards; } @@ -1050,6 +1056,31 @@ export class AgentSession { withBashBranchTransition: operation => this.#bash.withBranchTransition(operation), }; this.#recovery = new TurnRecovery(recoveryHost, { initialRetryFallback: config.initialRetryFallback }); + this.#detachUsageBeforeQueueDequeue = this.agent.addBeforeQueuedMessageDequeueHook(async signal => { + if ( + !this.settings.get("retry.usageAwareFallback") || + (this.#usagePreflightReadyForNextModelCall && this.#usagePreflightReadyModel === this.model) + ) { + return; + } + if (!(await this.#runQueuedUsageAwarePreflight(signal))) { + signal?.throwIfAborted(); + throw new DOMException("Usage preflight cancelled", "AbortError"); + } + }); + this.#detachUsageBeforeModelCall = this.agent.addBeforeModelCallHook(async signal => { + if (!this.settings.get("retry.usageAwareFallback")) return; + if (this.#usagePreflightReadyForNextModelCall) { + const checkedModel = this.#usagePreflightReadyModel; + this.#usagePreflightReadyForNextModelCall = false; + this.#usagePreflightReadyModel = undefined; + if (checkedModel === this.model) return; + } + if (!(await this.#runUsageAwarePreflight(signal))) { + signal?.throwIfAborted(); + throw new DOMException("Usage preflight cancelled", "AbortError"); + } + }); const statsHost: SessionStatsTrackerHost = { session: this, agent: this.agent, @@ -2967,19 +2998,17 @@ export class AgentSession { this.#beginInFlight(); try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if ( - this.settings.get("retry.modelFallback") && - this.settings.get("retry.usageAwareFallback") && - !(await this.#runUsageAwarePreflight()) - ) { - this.#skipAgentContinue("session-unavailable", options); - return; - } if (signal.aborted || this.#isDisposed) { this.#skipAgentContinue("post-restore-unavailable", options); return; } - await this.agent.continue(); + if (this.settings.get("retry.usageAwareFallback")) { + if (!(await this.#runQueuedUsageAwarePreflight(signal))) { + this.#skipAgentContinue("session-unavailable", options); + return; + } + } + await this.agent.continue(signal); } catch (error) { logger.warn("agent.continue failed after scheduling", { error: error instanceof Error ? error.message : String(error), @@ -2987,6 +3016,7 @@ export class AgentSession { }); options?.onError?.(error); } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#endInFlight(); } }, @@ -3605,6 +3635,12 @@ export class AgentSession { */ beginDispose(): void { this.#isDisposed = true; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; + this.#detachUsageBeforeQueueDequeue?.(); + this.#detachUsageBeforeQueueDequeue = undefined; + this.#detachUsageBeforeModelCall?.(); + this.#detachUsageBeforeModelCall = undefined; this.#memory.cancelLocalMemoryStartup(); this.#titleGenerationAbortController.abort(); this.#abortAutolearnCapture(); @@ -3927,176 +3963,63 @@ export class AgentSession { } /** Install the interactive decision surface for reserve-triggered model changes. */ - setUsageFallbackConfirmer( - confirmer: ((confirmation: UsageFallbackConfirmation) => Promise) | undefined, - ): void { + setUsageFallbackConfirmer(confirmer: UsageFallbackConfirmer | undefined): void { this.#usageFallbackConfirmer = confirmer; } - async #runUsageAwarePreflight(): Promise { + #allowQueuedMessageDrainRetry(): void { + this.#queuedMessageDrainBlocked = false; + } + + #reconcileQueuedMessageDrain(): void { + if (!this.agent.hasQueuedMessages()) { + this.#queuedMessageDrainBlocked = false; + } + } + + async #runQueuedUsageAwarePreflight(signal?: AbortSignal): Promise { + try { + const allowed = await this.#runUsageAwarePreflight(signal); + this.#usagePreflightReadyForNextModelCall = allowed; + this.#usagePreflightReadyModel = allowed ? this.model : undefined; + this.#queuedMessageDrainBlocked = !allowed && this.agent.hasQueuedMessages(); + return allowed; + } catch (error) { + this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages(); + throw error; + } + } + + async #runUsageAwarePreflightForNextModelCall(signal?: AbortSignal): Promise { + const allowed = await this.#runUsageAwarePreflight(signal); + this.#usagePreflightReadyForNextModelCall = allowed; + this.#usagePreflightReadyModel = allowed ? this.model : undefined; + return allowed; + } + + async #runUsageAwarePreflight(signal?: AbortSignal): Promise { + if (signal?.aborted) return false; const generation = this.#promptGeneration; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", onAbort, { once: true }); this.#usagePreflightAbortControllers.add(controller); try { - await this.#maybeApplyUsageAwareFallback(controller.signal); - return !controller.signal.aborted && this.#promptGeneration === generation; + const fallbackCommitted = await this.#recovery.maybeApplyUsageAwareFallback( + controller.signal, + this.#usageFallbackConfirmer, + ); + return fallbackCommitted || (!controller.signal.aborted && this.#promptGeneration === generation); } catch (error) { if (controller.signal.aborted || this.#promptGeneration !== generation) return false; throw error; } finally { + signal?.removeEventListener("abort", onAbort); this.#usagePreflightAbortControllers.delete(controller); } } - async #confirmUsageFallback(confirmation: UsageFallbackConfirmation, signal: AbortSignal): Promise { - const confirmer = this.#usageFallbackConfirmer; - if (!confirmer || signal.aborted) return false; - const aborted = Promise.withResolvers(); - const onAbort = () => aborted.resolve(false); - signal.addEventListener("abort", onAbort, { once: true }); - try { - return await Promise.race([confirmer(confirmation), aborted.promise]); - } finally { - signal.removeEventListener("abort", onAbort); - } - } - - async #maybeApplyUsageAwareFallback(signal: AbortSignal): Promise { - if (!this.settings.get("retry.modelFallback") || !this.settings.get("retry.usageAwareFallback")) return; - const currentModel = this.model; - if (!currentModel) return; - const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel); - let health: ModelUsageHealth; - try { - health = await this.#modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { - modelId: currentModel.id, - sessionId: this.sessionId, - baseUrl: currentModel.baseUrl, - reserveFraction: this.settings.get("retry.usageReservePct") / 100, - signal, - }); - } catch (error) { - logger.debug("Usage-aware runtime preflight failed open", { - provider: currentModel.provider, - model: currentModel.id, - error: String(error), - }); - return; - } - if (signal.aborted) return; - - if (health.state === "healthy") { - this.#usageReserveApprovedSelector = undefined; - const selected = health.accounts.find(account => account.selected); - if (selected && selected.state !== "healthy" && health.accounts.some(account => account.state === "healthy")) { - this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( - currentModel.provider, - this.sessionId, - ); - } - return; - } - if (health.state === "unknown") { - this.#usageReserveApprovedSelector = undefined; - return; - } - const reservePolicy = this.settings.get("retry.usageReservePolicy"); - if (reservePolicy === "fail-closed") { - const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; - throw new Error(`${condition} for ${currentSelector}; reserve policy is fail-closed.`); - } - - const role = this.#recovery.resolveRetryFallbackRole(currentSelector, currentModel); - if (!role) return; - let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; - for (const candidate of this.#recovery.findRetryFallbackCandidates(role, currentSelector, currentModel)) { - if (this.#recovery.isRetryFallbackSelectorSuppressed(candidate)) continue; - const resolved = resolveModelOverride([candidate.raw], this.#modelRegistry, this.settings); - const candidateModel = resolved.model ?? this.#modelRegistry.find(candidate.provider, candidate.id); - if (!candidateModel) continue; - if (!this.#modelRegistry.hasConfiguredAuth(candidateModel)) continue; - try { - const candidateHealth = await this.#modelRegistry.authStorage.getModelUsageHealth(candidateModel.provider, { - modelId: candidateModel.id, - sessionId: this.sessionId, - baseUrl: candidateModel.baseUrl, - reserveFraction: this.settings.get("retry.usageReservePct") / 100, - signal, - }); - if (signal.aborted) return; - if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; - if (candidateHealth.state === "healthy") { - const selected = candidateHealth.accounts.find(account => account.selected); - if ( - selected && - selected.state !== "healthy" && - candidateHealth.accounts.some(account => account.state === "healthy") - ) { - this.#modelRegistry.authStorage.releaseSessionCredentialForReselection( - candidateModel.provider, - this.sessionId, - ); - } - } - } catch { - if (signal.aborted) return; - // Unknown usage fails open for an otherwise valid fallback. - } - if (signal.aborted) return; - let apiKey: string | undefined; - try { - apiKey = await this.#modelRegistry.getApiKey(candidateModel, this.sessionId, { signal }); - } catch { - if (signal.aborted) return; - continue; - } - if (signal.aborted) return; - if (!apiKey) continue; - fallback = { selector: candidate, apiKey }; - break; - } - if (!fallback) return; - - if (health.state === "reserve") { - if (reservePolicy === "confirm" && this.#usageFallbackConfirmer) { - if (this.#usageReserveApprovedSelector === currentSelector) return; - const selected = health.accounts.find(account => account.selected); - const remainingFraction = - selected?.remainingFraction ?? - health.accounts.reduce( - (minimum, account) => - account.remainingFraction === undefined - ? minimum - : minimum === undefined - ? account.remainingFraction - : Math.min(minimum, account.remainingFraction), - undefined, - ); - const shouldFallback = await this.#confirmUsageFallback( - { - from: currentSelector, - to: fallback.selector.raw, - remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), - }, - signal, - ); - if (signal.aborted) return; - if (!shouldFallback) { - this.#usageReserveApprovedSelector = currentSelector; - return; - } - } - } - - if (signal.aborted) return; - this.#usageReserveApprovedSelector = undefined; - await this.#recovery.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { - pinFallback: true, - apiKey: fallback.apiKey, - signal, - }); - } - /** Effective thinking level applied to the agent (the resolved level when `auto`). */ get thinkingLevel(): ThinkingLevel | undefined { return this.#models.thinkingLevel; @@ -5027,7 +4950,7 @@ export class AgentSession { if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return false; + // Steer/follow-up the keyword notices BEFORE the queued user message so the // model reads the steering notice ahead of the prompt it modifies. for (const notice of keywordNotices) { @@ -5122,7 +5045,7 @@ export class AgentSession { if (options?.queueOnly) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return; + for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } @@ -5132,7 +5055,7 @@ export class AgentSession { if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); - if (!(await this.#runUsageAwarePreflight())) return; + for (const notice of keywordNotices) { await this.#queueCustomMessage(notice, streamingBehavior); } @@ -5169,7 +5092,7 @@ export class AgentSession { const generation = this.#promptGeneration; try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if (!(await this.#runUsageAwarePreflight())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; // Flush any pending bash messages before the new prompt await this.#bash.flushPending(); this.#eval.flushPending(); @@ -5380,6 +5303,7 @@ export class AgentSession { await this.#waitForPostPromptRecovery(generation); } } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#endInFlight(); } } @@ -5545,7 +5469,6 @@ export class AgentSession { } const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); - if (!(await this.#runUsageAwarePreflight())) return; await this.#queueUserMessage(expandedText, images, "steer"); } @@ -5563,7 +5486,6 @@ export class AgentSession { const expandedText = options?.expandPromptTemplates === false ? text : expandPromptTemplate(text, [...this.#promptTemplates]); - if (!(await this.#runUsageAwarePreflight())) return; if (!options?.synthetic) { await this.#queueUserMessage(expandedText, images, "followUp"); return; @@ -5580,6 +5502,7 @@ export class AgentSession { const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; + this.#allowQueuedMessageDrainRetry(); if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ role: "developer", @@ -5609,6 +5532,7 @@ export class AgentSession { const imageDescriptionNotice = normalizedImages?.length ? await this.#buildImageDescriptionNotice(normalizedImages) : undefined; + this.#allowQueuedMessageDrainRetry(); if (mode === "followUp") { if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice); this.agent.followUp({ @@ -5635,7 +5559,12 @@ export class AgentSession { } #scheduleQueuedMessageDrain(): void { - if (this.#queuedMessageDrainScheduled || !this.#canAutoContinueForFollowUp() || !this.agent.hasQueuedMessages()) { + if ( + this.#queuedMessageDrainScheduled || + this.#queuedMessageDrainBlocked || + !this.#canAutoContinueForFollowUp() || + !this.agent.hasQueuedMessages() + ) { return; } this.#queuedMessageDrainScheduled = true; @@ -5649,6 +5578,7 @@ export class AgentSession { }, onError: () => { this.#queuedMessageDrainScheduled = false; + this.#queuedMessageDrainBlocked = this.agent.hasQueuedMessages(); }, }); } @@ -5785,7 +5715,7 @@ export class AgentSession { ): Promise { this.#beginInFlight(); try { - if (!(await this.#runUsageAwarePreflight())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true; if (acceptTerminalEmptyStop) { this.#resetPromptMaintenanceState(); @@ -5794,6 +5724,7 @@ export class AgentSession { await this.agent.prompt(message); await this.#waitForPostPromptRecovery(); } finally { + this.#usagePreflightReadyForNextModelCall = false; this.#recovery.setAcceptTerminalEmptyStop(false); this.#endInFlight(); } @@ -5825,6 +5756,7 @@ export class AgentSession { timestamp: Date.now(), }; const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage); + this.#allowQueuedMessageDrainRetry(); if (deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); } else { @@ -5881,7 +5813,7 @@ export class AgentSession { this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false); return false; } - if (!(await this.#runUsageAwarePreflight())) return false; + this.#allowQueuedMessageDrainRetry(); if (options?.deliverAs === "followUp") { this.agent.followUp(normalizedAppMessage); @@ -5964,8 +5896,6 @@ export class AgentSession { if (images.length === 0) images = undefined; } - if (options?.deliverAs && !(await this.#runUsageAwarePreflight())) return; - if (options?.deliverAs === "followUp") { await this.#queueUserMessage(text, images, "followUp"); return; @@ -6005,6 +5935,7 @@ export class AgentSession { ? isAdvisorCard : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m); this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep)); + this.#reconcileQueuedMessageDrain(); return { steering, followUp }; } @@ -6054,12 +5985,14 @@ export class AgentSession { if (fromSteer >= 0) { const removed = steering[fromSteer]; this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice()); + this.#reconcileQueuedMessageDrain(); return toRestoredQueuedMessage(removed); } const fromFollowUp = lastUserIndex(followUp); if (fromFollowUp >= 0) { const removed = followUp[fromFollowUp]; this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp)); + this.#reconcileQueuedMessageDrain(); return toRestoredQueuedMessage(removed); } return undefined; @@ -6349,6 +6282,8 @@ export class AgentSession { await this.#memory.resetContextForNewTranscript(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel()); this.sessionManager.appendServiceTierChange(this.#models.serviceTierEntry()); @@ -7373,6 +7308,9 @@ export class AgentSession { const previousFollowUpMessages = [...this.agent.peekFollowUpQueue()]; const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages]; const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration; + const previousQueuedMessageDrainBlocked = this.#queuedMessageDrainBlocked; + const previousUsagePreflightReadyForNextModelCall = this.#usagePreflightReadyForNextModelCall; + const previousUsagePreflightReadyModel = this.#usagePreflightReadyModel; const previousModel = this.model; const previousThinkingLevel = this.thinkingLevel; const previousAutoThinking = this.isAutoThinking; @@ -7397,6 +7335,9 @@ export class AgentSession { this.agent.clearAllQueues(); this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; + this.#usagePreflightReadyModel = undefined; try { if (switchingToDifferentSession) { @@ -7565,6 +7506,9 @@ export class AgentSession { this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages); this.#pendingNextTurnMessages = previousPendingNextTurnMessages; this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; + this.#queuedMessageDrainBlocked = previousQueuedMessageDrainBlocked; + this.#usagePreflightReadyForNextModelCall = previousUsagePreflightReadyForNextModelCall; + this.#usagePreflightReadyModel = previousUsagePreflightReadyModel; this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey; this.#checkpointState = previousCheckpointState; this.#pendingRewindReport = previousPendingRewindReport; @@ -7653,6 +7597,8 @@ export class AgentSession { // Clear pending messages (bound to old session state) this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; await this.#bash.flushPending(); // Flush pending writes before branching @@ -7780,6 +7726,8 @@ export class AgentSession { this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; this.agent.replaceQueues([], []); + this.#queuedMessageDrainBlocked = false; + this.#usagePreflightReadyForNextModelCall = false; await this.#bash.flushPending(); await this.sessionManager.flush(); const bashTransition = this.#bash.beginSessionTransition(); diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 629138402..b0f7ae649 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -13,6 +13,7 @@ import type { CodexCompactionContext, Effort, Model, + ModelUsageHealth, TextContent, ToolChoice, } from "@oh-my-pi/pi-ai"; @@ -23,6 +24,7 @@ import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-cat import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelStringWithRouting, resolveModelOverride } from "../config/model-resolver"; + import type { Settings } from "../config/settings"; import type { RecoveredRetryError } from "../extensibility/shared-events"; import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" }; @@ -35,7 +37,11 @@ import { modelSupportsEffortCeiling, } from "../thinking"; import type { AgentSessionEvent } from "./agent-session-events"; -import type { InitialRetryFallbackState } from "./agent-session-types"; +import type { + InitialRetryFallbackState, + UsageFallbackConfirmation, + UsageFallbackConfirmer, +} from "./agent-session-types"; import { isEmptyErrorTurn } from "./messages"; import { type ActiveRetryFallbackState, @@ -64,6 +70,7 @@ const UNEXPECTED_STOP_TIMEOUT_MS = 4000; const EMPTY_STOP_MAX_RETRIES = 3; const SIBLING_UNBLOCK_BUFFER_MS = 1_000; const NON_WHITESPACE_RE = /\S/; +const USAGE_PREFLIGHT_BLOCKED_PREFIX = "Usage preflight blocked:"; function hasNonWhitespace(value: string): boolean { return NON_WHITESPACE_RE.test(value); @@ -172,6 +179,7 @@ export class TurnRecovery { #retryPromise: Promise | undefined; #retryResolve: (() => void) | undefined; #activeRetryFallback: ActiveRetryFallbackState | undefined; + #usageReserveApprovedSelector: string | undefined; #pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = []; #usageLimitOutcomes = new WeakMap>(); #emptyStopRetryCount = 0; @@ -298,6 +306,11 @@ export class TurnRecovery { return this.#maybeRestoreRetryFallbackPrimary(); } + /** Applies model fallback policy from live usage health before a turn starts. */ + maybeApplyUsageAwareFallback(signal: AbortSignal, confirmer?: UsageFallbackConfirmer): Promise { + return this.#maybeApplyUsageAwareFallback(signal, confirmer); + } + /** Applies automatic retry, credential rotation, and model fallback policy. */ handleRetryableError( message: AssistantMessage, @@ -840,6 +853,9 @@ export class TurnRecovery { return id; } + #isUsagePreflightBlocked(message: AssistantMessage): boolean { + return message.errorMessage?.startsWith(USAGE_PREFLIGHT_BLOCKED_PREFIX) === true; + } /** * Retry an empty, reason-less provider abort: a turn with no content that * carries the generic sentinel (bare `abort()`), whether the provider @@ -883,6 +899,7 @@ export class TurnRecovery { */ isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; const id = this.#classifyRetryMessage(message); // Context overflow is handled by compaction, not retry @@ -1008,7 +1025,6 @@ export class TurnRecovery { modelLookup: this.#host.modelRegistry, }; } - #getRetryFallbackChains(): RetryFallbackChains { return getRetryFallbackChains(this.#host.settings); } @@ -1071,23 +1087,185 @@ export class TurnRecovery { ); } + async #maybeApplyUsageAwareFallback(signal: AbortSignal, confirmer?: UsageFallbackConfirmer): Promise { + if (!this.#host.settings.get("retry.usageAwareFallback")) return false; + const currentModel = this.#host.model(); + if (!currentModel) return false; + const currentSelector = formatRetryFallbackSelector(currentModel, this.#host.thinkingLevel()); + let health: ModelUsageHealth; + try { + health = await this.#host.modelRegistry.authStorage.getModelUsageHealth(currentModel.provider, { + modelId: currentModel.id, + sessionId: this.#host.sessionId(), + baseUrl: currentModel.baseUrl, + reserveFraction: this.#host.settings.get("retry.usageReservePct") / 100, + signal, + }); + } catch (error) { + if (signal.aborted) return false; + logger.debug("Usage-aware runtime preflight failed open", { + provider: currentModel.provider, + model: currentModel.id, + error: String(error), + }); + return false; + } + if (signal.aborted) return false; + const selectedAccount = health.accounts.find(account => account.selected); + if (health.state === "healthy") { + this.#usageReserveApprovedSelector = undefined; + if ( + selectedAccount && + selectedAccount.state !== "healthy" && + health.accounts.some(account => account.state === "healthy") + ) { + this.#host.modelRegistry.authStorage.releaseSessionCredentialForReselection( + currentModel.provider, + this.#host.sessionId(), + ); + } + return false; + } + if (health.state === "unknown") { + this.#usageReserveApprovedSelector = undefined; + return false; + } + if (health.state !== "reserve") this.#usageReserveApprovedSelector = undefined; + + const reservePolicy = this.#host.settings.get("retry.usageReservePolicy"); + if (reservePolicy === "fail-closed") { + const condition = health.state === "reserve" ? "reserve reached" : "usage depleted"; + throw new Error( + `${USAGE_PREFLIGHT_BLOCKED_PREFIX} ${condition} for ${currentSelector}; reserve policy is fail-closed.`, + ); + } + if ( + reservePolicy === "confirm" && + health.state === "reserve" && + this.#usageReserveApprovedSelector === currentSelector + ) { + return false; + } + if (!this.#host.settings.get("retry.modelFallback")) return false; + + const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector, currentModel); + if (!role) return false; + let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; + for (const candidate of this.findRetryFallbackCandidates(role, currentSelector, currentModel)) { + if (this.isRetryFallbackSelectorSuppressed(candidate)) continue; + const resolved = resolveModelOverride([candidate.raw], this.#host.modelRegistry, this.#host.settings); + const candidateModel = resolved.model ?? this.#host.modelRegistry.find(candidate.provider, candidate.id); + if (!candidateModel || !this.#host.modelRegistry.hasConfiguredAuth(candidateModel)) continue; + try { + const candidateHealth = await this.#host.modelRegistry.authStorage.getModelUsageHealth( + candidateModel.provider, + { + modelId: candidateModel.id, + sessionId: this.#host.sessionId(), + baseUrl: candidateModel.baseUrl, + reserveFraction: this.#host.settings.get("retry.usageReservePct") / 100, + signal, + }, + ); + if (signal.aborted) return false; + if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; + if (candidateHealth.state === "healthy") { + const selected = candidateHealth.accounts.find(account => account.selected); + if ( + selected && + selected.state !== "healthy" && + candidateHealth.accounts.some(account => account.state === "healthy") + ) { + this.#host.modelRegistry.authStorage.releaseSessionCredentialForReselection( + candidateModel.provider, + this.#host.sessionId(), + ); + } + } + } catch { + if (signal.aborted) return false; + // Unknown usage fails open for an otherwise valid fallback. + } + if (signal.aborted) return false; + let apiKey: string | undefined; + try { + apiKey = await this.#host.modelRegistry.getApiKey(candidateModel, this.#host.sessionId(), { signal }); + } catch { + if (signal.aborted) return false; + continue; + } + if (signal.aborted) return false; + if (!apiKey) continue; + fallback = { selector: candidate, apiKey }; + break; + } + if (!fallback) return false; + + let shouldFallback = health.state === "depleted" || reservePolicy === "auto" || !confirmer; + if (!shouldFallback && health.state === "reserve" && confirmer) { + const remainingFraction = + selectedAccount?.remainingFraction ?? + health.accounts.reduce((minimum, account) => { + if (account.remainingFraction === undefined) return minimum; + return minimum === undefined ? account.remainingFraction : Math.min(minimum, account.remainingFraction); + }, undefined); + shouldFallback = await this.#confirmUsageFallback( + confirmer, + { + from: currentSelector, + to: fallback.selector.raw, + remainingPercent: remainingFraction === undefined ? undefined : Math.max(0, remainingFraction * 100), + }, + signal, + ); + if (signal.aborted) return false; + } + if (!shouldFallback) { + this.#usageReserveApprovedSelector = currentSelector; + return false; + } + this.#usageReserveApprovedSelector = undefined; + return this.applyRetryFallbackCandidate(role, fallback.selector, currentSelector, { + pinFallback: true, + apiKey: fallback.apiKey, + signal, + }); + } + + async #confirmUsageFallback( + confirmer: UsageFallbackConfirmer, + confirmation: UsageFallbackConfirmation, + signal: AbortSignal, + ): Promise { + if (signal.aborted) return false; + const aborted = Promise.withResolvers(); + const onAbort = () => aborted.resolve(false); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await Promise.race([confirmer(confirmation, signal), aborted.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } + } + async applyRetryFallbackCandidate( role: string, selector: RetryFallbackSelector, currentSelector: string, options?: { pinFallback?: boolean; apiKey?: string; signal?: AbortSignal }, - ): Promise { + ): Promise { const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings); const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id); if (!candidate) { throw new Error(`Retry fallback model not found: ${selector.raw}`); } const apiKey = - options?.apiKey ?? (await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), options)); + options?.apiKey ?? + (await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), { signal: options?.signal })); if (!apiKey) { throw new Error(`No API key for retry fallback ${selector.raw}`); } - if (options?.signal?.aborted) return; + if (options?.signal?.aborted) return false; // Capture the configured selector (auto-aware) so a fallback chain preserves // `auto` instead of collapsing it to the level it resolved to this turn. @@ -1101,7 +1279,14 @@ export class TurnRecovery { ? requestedThinkingLevel : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling()); const candidateSelector = formatModelStringWithRouting(candidate); + const previousModel = this.#host.model(); await this.#host.setModelWithProviderSessionReset(candidate); + if (options?.signal?.aborted) { + if (previousModel && this.#host.model() === candidate) { + await this.#host.setModelWithProviderSessionReset(previousModel); + } + return false; + } this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE); this.#host.settings.getStorage()?.recordModelUsage(candidateSelector); this.#host.setThinkingLevel(nextThinkingLevel); @@ -1123,6 +1308,7 @@ export class TurnRecovery { to: selector.raw, role, }); + return true; } async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise { @@ -1140,8 +1326,7 @@ export class TurnRecovery { if (ceiling !== undefined && !modelSupportsEffortCeiling(candidate, ceiling)) continue; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; - await this.applyRetryFallbackCandidate(role, selector, currentSelector, options); - return true; + return this.applyRetryFallbackCandidate(role, selector, currentSelector, options); } return false; @@ -1167,6 +1352,7 @@ export class TurnRecovery { const model = this.#activeFireworksFastModel(); if (!model) return false; if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; if (this.#hasReplayUnsafeOutput(message)) return false; // A content refusal/sensitivity stop is the model's decision, not a route // failure — switching to the base model would just re-trigger it. @@ -1190,6 +1376,7 @@ export class TurnRecovery { */ isHardErrorFallbackEligible(message: AssistantMessage): boolean { if (message.stopReason !== "error") return false; + if (this.#isUsagePreflightBlocked(message)) return false; const model = this.#host.model(); if (!model) return false; const retrySettings = this.#host.settings.getGroup("retry"); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 5538130a0..f14dbd1bf 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -166,9 +166,11 @@ function resolveSubagentRetryFallbackCandidates( ): SubagentRetryFallbackCandidate[] { const candidates: SubagentRetryFallbackCandidate[] = []; const seen = new Set(); + const disabledProviders = new Set(settings.get("disabledProviders")); for (const pattern of modelPatterns) { const resolved = resolveModelOverride([pattern], modelRegistry, settings); if (!resolved.model) continue; + if (disabledProviders.has(resolved.model.provider)) continue; const selector = resolved.explicitThinkingLevel ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel) : formatModelStringWithRouting(resolved.model); @@ -179,7 +181,10 @@ function resolveSubagentRetryFallbackCandidates( return candidates; } -function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] | undefined { +function resolveSubagentDefaultRetryFallbackChain( + settings: Settings, + modelRegistry: ModelRegistry, +): string[] | undefined { const fallbackChain = settings.get("retry.fallbackChains")?.default; if ( !Array.isArray(fallbackChain) || @@ -188,7 +193,11 @@ function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] ) { return undefined; } - return fallbackChain; + const disabledProviders = new Set(settings.get("disabledProviders")); + return fallbackChain.filter(entry => { + const resolved = resolveModelOverride([entry], modelRegistry, settings); + return !resolved.model || !disabledProviders.has(resolved.model.provider); + }); } function installSubagentRetryFallbackChain(args: { @@ -2766,7 +2775,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key"); authStorage.setRuntimeApiKey("openai", "openai-test-key"); + authStorage.setRuntimeApiKey("fireworks", "fireworks-test-key"); authStorage.setRuntimeApiKey("google", "google-test-key"); authStorage.setRuntimeApiKey("google-vertex", "google-vertex-test-key"); authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key"); @@ -290,9 +292,15 @@ describe("AgentSession retry fallback", () => { { credentialId: 1, credentialType: "oauth", + state: "reserve", + remainingFraction: 0.08, + }, + { + credentialId: 2, + credentialType: "oauth", selected: true, state: "reserve", - remainingFraction: 0.05, + remainingFraction: 0.02, }, ], } @@ -308,15 +316,72 @@ describe("AgentSession retry fallback", () => { session.setUsageFallbackConfirmer(confirmFallback); await session.prompt("Keep working on the same task"); await session.waitForIdle(); - expect(confirmFallback).toHaveBeenCalledWith({ - from: `${primaryModel.provider}/${primaryModel.id}`, - to: `${fallbackModel.provider}/${fallbackModel.id}`, - remainingPercent: 5, - }); + expect(confirmFallback).toHaveBeenCalledWith( + { + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + remainingPercent: 2, + }, + expect.any(AbortSignal), + ); expect(requestedModels).toEqual([`${fallbackModel.provider}/${fallbackModel.id}`]); expect(session.messages.some(message => message.role === "user")).toBe(true); }); + it("honors a live fail-closed policy after reserve spending was approved", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled reserve policy models"); + const mock = createMockModel({ responses: [{ content: ["stayed on primary"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: mock.stream, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmFallback = vi.fn(async () => false); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(confirmFallback); + + await session.prompt("Stay on the primary"); + await session.waitForIdle(); + settings.override("retry.usageReservePolicy", "fail-closed"); + expect(settings.get("retry.usageReservePolicy")).toBe("fail-closed"); + + await expect(session.prompt("Do not spend reserve")).rejects.toThrow("reserve policy is fail-closed"); + expect(confirmFallback).toHaveBeenCalledTimes(1); + expect(usageHealth).toHaveBeenCalledTimes(3); + }); it("reselects a healthy same-provider account before considering a model fallback", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); @@ -485,6 +550,565 @@ describe("AgentSession retry fallback", () => { expect(requestedModels).toEqual([]); }); + it("cancels a pending reserve confirmation without dispatching the prompt", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled confirmation cancellation models"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmationStarted = Promise.withResolvers(); + const pendingConfirmation = Promise.withResolvers(); + const confirmationAborted = Promise.withResolvers(); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(async (_confirmation, signal) => { + confirmationStarted.resolve(); + signal.addEventListener("abort", () => confirmationAborted.resolve(), { once: true }); + return pendingConfirmation.promise; + }); + + const prompt = session.prompt("Do not send after confirmation cancellation"); + await confirmationStarted.promise; + await session.abort(); + await confirmationAborted.promise; + await prompt; + + expect(requestedModels).toEqual([]); + }); + + it("defers usage fallback for a queued steer until the active stream finishes", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued fallback models"); + const requestedModels: string[] = []; + const streamStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers<{ content: string[] }>(); + const mock = createMockModel({ + responses: [ + async () => { + streamStarted.resolve(); + return firstResponse.promise; + }, + { content: ["queued steer completed"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "auto", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + let useReserve = false; + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async provider => + provider === primaryModel.provider + ? useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const firstPrompt = session.prompt("Keep the primary stream active"); + await streamStarted.promise; + useReserve = true; + await session.sendUserMessage("Queue this steer", { deliverAs: "steer" }); + + expect(usageHealth).toHaveBeenCalledTimes(1); + expect(session.model?.id).toBe(primaryModel.id); + + firstResponse.resolve({ content: ["primary stream completed"] }); + await firstPrompt; + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + }); + + it("cancels queued-turn usage confirmation when post-prompt work is disposed", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued cancellation models"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider => + provider === primaryModel.provider + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { state: "healthy", accounts: [] }, + ); + const confirmationStarted = Promise.withResolvers(); + const pendingConfirmation = Promise.withResolvers(); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.setUsageFallbackConfirmer(async () => { + confirmationStarted.resolve(); + return pendingConfirmation.promise; + }); + + await session.sendUserMessage("Queue this turn", { deliverAs: "steer" }); + await confirmationStarted.promise; + await session.dispose(); + session = undefined; + + expect(requestedModels).toEqual([]); + }); + + it("does not reschedule a queued drain after a dequeue hook rejects", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled queued-drain model"); + const requestedModels: string[] = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return createMockModel().stream(model, context, options); + }, + }); + const settings = Settings.isolated({ "compaction.enabled": false }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const hookRan = Promise.withResolvers(); + let attempts = 0; + const failingHook = vi.fn(() => { + hookRan.resolve(); + if (++attempts === 1) throw new Error("blocked before dequeue"); + }); + agent.addBeforeQueuedMessageDequeueHook(failingHook); + + await session.sendUserMessage("Keep this queued", { deliverAs: "steer" }); + await hookRan.promise; + await session.waitForIdle(); + + expect(failingHook).toHaveBeenCalledTimes(1); + expect(agent.hasQueuedMessages()).toBe(true); + expect(requestedModels).toEqual([]); + }); + + it("enforces fail-closed usage health when model fallback is disabled", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled fail-closed model"); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: createMockModel().stream, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.modelFallback": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockResolvedValue({ + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await expect(session.prompt("Do not spend reserve")).rejects.toThrow("reserve policy is fail-closed"); + }); + + it("does not degrade Fireworks Fast or retry a chain after queued fail-closed preflight", async () => { + const primaryModel = getBundledModel("fireworks", "kimi-k2.6-fast"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) throw new Error("Expected bundled queued fail-closed models"); + const requestedModels: string[] = []; + const streamStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers<{ content: string[] }>(); + const mock = createMockModel({ + responses: [ + async () => { + streamStarted.resolve(); + return firstResponse.promise; + }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + "retry.fallbackChains": { + default: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + let useReserve = false; + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const firstPrompt = session.prompt("Keep the primary stream active"); + await streamStarted.promise; + useReserve = true; + await session.sendUserMessage("Queue blocked work", { deliverAs: "steer" }); + firstResponse.resolve({ content: ["primary stream completed"] }); + await firstPrompt; + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + expect(session.model?.id).toBe(primaryModel.id); + expect(agent.hasQueuedMessages()).toBe(true); + }); + + it("rechecks fail-closed usage health before an internally scheduled continuation", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled scheduled continuation model"); + const requestedModels: string[] = []; + let useReserve = false; + const mock = createMockModel({ + responses: [ + async () => { + useReserve = true; + return { content: [], stopReason: "stop" }; + }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await session.prompt("Retry this empty response"); + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + }); + + it("rechecks fail-closed usage health before a same-turn tool continuation", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel) throw new Error("Expected bundled tool-continuation model"); + const requestedModels: string[] = []; + let useReserve = false; + const toolSchema = z.object({ value: z.string() }); + const tool: AgentTool = { + name: "consume", + label: "Consume", + description: "Consume plan quota", + parameters: toolSchema, + async execute(_toolCallId, params) { + useReserve = true; + return { content: [{ type: "text", text: params.value }], details: params }; + }, + }; + const mock = createMockModel({ + responses: [ + { content: [{ type: "toolCall", id: "tool-1", name: "consume", arguments: { value: "done" } }] }, + { content: ["must not run"] }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [tool], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async () => + useReserve + ? { + state: "reserve", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "reserve", + remainingFraction: 0.05, + }, + ], + } + : { + state: "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: "healthy", + remainingFraction: 0.8, + }, + ], + }, + ); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await session.prompt("Use the tool"); + await session.waitForIdle(); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + }); + it("rechecks fail-closed usage health when prompt setup changes the model", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const setupTarget = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!primaryModel || !setupTarget) throw new Error("Expected bundled setup-handoff models"); + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const mock = createMockModel({ responses: [{ content: ["must not run"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + const reserve = options.modelId === setupTarget.id; + return { + state: reserve ? "reserve" : "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: reserve ? "reserve" : "healthy", + remainingFraction: reserve ? 0.05 : 0.8, + }, + ], + }; + }); + const extensionRunner = { + emit: vi.fn().mockResolvedValue(undefined), + hasHandlers: vi.fn().mockReturnValue(false), + emitBeforeAgentStart: vi.fn(async () => { + if (!session) throw new Error("Expected active session"); + await session.setModelTemporary(setupTarget, undefined, { ephemeral: true }); + return undefined; + }), + } as unknown as ExtensionRunner; + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + extensionRunner, + }); + + await session.prompt("Change models during setup"); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(usageChecks).toEqual([primaryModel.id, setupTarget.id]); + expect(requestedModels).toEqual([]); + }); + it("continues a startup-owned role fallback chain from the active fallback", async () => { const firstFallback = getBundledModel("openai", "gpt-4o-mini"); const secondFallback = getBundledModel("openai", "gpt-4o"); diff --git a/packages/coding-agent/test/issue-905-repro.test.ts b/packages/coding-agent/test/issue-905-repro.test.ts index 8d7d1aa94..e9901670c 100644 --- a/packages/coding-agent/test/issue-905-repro.test.ts +++ b/packages/coding-agent/test/issue-905-repro.test.ts @@ -208,10 +208,8 @@ test("omp models prints invalid models.yml schema errors before listing output", `providers: myprovider: baseUrl: http://localhost:8000/v1 - api: openai-completions + api: invalid-api auth: none - compat: - thinkingFormat: deepseek models: - id: my-model name: My Model @@ -248,8 +246,8 @@ test("omp models prints invalid models.yml schema errors before listing output", const output = captured.join(""); expect(output).toContain("Warning: models.yml validation failed — custom providers disabled"); - expect(output).toContain("providers.myprovider.compat.thinkingFormat"); - expect(output).toContain("deepseek"); + expect(output).toContain("providers.myprovider.api"); + expect(output).toContain("invalid-api"); } finally { authStorage.close(); } diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 87ea06ffd..14f8fa2ab 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -512,6 +512,33 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("rejects a depleted terminal fallback after startup skips the primary", async () => { + const settings = Settings.isolated({ + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "confirm", + }); + settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); + const options = await buildSessionOptions("task"); + const usageHealth = vi.spyOn(options.authStorage, "getModelUsageHealth").mockResolvedValue({ + state: "depleted", + accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], + }); + + const { session, modelFallbackMessage } = await createAgentSession({ + ...options, + modelPatternFallbackRole: "subagent:usage-aware-terminal", + settings, + hasUI: false, + }); + try { + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(session.model).toBeUndefined(); + expect(modelFallbackMessage).toContain("not found"); + } finally { + await session.dispose(); + } + }); + test("defers ACP reserve fallback until prompt-time capabilities are configured", async () => { const settings = Settings.isolated({ "retry.usageAwareFallback": true, diff --git a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts index b9494bab4..52a275712 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -95,6 +95,98 @@ describe("TurnRecovery replay-unsafe output classification", () => { tempDir.removeSync(); }); + it("rolls back a usage fallback cancelled during model reconciliation", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback model"); + let activeModel = model; + const fallbackApplied = Promise.withResolvers(); + const releaseReconciliation = Promise.withResolvers(); + const modelChanges: string[] = []; + const emittedEvents: string[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + if (nextModel.provider === fallback.provider && nextModel.id === fallback.id) { + fallbackApplied.resolve(); + await releaseReconciliation.promise; + } + }; + host.emitSessionEvent = async event => { + emittedEvents.push(event.type); + }; + const recovery = new TurnRecovery(host); + const controller = new AbortController(); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key", signal: controller.signal }, + ); + + await fallbackApplied.promise; + controller.abort(); + releaseReconciliation.resolve(); + const committed = await applying; + + expect(committed).toBe(false); + expect(activeModel).toBe(model); + expect(modelChanges).toEqual([]); + expect(emittedEvents).toEqual([]); + }); + it("keeps a committed fallback when cancellation arrives during applied-event delivery", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback model"); + let activeModel = model; + const eventStarted = Promise.withResolvers(); + const releaseEvent = Promise.withResolvers(); + const modelChanges: string[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + }; + host.emitSessionEvent = async event => { + if (event.type !== "retry_fallback_applied") return; + eventStarted.resolve(); + await releaseEvent.promise; + }; + const recovery = new TurnRecovery(host); + const controller = new AbortController(); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key", signal: controller.signal }, + ); + + await eventStarted.promise; + controller.abort(); + releaseEvent.resolve(); + const committed = await applying; + + expect(committed).toBe(true); + expect(activeModel.provider).toBe(fallback.provider); + expect(activeModel.id).toBe(fallback.id); + expect(modelChanges).toEqual([`${fallback.provider}/${fallback.id}`]); + }); + it("treats a failed turn with partial non-whitespace text as NOT retriable", () => { const recovery = new TurnRecovery(createHost(model, modelRegistry)); const message = makeMessage([{ type: "text", text: "Here is the first part of my answer" }], model); From 353bbc034bd117da9388ae6cf783058c3881c9a4 Mon Sep 17 00:00:00 2001 From: Brent <67750428+eggpeat@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:15:57 +0000 Subject: [PATCH 21/97] fix(coding-agent): harden usage fallback review races --- packages/agent/CHANGELOG.md | 5 +- packages/agent/src/agent.ts | 151 +++++++++++------- packages/agent/test/agent.test.ts | 72 ++++++++- packages/ai/CHANGELOG.md | 5 +- packages/ai/src/auth-storage.ts | 7 +- .../test/auth-storage-codex-selection.test.ts | 40 +++++ packages/coding-agent/CHANGELOG.md | 5 +- .../src/session/agent-session-types.ts | 6 + .../coding-agent/src/session/agent-session.ts | 24 ++- .../coding-agent/src/session/turn-recovery.ts | 17 +- .../test/agent-session-retry-fallback.test.ts | 82 ++++++++++ 11 files changed, 330 insertions(+), 84 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4e8ae3ebf..de1366ec0 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,10 +2,13 @@ ## [Unreleased] +### Fixed + +- Preserved queued steering and follow-up messages when a continuation is cancelled before or during pre-dequeue hooks, and propagated the caller's cancellation signal through every continuation model-call loop. + ## [17.2.6] - 2026-08-03 ### Fixed -- Preserved queued steering and follow-up messages when a continuation is cancelled before or during pre-dequeue hooks, and propagated the caller's cancellation signal through every continuation model-call loop. - Fixed an issue where peer-IRC interrupts (such as subagent messages) incorrectly skipped non-interruptible tool calls queued in the same batch. - Improved interruption messaging to clearly distinguish between parent-agent steering and system-advisory interruptions. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b0a8e5246..c26993463 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1174,17 +1174,21 @@ export class Agent { * Continue from current context (used for retries and resuming queued messages). */ #continuationDequeueSignal(signal?: AbortSignal): AbortSignal | undefined { - if (this.#deadline === undefined) return signal; - const delay = this.#deadline - Date.now(); - let deadlineSignal: AbortSignal; - if (delay <= 0) { - const controller = new AbortController(); - controller.abort(new DOMException("Deadline exceeded", "TimeoutError")); - deadlineSignal = controller.signal; - } else { - deadlineSignal = AbortSignal.timeout(delay); + const signals: AbortSignal[] = []; + if (this.#abortController) signals.push(this.#abortController.signal); + if (signal) signals.push(signal); + if (this.#deadline !== undefined) { + const delay = this.#deadline - Date.now(); + if (delay <= 0) { + const controller = new AbortController(); + controller.abort(new DOMException("Deadline exceeded", "TimeoutError")); + signals.push(controller.signal); + } else { + signals.push(AbortSignal.timeout(delay)); + } } - return signal ? AbortSignal.any([signal, deadlineSignal]) : deadlineSignal; + if (signals.length === 0) return undefined; + return signals.length === 1 ? signals[0] : AbortSignal.any(signals); } async continue(signal?: AbortSignal) { @@ -1192,44 +1196,67 @@ export class Agent { throw new AgentBusyError(); } - const dequeueSignal = this.#continuationDequeueSignal(signal); - const messages = this.#state.messages; - if (messages.length === 0) { - // An empty transcript has nothing to resume, but a queued steer/follow-up - // must still be delivered as the opening turn — mirroring the assistant-tail - // branch below. Throwing here leaves the message undeliverable, and idle-drain - // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every - // microtask because hasQueuedMessages() never clears, spinning an unbounded - // allocation loop until OOM (issue #6344). - const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); - if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal); - return; + const { promise, resolve } = Promise.withResolvers(); + this.#runningPrompt = promise; + this.#resolveRunningPrompt = resolve; + const continuationAbortController = new AbortController(); + this.#abortController = continuationAbortController; + this.#state.isStreaming = true; + this.#state.streamMessage = null; + this.#state.error = undefined; + + try { + const dequeueSignal = this.#continuationDequeueSignal(signal); + const messages = this.#state.messages; + if (messages.length === 0) { + // An empty transcript has nothing to resume, but a queued steer/follow-up + // must still be delivered as the opening turn — mirroring the assistant-tail + // branch below. Throwing here leaves the message undeliverable, and idle-drain + // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every + // microtask because hasQueuedMessages() never clears, spinning an unbounded + // allocation loop until OOM (issue #6344). + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); + if (queuedSteering.length > 0) { + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true); + return; + } + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); + if (queuedFollowUp.length > 0) { + await this.#runLoop(queuedFollowUp, undefined, signal, true); + return; + } + throw new Error("No messages to continue from"); } - const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); - if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp, undefined, signal); - return; + if (messages[messages.length - 1].role === "assistant") { + const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); + if (queuedSteering.length > 0) { + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true); + return; + } + + const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); + if (queuedFollowUp.length > 0) { + await this.#runLoop(queuedFollowUp, undefined, signal, true); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.#runLoop(undefined, undefined, signal, true); + } finally { + resolve(); + if (this.#abortController === continuationAbortController) { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls.clear(); + this.#abortController = undefined; + if (this.#runningPrompt === promise) { + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + } } - throw new Error("No messages to continue from"); } - if (messages[messages.length - 1].role === "assistant") { - const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal); - if (queuedSteering.length > 0) { - await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal); - return; - } - - const queuedFollowUp = await this.#dequeueFollowUpMessagesAfterHooks(dequeueSignal); - if (queuedFollowUp.length > 0) { - await this.#runLoop(queuedFollowUp, undefined, signal); - return; - } - - throw new Error("Cannot continue from message role: assistant"); - } - - await this.#runLoop(undefined, undefined, signal); } /** @@ -1241,20 +1268,24 @@ export class Agent { messages?: AgentMessage[], options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }, continuationSignal?: AbortSignal, + runStateClaimed = false, ) { const model = this.#state.model; if (!model) throw new Error("No model configured"); let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true; using _ = new EventLoopKeepalive(); - const { promise, resolve } = Promise.withResolvers(); - this.#runningPrompt = promise; - this.#resolveRunningPrompt = resolve; - - this.#abortController = new AbortController(); + if (!runStateClaimed) { + const { promise, resolve } = Promise.withResolvers(); + this.#runningPrompt = promise; + this.#resolveRunningPrompt = resolve; + this.#abortController = new AbortController(); + } + const loopAbortController = this.#abortController; + if (!loopAbortController) throw new Error("Agent run state was not initialized"); const loopSignal = continuationSignal - ? AbortSignal.any([this.#abortController.signal, continuationSignal]) - : this.#abortController.signal; + ? AbortSignal.any([loopAbortController.signal, continuationSignal]) + : loopAbortController.signal; this.#state.isStreaming = true; this.#state.streamMessage = null; this.#state.error = undefined; @@ -1641,13 +1672,15 @@ export class Agent { this.#emit({ type: "agent_end", messages: [errorMsg] }); } } finally { - this.#state.isStreaming = false; - this.#state.streamMessage = null; - this.#state.pendingToolCalls.clear(); - this.#abortController = undefined; - this.#resolveRunningPrompt?.(); - this.#runningPrompt = undefined; - this.#resolveRunningPrompt = undefined; + if (this.#abortController === loopAbortController) { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls.clear(); + this.#abortController = undefined; + this.#resolveRunningPrompt?.(); + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + } } } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 6155bc2e6..625e6ebb7 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Agent, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Agent, AgentBusyError, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type SimpleStreamOptions, type ToolResultMessage, z } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; @@ -312,6 +312,76 @@ describe("Agent", () => { expect(agent.peekFollowUpQueue()).toHaveLength(1); }); + it("claims an abortable busy state while continue() awaits dequeue hooks", async () => { + const agent = new Agent(); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "stay queued", timestamp: Date.now() }); + const hookStarted = Promise.withResolvers(); + agent.addBeforeQueuedMessageDequeueHook(async signal => { + if (!signal) throw new Error("Expected continuation dequeue signal"); + hookStarted.resolve(); + if (signal.aborted) return; + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + }); + + const continuing = agent.continue(); + await hookStarted.promise; + let idleResolved = false; + const idle = agent.waitForIdle().then(() => { + idleResolved = true; + }); + await Promise.resolve(); + + expect(agent.state.isStreaming).toBe(true); + expect(idleResolved).toBe(false); + await expect(agent.prompt("must not overlap")).rejects.toBeInstanceOf(AgentBusyError); + + agent.abort("cancel dequeue"); + await expect(continuing).rejects.toThrow("Cannot continue from message role: assistant"); + await idle; + expect(idleResolved).toBe(true); + expect(agent.state.isStreaming).toBe(false); + expect(agent.peekFollowUpQueue()).toHaveLength(1); + }); + + it("does not clear a successor prompt after continue() releases idle waiters", async () => { + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const mock = createMockModel({ + responses: [ + async () => { + firstStarted.resolve(); + await releaseFirst.promise; + return { content: ["continued"] }; + }, + async () => { + secondStarted.resolve(); + await releaseSecond.promise; + return { content: ["successor"] }; + }, + ], + }); + const agent = new Agent({ streamFn: mock.stream }); + agent.replaceMessages([createAssistantMessage([{ type: "text", text: "ready" }])]); + agent.followUp({ role: "user", content: "continue", timestamp: Date.now() }); + + const continuing = agent.continue(); + await firstStarted.promise; + const successor = agent.waitForIdle().then(() => agent.prompt("next prompt")); + releaseFirst.resolve(); + await secondStarted.promise; + await continuing; + + expect(agent.state.isStreaming).toBe(true); + releaseSecond.resolve(); + await successor; + expect(agent.state.isStreaming).toBe(false); + }); + it("classifies an in-flight continuation cancellation as aborted", async () => { const providerStarted = Promise.withResolvers(); const agent = new Agent({ diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e1ba32b4e..2d5c26217 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. + ## [17.2.6] - 2026-08-03 ### Added @@ -9,7 +13,6 @@ - Added profile-aware Bedrock Mantle region selection, authenticated model discovery, bearer-token or SigV4 authentication, and credential refresh handling for OpenAI Responses models. ### Fixed -- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. - Fixed an issue where Ollama requests without a user-role message would fail to generate output or silently fail with a misleading error. diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index ac4e6dba8..7cc86b48d 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3947,11 +3947,8 @@ export class AuthStorage { }; }), ); - if ( - planRequirement !== "none" && - accounts.some(account => planEligibilityByCredential.get(account.credentialId) === true) - ) { - accounts = accounts.filter(account => planEligibilityByCredential.get(account.credentialId) === true); + if (planRequirement !== "none") { + accounts = accounts.filter(account => planEligibilityByCredential.get(account.credentialId) !== false); } if (selectedCredentialId !== undefined) { const selectedAccount = accounts.find(account => account.credentialId === selectedCredentialId); diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index b363c4f17..fe769f1c4 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -2071,6 +2071,46 @@ describe("AuthStorage codex oauth ranking", () => { expect(health.accounts[0]?.state).toBe("depleted"); }); + test("reports an all-plan-ineligible Codex pool as depleted", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-plus", "plus@example.com") }, + ]); + usageByAccount.set( + "acct-free", + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + ); + usageByAccount.set( + "acct-plus", + createCodexUsageReport({ + accountId: "acct-plus", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "plus", email: "plus@example.com" }, + }), + ); + + const paidHealth = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.6-sol", + reserveFraction: 0.1, + }); + const proHealth = await authStorage.getModelUsageHealth("openai-codex", { + modelId: "gpt-5.3-codex-spark", + reserveFraction: 0.1, + }); + + expect(paidHealth.state).toBe("healthy"); + expect(paidHealth.accounts).toHaveLength(1); + expect(proHealth).toEqual({ state: "depleted", accounts: [] }); + }); + test("routes codex spark to a single Plus account when no Pro is connected", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5bf34f4fe..f66586a74 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. + ## [17.2.6] - 2026-08-03 ### Added @@ -9,7 +13,6 @@ - Added the `/reset` slash command to reset the conversation context in place: it drops the live messages, queued turns, and pending tool calls (and cancels the turn's async jobs, post-prompt continuations, and checkpoint/plan runtime state) while keeping the session id, title, cwd, model, and on-disk transcript. It records a durable reset boundary so the live transcript stays cleared across rebuilds (theme change, focus attach, `/shake`, resume) instead of resurrecting the pre-reset messages, while the full pre-reset history stays on disk ([#3580](https://github.com/can1357/oh-my-pi/issues/3580)). ### Fixed -- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. - Fixed extension slash commands appearing as user prompts after being handled locally. - Preserved explicit session titles when branching from an earlier conversation turn. diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 2fe0f0f91..5b5381518 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -92,6 +92,12 @@ export interface UsageFallbackConfirmation { remainingPercent: number | undefined; } +/** + * Confirms whether a reserve-triggered model fallback may proceed. + * + * Interactive callers use the confirmation details to present the pending + * route change; aborting `signal` cancels that pending confirmation. + */ export type UsageFallbackConfirmer = (confirmation: UsageFallbackConfirmation, signal: AbortSignal) => Promise; /** Identifies a retry fallback chain already entered during startup model resolution. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c116a3587..a3fd69766 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4006,14 +4006,22 @@ export class AgentSession { signal?.addEventListener("abort", onAbort, { once: true }); this.#usagePreflightAbortControllers.add(controller); try { - const fallbackCommitted = await this.#recovery.maybeApplyUsageAwareFallback( - controller.signal, - this.#usageFallbackConfirmer, - ); - return fallbackCommitted || (!controller.signal.aborted && this.#promptGeneration === generation); - } catch (error) { - if (controller.signal.aborted || this.#promptGeneration !== generation) return false; - throw error; + while (true) { + const model = this.model; + try { + const fallbackCommitted = await this.#recovery.maybeApplyUsageAwareFallback( + controller.signal, + this.#usageFallbackConfirmer, + ); + if (fallbackCommitted) return true; + if (controller.signal.aborted || this.#promptGeneration !== generation) return false; + if (this.model === model || modelsAreEqual(this.model, model)) return true; + } catch (error) { + if (controller.signal.aborted || this.#promptGeneration !== generation) return false; + if (this.model !== model && !modelsAreEqual(this.model, model)) continue; + throw error; + } + } } finally { signal?.removeEventListener("abort", onAbort); this.#usagePreflightAbortControllers.delete(controller); diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index b0f7ae649..542607b71 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -21,6 +21,7 @@ import { calculateRateLimitBackoffMs, parseRateLimitReason } from "@oh-my-pi/pi- import * as AIError from "@oh-my-pi/pi-ai/error"; import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelStringWithRouting, resolveModelOverride } from "../config/model-resolver"; @@ -1102,7 +1103,7 @@ export class TurnRecovery { signal, }); } catch (error) { - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; logger.debug("Usage-aware runtime preflight failed open", { provider: currentModel.provider, model: currentModel.id, @@ -1110,7 +1111,7 @@ export class TurnRecovery { }); return false; } - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; const selectedAccount = health.accounts.find(account => account.selected); if (health.state === "healthy") { this.#usageReserveApprovedSelector = undefined; @@ -1167,7 +1168,7 @@ export class TurnRecovery { signal, }, ); - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; if (candidateHealth.state === "depleted" || candidateHealth.state === "reserve") continue; if (candidateHealth.state === "healthy") { const selected = candidateHealth.accounts.find(account => account.selected); @@ -1183,18 +1184,18 @@ export class TurnRecovery { } } } catch { - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; // Unknown usage fails open for an otherwise valid fallback. } - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; let apiKey: string | undefined; try { apiKey = await this.#host.modelRegistry.getApiKey(candidateModel, this.#host.sessionId(), { signal }); } catch { - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; continue; } - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; if (!apiKey) continue; fallback = { selector: candidate, apiKey }; break; @@ -1218,7 +1219,7 @@ export class TurnRecovery { }, signal, ); - if (signal.aborted) return false; + if (signal.aborted || !modelsAreEqual(this.#host.model(), currentModel)) return false; } if (!shouldFallback) { this.#usageReserveApprovedSelector = currentSelector; diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index fd35d7837..5181bfec9 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1109,6 +1109,88 @@ describe("AgentSession retry fallback", () => { expect(requestedModels).toEqual([]); }); + it("restarts usage preflight when the model changes during a health request", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const selectedModel = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!primaryModel || !selectedModel) throw new Error("Expected bundled preflight race models"); + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const healthStarted = Promise.withResolvers(); + const releaseHealth = Promise.withResolvers(); + const mock = createMockModel({ responses: [{ content: ["must not run"] }] }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "fail-closed", + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + const usageHealth = vi + .spyOn(modelRegistry.authStorage, "getModelUsageHealth") + .mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + if (options.modelId === primaryModel.id) { + healthStarted.resolve(); + await releaseHealth.promise; + } + const reserve = options.modelId === selectedModel.id; + return { + state: reserve ? "reserve" : "healthy", + accounts: [ + { + credentialId: 1, + credentialType: "oauth", + state: reserve ? "reserve" : "healthy", + remainingFraction: reserve ? 0.05 : 0.8, + }, + ], + }; + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + const prompting = session.prompt("Change models during preflight"); + await healthStarted.promise; + await session.setModelTemporary(selectedModel, undefined, { ephemeral: true }); + releaseHealth.resolve(); + await expect(prompting).rejects.toThrow(`reserve reached for ${selectedModel.provider}/${selectedModel.id}`); + + expect(usageHealth).toHaveBeenCalledTimes(2); + expect(usageChecks).toEqual([primaryModel.id, selectedModel.id]); + expect(session.model?.id).toBe(selectedModel.id); + expect(requestedModels).toEqual([]); + }); + + it("finishes usage preflight when no model is selected", async () => { + const agent = new Agent({ + initialState: { model: undefined, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + await expect(session.prompt("No model configured")).rejects.toThrow("No model selected"); + expect(agent.state.isStreaming).toBe(false); + }); + it("continues a startup-owned role fallback chain from the active fallback", async () => { const firstFallback = getBundledModel("openai", "gpt-4o-mini"); const secondFallback = getBundledModel("openai", "gpt-4o"); From 2da96d77a70be5df6ec5eda04102227eb38390d5 Mon Sep 17 00:00:00 2001 From: Brent <67750428+eggpeat@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:51:32 +0000 Subject: [PATCH 22/97] fix(coding-agent): close fallback lifecycle races --- packages/agent/src/agent.ts | 3 +- packages/agent/test/agent.test.ts | 37 ++++++++++++++ .../coding-agent/src/session/turn-recovery.ts | 3 ++ .../test/agent-session-retry-fallback.test.ts | 48 +++++++++++++++++ .../test/turn-recovery-replay-unsafe.test.ts | 51 +++++++++++++++++++ 5 files changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index c26993463..c2e779a7e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1281,6 +1281,7 @@ export class Agent { this.#resolveRunningPrompt = resolve; this.#abortController = new AbortController(); } + const resolveRun = this.#resolveRunningPrompt; const loopAbortController = this.#abortController; if (!loopAbortController) throw new Error("Agent run state was not initialized"); const loopSignal = continuationSignal @@ -1672,12 +1673,12 @@ export class Agent { this.#emit({ type: "agent_end", messages: [errorMsg] }); } } finally { + resolveRun?.(); if (this.#abortController === loopAbortController) { this.#state.isStreaming = false; this.#state.streamMessage = null; this.#state.pendingToolCalls.clear(); this.#abortController = undefined; - this.#resolveRunningPrompt?.(); this.#runningPrompt = undefined; this.#resolveRunningPrompt = undefined; } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 625e6ebb7..1f4604efe 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -382,6 +382,43 @@ describe("Agent", () => { expect(agent.state.isStreaming).toBe(false); }); + it("resolves a predecessor idle waiter when agent_end starts a successor", async () => { + const secondStarted = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const mock = createMockModel({ + responses: [ + { content: ["first"] }, + async () => { + secondStarted.resolve(); + await releaseSecond.promise; + return { content: ["second"] }; + }, + ], + }); + const agent = new Agent({ streamFn: mock.stream }); + let successor: Promise | undefined; + agent.subscribe(event => { + if (event.type === "agent_end" && !successor) { + successor = agent.prompt("successor"); + } + }); + + const predecessor = agent.prompt("predecessor"); + let predecessorIdleResolved = false; + void agent.waitForIdle().then(() => { + predecessorIdleResolved = true; + }); + await secondStarted.promise; + await predecessor; + expect(agent.state.isStreaming).toBe(true); + + releaseSecond.resolve(); + await successor; + await Promise.resolve(); + expect(predecessorIdleResolved).toBe(true); + expect(agent.state.isStreaming).toBe(false); + }); + it("classifies an in-flight continuation cancellation as aborted", async () => { const providerStarted = Promise.withResolvers(); const agent = new Agent({ diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 542607b71..0273e4ee3 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -1152,11 +1152,13 @@ export class TurnRecovery { const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector, currentModel); if (!role) return false; let fallback: { selector: RetryFallbackSelector; apiKey: string } | undefined; + const ceiling = this.#host.thinkingLevelCeiling(); for (const candidate of this.findRetryFallbackCandidates(role, currentSelector, currentModel)) { if (this.isRetryFallbackSelectorSuppressed(candidate)) continue; const resolved = resolveModelOverride([candidate.raw], this.#host.modelRegistry, this.#host.settings); const candidateModel = resolved.model ?? this.#host.modelRegistry.find(candidate.provider, candidate.id); if (!candidateModel || !this.#host.modelRegistry.hasConfiguredAuth(candidateModel)) continue; + if (ceiling !== undefined && !modelSupportsEffortCeiling(candidateModel, ceiling)) continue; try { const candidateHealth = await this.#host.modelRegistry.authStorage.getModelUsageHealth( candidateModel.provider, @@ -1288,6 +1290,7 @@ export class TurnRecovery { } return false; } + if (this.#host.model() !== candidate) return false; this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE); this.#host.settings.getStorage()?.recordModelUsage(candidateSelector); this.#host.setThinkingLevel(nextThinkingLevel); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 5181bfec9..b15931c60 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -3576,6 +3576,54 @@ describe("AgentSession retry fallback", () => { expect(session.thinkingLevel).toBe(Effort.Low); }); + it("skips usage fallbacks whose effort floor exceeds the session ceiling", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const incompatibleFallback = getBundledModel("fireworks", "deepseek-v4-pro"); + const compatibleFallback = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !incompatibleFallback || !compatibleFallback) { + throw new Error("Expected bundled usage fallback effort models"); + } + const requestedModels: string[] = []; + const usageChecks: string[] = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.usageAwareFallback": true, + "retry.usageReservePolicy": "auto", + "retry.fallbackChains": { + default: [ + `${incompatibleFallback.provider}/${incompatibleFallback.id}`, + `${compatibleFallback.provider}/${compatibleFallback.id}`, + ], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, options) => { + usageChecks.push(options.modelId ?? ""); + return options.modelId === primaryModel.id + ? { + state: "depleted", + accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], + } + : { state: "healthy", accounts: [] }; + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + thinkingLevel: Effort.Low, + thinkingLevelCeiling: Effort.Low, + }); + + await session.prompt("Use an effort-compatible fallback"); + await session.waitForIdle(); + + expect(usageChecks).toEqual([primaryModel.id, compatibleFallback.id]); + expect(requestedModels).toEqual([`${compatibleFallback.provider}/${compatibleFallback.id}`]); + expect(session.model?.id).toBe(compatibleFallback.id); + }); + it("accepts cached Ollama Cloud fallback selectors during startup validation", () => { const primaryModel = getBundledModel("openai", "gpt-4o-mini"); if (!primaryModel) { diff --git a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts index 52a275712..250798d9f 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -142,6 +142,57 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(modelChanges).toEqual([]); expect(emittedEvents).toEqual([]); }); + + it("does not commit a fallback superseded during model reconciliation", async () => { + const fallback = getBundledModel("openai", "gpt-4o-mini"); + if (!fallback) throw new Error("Expected bundled fallback race model"); + const selectedModel = { ...fallback, baseUrl: "https://user-selected-route.example" }; + let activeModel = model; + const fallbackApplied = Promise.withResolvers(); + const releaseReconciliation = Promise.withResolvers(); + const modelChanges: string[] = []; + const emittedEvents: string[] = []; + const thinkingChanges: unknown[] = []; + const host = createHost(model, modelRegistry); + host.model = () => activeModel; + host.sessionManager = { + appendModelChange: (selector: string) => modelChanges.push(selector), + } as never; + host.setThinkingLevel = level => thinkingChanges.push(level); + host.setModelWithProviderSessionReset = async nextModel => { + activeModel = nextModel; + if (nextModel.provider === fallback.provider && nextModel.id === fallback.id) { + fallbackApplied.resolve(); + await releaseReconciliation.promise; + } + }; + host.emitSessionEvent = async event => { + emittedEvents.push(event.type); + }; + const recovery = new TurnRecovery(host); + const applying = recovery.applyRetryFallbackCandidate( + "default", + { + raw: `${fallback.provider}/${fallback.id}`, + provider: fallback.provider, + id: fallback.id, + thinkingLevel: undefined, + }, + `${model.provider}/${model.id}`, + { pinFallback: true, apiKey: "test-key" }, + ); + + await fallbackApplied.promise; + activeModel = selectedModel; + releaseReconciliation.resolve(); + const committed = await applying; + + expect(committed).toBe(false); + expect(activeModel).toBe(selectedModel); + expect(modelChanges).toEqual([]); + expect(thinkingChanges).toEqual([]); + expect(emittedEvents).toEqual([]); + }); it("keeps a committed fallback when cancellation arrives during applied-event delivery", async () => { const fallback = getBundledModel("openai", "gpt-4o-mini"); if (!fallback) throw new Error("Expected bundled fallback model"); From fed51f1ffcc1ecac432eb430ad0bf7be0be807cc Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Mon, 3 Aug 2026 14:06:48 -0400 Subject: [PATCH 23/97] fix(coding-agent): coalesce streaming message_update handler work per render window (#7443) --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/controllers/event-controller.ts | 78 +++++++++- ...controller-message-update-coalesce.test.ts | 141 ++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/event-controller-message-update-coalesce.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 46d565c01..715da230d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) + ## [17.2.6] - 2026-08-03 ### Added diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 242196fae..6d9404255 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -170,6 +170,22 @@ export class EventController { #prevHideThinking = false; #handlers: AgentSessionEventHandlers; #terminalProgressActive = false; + // Coalescing window for `message_update` events at the subscription boundary. + // `message_update` carries the CUMULATIVE assistant message (every update + // re-lists all content blocks), so when a burst of deltas arrives faster than + // this window only the latest snapshot needs to rebuild streaming state — the + // intermediate rebuilds are redundant work. The TUI already caps the paint + // rate via its own render cadence; this caps the per-token handler work that + // feeds it. Speech stays intact: `#vocalizeDelta` runs at ARRIVAL for every + // delta before the snapshot is coalesced away. + #pendingMessageUpdate: Extract | undefined = undefined; + #messageUpdateTimer: NodeJS.Timeout | undefined = undefined; + // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate` + // also vocalizes so the direct `handleEvent` path (tests, session focus replay) + // keeps working — the WeakSet makes the coalesced path speak each delta exactly + // once instead of twice. + #vocalizedMessageUpdates = new WeakSet(); + static readonly #MESSAGE_UPDATE_COALESCE_MS = 33; constructor(private ctx: InteractiveModeContext) { // Enhanced speech (`speech.enhanced`) rewrites blocks through the @@ -253,6 +269,11 @@ export class EventController { } dispose(): void { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + this.#pendingMessageUpdate = undefined; this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#cancelIdleCompaction(); @@ -426,9 +447,57 @@ export class EventController { subscribeToAgent(): void { this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => { + // Coalesce the cumulative `message_update` deltas of a streaming turn + // into at most one handler run per window. `#handleMessageUpdate` is + // synchronous, so without this every token re-runs the whole + // streaming rebuild (splitAssistantMessageToolTimeline, reveal + // setTarget, per-block tool-call reconciliation) even though the TUI + // paints at most ~30fps — at 40-100 tps the handler work then + // dominates the CPU profile of an idle-looking streaming session + // (issue #7443). Only the latest snapshot is meaningful; non-update + // events flush the pending snapshot first so ordering is preserved. + if (event.type === "message_update") { + this.#enqueueMessageUpdate(event); + return; + } + await this.#flushPendingMessageUpdate(); await this.handleEvent(event); }); } + + /** + * Queue a streaming `message_update` for the next coalesced handler run. + * Speech is per-delta, so the delta is vocalized at arrival before the + * snapshot is (possibly) superseded by a newer one. + */ + #enqueueMessageUpdate(event: Extract): void { + // Speech is per-delta: every delta is spoken at arrival even when its + // cumulative snapshot is later superseded and never rebuilt. + this.#vocalizeDelta(event); + this.#vocalizedMessageUpdates.add(event); + this.#pendingMessageUpdate = event; + if (this.#messageUpdateTimer) return; + this.#messageUpdateTimer = setTimeout(() => { + this.#messageUpdateTimer = undefined; + void this.#flushPendingMessageUpdate(); + }, EventController.#MESSAGE_UPDATE_COALESCE_MS); + } + + /** + * Run the coalesced `message_update` handler on the latest pending snapshot + * (dropping any superseded intermediates) and clear the queue. Safe to call + * more than once; no-ops when nothing is pending. + */ + async #flushPendingMessageUpdate(): Promise { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + const event = this.#pendingMessageUpdate; + if (!event) return; + this.#pendingMessageUpdate = undefined; + await this.handleEvent(event); + } /** * Clear every transcript-anchored/turn-scoped piece of state. Used by the * session focus proxy when re-pointing the transcript at another session: @@ -436,6 +505,11 @@ export class EventController { * session's transcript and must not bleed into the new one. */ resetTranscriptAnchors(): void { + if (this.#messageUpdateTimer) { + clearTimeout(this.#messageUpdateTimer); + this.#messageUpdateTimer = undefined; + } + this.#pendingMessageUpdate = undefined; this.#resetReadGroup(); this.#lastVisibleBlockCount = 0; this.#renderedCustomMessages.clear(); @@ -814,7 +888,9 @@ export class EventController { async #handleMessageUpdate(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); - this.#vocalizeDelta(event); + if (!this.#vocalizedMessageUpdates.delete(event)) { + this.#vocalizeDelta(event); + } if (this.ctx.streamingComponent && event.message.role === "assistant") { const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(event.message); if (unlockedThinkingVisibility) { diff --git a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts new file mode 100644 index 000000000..cc3b5dafe --- /dev/null +++ b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { vocalizer } from "@oh-my-pi/pi-coding-agent/tts/vocalizer"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +function zeroUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + usage: zeroUsage(), + stopReason: undefined, + createdAt: new Date(0), + } as unknown as AssistantMessage; +} + +function messageUpdate(text: string): Extract { + return { + type: "message_update", + message: assistantMessage(text), + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: text, partial: assistantMessage(text) }, + } as unknown as Extract; +} + +function createStreamingFixture() { + const listeners: Array<(event: AgentSessionEvent) => void> = []; + const ui = { + requestRender: vi.fn(), + requestComponentRender: vi.fn(), + } as unknown as TUI; + const viewSession = { isStreaming: true, getToolByName: () => undefined }; + const ctx = { + isInitialized: true, + init: vi.fn(async () => {}), + ui, + settings, + chatContainer: { addChild: vi.fn(), children: [] }, + pendingTools: new Map(), + transcriptMessageComponents: new WeakMap(), + streamingComponent: { + setHideThinkingBlock: vi.fn(), + markTranscriptBlockFinalized: vi.fn(), + updateContent: vi.fn(), + }, + noteDisplayableThinkingContent: vi.fn(() => false), + ensureLoadingAnimation: vi.fn(), + statusLine: { invalidate: vi.fn() }, + updateEditorTopBorder: vi.fn(), + setWorkingMessage: vi.fn(), + viewSession, + session: { + subscribe: (listener: (event: AgentSessionEvent) => void) => { + listeners.push(listener); + return () => {}; + }, + } as unknown as InteractiveModeContext["session"], + } as unknown as InteractiveModeContext; + const controller = new EventController(ctx); + controller.subscribeToAgent(); + const emit = (event: AgentSessionEvent) => { + for (const listener of listeners) void listener(event); + }; + return { controller, ctx, ui, emit }; +} + +describe("EventController message_update coalescing", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { "display.smoothStreaming": false } }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetSettingsForTest(); + }); + + it("runs the streaming rebuild once per window instead of once per delta, applying the latest snapshot", async () => { + const { ctx, ui, emit } = createStreamingFixture(); + + emit(messageUpdate("tok1")); + emit(messageUpdate("tok1 tok2")); + emit(messageUpdate("tok1 tok2 tok3")); + emit(messageUpdate("tok1 tok2 tok3 tok4")); + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5")); + + // The coalescing window is 33ms; give the trailing flush a chance to fire. + await Bun.sleep(60); + + expect(ui.requestRender).toHaveBeenCalledTimes(1); + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2 tok3 tok4 tok5" }, + ]); + }); + + it("flushes the pending snapshot before a subsequent non-update event", async () => { + const { ctx, emit } = createStreamingFixture(); + + emit(messageUpdate("tok1")); + emit(messageUpdate("tok1 tok2")); + emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2" }, + ]); + }); + + it("speaks every delta exactly once even when intermediate snapshots are coalesced away", async () => { + const { emit } = createStreamingFixture(); + const pushDelta = vi.spyOn(vocalizer, "pushDelta"); + settings.set("speech.enabled", true); + settings.set("speech.mode", "assistant"); + + emit(messageUpdate("one ")); + emit(messageUpdate("one two ")); + emit(messageUpdate("one two three ")); + + await Bun.sleep(60); + + expect(pushDelta).toHaveBeenCalledTimes(3); + expect(pushDelta).toHaveBeenNthCalledWith(1, "one "); + expect(pushDelta).toHaveBeenNthCalledWith(2, "one two "); + expect(pushDelta).toHaveBeenNthCalledWith(3, "one two three "); + }); +}); From 84813ad37afee318e636d6ec7a5e868ec268708d Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Mon, 3 Aug 2026 14:14:07 -0400 Subject: [PATCH 24/97] fix(coding-agent): handle timer-triggered message update flush rejections (#7443) --- .../src/modes/controllers/event-controller.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 6d9404255..38352f4fe 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -479,7 +479,14 @@ export class EventController { if (this.#messageUpdateTimer) return; this.#messageUpdateTimer = setTimeout(() => { this.#messageUpdateTimer = undefined; - void this.#flushPendingMessageUpdate(); + // Mirror AgentSession.#emit: attach a catch so a streaming rebuild + // failure surfaces as a logged warning instead of a process-level + // unhandled rejection (the timer path has no listener to attach one). + void this.#flushPendingMessageUpdate().catch(err => { + logger.warn("Message update flush rejected", { + error: err instanceof Error ? err.message : String(err), + }); + }); }, EventController.#MESSAGE_UPDATE_COALESCE_MS); } From b80a4af95c92fe72473dbe6305deaa067c260f9e Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Mon, 3 Aug 2026 14:27:15 -0400 Subject: [PATCH 25/97] ci: retrigger native/unit job (pre-existing MCP spawn-race flake, unrelated to diff) From 95c6dd146fab081760120fcced072c4ee4a4b298 Mon Sep 17 00:00:00 2001 From: jessemcnew <247455081+jessemcnew@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:08:12 -0500 Subject: [PATCH 26/97] fix(cli): preserve OMP identity inside Herdr --- packages/coding-agent/scripts/omp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/scripts/omp b/packages/coding-agent/scripts/omp index ef7a82b96..82721c719 100755 --- a/packages/coding-agent/scripts/omp +++ b/packages/coding-agent/scripts/omp @@ -36,7 +36,16 @@ mkdir -p "$launch_dir" OMP_LAUNCH_CWD=$PWD export OMP_LAUNCH_CWD cd "$launch_dir" +# Herdr 0.7.5 validates the foreground process name. The macOS shell can +# preserve OMP's identity while still running the development CLI through Bun. +run_bun() { + if [ "${HERDR_ENV:-}" = 1 ] && [ "$(uname -s)" = Darwin ]; then + exec -a omp bun "$@" + fi + exec bun "$@" +} + if [ -n "${PI_TIMING:-}" ]; then - exec bun --preload "$preload" --preload "$timing_preload" "$cli" "$@" + run_bun --preload "$preload" --preload "$timing_preload" "$cli" "$@" fi -exec bun --preload "$preload" "$cli" "$@" +run_bun --preload "$preload" "$cli" "$@" From 510661437fa3f272270c7faf60a7178a4dba8710 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 23:28:44 +0000 Subject: [PATCH 27/97] fix(coding-agent/hindsight): route recall through single injection path Two recall paths shared the hasRecalledForFirstTurn flag: the agent_start subscription (maybeRecallOnAgentStart, fire-and-forget with an unawaited background prompt rebuild) and beforeAgentStartPrompt (awaited before the turn builds). When the event path consumed the flag first, beforeAgentStartPrompt returned undefined and the recalled context reached the model only if the background rebuild had already landed -- on a fast turn it had not, so autoRecall intermittently never reached the model. Make beforeAgentStartPrompt the sole injection path and drop the racing agent_start recall. Fixes #7568 --- packages/coding-agent/CHANGELOG.md | 4 +++ packages/coding-agent/src/hindsight/state.ts | 24 ++----------- .../test/hindsight-backend.test.ts | 36 +++++++++++++++++++ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..070296aec 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Hindsight `autoRecall` intermittently not reaching the model: two recall paths shared the `hasRecalledForFirstTurn` flag, and the `agent_start` event path could consume it first and inject only via an unawaited background prompt rebuild that a fast turn outran. `beforeAgentStartPrompt` (awaited before the turn builds) is now the sole injection path ([#7568](https://github.com/can1357/oh-my-pi/issues/7568)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/hindsight/state.ts b/packages/coding-agent/src/hindsight/state.ts index ae39a90e1..80fa6036d 100644 --- a/packages/coding-agent/src/hindsight/state.ts +++ b/packages/coding-agent/src/hindsight/state.ts @@ -411,24 +411,6 @@ export class HindsightSessionState { } } - async maybeRecallOnAgentStart(): Promise { - if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return; - const messages = extractMessages(this.session.sessionManager); - const lastUser = messages.findLast(m => m.role === "user"); - if (!lastUser) return; - - const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns); - const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars); - const { context, ok } = await this.recallForContext(truncated); - if (!ok) return; - - this.hasRecalledForFirstTurn = true; - if (!context) return; - - this.lastRecallSnippet = context; - await this.#refreshBaseSystemPromptAfter("recall"); - } - async beforeAgentStartPrompt(promptText: string): Promise { if (this.config.mentalModelsEnabled && this.mentalModelsLoadPromise && this.mentalModelsLoadedAt === undefined) { await Promise.race([this.mentalModelsLoadPromise, Bun.sleep(MENTAL_MODEL_FIRST_TURN_DEADLINE_MS)]); @@ -509,9 +491,7 @@ export class HindsightSessionState { attachSessionListeners(): void { this.unsubscribe?.(); this.unsubscribe = this.session.subscribe(event => { - if (event.type === "agent_start") { - void this.maybeRecallOnAgentStart(); - } else if (event.type === "agent_end") { + if (event.type === "agent_end") { void this.maybeRetainOnAgentEnd(); // Drain any queued tool-initiated retain calls now that the turn // is settled. The queue is also debounced/size-bounded, but @@ -540,7 +520,7 @@ export class HindsightSessionState { this.retainQueue.dispose(); } - async #refreshBaseSystemPromptAfter(reason: "recall" | "MM load" | "MM reload" | "MM TTL reload"): Promise { + async #refreshBaseSystemPromptAfter(reason: "MM load" | "MM reload" | "MM TTL reload"): Promise { try { await this.session.refreshBaseSystemPrompt(); } catch (err) { diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index 969f06fa6..a7415e3ed 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -338,6 +338,42 @@ describe("hindsightBackend first-turn injection", () => { expect(session.getHindsightSessionState()?.lastRecallSnippet).toBe(block); }); + it("does not let agent_start preempt first-turn recall injection", async () => { + const settings = Settings.isolated({ + "memory.backend": "hindsight", + "hindsight.apiUrl": "http://localhost:8888", + }); + const session = makeFakeSession({ + sessionId: "s-race", + entries: [{ role: "user", text: "What is the canary phrase?" }], + }); + await hindsightBackend.start({ + session: session as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 0, + }); + + vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ + results: [{ id: "1", text: "The canary phrase is PURPLE-OTTER-9931." }], + } as never); + + // The agent loop fires agent_start once the turn begins. This must NOT run + // its own recall: doing so consumed the shared first-turn flag and left + // injection to a racing background prompt rebuild that a fast turn outran, + // dropping recalled memory from the model's prompt (#7568). + session.emit({ type: "agent_start" }); + for (let i = 0; i < 50; i++) await Promise.resolve(); + + expect(session.getHindsightSessionState()?.hasRecalledForFirstTurn).toBe(false); + + // beforeAgentStartPrompt is the sole, awaited injection path. + const block = await hindsightBackend.beforeAgentStartPrompt?.(session as never, "What is the canary phrase?"); + expect(block).toContain("PURPLE-OTTER-9931"); + expect(session.getHindsightSessionState()?.hasRecalledForFirstTurn).toBe(true); + }); + it("keeps the wrapper in buildDeveloperInstructions", async () => { const settings = Settings.isolated({ "memory.backend": "hindsight", From 6b2d53226992708443ed9cfaa2287ec6c5909667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B7=BC=E7=95=94?= <2173679279@qq.com> Date: Tue, 4 Aug 2026 09:56:54 +0800 Subject: [PATCH 28/97] fix(tui): terminate wrapped table cell lines' SGR state so borders keep default color Table rows splice every wrapped cell line between unstyled border glyphs. The native wrap deliberately leaves fg color and bold/italic open at line ends so continuation lines can re-open them, so a codespan broken across a wrap leaked its color into the '|' borders, inter-cell padding, and the following cells. Close SGR state per cell line (bold/italic off, default fg; background preserved) in #wrapCellText. Closes #7575 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/markdown.ts | 8 +++- packages/tui/test/markdown.test.ts | 50 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a3da37fb2..e03bfc07d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed table borders (and adjacent cells) inheriting an open inline-code color when a cell's content wraps mid-code-span, by terminating each wrapped cell line's SGR state before the border glyphs ([#7575](https://github.com/can1357/oh-my-pi/issues/7575)). + ## [17.2.5] - 2026-08-03 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a5dec34bd..bb363f8aa 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -2816,7 +2816,13 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") { wrapped.pop(); } - return wrapped; + // The native wrap deliberately leaves fg color and bold/italic open at + // line ends so continuation lines can re-open them. Table rows splice + // every cell line between unstyled border glyphs, so an open style + // (e.g. mdCode) would bleed into the "│" and the following cells. + // Terminate each line at default fg, clearing bold/italic but keeping + // any ambient background (message-bg rendering) intact. + return wrapped.map(line => `${line}\x1b[22m\x1b[23m\x1b[39m`); } /** diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 709ba234f..35057dfa9 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -466,6 +466,56 @@ describe("Markdown component", () => { } }); + it("does not leak inline-code color into table borders when cells wrap", () => { + const markdown = new Markdown( + `| Command | Notes | +| --- | --- | +| \`config.setupgrading(pendingRequests, emptyFlag)\` | plain | +| short | other |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Narrow enough to force the long codespan to wrap mid-run. + const lines = markdown.render(24); + const joinedOutput = lines.join("\n"); + expect(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)").toBeTruthy(); + expect(lines.filter(line => line.includes("|")).length).toBeGreaterThan(3); + + // Walk SGR state through every table row: the "|" border glyphs (and + // everything after them on the line) must never be rendered under an + // open fg color or bold attribute. + for (const line of lines) { + if (!line.includes("|")) continue; + let bold = false; + let fgOpen = false; + let i = 0; + while (i < line.length) { + if (line[i] === "\x1b") { + const seq = line.slice(i).match(/^\x1b\[([0-9;]*)m/); + expect(seq, `unparseable SGR in: ${JSON.stringify(line)}`).not.toBeNull(); + for (const p of seq![1]!.split(";")) { + if (p === "1") bold = true; + else if (p === "22") bold = false; + else if (p === "0") { + bold = false; + fgOpen = false; + } else if (p === "39") fgOpen = false; + else if (p === "38" || /^3[0-7]$/.test(p) || /^9[0-7]$/.test(p)) fgOpen = true; + } + i += seq![0].length; + continue; + } + if (line[i] === "|") { + expect(fgOpen, `Border inherits fg color in: ${JSON.stringify(line)}`).toBe(false); + expect(bold, `Border inherits bold in: ${JSON.stringify(line)}`).toBe(false); + } + i++; + } + } + }); + it("should handle extremely narrow width gracefully", () => { const markdown = new Markdown( `| A | B | C | From 6eb9574436c314f5fab14b968e13160eaa1a4820 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 02:55:38 +0000 Subject: [PATCH 29/97] fix(tui): ran sixel probe under wsl windows terminal The SIXEL capability probe gated on process.platform === "win32", but under WSL process.platform is "linux", so the probe never ran and inline images fell back to the text placeholder even on Sixel-capable Windows Terminal. Gate on isConPTYHosted() (native win32 or WSL) instead; the existing WT_SESSION and TTY requirements still scope it to WT. Fixes #6009 --- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/tui.ts | 5 ++++- packages/tui/test/sixel-probe.test.ts | 29 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7d7e0c430..0023f9ba0 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed inline images not rendering under WSL + Windows Terminal: the SIXEL capability probe gated on `process.platform === "win32"`, but WSL reports `linux`, so the probe never ran and images fell back to the text placeholder even on Sixel-capable Windows Terminal. The probe now runs on any ConPTY host (native win32 or WSL) ([#6009](https://github.com/can1357/oh-my-pi/issues/6009)). + ## [17.0.3] - 2026-07-17 ### Fixed diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 63b74dbd0..986d4e0ab 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1608,7 +1608,10 @@ export class TUI extends Container { #querySixelSupport(): void { if (TERMINAL.imageProtocol) return; - if (process.platform !== "win32") return; + // win32 native or WSL under Windows Terminal — both are ConPTY-hosted and + // reach the same WT graphics negotiation. WSL reports process.platform + // "linux", so a bare win32 check silently skips the probe there (#6009). + if (!isConPTYHosted()) return; if (!Bun.env.WT_SESSION) return; if (!process.stdin.isTTY || !process.stdout.isTTY) return; diff --git a/packages/tui/test/sixel-probe.test.ts b/packages/tui/test/sixel-probe.test.ts index 8c532908a..45767aeeb 100644 --- a/packages/tui/test/sixel-probe.test.ts +++ b/packages/tui/test/sixel-probe.test.ts @@ -9,6 +9,8 @@ type MutableTerminalInfo = { const terminalInfo = TERMINAL as unknown as MutableTerminalInfo; const originalProtocol = TERMINAL.imageProtocol; const originalWtSession = Bun.env.WT_SESSION; +const originalWslDistro = Bun.env.WSL_DISTRO_NAME; +const originalWslInterop = Bun.env.WSL_INTEROP; const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); @@ -29,6 +31,10 @@ describe("TUI SIXEL capability probe", () => { terminalInfo.imageProtocol = originalProtocol; if (originalWtSession === undefined) delete Bun.env.WT_SESSION; else Bun.env.WT_SESSION = originalWtSession; + if (originalWslDistro === undefined) delete Bun.env.WSL_DISTRO_NAME; + else Bun.env.WSL_DISTRO_NAME = originalWslDistro; + if (originalWslInterop === undefined) delete Bun.env.WSL_INTEROP; + else Bun.env.WSL_INTEROP = originalWslInterop; restoreIsTty(process.stdin, stdinIsTtyDescriptor); restoreIsTty(process.stdout, stdoutIsTtyDescriptor); }); @@ -102,4 +108,27 @@ describe("TUI SIXEL capability probe", () => { expect(TERMINAL.imageProtocol).toBeNull(); tui.stop(); }); + + it("enables SIXEL under WSL + Windows Terminal (process.platform is linux)", () => { + // Regression for #6009: inside WSL, process.platform reports "linux" even + // though the host is Windows Terminal. The probe used to gate on + // process.platform === "win32", so WSL sessions never negotiated SIXEL and + // fell back to the text image card. It now gates on isConPTYHosted(), which + // treats WSL (WSL_DISTRO_NAME/WSL_INTEROP) as a Windows host. + if (process.platform !== "linux") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Bun.env.WSL_DISTRO_NAME = "Ubuntu"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2;4c"); + + expect(TERMINAL.imageProtocol).toBe(ImageProtocol.Sixel); + tui.stop(); + }); }); From a29acbfa408178507a956914e85df47995126536 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 03:18:36 +0000 Subject: [PATCH 30/97] fix(config): read server input modalities in openai-models-list discovery discoverOpenAIModelsList never consulted the /v1/models row's input field, so custom virtual tier ids absent from the bundled catalog fell through to the ["text"] fallback and showed images: no even when the server advertised input: ["text","image"]. Parse the direct input array and OpenRouter-style architecture.input_modalities from the response row, preferring server-reported modalities over native metadata and the bundled reference. Fixes #7583 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/config/model-discovery.ts | 36 ++++++++++++++++- .../coding-agent/test/model-discovery.test.ts | 40 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..0a3d85e2c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index 7e30f0fd7..0e98ad377 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -724,6 +724,30 @@ export async function discoverLlamaCppModelRuntimeMetadata( } } +/** + * Read image-input support from an OpenAI-compatible `/v1/models` row. Handles + * the direct `input: ["text","image"]` array thin proxies emit and the + * OpenRouter-style `architecture.input_modalities` array; returns undefined when + * neither is present so the bundled reference (or the `["text"]` default) can + * take over. + */ +function extractOpenAIModelsListInputCapabilities(item: { + input?: unknown; + architecture?: unknown; +}): ("text" | "image")[] | undefined { + const modalities = new Set(); + const collect = (value: unknown): void => { + if (!Array.isArray(value)) return; + for (const entry of value) { + if (typeof entry === "string") modalities.add(entry.toLowerCase()); + } + }; + collect(item.input); + if (isRecord(item.architecture)) collect(item.architecture.input_modalities); + if (modalities.size === 0) return undefined; + return modalities.has("image") ? ["text", "image"] : ["text"]; +} + export async function discoverOpenAIModelsList( providerConfig: DiscoveryProviderConfig, ctx: DiscoveryContext, @@ -752,7 +776,13 @@ export async function discoverOpenAIModelsList( } headers = h; return (await res.json()) as { - data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>; + data?: Array<{ + id?: string; + max_model_len?: unknown; + context_length?: unknown; + input?: unknown; + architecture?: unknown; + }>; }; }), nativeMetadataPromise, @@ -796,7 +826,9 @@ export async function discoverOpenAIModelsList( baseUrl, reasoning: reference?.reasoning ?? false, thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider), - input: nativeMetadataForModel?.input ?? reference?.input ?? ["text"], + input: extractOpenAIModelsListInputCapabilities(item) ?? + nativeMetadataForModel?.input ?? + reference?.input ?? ["text"], ...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}), // Proxy/gateway pricing is provider-specific and rarely matches // upstream bundled catalogs, so keep costs local-unknown even diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 2b740a3c7..15f1d2f19 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -2172,6 +2172,46 @@ providers: expect(unknown?.reasoning).toBe(false); }); + test("openai-models-list discovery reads server-advertised input modalities for ids absent from the catalog", async () => { + writeRawModelsJson({ + "openai-test": { + baseUrl: "http://127.0.0.1:9996", + api: "openai-completions", + auth: "none", + discovery: { type: "openai-models-list" }, + }, + }); + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:9996/v1/models") { + // Custom virtual tier ids that are absent from the bundled + // catalog: their vision support can only come from the server row. + return new Response( + JSON.stringify({ + data: [ + { id: "high", object: "model", input: ["text", "image"] }, + { id: "leftover", object: "model", architecture: { input_modalities: ["text", "image"] } }, + { id: "low", object: "model", input: ["text"] }, + { id: "medium", object: "model" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + // Direct `input` array and OpenRouter-style `architecture.input_modalities` + // both surface vision support. + expect(registry.find("openai-test", "high")?.input).toEqual(["text", "image"]); + expect(registry.find("openai-test", "leftover")?.input).toEqual(["text", "image"]); + // Server explicitly reports text-only; no image support invented. + expect(registry.find("openai-test", "low")?.input).toEqual(["text"]); + // Silent server → default text-only fallback. + expect(registry.find("openai-test", "medium")?.input).toEqual(["text"]); + }); + test("proxy discovery honors API-reported context_length and endpoint routing", async () => { writeRawModelsJson({ "proxy-test": { From 0c27e521c4516f728e61cc9c06d6bbf0e68b56cb Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 03:24:53 +0000 Subject: [PATCH 31/97] fix(config): preserved LM Studio native modalities Keep the richer LM Studio /api/v0/models input metadata ahead of the thin OpenAI-compatible row while retaining row-first behavior for generic openai-models-list discovery. Add a regression covering a text-only /v1/models row paired with a native VLM record. --- .../src/config/model-discovery.ts | 4 +-- .../coding-agent/test/model-discovery.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index 0e98ad377..ba54b12ca 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -826,8 +826,8 @@ export async function discoverOpenAIModelsList( baseUrl, reasoning: reference?.reasoning ?? false, thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider), - input: extractOpenAIModelsListInputCapabilities(item) ?? - nativeMetadataForModel?.input ?? + input: nativeMetadataForModel?.input ?? + extractOpenAIModelsListInputCapabilities(item) ?? reference?.input ?? ["text"], ...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}), // Proxy/gateway pricing is provider-specific and rarely matches diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 15f1d2f19..0c98a8c00 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -2212,6 +2212,38 @@ providers: expect(registry.find("openai-test", "medium")?.input).toEqual(["text"]); }); + test("lm-studio discovery keeps native VLM modalities over a thin OpenAI row", async () => { + writeRawModelsJson({ + "lm-studio-test": { + baseUrl: "http://127.0.0.1:9995", + api: "openai-completions", + auth: "none", + discovery: { type: "lm-studio" }, + }, + }); + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:9995/v1/models") { + return new Response(JSON.stringify({ data: [{ id: "local-vlm", object: "model", input: ["text"] }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:9995/api/v0/models") { + return new Response( + JSON.stringify({ + data: [{ id: "local-vlm", type: "vlm", capabilities: ["vision"], state: "loaded" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + expect(registry.find("lm-studio-test", "local-vlm")?.input).toEqual(["text", "image"]); + }); + test("proxy discovery honors API-reported context_length and endpoint routing", async () => { writeRawModelsJson({ "proxy-test": { From 67441e2eec2791542a765c050851fbb88be6502d Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 03:32:06 +0000 Subject: [PATCH 32/97] fix(config): bumped openai-models-list discovery cache namespace Warm context-v2 rows cached before server input-modality parsing pinned vision-capable ids at input: ["text"] until a forced refresh. Bump the namespace to context-v3 so the modality fix takes effect on the next online-if-uncached refresh, and add a regression proving v2 rows are orphaned. --- .../coding-agent/src/config/model-registry.ts | 5 +- .../coding-agent/test/model-registry.test.ts | 49 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 1662b2fed..45a87627b 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1715,7 +1715,10 @@ export class ModelRegistry { return resolveOllamaModelCacheProviderId(providerConfig.provider, providerConfig.baseUrl); } if (providerConfig.discovery.type === "openai-models-list") { - return `${providerConfig.provider}:openai-models-list-context-v2`; + // context-v3 invalidates rows cached before server-advertised input + // modalities were parsed from `/v1/models`; warm v2 rows pinned + // vision-capable ids at `input: ["text"]` until a forced refresh. + return `${providerConfig.provider}:openai-models-list-context-v3`; } if (providerConfig.discovery.type === "litellm") { // rich-v2 invalidates rows cached before reseller usage-suffix stripping diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 87f1f7247..591c3cb53 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1876,6 +1876,7 @@ describe("ModelRegistry", () => { let vertexStale: ModelRegistry; let litellmStaleNamespaceCache: ModelRegistry; let litellmCurrentNamespaceCache: ModelRegistry; + let openaiModelsListStaleNamespaceCache: ModelRegistry; const vertexProjectModel = () => buildModel({ id: "zai-org/glm-4.7-maas", @@ -2101,7 +2102,7 @@ describe("ModelRegistry", () => { { seedCache: dbPath => writeModelCache( - "cached-compact-proxy:openai-models-list-context-v2", + "cached-compact-proxy:openai-models-list-context-v3", Date.now(), [ buildModel({ @@ -2171,6 +2172,45 @@ describe("ModelRegistry", () => { dbPath, ), }); + openaiModelsListStaleNamespaceCache = readonlyRegistry( + { + providers: { + "stale-openai-proxy": { + baseUrl: "https://stale-proxy.example.com/v1", + apiKey: "TEST_KEY", + api: "openai-completions", + discovery: { type: "openai-models-list" }, + models: [], + }, + }, + }, + { + // Row under the retired pre-modality namespace; the context-v3 + // bump must orphan it instead of serving the stale text-only row. + seedCache: dbPath => + writeModelCache( + "stale-openai-proxy:openai-models-list-context-v2", + Date.now(), + [ + buildModel({ + id: "stale-vlm", + name: "Stale VLM", + api: "openai-completions", + provider: "stale-openai-proxy", + baseUrl: "https://stale-proxy.example.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }), + ], + true, + "", + dbPath, + ), + }, + ); }); test("legacy cached discovery sentinels are ignored after nullable limit cutover", () => { @@ -2223,6 +2263,13 @@ describe("ModelRegistry", () => { expect(model?.provider).toBe("litellm-proxy"); }); + test("ignores openai-models-list rows cached under the retired context-v2 namespace", () => { + // PR #7584 added server-advertised input-modality parsing; warm v2 rows + // pinned vision-capable ids at text-only and must not load. + expect(openaiModelsListStaleNamespaceCache.find("stale-openai-proxy", "stale-vlm")).toBeUndefined(); + expect(getModelsForProvider(openaiModelsListStaleNamespaceCache, "stale-openai-proxy")).toHaveLength(0); + }); + test("replaces bundled google-vertex models with authoritative Vertex project discovery", () => { const vertexModels = getModelsForProvider(vertexAuthoritative, "google-vertex"); expect(vertexModels.map(model => model.id)).toEqual(["zai-org/glm-4.7-maas"]); From 9011e8ed44d0c268f118539d197540c99e35c4f4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 03:37:38 +0000 Subject: [PATCH 33/97] fix(config): parsed top-level input modalities OpenAI-compatible endpoints such as Synthetic advertise vision support through a top-level input_modalities array. Include that response field alongside direct input and nested architecture.input_modalities, with regression coverage for a catalog-absent id. --- packages/coding-agent/src/config/model-discovery.ts | 10 ++++++---- packages/coding-agent/test/model-discovery.test.ts | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts index ba54b12ca..8ea4b99b0 100644 --- a/packages/coding-agent/src/config/model-discovery.ts +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -726,13 +726,13 @@ export async function discoverLlamaCppModelRuntimeMetadata( /** * Read image-input support from an OpenAI-compatible `/v1/models` row. Handles - * the direct `input: ["text","image"]` array thin proxies emit and the - * OpenRouter-style `architecture.input_modalities` array; returns undefined when - * neither is present so the bundled reference (or the `["text"]` default) can - * take over. + * direct `input` arrays, Synthetic-style top-level `input_modalities`, and + * OpenRouter-style `architecture.input_modalities`; returns undefined when none + * is present so the bundled reference (or the `["text"]` default) can take over. */ function extractOpenAIModelsListInputCapabilities(item: { input?: unknown; + input_modalities?: unknown; architecture?: unknown; }): ("text" | "image")[] | undefined { const modalities = new Set(); @@ -743,6 +743,7 @@ function extractOpenAIModelsListInputCapabilities(item: { } }; collect(item.input); + collect(item.input_modalities); if (isRecord(item.architecture)) collect(item.architecture.input_modalities); if (modalities.size === 0) return undefined; return modalities.has("image") ? ["text", "image"] : ["text"]; @@ -781,6 +782,7 @@ export async function discoverOpenAIModelsList( max_model_len?: unknown; context_length?: unknown; input?: unknown; + input_modalities?: unknown; architecture?: unknown; }>; }; diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 0c98a8c00..a252926df 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -2191,6 +2191,7 @@ providers: data: [ { id: "high", object: "model", input: ["text", "image"] }, { id: "leftover", object: "model", architecture: { input_modalities: ["text", "image"] } }, + { id: "synthetic-tier", object: "model", input_modalities: ["text", "image"] }, { id: "low", object: "model", input: ["text"] }, { id: "medium", object: "model" }, ], @@ -2202,10 +2203,11 @@ providers: }; const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); await registry.refresh(); - // Direct `input` array and OpenRouter-style `architecture.input_modalities` - // both surface vision support. + // Direct `input`, top-level `input_modalities`, and OpenRouter-style + // `architecture.input_modalities` all surface vision support. expect(registry.find("openai-test", "high")?.input).toEqual(["text", "image"]); expect(registry.find("openai-test", "leftover")?.input).toEqual(["text", "image"]); + expect(registry.find("openai-test", "synthetic-tier")?.input).toEqual(["text", "image"]); // Server explicitly reports text-only; no image support invented. expect(registry.find("openai-test", "low")?.input).toEqual(["text"]); // Silent server → default text-only fallback. From 8b152f7bdfb29918dcbf6348e8ec2e26ac512245 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 03:42:57 +0000 Subject: [PATCH 34/97] fix(coding-agent): give memory:// a corrective error under hindsight backend The shared `recall` tool description instructs a follow-up `read memory://`, but the memory:// handler resolves only `root` and mnemopi ids. Under memory.backend=hindsight, following the tool's own docs hit the generic "Unknown memory namespace" error that only mentions mnemopi, causing repeated failed reads and derailment. Make the handler backend-aware: when a memory:// read arrives while a Hindsight session is active and no mnemopi state can serve it, throw a corrective error pointing at `recall`/`reflect` so a stray read self-corrects in one turn. Fixes #7587 --- .../src/internal-urls/memory-protocol.ts | 14 ++++++ .../internal-urls/memory-protocol.test.ts | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 6b8915957..2917f63cb 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -291,6 +291,20 @@ export class MemoryProtocolHandler implements ProtocolHandler { if (namespace !== MEMORY_NAMESPACE) { const mnemopiStates = mnemopiSessionStatesFromRegistry(); if (mnemopiStates.length === 0) { + // Hindsight keeps memories server-side and exposes no + // `memory://` addressing, yet the shared `recall` tool + // description still steers a follow-up `read memory://`. + // Return a corrective pointer so that stray read self-corrects in + // one turn instead of derailing on the generic namespace error + // (issue #7587). + const hindsightActive = AgentRegistry.global() + .list() + .some(ref => ref.session?.getHindsightSessionState?.()); + if (hindsightActive) { + throw new Error( + "Hindsight memories are not addressable via memory://. Recall results are final — use `recall` to search or `reflect` to synthesize. `read memory://` is only available with memory.backend=mnemopi.", + ); + } throw new Error( `Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE} (file-backed memory summary), or a mnemopi memory id when memory.backend=mnemopi is active.`, ); diff --git a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts index 6bd8f71c8..b4a3e2d93 100644 --- a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts @@ -524,3 +524,50 @@ describe("MemoryProtocolHandler — mnemopi bridge (issue #4443)", () => { }); }); }); + +/** + * Register a live session simulating memory.backend=hindsight: it exposes a + * Hindsight state but no mnemopi state, so the handler must treat memory:// + * as unaddressable and return a corrective pointer (issue #7587). + */ +function withHindsightSession(fn: () => Promise): Promise { + const session = { + getHindsightSessionState: () => ({ bankId: "test-bank" }), + } as unknown as AgentSession; + AgentRegistry.global().register({ + id: "test-hindsight", + displayName: "test-hindsight", + kind: "main", + session, + sessionFile: null, + }); + return fn(); +} + +describe("MemoryProtocolHandler — hindsight (issue #7587)", () => { + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + afterEach(() => { + AgentRegistry.resetGlobalForTests(); + InternalUrlRouter.resetForTests(); + }); + + it("returns a corrective error for memory:// when hindsight is active", async () => { + await withHindsightSession(async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( + /Hindsight memories are not addressable via memory:\/\/.*use `recall`.*`reflect`/s, + ); + }); + }); + + it("keeps the generic namespace error when no memory backend is active", async () => { + const router = InternalUrlRouter.instance(); + await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( + /Unknown memory namespace: a1b2c3d4e5f6\. Supported: root/, + ); + }); +}); From 7d7c21cea548b0cb1124afc46046eb9a196ad1b7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 03:44:13 +0000 Subject: [PATCH 35/97] docs(coding-agent): changelog for #7587 memory:// hindsight corrective error --- packages/coding-agent/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..734084a85 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `read memory://` returning a confusing "Unknown memory namespace" error under `memory.backend=hindsight` (Hindsight stores memories server-side and has no `memory://` addressing); the handler now returns a corrective pointer to `recall`/`reflect` so a stray read — steered by the shared `recall` tool description — self-corrects in one turn ([#7587](https://github.com/can1357/oh-my-pi/issues/7587)). + ## [17.2.7] - 2026-08-03 ### Changed From 9be8d797fc9baf0d7de9a7863c04a7fd8690c130 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 04:13:35 +0000 Subject: [PATCH 36/97] fix(cursor): corrected inline read range metadata Detected path-embedded OMP line selectors when reporting Cursor read results and stopped treating ranged payload lengths as whole-file totals. Exposed exact source line counts from EOF-reaching read results and covered both the wire response and read metadata contracts. Fixes #7590 --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/providers/cursor-pi-args.ts | 30 +++++++++++++++++++ packages/ai/src/providers/cursor.ts | 25 ++++++++++------ .../ai/src/providers/cursor/exec-modern.ts | 1 + packages/ai/test/cursor-exec-modern.test.ts | 25 ++++++++++++++++ packages/coding-agent/CHANGELOG.md | 4 +++ packages/coding-agent/src/tools/read.ts | 5 ++++ .../test/tools/read-raw-range.test.ts | 6 ++++ 8 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3f4da9b40..2da5bc87f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor reads with inline OMP range selectors reporting the returned slice length as the source file's `totalLines`, which made sequential reads of an unchanged file appear inconsistent ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/ai/src/providers/cursor-pi-args.ts b/packages/ai/src/providers/cursor-pi-args.ts index d7b759f5a..c88275b8f 100644 --- a/packages/ai/src/providers/cursor-pi-args.ts +++ b/packages/ai/src/providers/cursor-pi-args.ts @@ -47,6 +47,36 @@ export function piReadPath(readPath: string, offset?: number, limit?: number): s return count === undefined ? `${readPath}:raw:${start}-` : `${readPath}:raw:${start}+${count}`; } +const READ_RANGE_CHUNK_RE = /^L?(\d+)(?:(\.\.|[-+])L?(\d+)?)?$/i; + +function isReadRangeList(value: string): boolean { + return value.split(",").every(chunk => { + const match = READ_RANGE_CHUNK_RE.exec(chunk); + if (!match) return false; + const start = Number.parseInt(match[1]!, 10); + if (start < 1) return false; + const separator = match[2]; + if (!separator) return true; + const end = match[3] ? Number.parseInt(match[3], 10) : undefined; + if (separator === "+") return end !== undefined && end >= 1; + return end === undefined || end >= start; + }); +} + +/** + * Whether a read path ends in an OMP line selector, including compound `raw` + * forms. Cursor uses this only to describe the operation already executed by + * the coding-agent read tool; the selector remains embedded in the path. + */ +export function piReadPathHasRange(readPath: string): boolean { + const chunks = readPath.split(":"); + const last = chunks.at(-1); + if (last && isReadRangeList(last)) return true; + if (last?.toLowerCase() !== "raw") return false; + const preceding = chunks.at(-2); + return preceding !== undefined && isReadRangeList(preceding); +} + /** * The same range as {@link piReadPath}, rendered for a transcript block rather * than for execution. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f1c466041..d276a27b0 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -216,6 +216,7 @@ import { piLimit, piLsPath, piReadDisplayPath, + piReadPathHasRange, piTimeout, } from "./cursor/exec-modern"; @@ -1322,7 +1323,7 @@ async function handleExecServerMessage( buildReadResultFromToolResult( args.path, toolResult, - args.offset !== undefined || args.limit !== undefined, + args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path), ), reason => buildReadRejectedResult(args.path, reason), error => buildReadErrorResult(args.path, error), @@ -2439,15 +2440,21 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo /** * The file's own line count, when the tool recorded one. * - * `details.meta.truncation.totalLines` is the whole file; the flat - * `details.truncation.totalLines` counts from the window's start line and is - * deliberately not consulted here. Absent for a read that returned the file - * whole, where the payload IS the file and counting it is exact. + * Read results expose the source-wide count directly when known. Older tool + * results carry it at `details.meta.truncation.totalLines`; the flat + * `details.truncation.totalLines` counts from a window's start and is + * deliberately not consulted here. */ function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined { - if (!toolResult.details || typeof toolResult.details !== "object") return undefined; - const meta = (toolResult.details as { meta?: { truncation?: { totalLines?: unknown } } }).meta; - const totalLines = meta?.truncation?.totalLines; + const details = toolResult.details; + if (!details || typeof details !== "object") return undefined; + const direct = "totalLines" in details ? details.totalLines : undefined; + if (typeof direct === "number" && Number.isFinite(direct)) return direct; + const meta = "meta" in details ? details.meta : undefined; + if (!meta || typeof meta !== "object") return undefined; + const truncation = "truncation" in meta ? meta.truncation : undefined; + if (!truncation || typeof truncation !== "object") return undefined; + const totalLines = "totalLines" in truncation ? truncation.totalLines : undefined; return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined; } @@ -2467,7 +2474,7 @@ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessa // whole file. Under a composed window it is the window's, and answering a // 20-line page of a 100-line file with `total_lines: 20` tells a paginating // server it has reached the end. - const totalLines = readTotalLinesFromDetails(toolResult) ?? (text ? text.split("\n").length : 0); + const totalLines = readTotalLinesFromDetails(toolResult) ?? (rangeApplied ? 0 : text ? text.split("\n").length : 0); return create(ReadResultSchema, { result: { case: "success", diff --git a/packages/ai/src/providers/cursor/exec-modern.ts b/packages/ai/src/providers/cursor/exec-modern.ts index ebf1bccdf..621f00a6e 100644 --- a/packages/ai/src/providers/cursor/exec-modern.ts +++ b/packages/ai/src/providers/cursor/exec-modern.ts @@ -81,6 +81,7 @@ export { piLsPath, piReadDisplayPath, piReadPath, + piReadPathHasRange, piTimeout, } from "../cursor-pi-args"; diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 45947f29e..1a796958e 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -1680,6 +1680,31 @@ describe("Cursor legacy read frame: range reporting", () => { if (wholeAnswer.value.result.case !== "success") throw new Error(`got ${wholeAnswer.value.result.case}`); expect(wholeAnswer.value.result.value.rangeApplied).toBe(false); }); + it("treats a path-embedded selector as ranged without reporting the slice as the file total", async () => { + const slice = Array.from({ length: 55 }, (_, index) => `line ${index + 301}`).join("\n"); + const { frames } = await dispatchExec( + buildExecMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { + path: "/repo/plan.md:raw:301-", + toolCallId: "c-inline", + }), + }), + { + execHandlers: { + async read() { + return toolResult(slice, { details: { fileSize: 21_015 } }); + }, + }, + }, + ); + const answer = soleResult(frames); + if (answer.case !== "readResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`); + expect(answer.value.result.value.totalLines).toBe(0); + expect(answer.value.result.value.rangeApplied).toBe(true); + expect(answer.value.result.value.fileSize).toBe(21_015n); + }); it("carries the composed selector into the synthesized call", async () => { // A bare path beside a ranged result makes the slice look like the whole diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..195fa975f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index c2d98dbff..454aa828d 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -737,6 +737,8 @@ export interface ReadToolDetails { meta?: OutputMeta; /** Full on-disk byte size recorded before applying a file range. */ fileSize?: number; + /** Full source line count when the read reached EOF and the count is exact. */ + totalLines?: number; /** Raw text + start line for user-visible TUI rendering, set when content is text-like. * Mirrors the same lines the model receives but without hashline/line-number prefixes, * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */ @@ -1397,6 +1399,7 @@ export class ReadTool implements AgentTool { const details = options.details ?? {}; const allLines = text.split("\n"); const totalLines = allLines.length; + details.totalLines = totalLines; // User-requested 0-indexed range start. Lines BEFORE this are leading // context (added below if offset is explicit). const requestedStart = offset ? Math.max(0, offset - 1) : 0; @@ -1592,6 +1595,7 @@ export class ReadTool implements AgentTool { const details = options.details ?? {}; const allLines = text.split("\n"); const totalLines = allLines.length; + details.totalLines = totalLines; const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; const hashContext = @@ -2908,6 +2912,7 @@ export class ReadTool implements AgentTool { details = {}; sourcePath = absolutePath; } + if (reachedEof) details.totalLines = totalFileLines; if (hashContext?.tag) { recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText); diff --git a/packages/coding-agent/test/tools/read-raw-range.test.ts b/packages/coding-agent/test/tools/read-raw-range.test.ts index 75d5c3cc9..10f4b292b 100644 --- a/packages/coding-agent/test/tools/read-raw-range.test.ts +++ b/packages/coding-agent/test/tools/read-raw-range.test.ts @@ -59,6 +59,12 @@ describe("read tool raw range exactness", () => { expect(output.trimEnd()).toBe("L01\nL02"); }); + it("records the source line count for an open-ended range that reaches EOF", async () => { + const result = await tool.execute("call-raw-tail", { path: `${filePath}:raw:31-` }); + + expect(result.details?.totalLines).toBe(60); + }); + it("keeps context padding for numbered range reads", async () => { // Numbered mode intentionally pads (leading anchor buffer + trailing // disambiguation lines) — line numbers make the padding self-describing. From 262ce960fcda6f41b0aaa0277974a431b81ca23a Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 06:07:42 +0000 Subject: [PATCH 37/97] fix(coding-agent): split semicolon path lists that trip ENAMETOOLONG The grep/glob multipath detector probed the raw joined string with lstat and only split when the probe reported "missing" (ENOENT/ENOTDIR). ENAMETOOLONG was classified as "unknown", which suppressed the split, so a semicolon-delimited path list long enough to exceed NAME_MAX or PATH_MAX collapsed to one literal path and failed with "Path not found: " even though every entry existed. Classify ENAMETOOLONG as "missing" in probeLiteralPathExists and delimitedPathPartResolves (a too-long string can never name a real single entry), and broaden glob's stat catch so the raw errno never reaches the caller. Fixes #7597 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/tools/glob.ts | 6 ++++-- packages/coding-agent/src/tools/path-utils.ts | 16 +++++++++++++--- .../test/tools/glob-validate-paths.test.ts | 18 ++++++++++++++++++ .../tools/path-literal-colon-selector.test.ts | 7 +++++++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..6068c4e07 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `grep`/`glob` silently collapsing a semicolon-delimited `path` list to one literal path when the joined string was too long for the OS to name (`ENAMETOOLONG`) — a list of bare filenames past `NAME_MAX` or absolute paths past `PATH_MAX` failed with `Path not found: ` even though every entry existed. The multipath probe now treats `ENAMETOOLONG` as a definitively non-existent single path so the split proceeds, and `glob` surfaces a clean `Path not found` instead of leaking the raw errno ([#7597](https://github.com/can1357/oh-my-pi/issues/7597)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/tools/glob.ts b/packages/coding-agent/src/tools/glob.ts index 882205191..7a8f797ad 100644 --- a/packages/coding-agent/src/tools/glob.ts +++ b/packages/coding-agent/src/tools/glob.ts @@ -6,7 +6,7 @@ import type { ToolExample } from "@oh-my-pi/pi-ai"; import * as natives from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; +import { formatGroupedPaths, hasFsCode, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol"; @@ -415,7 +415,9 @@ export class GlobTool implements AgentTool { try { stat = await fs.promises.stat(target.searchPath); } catch (err) { - if (isEnoent(err)) { + // ENAMETOOLONG can never name a real target; surface a clean + // "Path not found" instead of leaking the raw errno (issue #7597). + if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) { if (isSingle) throw new ToolError(`Path not found: ${scopePath}`); return []; } diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index aea44a068..a69bfb2e2 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import * as url from "node:url"; import { glob } from "@oh-my-pi/pi-natives"; -import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils"; +import { hasFsCode, isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils"; import type { Skill } from "../extensibility/skills"; import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls"; import { ToolAbortError, ToolError } from "./tool-errors"; @@ -337,6 +337,13 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string } * plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a * dangling symlink is still detected as present; ambiguous errors resolve to * `"unknown"` so callers keep the raw path instead of guessing. + * + * `ENAMETOOLONG` resolves to `"missing"` rather than `"unknown"`: a path whose + * component or whole length exceeds the OS limit can never name a real single + * entry, so it is strictly stronger evidence of non-existence than `ENOENT`. + * Without this, a semicolon-joined `path` list long enough to trip the limit + * (bare filenames past `NAME_MAX`, or a total past `PATH_MAX`) was read as one + * literal path and the delimited split was suppressed (issue #7597). */ export async function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown"> { const resolved = resolveReadPath(filePath, cwd); @@ -344,7 +351,7 @@ export async function probeLiteralPathExists(filePath: string, cwd: string): Pro await fs.promises.lstat(resolved); return "exists"; } catch (err) { - if (isEnoent(err) || isEnotdir(err)) return "missing"; + if (isEnoent(err) || isEnotdir(err) || hasFsCode(err, "ENAMETOOLONG")) return "missing"; return "unknown"; } } @@ -762,7 +769,10 @@ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: P await fs.promises.stat(absoluteBasePath); return true; } catch (err) { - if (isEnoent(err)) return false; + // ENOENT and ENAMETOOLONG both mean this string cannot name an existing + // path, so the whole entry does not resolve and the delimited split may + // proceed (issue #7597). Other errors (EACCES, transient I/O) stay fatal. + if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) return false; throw err; } } diff --git a/packages/coding-agent/test/tools/glob-validate-paths.test.ts b/packages/coding-agent/test/tools/glob-validate-paths.test.ts index 91187f689..567eec7ac 100644 --- a/packages/coding-agent/test/tools/glob-validate-paths.test.ts +++ b/packages/coding-agent/test/tools/glob-validate-paths.test.ts @@ -106,6 +106,24 @@ describe("delimited path expansion", () => { ).toEqual(["apps/**/*.txt", "packages/**/*.txt"]); }); + it("splits a semicolon list whose joined string exceeds NAME_MAX (issue #7597)", async () => { + // Bare filenames in one directory form a single slash-free run once joined, + // so ~12 short entries already push the run past NAME_MAX (255). lstat on + // the joined string then throws ENAMETOOLONG, which used to be read as an + // inconclusive probe and suppress the split, collapsing the whole list to + // one non-existent literal path. + const names: string[] = []; + for (let i = 0; i < 20; i++) { + const name = `enametoolong-probe-${String(i).padStart(2, "0")}.txt`; + await Bun.write(path.join(tempDir, name), "needle\n"); + names.push(name); + } + const joined = names.join("; "); + expect(joined.length).toBeGreaterThan(255); + expect(await splitDelimitedPathEntry(joined, tempDir)).toEqual(names); + expect(await expandDelimitedPathEntries([joined], tempDir)).toEqual(names); + }); + it("normalizes Windows path separators before parsing find globs", async () => { expect(parseFindPattern("apps\\**\\*.txt")).toEqual({ basePath: "apps", diff --git a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts index e79abfbde..6e82c64fd 100644 --- a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts +++ b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts @@ -121,6 +121,13 @@ describe("literal colon filename resolution (issue #4618)", () => { await fs.symlink(path.join(tmpDir, "nowhere"), literal); expect(await probeLiteralPathExists(literal, tmpDir)).toBe("exists"); }); + + it('returns "missing" for an ENAMETOOLONG path (issue #7597)', async () => { + // A single component past NAME_MAX can never name a real entry, so the + // probe must report "missing" (not "unknown") to let delimited splits run. + const overlong = path.join(tmpDir, "x".repeat(300)); + expect(await probeLiteralPathExists(overlong, tmpDir)).toBe("missing"); + }); }); describe("read tool", () => { From 62cb7a0e629308c1061eb2c3cd9dab37a5e87086 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 06:37:29 +0000 Subject: [PATCH 38/97] fix(browser): honored explicit Chromium executable override Selected PUPPETEER_EXECUTABLE_PATH before probing system browser installations so compatible headless-shell binaries remain usable by the shared daemon. Added an isolated Windows candidate-selection regression probe. Fixes #7601 --- packages/coding-agent/CHANGELOG.md | 4 +++ .../coding-agent/src/tools/browser/launch.ts | 10 +++--- .../test/fixtures/browser-executable-probe.ts | 7 ++++ .../test/tools/browser-launch.test.ts | 35 +++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/fixtures/browser-executable-probe.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..8ca023f59 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `PUPPETEER_EXECUTABLE_PATH` being ignored when a system Chrome installation was detected, preventing Windows users from selecting a compatible headless browser for the shared browser daemon ([#7601](https://github.com/can1357/oh-my-pi/issues/7601)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index c3bb19b05..755a559ec 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -121,9 +121,9 @@ async function loadBrowsers(): Promise { } /** - * Resolve the Chromium executable puppeteer will launch, lazily downloading it - * on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS) - * or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under + * Resolve the Chromium executable puppeteer will launch, honoring + * PUPPETEER_EXECUTABLE_PATH before system browser detection and lazily + * downloading Chromium otherwise. The browser is cached under * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform * detection fails (puppeteer default resolution takes over). Exported so * real-browser tests can probe launchability and skip on hosts missing @@ -131,10 +131,10 @@ async function loadBrowsers(): Promise { */ let chromiumExecutablePromise: Promise | undefined; export async function ensureChromiumExecutable(): Promise { - const sysChrome = resolveSystemChromium(); - if (sysChrome) return sysChrome; const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; if (envPath) return envPath; + const sysChrome = resolveSystemChromium(); + if (sysChrome) return sysChrome; if (chromiumExecutablePromise) return chromiumExecutablePromise; chromiumExecutablePromise = (async () => { diff --git a/packages/coding-agent/test/fixtures/browser-executable-probe.ts b/packages/coding-agent/test/fixtures/browser-executable-probe.ts new file mode 100644 index 000000000..d0f5641e7 --- /dev/null +++ b/packages/coding-agent/test/fixtures/browser-executable-probe.ts @@ -0,0 +1,7 @@ +import { ensureChromiumExecutable } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; + +const platform = process.env.OMP_BROWSER_PROBE_PLATFORM; +if (platform) Object.defineProperty(process, "platform", { value: platform }); + +const executable = await ensureChromiumExecutable(); +process.stdout.write(executable ?? ""); diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index 1d677507f..66d716378 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; import { stealthIgnoreDefaultArgsForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/launch"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const EXECUTABLE_PROBE = path.resolve(import.meta.dir, "../fixtures/browser-executable-probe.ts"); const AUTOMATION_FLAG = "--enable-automation"; @@ -33,3 +37,34 @@ describe("browser launch stealth defaults", () => { } }); }); + +describe("browser executable selection", () => { + it("honors PUPPETEER_EXECUTABLE_PATH before a detected Windows system Chrome", async () => { + const tempDir = TempDir.createSync("@browser-executable-"); + try { + const override = path.join(tempDir.path(), "chrome-headless-shell.exe"); + const systemChrome = path.join(tempDir.path(), "Google\\Chrome\\Application\\chrome.exe"); + await Bun.write(override, "override"); + await Bun.write(systemChrome, "system"); + + const result = Bun.spawnSync([process.execPath, EXECUTABLE_PROBE], { + env: { + ...process.env, + OMP_BROWSER_PROBE_PLATFORM: "win32", + ProgramFiles: tempDir.path(), + "ProgramFiles(x86)": path.join(tempDir.path(), "missing-x86"), + LOCALAPPDATA: path.join(tempDir.path(), "missing-local"), + PUPPETEER_EXECUTABLE_PATH: override, + }, + stdout: "pipe", + stderr: "pipe", + }); + const stderr = new TextDecoder().decode(result.stderr); + + expect(result.exitCode, stderr).toBe(0); + expect(new TextDecoder().decode(result.stdout)).toBe(override); + } finally { + await tempDir.remove(); + } + }); +}); From 2cfaa7adbc980b31087152e1b28d1a952e8e934e Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 07:29:17 +0000 Subject: [PATCH 39/97] fix(coding-agent): restored legacy convertToPng export - Restored the package-root image conversion contract through the legacy compatibility shim. - Covered PNG passthrough, image conversion, and decoder failure behavior. Fixes #7610 --- packages/coding-agent/CHANGELOG.md | 4 +++ .../legacy-pi-coding-agent-shim.ts | 23 ++++++++++++++++ .../legacy-pi-image-convert.test.ts | 27 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..06cfdeae6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed legacy Pi extension validation rejecting plugins such as `remote-pi` that import the package-root `convertToPng` image helper. ([#7610](https://github.com/can1357/oh-my-pi/issues/7610)) + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 064d79dc4..30f483184 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -54,6 +54,7 @@ import { ReadTool } from "../tools/read"; import { formatBytes } from "../tools/render-utils"; import { WriteTool } from "../tools/write"; import { EventBus } from "../utils/event-bus"; +import { convertImageToPng } from "../utils/image-loading"; import { discoverExtensionPaths, loadExtensionFromFactory, loadExtensions } from "./extensions"; import { ExtensionRuntime } from "./extensions/loader"; import type { ExtensionFactory, ToolDefinition } from "./extensions/types"; @@ -384,6 +385,28 @@ async function executeLegacyBashOperations( } } +/** + * Convert an image attachment to PNG using the legacy package-root contract. + * + * Invalid or unsupported image data returns `null`, matching Pi's historical + * helper instead of surfacing Bun's decoder error to extensions. + */ +export async function convertToPng( + base64Data: string, + mimeType: string, +): Promise<{ data: string; mimeType: string } | null> { + if (mimeType === "image/png") { + return { data: base64Data, mimeType }; + } + + try { + const converted = await convertImageToPng({ type: "image", data: base64Data, mimeType }); + return { data: converted.data, mimeType: converted.mimeType }; + } catch { + return null; + } +} + /** Format the active shortcut for legacy extensions that render keybinding hints. */ export function keyText(action: Keybinding): string { return formatKeyHints(getKeybindings().getKeys(action)); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts new file mode 100644 index 000000000..010569014 --- /dev/null +++ b/packages/coding-agent/test/extensibility/legacy-pi-image-convert.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { convertToPng } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-pi-coding-agent-shim"; + +const RED_1X1_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +describe("legacy shim image conversion", () => { + it("preserves PNG attachments without re-encoding", async () => { + await expect(convertToPng(RED_1X1_PNG_BASE64, "image/png")).resolves.toEqual({ + data: RED_1X1_PNG_BASE64, + mimeType: "image/png", + }); + }); + + it("converts decodable image attachments to PNG", async () => { + const jpeg = await new Bun.Image(Buffer.from(RED_1X1_PNG_BASE64, "base64")).jpeg({ quality: 90 }).toBase64(); + const converted = await convertToPng(jpeg, "image/jpeg"); + + expect(converted?.mimeType).toBe("image/png"); + expect(Buffer.from(converted?.data ?? "", "base64").subarray(0, PNG_SIGNATURE.byteLength)).toEqual(PNG_SIGNATURE); + }); + + it("returns null when image decoding fails", async () => { + await expect(convertToPng("not-base64-image-data", "image/jpeg")).resolves.toBeNull(); + }); +}); From fe84294538441c4aa135ffdd1e9cbaa9c3210eb5 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 10:56:57 +0000 Subject: [PATCH 40/97] fix(ai): respected explicit Codex usage allowance Treated an explicit allowed=true and limit_reached=false verdict as authoritative when Codex rounds usage to 100 percent. AuthStorage now honors normalized non-exhausted statuses before numeric fallbacks, keeping a usable Team credential ahead of rejected siblings. Fixes #7617 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/auth-storage.ts | 2 +- packages/ai/src/usage/openai-codex.ts | 49 ++++++++++++------- .../test/auth-storage-codex-selection.test.ts | 31 ++++++++++++ packages/ai/test/openai-codex-usage.test.ts | 18 +++++++ 5 files changed, 84 insertions(+), 20 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3f4da9b40..e67f5f068 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Codex usage telemetry blocking explicitly allowed ChatGPT Team credentials when a weekly `used_percent` rounded to 100, which could route multi-account sessions to an actually exhausted sibling instead ([#7617](https://github.com/can1357/oh-my-pi/issues/7617)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e1c76bda2..4d6008793 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3708,7 +3708,7 @@ export class AuthStorage { } #isUsageLimitExhausted(limit: UsageLimit): boolean { - if (limit.status === "exhausted") return true; + if (limit.status !== undefined && limit.status !== "unknown") return limit.status === "exhausted"; const amount = limit.amount; if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true; if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true; diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 57b080199..48f2248ca 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -263,11 +263,10 @@ function buildUsageAmount(window: ParsedUsageWindow): UsageAmount { }; } -function buildUsageStatus(usedFraction?: number, limitReached?: boolean): UsageLimit["status"] { - if (limitReached) return "exhausted"; - if (usedFraction === undefined) return "unknown"; - if (usedFraction >= 1) return "exhausted"; - if (usedFraction >= 0.9) return "warning"; +function buildUsageStatus(args: { usedFraction?: number; explicitlyAllowed: boolean }): UsageLimit["status"] { + if (args.usedFraction === undefined) return "unknown"; + if (args.usedFraction >= 1) return args.explicitlyAllowed ? "warning" : "exhausted"; + if (args.usedFraction >= 0.9) return "warning"; return "ok"; } @@ -276,6 +275,8 @@ function buildUsageLimit(args: { window: ParsedUsageWindow; accountId?: string; planType?: string; + allowed?: boolean; + limitReached?: boolean; nowMs: number; }): UsageLimit { const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); @@ -290,16 +291,14 @@ function buildUsageLimit(args: { }, window: usageWindow, amount, - // Each chat window's status reflects ONLY its own usage. The account-level - // `rate_limit.limit_reached` flag is intentionally not applied here: Codex - // returns a single shared flag for the whole account, so threading it into - // both the primary (5h) and secondary (weekly) windows marked a window with - // real headroom `exhausted` purely because a different window (or a separate - // metered feature) was at its limit, which over-blocked sibling accounts - // during credential selection. `usedFraction >= 1` already marks a window - // that is genuinely full; a real enforced limit not reflected in - // `used_percent` is caught when the live request returns usage_limit_reached. - status: buildUsageStatus(amount.usedFraction), + // The shared account-level rejection flag cannot identify which window + // is binding, but an explicit positive verdict applies to both windows. + // Preserve 100% as a warning when Codex still allows requests; live + // usage_limit_reached responses remain authoritative for blocking. + status: buildUsageStatus({ + usedFraction: amount.usedFraction, + explicitlyAllowed: args.allowed === true && args.limitReached === false, + }), }; } function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string }): string { @@ -331,6 +330,8 @@ function buildAdditionalUsageLimit(args: { accountId?: string; limitName?: string; meteredFeature?: string; + allowed?: boolean; + limitReached?: boolean; nowMs: number; }): UsageLimit { const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); @@ -348,10 +349,12 @@ function buildAdditionalUsageLimit(args: { }, window: usageWindow, amount, - // The additional meter exposes one account-level flag for both windows. - // Status must follow this window's own usage or a full weekly meter marks - // the shorter window exhausted and schedules a premature retry. - status: buildUsageStatus(amount.usedFraction), + // A positive meter verdict is authoritative even when the advisory + // percentage rounds to 100; negative shared verdicts remain window-local. + status: buildUsageStatus({ + usedFraction: amount.usedFraction, + explicitlyAllowed: args.allowed === true && args.limitReached === false, + }), }; } @@ -449,6 +452,8 @@ export const openaiCodexUsageProvider: UsageProvider = { window: parsed.primary, accountId, planType, + allowed: parsed.allowed, + limitReached: parsed.limitReached, nowMs, }), ); @@ -460,6 +465,8 @@ export const openaiCodexUsageProvider: UsageProvider = { window: parsed.secondary, accountId, planType, + allowed: parsed.allowed, + limitReached: parsed.limitReached, nowMs, }), ); @@ -478,6 +485,8 @@ export const openaiCodexUsageProvider: UsageProvider = { accountId, limitName: extra.limitName, meteredFeature: extra.meteredFeature, + allowed: extra.allowed, + limitReached: extra.limitReached, nowMs, }), ); @@ -492,6 +501,8 @@ export const openaiCodexUsageProvider: UsageProvider = { accountId, limitName: extra.limitName, meteredFeature: extra.meteredFeature, + allowed: extra.allowed, + limitReached: extra.limitReached, nowMs, }), ); diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 365e7da0b..f1e2d2818 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -418,6 +418,37 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-healthy"); }); + test("selects an explicitly allowed 100% Team account over a rejected exhausted sibling", async () => { + if (!authStorage) throw new Error("test setup failed"); + + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-exhausted", "exhausted@example.com") }, + { type: "oauth", ...createCredential("acct-team", "team@example.com") }, + ]); + + usageByAccount.set( + "acct-exhausted", + createCodexUsageReport({ + accountId: "acct-exhausted", + primary: { usedFraction: 1, resetInMs: 3 * 24 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 3 * 24 * HOUR_MS }, + metadata: { allowed: false, limitReached: true, planType: "prolite" }, + }), + ); + const teamReport = createCodexUsageReport({ + accountId: "acct-team", + primary: { usedFraction: 0.2, resetInMs: HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * HOUR_MS }, + metadata: { allowed: true, limitReached: false, planType: "team" }, + }); + const teamSecondary = teamReport.limits.find(limit => limit.id === "openai-codex:secondary"); + if (!teamSecondary) throw new Error("expected Team weekly usage limit"); + teamSecondary.status = "warning"; + usageByAccount.set("acct-team", teamReport); + + expect(await authStorage.getApiKey("openai-codex", "allowed-team-at-100-percent")).toBe("api-acct-team"); + }); + test("temporarily blocks only the exhausted Codex OAuth credential after a quota 429", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 56a83e7c4..cf3dddc3a 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -67,6 +67,24 @@ describe("openai-codex usage parser", () => { expect(main?.[0].amount.usedFraction).toBeCloseTo(0.04, 5); }); + it("keeps an explicitly allowed Team window usable at 100% reported usage", async () => { + const payload = makePayload(); + payload.plan_type = "team"; + payload.rate_limit.secondary_window.used_percent = 100; + const report = await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fakeFetch(payload) }, + ); + + const secondary = report?.limits.find(limit => limit.id === "openai-codex:secondary"); + expect(secondary?.amount.usedFraction).toBe(1); + expect(secondary?.status).toBe("warning"); + expect(report?.metadata).toMatchObject({ planType: "team", allowed: true, limitReached: false }); + }); + it("surfaces additional_rate_limits as spark UsageLimit entries the widget can detect", async () => { const report = await openaiCodexUsageProvider.fetchUsage( { From b8779dae63495dd67f30f176b63a07d0e7fc6532 Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Tue, 4 Aug 2026 08:35:01 -0400 Subject: [PATCH 41/97] fix(coding-agent/eval): remove per-call model override from agent() (#6438) Completes the maintainer's removal of per-call model selection from subagent spawns (9f8aa87dbf removed it from the task tool and the model-facing agent() docs/prompt, but the eval agent() runtime and all four preludes still accepted and forwarded a per-call model). Subagents now always resolve through the selected agent's frontmatter model and settings, so an explicit model: "default" can no longer silently route children onto the parent session model. - agent-bridge: drops "model?" from agentArgsSchema and the request forward; adds "+": "delete" so a legacy model argument is stripped (same contract as the task wire schemas). - JS/Python/Ruby/Julia preludes: remove the model parameter from agent(); completion()'s tier selector is unchanged. - docs (tools/eval.md, python-repl.md) updated to the removed surface. Refs #6438 --- docs/python-repl.md | 2 +- docs/tools/eval.md | 4 ++-- packages/coding-agent/CHANGELOG.md | 4 ++++ .../coding-agent/src/eval/agent-bridge.ts | 4 +--- packages/coding-agent/src/eval/jl/prelude.jl | 5 +--- .../src/eval/js/shared/prelude.txt | 4 ++-- packages/coding-agent/src/eval/py/prelude.py | 3 --- packages/coding-agent/src/eval/rb/prelude.rb | 3 +-- .../test/eval/agent-bridge-policy.test.ts | 23 +++++++++++++++---- .../test/eval/prelude-agent.test.ts | 3 +-- 10 files changed, 31 insertions(+), 24 deletions(-) diff --git a/docs/python-repl.md b/docs/python-repl.md index e5b940739..2fac77bbb 100644 --- a/docs/python-repl.md +++ b/docs/python-repl.md @@ -146,7 +146,7 @@ The backend settings `eval.py` / `eval.js` default to `true`; `eval.rb` / `eval. The tool's session-scoped schema lists only enabled runtimes. If Python preflight fails while another runtime is enabled, `eval` remains available for that runtime and a `py` call reports a Python-backend availability error with enabled alternatives. -Python prelude helpers include `agent(prompt, *, agent="task", model=None, label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False)`. It synchronously calls the host bridge and returns final text, or parsed data when `schema` is supplied. `schema_mode` selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With `handle=True`, it returns a DAG node dict (`{"text", "output", "handle", "id", "agent"}`) whose handle is the recoverable `agent://` URI; parsed output is also stored under `"data"` when available. +Python prelude helpers include `agent(prompt, *, agent="task", label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False)`. It synchronously calls the host bridge and returns final text, or parsed data when `schema` is supplied. `schema_mode` selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With `handle=True`, it returns a DAG node dict (`{"text", "output", "handle", "id", "agent"}`) whose handle is the recoverable `agent://` URI; parsed output is also stored under `"data"` when available. ## Execution flow and cancellation/timeout diff --git a/docs/tools/eval.md b/docs/tools/eval.md index 26347a1e5..5c4760a1f 100644 --- a/docs/tools/eval.md +++ b/docs/tools/eval.md @@ -149,9 +149,9 @@ A stateless, tool-free one-shot model call: Runs one subagent through `runStructuredSubagent(...)`: -- JS supports the preferred `await agent(prompt, { agent?, model?, label?, schema?, schemaMode?, isolated?, apply?, merge?, handle? })`; legacy positional slots are still implemented. +- JS supports the preferred `await agent(prompt, { agent?, label?, schema?, schemaMode?, isolated?, apply?, merge?, handle? })`; legacy positional slots are still implemented. - Python/Ruby/Julia use keyword arguments (`schema_mode` outside JS). -- `agent` defaults from the current spawn policy. `model` may pin a selector/fallback chain. `schema` overrides agent/session schemas; `schemaMode`/`schema_mode` chooses `permissive` or `strict`. +- `agent` defaults from the current spawn policy; the selected agent's frontmatter model and settings always apply (there is no per-call model override — `model` is not accepted). `schema` overrides agent/session schemas; `schemaMode`/`schema_mode` chooses `permissive` or `strict`. - `isolated` requests isolation. `apply` controls whether captured changes are integrated; `merge=false` selects patch mode while the normal setting controls branch mode. - `handle=true` returns `{ text, output, handle, id, agent }`, optional parsed `data`, and isolation metadata instead of only output/data. - Eval subagents are one-shot (`keepAlive=false`), are unregistered/disposed after completion, and **do not share the caller's eval executor** (`shareEvalSession=false`). Their code mutations therefore do not appear in the caller's retained VM/kernel. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..172c8a7ff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index 07483da39..296e7726b 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -23,7 +23,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__"; const agentArgsSchema = type({ prompt: "string>0", "agent?": "string>0", - "model?": "string>0|string>0[]", "label?": "string", "schema?": "unknown", "schemaMode?": "'permissive' | 'strict'", @@ -31,12 +30,12 @@ const agentArgsSchema = type({ "apply?": "boolean", "merge?": "boolean", "handle?": "boolean", + "+": "delete", }); interface EvalAgentArgs { prompt: string; agent?: string; - model?: string | string[]; label?: string; schema?: unknown; schemaMode?: StructuredSubagentSchemaMode; @@ -148,7 +147,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption invocationKind: "eval", assignment: parsed.prompt, ...(parsed.agent !== undefined ? { agent: parsed.agent } : {}), - ...(parsed.model !== undefined ? { model: parsed.model } : {}), ...(Object.hasOwn(parsed, "schema") ? { outputSchema: parsed.schema } : {}), ...(parsed.schemaMode !== undefined ? { schemaMode: parsed.schemaMode } : {}), ...(parsed.label !== undefined ? { identity: { label: parsed.label } } : {}), diff --git a/packages/coding-agent/src/eval/jl/prelude.jl b/packages/coding-agent/src/eval/jl/prelude.jl index 13dcfd9c7..c90481222 100644 --- a/packages/coding-agent/src/eval/jl/prelude.jl +++ b/packages/coding-agent/src/eval/jl/prelude.jl @@ -519,14 +519,11 @@ function completion(prompt::String; model="default", system=nothing, schema=noth return schema === nothing ? text : Main.json_parse(string(text)) end -function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...) +function agent(prompt::String; agent="task", label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...) args_dict = Dict{String, Any}("prompt" => prompt) if agent !== nothing args_dict["agent"] = agent end - if model !== nothing - args_dict["model"] = model - end if label !== nothing args_dict["label"] = label end diff --git a/packages/coding-agent/src/eval/js/shared/prelude.txt b/packages/coding-agent/src/eval/js/shared/prelude.txt index c29df1c9c..9acdf51a5 100644 --- a/packages/coding-agent/src/eval/js/shared/prelude.txt +++ b/packages/coding-agent/src/eval/js/shared/prelude.txt @@ -104,8 +104,8 @@ if (!globalThis.__omp_js_prelude_loaded__) { "agent", opts, rest, - ["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"], - "{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }", + ["agent", "label", "schema", "isolated", "apply", "merge", "schemaMode"], + "{ agent, label, schema, isolated, apply, merge, schemaMode, handle }", ); const { handle, ...callArgs } = o; const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) }); diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 8d6c8fd51..491eac219 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -488,7 +488,6 @@ if "__omp_prelude_loaded__" not in globals(): prompt, *, agent="task", - model=None, label=None, schema=None, schema_mode=None, @@ -506,8 +505,6 @@ if "__omp_prelude_loaded__" not in globals(): args = {"prompt": prompt} if agent is not None: args["agent"] = agent - if model is not None: - args["model"] = model if label is not None: args["label"] = label if schema is not None: diff --git a/packages/coding-agent/src/eval/rb/prelude.rb b/packages/coding-agent/src/eval/rb/prelude.rb index ddb8991ae..c6ae9c321 100644 --- a/packages/coding-agent/src/eval/rb/prelude.rb +++ b/packages/coding-agent/src/eval/rb/prelude.rb @@ -392,10 +392,9 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded schema.nil? ? text : JSON.parse(text) end - def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false) + def agent(prompt, agent: "task", label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false) args = { "prompt" => prompt } args["agent"] = agent unless agent.nil? - args["model"] = model unless model.nil? args["label"] = label unless label.nil? args["schema"] = schema unless schema.nil? args["schemaMode"] = schema_mode unless schema_mode.nil? diff --git a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts index fb290ced8..95036176c 100644 --- a/packages/coding-agent/test/eval/agent-bridge-policy.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge-policy.test.ts @@ -290,10 +290,7 @@ describe("runEvalAgent", () => { }), }); - await runEvalAgent( - { prompt: " hello ", label: "My Agent", model: "p/override", schema }, - { session, signal: abortController.signal }, - ); + await runEvalAgent({ prompt: " hello ", label: "My Agent", schema }, { session, signal: abortController.signal }); await runEvalAgent({ prompt: "plain" }, { session }); const firstOptions = runSpy.mock.calls[0]?.[0]; @@ -306,10 +303,26 @@ describe("runEvalAgent", () => { expect(firstOptions.outputSchemaOverridesAgent).toBe(true); expect(firstOptions.assignment).toBe("hello"); expect(firstOptions.description).toBe("My Agent"); - expect(firstOptions.modelOverride).toEqual(["p/override"]); + // No per-call override: the agent's own frontmatter model applies. + expect(firstOptions.modelOverride).toEqual(["p/current"]); expect(secondOptions.outputSchema).toBeUndefined(); expect(secondOptions.outputSchemaOverridesAgent).toBeUndefined(); }); + + it("drops a per-call model argument on agent() (removed, issue #6438)", async () => { + mockAgents(); + const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options)); + + // The schema strips unknown keys; a legacy `model` argument is silently + // discarded so resolution is identical to omitting it — the agent's own + // frontmatter model applies (issue #6438). + await runEvalAgent({ prompt: "work", model: "default" }, { session: makeSession() }); + await runEvalAgent({ prompt: "work" }, { session: makeSession() }); + + const withModel = runSpy.mock.calls[0]?.[0]; + const withoutModel = runSpy.mock.calls[1]?.[0]; + expect(withModel?.modelOverride).toEqual(withoutModel?.modelOverride); + }); it("returns host-parsed data for caller, agent, and inherited schemas", async () => { const agentSchema = { type: "object" }; const sessionSchema = { type: "object" }; diff --git a/packages/coding-agent/test/eval/prelude-agent.test.ts b/packages/coding-agent/test/eval/prelude-agent.test.ts index 4aa4019aa..e74aa34f5 100644 --- a/packages/coding-agent/test/eval/prelude-agent.test.ts +++ b/packages/coding-agent/test/eval/prelude-agent.test.ts @@ -66,12 +66,11 @@ describe("eval js agent() handle", () => { ) => Promise; const schema = { type: "object", properties: { ok: { type: "boolean" } } }; - await positionalAgent("scout", "reviewer", "p/model", "Legacy", schema, true, false, true, "strict"); + await positionalAgent("scout", "reviewer", "Legacy", schema, true, false, true, "strict"); expect(seenArgs).toEqual({ prompt: "scout", agent: "reviewer", - model: "p/model", label: "Legacy", schema, isolated: true, From 929ab1a20f64307f8e073436b38a49cdd7b99c9e Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 13:50:09 +0000 Subject: [PATCH 42/97] fix(coding-agent): allowed retries for buffered print output Made text replay safety depend on whether the active output sink has committed streamed text. Kept tool calls, images, and server tools replay-unsafe while covering text and JSON print policies plus a transient socket-close recovery. Fixes #7625 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/modes/print-mode.ts | 3 ++ .../coding-agent/src/session/agent-session.ts | 7 +++ .../coding-agent/src/session/turn-recovery.ts | 16 ++++--- .../test/agent-session-retry-cap.test.ts | 24 +++------- .../test/print-mode-working-indicator.test.ts | 10 +++++ .../test/turn-recovery-replay-unsafe.test.ts | 44 +++++++++++++++---- 7 files changed, 74 insertions(+), 34 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..e30e5658e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed text print mode treating buffered partial responses as replay-unsafe, allowing transient mid-stream connection failures to retry without exposing duplicated output ([#7625](https://github.com/can1357/oh-my-pi/issues/7625)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 3da757e7d..3217d14e6 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -186,12 +186,14 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Send initial message with attachments if (initialMessage !== undefined) { writeTextWorkingIndicator(); + if (mode === "text") session.setTextOutputCommitted(false); await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages })); } // Send remaining messages for (const message of messages) { writeTextWorkingIndicator(); + if (mode === "text") session.setTextOutputCommitted(false); await logger.time("print:prompt:next", () => session.prompt(message)); } @@ -249,6 +251,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti } } } + session.setTextOutputCommitted(true); } await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b86ad9bdb..e247468e2 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -485,6 +485,7 @@ export class AgentSession { // Retry state readonly #recovery: TurnRecovery; + #textOutputCommitted = true; #planModeReminderCount = 0; #planModeReminderAwaitingProgress = false; readonly #todo: TodoTracker; @@ -1025,6 +1026,7 @@ export class AgentSession { modelRegistry: this.#modelRegistry, configWarnings: this.configWarnings, model: () => this.model, + textOutputCommitted: () => this.#textOutputCommitted, thinkingLevel: () => this.thinkingLevel, configuredThinkingLevel: () => this.configuredThinkingLevel(), setThinkingLevel: level => this.setThinkingLevel(level), @@ -4184,6 +4186,11 @@ export class AgentSession { return this.agent.state.systemPrompt; } + /** Marks streamed text as committed or buffered for turn-recovery replay decisions. */ + setTextOutputCommitted(committed: boolean): void { + this.#textOutputCommitted = committed; + } + /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this.#recovery.attempt; diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 629138402..ef5b0a0b0 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -101,6 +101,8 @@ export interface TurnRecoveryHost { modelRegistry: ModelRegistry; configWarnings: string[]; model(): Model | undefined; + /** Whether streamed text has already been committed to the active output sink. */ + textOutputCommitted(): boolean; thinkingLevel(): ThinkingLevel | undefined; configuredThinkingLevel(): ConfiguredThinkingLevel | undefined; setThinkingLevel(level: ConfiguredThinkingLevel | undefined): void; @@ -890,8 +892,8 @@ export class TurnRecovery { if (AIError.isContextOverflow(message, contextWindow)) return false; // A classifier refusal/sensitivity stop is the model's decision, not a route - // failure, but only after we confirm no user-visible output has already been - // streamed. Visible text, images, tool calls, or server tools must not be + // failure, but only after we confirm no replay-unsafe output has already + // streamed. Committed text, images, tool calls, or server tools must not be // discarded and replayed. if (this.#hasReplayUnsafeOutput(message)) return false; if (this.isClassifierRefusal(message)) return true; @@ -966,10 +968,10 @@ export class TurnRecovery { * Thinking-only partials are safe to discard and replay: reasoning models * routinely stall after long thinking with no visible output, and duplicated * thinking display is materially lower harm than duplicated final text. - * Whitespace-only text is likewise safe since nothing meaningful reached the - * user. Visible text, generated images, server tools, and retained tool calls - * are NOT safe: each has already rendered or may have side effects, so replaying - * the turn can duplicate user-visible output or work. + * Whitespace-only and buffered text are likewise safe since nothing meaningful + * reached the user. Committed text, generated images, server tools, and retained + * tool calls are NOT safe: each has already rendered or may have side effects, + * so replaying the turn can duplicate user-visible output or work. */ #hasReplayUnsafeOutput(message: AssistantMessage): boolean { return message.content.some( @@ -977,7 +979,7 @@ export class TurnRecovery { block.type === "toolCall" || block.type === "image" || block.type === "anthropicServerTool" || - (block.type === "text" && block.text.trim().length > 0), + (block.type === "text" && this.#host.textOutputCommitted() && block.text.trim().length > 0), ); } diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index fa70f6f87..6b2f9ea65 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -1164,29 +1164,14 @@ describe("AgentSession retry delay cap", () => { if (streamCalls === 1) { const thinking = { type: "thinking" as const, thinking: "partial thought" }; - // No visible text: a committed text block makes the failed turn - // replay-unsafe (turn-recovery #hasReplayUnsafeOutput), which would - // correctly suppress this retry. The delay-cap contract under test - // needs a replay-safe partial turn, so only thinking plus an - // incomplete (never toolcall_end'd) tool call is emitted. - const toolCall: ToolCall = { - type: "toolCall", - id: "tc-incomplete", - name: "bash", - arguments: { command: "bun probe-archive3.ts" }, - }; - partial.content.push(thinking, toolCall); + const text = { type: "text" as const, text: "partial buffered answer" }; + partial.content.push(thinking, text); stream.push({ type: "start", partial }); stream.push({ type: "thinking_start", contentIndex: 0, partial }); stream.push({ type: "thinking_delta", contentIndex: 0, delta: thinking.thinking, partial }); stream.push({ type: "thinking_end", contentIndex: 0, content: thinking.thinking, partial }); - stream.push({ type: "toolcall_start", contentIndex: 1, partial }); - stream.push({ - type: "toolcall_delta", - contentIndex: 1, - delta: JSON.stringify(toolCall.arguments), - partial, - }); + stream.push({ type: "text_start", contentIndex: 1, partial }); + stream.push({ type: "text_delta", contentIndex: 1, delta: text.text, partial }); stream.push({ type: "error", reason: "error", @@ -1243,6 +1228,7 @@ describe("AgentSession retry delay cap", () => { if (event.type === "auto_retry_end") retryEndEvents.push(event); }); + session.setTextOutputCommitted(false); await session.prompt("Trigger partial socket close"); await session.waitForIdle(); diff --git a/packages/coding-agent/test/print-mode-working-indicator.test.ts b/packages/coding-agent/test/print-mode-working-indicator.test.ts index fc86976f4..3db6b93ef 100644 --- a/packages/coding-agent/test/print-mode-working-indicator.test.ts +++ b/packages/coding-agent/test/print-mode-working-indicator.test.ts @@ -36,6 +36,7 @@ interface DelayedSession { promptStarted: Promise; resolvePrompt: () => void; getPlanModeAtPrompt: () => PlanModeState | undefined; + getTextOutputCommitted: () => boolean; getModeChanges: () => Array<{ mode: string; data?: Record }>; getPlanProposalHandler: () => PlanProposalHandler | undefined; getCurrentPlanMode: () => PlanModeState | undefined; @@ -57,6 +58,7 @@ function createDelayedSession( const modeChanges: Array<{ mode: string; data?: Record }> = []; let planProposalHandler: PlanProposalHandler | undefined; let subscriber: ((event: AgentSessionEvent) => void) | undefined; + let textOutputCommitted = true; let abortCalls = 0; const session = { @@ -105,6 +107,9 @@ function createDelayedSession( abort: async () => { abortCalls++; }, + setTextOutputCommitted: (committed: boolean) => { + textOutputCommitted = committed; + }, subscribe: (listener: (event: AgentSessionEvent) => void) => { subscriber = listener; return () => {}; @@ -133,6 +138,7 @@ function createDelayedSession( getPlanModeAtPrompt: () => planModeAtPrompt, getModeChanges: () => modeChanges, getPlanProposalHandler: () => planProposalHandler, + getTextOutputCommitted: () => textOutputCommitted, getCurrentPlanMode: () => planModeState, emit: event => subscriber?.(event), getAbortCalls: () => abortCalls, @@ -226,12 +232,14 @@ describe("print mode working indicator", () => { try { expect(stderrOutput.join("")).toContain("Working"); expect(stdoutOutput.join("")).toBe(""); + expect(delayed.getTextOutputCommitted()).toBe(false); } finally { delayed.resolvePrompt(); await run; } expect(stdoutOutput.join("")).toBe("final answer\n"); + expect(delayed.getTextOutputCommitted()).toBe(true); }); it("does not write the text-mode working indicator in JSON mode while the prompt is pending", async () => { @@ -241,6 +249,7 @@ describe("print mode working indicator", () => { await delayed.promptStarted; try { expect(stderrOutput.join("")).toBe(""); + expect(delayed.getTextOutputCommitted()).toBe(true); } finally { delayed.resolvePrompt(); await run; @@ -351,6 +360,7 @@ describe("print mode working indicator", () => { messages.push(message); return true; }, + setTextOutputCommitted: () => {}, prepareForHeadlessAdvisorDrain: () => {}, waitForAdvisorCatchup: async (timeoutMs: number) => { catchupTimeoutMs = timeoutMs; diff --git a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts index b9494bab4..98f5c1891 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -40,9 +40,12 @@ function makeMessage(content: AssistantMessage["content"], model: Model): Assist function createHost( model: Model, modelRegistry: ModelRegistry, - fallbackChains?: Record, + options: { + fallbackChains?: Record; + textOutputCommitted?: boolean; + } = {}, ): TurnRecoveryHost { - const settings = Settings.isolated(fallbackChains ? { "retry.fallbackChains": fallbackChains } : {}); + const settings = Settings.isolated(options.fallbackChains ? { "retry.fallbackChains": options.fallbackChains } : {}); return { agent: undefined as never, sessionManager: undefined as never, @@ -51,6 +54,7 @@ function createHost( modelRegistry, configWarnings: [], model: () => model, + textOutputCommitted: () => options.textOutputCommitted !== false, thinkingLevel: () => undefined, configuredThinkingLevel: () => undefined, setThinkingLevel: () => {}, @@ -101,12 +105,11 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isRetryableError(message)).toBe(false); }); - it("allows replay-safe hard fallback and excludes visible text with a configured chain", () => { - const recovery = new TurnRecovery( - createHost(model, modelRegistry, { - [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], - }), - ); + it("allows replay-safe hard fallback and excludes committed text with a configured chain", () => { + const fallbackChains = { + [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], + }; + const recovery = new TurnRecovery(createHost(model, modelRegistry, { fallbackChains })); // Thinking-only output is replay-safe: nothing visible reached the user. const message = makeMessage([{ type: "thinking", thinking: "safe reasoning before failing" }], model); const visible = makeMessage([{ type: "text", text: "Already shown" }], model); @@ -114,6 +117,18 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isHardErrorFallbackEligible(message)).toBe(true); }); + it("retries partial text while its buffered output remains uncommitted", () => { + const fallbackChains = { + [`${model.provider}/${model.id}`]: ["openai/gpt-4o-mini"], + }; + const recovery = new TurnRecovery( + createHost(model, modelRegistry, { fallbackChains, textOutputCommitted: false }), + ); + const message = makeMessage([{ type: "text", text: "Buffered partial answer" }], model); + expect(recovery.isRetryableError(message)).toBe(true); + expect(recovery.isHardErrorFallbackEligible(message)).toBe(true); + }); + it("excludes a Fireworks Fast failed turn with partial visible text from Fast→base fallback", () => { const fastModel = getBundledModel("fireworks", "kimi-k2.6-fast"); if (!fastModel) throw new Error("Expected bundled model kimi-k2.6-fast"); @@ -154,6 +169,19 @@ describe("TurnRecovery replay-unsafe output classification", () => { expect(recovery.isHardErrorFallbackEligible(message)).toBe(false); }); + it("keeps side-effecting output replay-unsafe while text is uncommitted", () => { + const recovery = new TurnRecovery(createHost(model, modelRegistry, { textOutputCommitted: false })); + const message = makeMessage( + [ + { type: "text", text: "Buffered partial answer" }, + { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "ls" } }, + ], + model, + ); + expect(recovery.isRetryableError(message)).toBe(false); + expect(recovery.isHardErrorFallbackEligible(message)).toBe(false); + }); + it("keeps an empty-content error retriable (baseline)", () => { const recovery = new TurnRecovery(createHost(model, modelRegistry)); const message = makeMessage([], model); From fe6e9c28ab3cda73caf241c77a9009351b6daa7b Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 13:57:04 +0000 Subject: [PATCH 43/97] test(coding-agent): stub setTextOutputCommitted in print mocks Added the no-op setter to lightweight print-mode session mocks so text-mode runs without a submitted prompt no longer throw at the final commit call. Fixes #7625 --- packages/coding-agent/test/modes/noninteractive-dispose.test.ts | 1 + packages/coding-agent/test/silent-abort-print-mode.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/coding-agent/test/modes/noninteractive-dispose.test.ts b/packages/coding-agent/test/modes/noninteractive-dispose.test.ts index 918ecb201..b6b5b12e1 100644 --- a/packages/coding-agent/test/modes/noninteractive-dispose.test.ts +++ b/packages/coding-agent/test/modes/noninteractive-dispose.test.ts @@ -40,6 +40,7 @@ describe("print-mode error exit disposes the session before exit", () => { state: { messages: [errorMsg] }, getLastAssistantMessage: () => errorMsg, prepareForHeadlessAdvisorDrain: () => {}, + setTextOutputCommitted: () => {}, waitForAdvisorCatchup: async () => { order.push("catchup"); return true; diff --git a/packages/coding-agent/test/silent-abort-print-mode.test.ts b/packages/coding-agent/test/silent-abort-print-mode.test.ts index 32cae2d3f..2240f078b 100644 --- a/packages/coding-agent/test/silent-abort-print-mode.test.ts +++ b/packages/coding-agent/test/silent-abort-print-mode.test.ts @@ -55,6 +55,7 @@ function createMockSession( subscribe: () => () => {}, prompt: async () => {}, prepareForHeadlessAdvisorDrain: () => {}, + setTextOutputCommitted: () => {}, waitForAdvisorCatchup: async () => true, dispose, } as unknown as AgentSession; From 70dd7a318e4e99bdff6843923e9fa3dd9925566d Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 15:26:48 +0000 Subject: [PATCH 44/97] fix(stats): restricted dashboard access Bound the dashboard and reuse probe to IPv4 loopback, removed wildcard CORS, and reported the actual listening hostname. Added regression coverage for non-loopback refusal and absent cross-origin access. Fixes #7633 --- packages/stats/CHANGELOG.md | 4 ++ packages/stats/src/index.ts | 4 +- packages/stats/src/port-conflict.ts | 5 ++- packages/stats/src/server.ts | 28 +++++++------- .../stats/test/server-port-conflict.test.ts | 38 +++++++++++++++---- 5 files changed, 55 insertions(+), 24 deletions(-) diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 74d8616a2..85d43e7ae 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Restricted the stats dashboard to IPv4 loopback and removed wildcard CORS access to its API ([#7633](https://github.com/can1357/oh-my-pi/issues/7633)). + ## [17.2.4] - 2026-08-01 ### Fixed diff --git a/packages/stats/src/index.ts b/packages/stats/src/index.ts index d74c4080b..5894f73fb 100755 --- a/packages/stats/src/index.ts +++ b/packages/stats/src/index.ts @@ -171,8 +171,8 @@ Examples: // Start server const port = parseInt(values.port || "3847", 10); - const { port: actualPort } = await startServer(port); - console.log(`Dashboard available at: http://localhost:${actualPort}`); + const { hostname, port: actualPort } = await startServer(port); + console.log(`Dashboard available at: http://${hostname}:${actualPort}`); console.log("Press Ctrl+C to stop\n"); // Keep process running diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index c49045d64..ec4555c26 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -18,9 +18,12 @@ interface PortHolder { /** Header stamped on every dashboard response so reuse probes can identify us. */ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard"; +/** IPv4 loopback address shared by the dashboard server and reuse probe. */ +export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1"; + async function probeStatsDashboard(port: number): Promise { try { - const response = await fetch(`http://localhost:${port}/api/stats/models`, { + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), }); if (response.status !== 200) { diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 8349a868d..2021b1788 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -21,7 +21,7 @@ import { import { decodeEmbeddedClientArchive } from "./embedded-client"; import embeddedClientArchiveTxt from "./embedded-client.generated.txt"; import { getGainDashboardStats } from "./gain-aggregator"; -import { recoverStatsPort, STATS_DASHBOARD_HEADER } from "./port-conflict"; +import { recoverStatsPort, STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME } from "./port-conflict"; const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt); @@ -303,21 +303,19 @@ async function handleStatic(requestPath: string): Promise { function createDashboardServer(port: number) { const server = Bun.serve({ port, + hostname: STATS_DASHBOARD_HOSTNAME, async fetch(req) { const url = new URL(req.url); const path = url.pathname; - // CORS headers for local development; the identity header lets another - // omp session's reuse probe positively recognize this dashboard. - const corsHeaders: Record = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + // The identity header lets another omp session's reuse probe positively + // recognize this dashboard without allowing cross-origin API reads. + const dashboardHeaders: Record = { [STATS_DASHBOARD_HEADER]: "1", }; if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); + return new Response(null, { headers: dashboardHeaders }); } try { @@ -329,10 +327,10 @@ function createDashboardServer(port: number) { response = await handleStatic(path); } - // Add CORS headers to all responses + // Add the dashboard identity header to all responses. const headers = new Headers(response.headers); - for (const key in corsHeaders) { - headers.set(key, corsHeaders[key]); + for (const key in dashboardHeaders) { + headers.set(key, dashboardHeaders[key]); } return new Response(response.body, { @@ -343,7 +341,7 @@ function createDashboardServer(port: number) { console.error("Server error:", error); return Response.json( { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500, headers: corsHeaders }, + { status: 500, headers: dashboardHeaders }, ); } }, @@ -354,12 +352,13 @@ function createDashboardServer(port: number) { /** * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener. */ -export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> { +export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> { await ensureClientBuild(); try { const server = createDashboardServer(port); return { + hostname: STATS_DASHBOARD_HOSTNAME, port: server.port ?? port, stop: () => server.stop(), }; @@ -368,12 +367,13 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: () const recovery = await recoverStatsPort(port); if (recovery === "reuse") { - return { port, stop: () => {} }; + return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; } try { const server = createDashboardServer(port); return { + hostname: STATS_DASHBOARD_HOSTNAME, port: server.port ?? port, stop: () => server.stop(), }; diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 771953bdd..fc8447dfa 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,22 +1,22 @@ import { afterEach, describe, expect, it } from "bun:test"; import type { Subprocess } from "bun"; -import { STATS_DASHBOARD_HEADER } from "../src/port-conflict"; +import { STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME } from "../src/port-conflict"; import { startServer } from "../src/server"; const holderProcesses: Array> = []; async function startBunHolder(responseExpr: string, options?: { statsOwned?: boolean }) { - // Bind the wildcard address: `startServer` binds the wildcard too, and on - // macOS SO_REUSEADDR lets a wildcard bind coexist with a 127.0.0.1-only - // listener, which would bypass the EADDRINUSE path this suite exercises. + // Bind the same loopback address as `startServer` so macOS cannot let a + // wildcard and address-specific listener coexist on the reserved port. const reservation = Bun.serve({ port: 0, + hostname: STATS_DASHBOARD_HOSTNAME, fetch: () => new Response("reserved"), }); const port = reservation.port; reservation.stop(true); - const source = `Bun.serve({ port: ${port}, fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; + const source = `Bun.serve({ port: ${port}, hostname: "${STATS_DASHBOARD_HOSTNAME}", fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; const args = [process.execPath, "-e", source]; if (options?.statsOwned) args.push("omp-stats"); const child = Bun.spawn(args, { @@ -46,10 +46,34 @@ afterEach(async () => { holderProcesses.length = 0; }); +describe("startServer access", () => { + it("only serves loopback requests without cross-origin access", async () => { + const server = await startServer(0); + + try { + expect(server.hostname).toBe(STATS_DASHBOARD_HOSTNAME); + const response = await fetch(`http://${server.hostname}:${server.port}/api/stats/models`); + expect(response.status).toBe(200); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); + + await expect( + fetch(`http://127.0.0.2:${server.port}/api/stats/models`, { + signal: AbortSignal.timeout(1_000), + }), + ).rejects.toThrow(); + } finally { + server.stop(); + } + }); +}); + describe("startServer port conflicts", () => { it("reuses a live stats dashboard identified by its header", async () => { const existing = Bun.serve({ port: 0, + hostname: STATS_DASHBOARD_HOSTNAME, fetch: request => new URL(request.url).pathname === "/api/stats/models" ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: "1" } }) @@ -62,7 +86,7 @@ describe("startServer port conflicts", () => { server.stop(); // The existing dashboard is untouched: it still answers on the port. - const response = await fetch(`http://127.0.0.1:${existing.port}/api/stats/models`); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${existing.port}/api/stats/models`); expect(response.status).toBe(200); expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1"); await response.body?.cancel(); @@ -76,7 +100,7 @@ describe("startServer port conflicts", () => { await expect(startServer(holder.port)).rejects.toThrow("not identifiable as an omp stats dashboard"); expect(holder.child.exitCode).toBeNull(); - const response = await fetch(`http://127.0.0.1:${holder.port}/api/stats/models`); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${holder.port}/api/stats/models`); expect(await response.json()).toEqual({ app: "spa" }); }); From 951051086db0e4c0077f6526270f76155cc625aa Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 15:34:09 +0000 Subject: [PATCH 45/97] fix(stats): opened dashboard on bound hostname Threaded the loopback hostname returned by startServer through the omp stats CLI and /stats slash command so the browser and logged URL target the actual listener instead of localhost. Fixes #7633 --- packages/coding-agent/src/cli/stats-cli.ts | 6 +++--- .../src/slash-commands/helpers/stats-dashboard.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index 31177ab71..84279da3f 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -136,11 +136,11 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise { } // Start the dashboard server - const { port } = await startServer(cmd.port); - console.log(chalk.green(`Dashboard available at: http://localhost:${port}`)); + const { hostname, port } = await startServer(cmd.port); + const url = `http://${hostname}:${port}`; + console.log(chalk.green(`Dashboard available at: ${url}`)); // Open browser - const url = `http://localhost:${port}`; openPath(url); console.log("Press Ctrl+C to stop\n"); diff --git a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts index 8cb1c944f..a22da28a6 100644 --- a/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts +++ b/packages/coding-agent/src/slash-commands/helpers/stats-dashboard.ts @@ -4,6 +4,7 @@ import * as openUtils from "../../utils/open"; export const DEFAULT_STATS_DASHBOARD_PORT = 3847; interface StatsDashboardServer { + hostname: string; port: number; stop: () => void; } @@ -64,7 +65,7 @@ export async function launchStatsDashboard(args: StatsDashboardArgs): Promise Date: Tue, 4 Aug 2026 15:38:37 +0000 Subject: [PATCH 46/97] test(stats): probed loopback bind over direct tcp Replaced the 127.0.0.2 fetch, which a configured HTTP proxy could intercept, with a direct Bun.connect TCP probe so the loopback-only bind is asserted against the real listener. Fixes #7633 --- .../stats/test/server-port-conflict.test.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index fc8447dfa..5db2e4ca9 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,8 +1,23 @@ import { afterEach, describe, expect, it } from "bun:test"; -import type { Subprocess } from "bun"; +import { connect, type Subprocess } from "bun"; import { STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME } from "../src/port-conflict"; import { startServer } from "../src/server"; +/** + * Directly probe a TCP endpoint, bypassing any configured HTTP proxy so the + * loopback-only bind is asserted against the real listener rather than a proxy + * response. Resolves true when the connection is accepted, false when refused. + */ +async function tcpConnects(hostname: string, port: number): Promise { + try { + const socket = await connect({ hostname, port, socket: { data() {}, open() {}, close() {}, error() {} } }); + socket.end(); + return true; + } catch { + return false; + } +} + const holderProcesses: Array> = []; async function startBunHolder(responseExpr: string, options?: { statsOwned?: boolean }) { @@ -58,11 +73,10 @@ describe("startServer access", () => { expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); await response.body?.cancel(); - await expect( - fetch(`http://127.0.0.2:${server.port}/api/stats/models`, { - signal: AbortSignal.timeout(1_000), - }), - ).rejects.toThrow(); + // 127.0.0.2 also routes to the loopback interface, but the server bound + // only 127.0.0.1, so a direct connection there must be refused. + expect(await tcpConnects(server.hostname, server.port)).toBe(true); + expect(await tcpConnects("127.0.0.2", server.port)).toBe(false); } finally { server.stop(); } From 6e3718675ea3e5f8a392556a25c8e5dcfa888ec9 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 15:45:45 +0000 Subject: [PATCH 47/97] fix(stats): rejected insecure dashboard reuse Versioned the dashboard identity header and required the current security version with no CORS permission before reusing a listener. Older versioned and headerless stats dashboards are now identified by their process and restarted so loopback-only binding takes effect immediately. Fixes #7633 --- packages/stats/src/port-conflict.ts | 32 ++++++---------- packages/stats/src/server.ts | 9 ++++- .../stats/test/server-port-conflict.test.ts | 38 +++++++++++++++++-- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index ec4555c26..015ab23a9 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -18,31 +18,23 @@ interface PortHolder { /** Header stamped on every dashboard response so reuse probes can identify us. */ export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard"; +/** Identity-header value for dashboards enforcing loopback-only, same-origin access. */ +export const STATS_DASHBOARD_SECURITY_VERSION = "2"; + /** IPv4 loopback address shared by the dashboard server and reuse probe. */ export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1"; -async function probeStatsDashboard(port: number): Promise { +async function probeReusableStatsDashboard(port: number): Promise { try { const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), }); - if (response.status !== 200) { - await response.body?.cancel(); - return false; - } - // A live omp-stats dashboard stamps this header on every response. - if (response.headers.get(STATS_DASHBOARD_HEADER)) { - await response.body?.cancel(); - return true; - } - // Older dashboards predate the header; fall back to the response shape - // (`/api/stats/models` returns a JSON array) so we never reuse — or later - // kill — a foreign 200 responder such as an SPA dev server catch-all. - if (!(response.headers.get("content-type") ?? "").includes("application/json")) { - await response.body?.cancel(); - return false; - } - return Array.isArray(await response.json()); + const reusable = + response.status === 200 && + response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION && + !response.headers.has("Access-Control-Allow-Origin"); + await response.body?.cancel(); + return reusable; } catch { return false; } @@ -219,9 +211,9 @@ async function terminatePortHolder(holder: PortHolder): Promise { await Bun.sleep(PROCESS_EXIT_POLL_MS); } -/** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */ +/** Reuse a securely configured dashboard or reclaim the port from an older omp runtime. */ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { - if (await probeStatsDashboard(port)) return "reuse"; + if (await probeReusableStatsDashboard(port)) return "reuse"; const holder = await findPortHolder(port); if (!holder) { diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 2021b1788..b08051e74 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -21,7 +21,12 @@ import { import { decodeEmbeddedClientArchive } from "./embedded-client"; import embeddedClientArchiveTxt from "./embedded-client.generated.txt"; import { getGainDashboardStats } from "./gain-aggregator"; -import { recoverStatsPort, STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME } from "./port-conflict"; +import { + recoverStatsPort, + STATS_DASHBOARD_HEADER, + STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_SECURITY_VERSION, +} from "./port-conflict"; const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt); @@ -311,7 +316,7 @@ function createDashboardServer(port: number) { // The identity header lets another omp session's reuse probe positively // recognize this dashboard without allowing cross-origin API reads. const dashboardHeaders: Record = { - [STATS_DASHBOARD_HEADER]: "1", + [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION, }; if (req.method === "OPTIONS") { diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 5db2e4ca9..2bd749620 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from "bun:test"; import { connect, type Subprocess } from "bun"; -import { STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME } from "../src/port-conflict"; +import { + STATS_DASHBOARD_HEADER, + STATS_DASHBOARD_HOSTNAME, + STATS_DASHBOARD_SECURITY_VERSION, +} from "../src/port-conflict"; import { startServer } from "../src/server"; /** @@ -69,7 +73,7 @@ describe("startServer access", () => { expect(server.hostname).toBe(STATS_DASHBOARD_HOSTNAME); const response = await fetch(`http://${server.hostname}:${server.port}/api/stats/models`); expect(response.status).toBe(200); - expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1"); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); await response.body?.cancel(); @@ -90,7 +94,7 @@ describe("startServer port conflicts", () => { hostname: STATS_DASHBOARD_HOSTNAME, fetch: request => new URL(request.url).pathname === "/api/stats/models" - ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: "1" } }) + ? Response.json([], { headers: { [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION } }) : new Response("dashboard"), }); @@ -102,13 +106,39 @@ describe("startServer port conflicts", () => { // The existing dashboard is untouched: it still answers on the port. const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${existing.port}/api/stats/models`); expect(response.status).toBe(200); - expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe("1"); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); await response.body?.cancel(); } finally { existing.stop(true); } }); + for (const fixture of [ + { + name: "reclaims a version 1 dashboard with wildcard CORS", + response: `Response.json([], { headers: { "${STATS_DASHBOARD_HEADER}": "1", "Access-Control-Allow-Origin": "*" } })`, + }, + { + name: "reclaims a headerless legacy dashboard", + response: "Response.json([])", + }, + ]) { + it(fixture.name, async () => { + const holder = await startBunHolder(fixture.response, { statsOwned: true }); + const server = await startServer(holder.port); + + try { + expect(await holder.child.exited).not.toBe(0); + const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${server.port}/api/stats/models`); + expect(response.headers.get(STATS_DASHBOARD_HEADER)).toBe(STATS_DASHBOARD_SECURITY_VERSION); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.body?.cancel(); + } finally { + server.stop(); + } + }); + } + it("refuses to stop a foreign 200 responder", async () => { const holder = await startBunHolder('Response.json({ app: "spa" })'); From 03d90bdd9f1b1ac16d9963e9dd866d6f320385d8 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 16:21:29 +0000 Subject: [PATCH 48/97] fix(cli): flush final json record before print-mode exit Per-event stdout writes in --mode json (and text) were fire-and-forget and relied on an empty-write flush barrier before dispose/exit. The barrier awaited its own callback, not the preceding large write, so a big final agent_end could be truncated when the process exited before the pipe drained -- while still exiting 0. Serialize every print-mode stdout write on its own completion callback (honoring backpressure) and block shutdown on the tail so the terminal record is delivered in full. Fixes #7635 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/modes/print-mode.ts | 39 +++-- .../test/print-mode-json-flush.test.ts | 148 ++++++++++++++++++ 3 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/print-mode-json-flush.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..4f8ca6459 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `--mode json` (and text) print mode truncating a large final record (e.g. a multi-MB `agent_end`) when the process exited before stdout drained, while still exiting 0. Per-event writes are now serialized on their own completion callbacks and shutdown blocks on the last one, so the terminal record is delivered in full ([#7635](https://github.com/can1357/oh-my-pi/issues/7635)). + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 3da757e7d..7c0efb10b 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -91,11 +91,30 @@ export function printableEvent(event: AgentSessionEvent): unknown { export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise { const { mode, messages = [], initialMessage, initialImages, printThoughts } = options; + // process.stdout.write is fire-and-forget: a large final record (e.g. a + // multi-MB agent_end) can be dropped when the process exits before the pipe + // drains, truncating the record mid-line while the process still exits 0. + // Serialize every stdout write on the previous write's completion callback so + // records stay ordered and honor backpressure, then block shutdown on the + // tail before dispose/exit. Same truncation class as issue #5309 (issue #7635). + let stdoutTail: Promise = Promise.resolve(); + const writeStdoutLine = (text: string): void => { + stdoutTail = stdoutTail.then( + () => + new Promise(resolve => { + process.stdout.write(text, err => { + if (err) logger.warn("print-mode stdout write failed", { error: err.message }); + resolve(); + }); + }), + ); + }; + // Emit session header for JSON mode if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { - process.stdout.write(`${JSON.stringify(header)}\n`); + writeStdoutLine(`${JSON.stringify(header)}\n`); } } // Set up extensions for print mode (no UI, no command context) @@ -172,7 +191,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti } // In JSON mode, output all events if (mode === "json") { - process.stdout.write(`${JSON.stringify(printableEvent(event))}\n`); + writeStdoutLine(`${JSON.stringify(printableEvent(event))}\n`); } }); @@ -243,9 +262,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // Output text content for (const content of assistantMsg.content) { if (content.type === "text") { - process.stdout.write(`${sanitizeText(content.text)}\n`); + writeStdoutLine(`${sanitizeText(content.text)}\n`); } else if (printThoughts && content.type === "thinking" && content.thinking.trim().length > 0) { - process.stdout.write(`${sanitizeText(content.thinking)}\n`); + writeStdoutLine(`${sanitizeText(content.thinking)}\n`); } } } @@ -253,13 +272,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS); - // Ensure stdout, including late JSON advisor events, is fully flushed before returning. - // This prevents race conditions where the process exits before all output is written. - await new Promise((resolve, reject) => { - process.stdout.write("", err => { - if (err) reject(err); - else resolve(); - }); - }); + // Block shutdown until every serialized stdout write (including the final + // agent_end and late JSON advisor events) has drained; process.exit would + // otherwise discard the buffered tail and truncate the last record. + await stdoutTail; await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS }); } diff --git a/packages/coding-agent/test/print-mode-json-flush.test.ts b/packages/coding-agent/test/print-mode-json-flush.test.ts new file mode 100644 index 000000000..a21e8cd40 --- /dev/null +++ b/packages/coding-agent/test/print-mode-json-flush.test.ts @@ -0,0 +1,148 @@ +/** + * Regression (#7635): `--mode json` must not exit until the final record's + * stdout write has fully drained. + * + * The JSON path emitted each event with a fire-and-forget `process.stdout.write` + * and relied on an empty-write "flush barrier" before dispose/exit. The barrier + * awaited its own callback, not the preceding large write, so a big final + * `agent_end` (multi-MB) could be truncated when the process exited before the + * pipe drained — while still exiting 0. The fix serializes every print-mode + * stdout write on its own completion callback and blocks shutdown on the tail. + * + * Contract: `runPrintMode` stays pending until the final record's write callback + * fires (so `process.exit` can't discard it), and the full record is delivered. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { runPrintMode } from "@oh-my-pi/pi-coding-agent/modes/print-mode"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; + +interface FlushHarness { + session: AgentSession; + promptStarted: Promise; + resolvePrompt: () => void; + emit: (event: AgentSessionEvent) => void; + disposed: () => boolean; +} + +function createFlushHarness(): FlushHarness { + const { promise: promptStarted, resolve: markPromptStarted } = Promise.withResolvers(); + const { promise: promptReleased, resolve: resolvePrompt } = Promise.withResolvers(); + let subscriber: ((event: AgentSessionEvent) => void) | undefined; + let disposed = false; + let advisorDrainPrepared = false; + + const session = { + sessionManager: { + getHeader: () => undefined, + buildSessionContext: () => ({ messages: [] }), + getEntries: () => [], + }, + settings: { get: () => false }, + extensionRunner: undefined, + subscribe: (listener: (event: AgentSessionEvent) => void) => { + subscriber = listener; + return () => {}; + }, + prompt: async () => { + markPromptStarted(); + await promptReleased; + return true; + }, + prepareForHeadlessAdvisorDrain: () => { + advisorDrainPrepared = true; + }, + waitForAdvisorCatchup: async () => { + if (!advisorDrainPrepared) throw new Error("advisor catch-up started before headless delivery was armed"); + }, + dispose: async () => { + disposed = true; + }, + } as unknown as AgentSession; + + return { + session, + promptStarted, + resolvePrompt, + emit: event => subscriber?.(event), + disposed: () => disposed, + }; +} + +function makeLargeAgentEnd(payload: string): AgentSessionEvent { + return { + type: "agent_end", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: payload }], + stopReason: "aborted", + errorMessage: "Deadline exceeded", + timestamp: Date.now(), + }, + ], + } as unknown as AgentSessionEvent; +} + +describe("print-mode JSON flush (#7635)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("blocks exit until the final agent_end write drains, then delivers it in full", async () => { + const writes: string[] = []; + let releaseAgentEnd: (() => void) | undefined; + const { promise: agentEndWriteIssued, resolve: markAgentEndWriteIssued } = Promise.withResolvers(); + vi.spyOn(process.stdout, "write").mockImplementation((...args: unknown[]) => { + const chunk = args[0]; + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString(); + writes.push(text); + const cb = args[args.length - 1]; + const invoke = typeof cb === "function" ? (cb as (err?: Error | null) => void) : undefined; + // Defer the large agent_end record's completion callback to emulate a + // backpressured pipe; every other write completes synchronously. + if (text.includes('"type":"agent_end"')) { + releaseAgentEnd = () => invoke?.(null); + markAgentEndWriteIssued(); + } else { + invoke?.(null); + } + return true; + }); + + const payload = "x".repeat(1_500_000); + const harness = createFlushHarness(); + + const run = runPrintMode(harness.session, { mode: "json", initialMessage: "hello" }); + let settled = false; + void run.then(() => { + settled = true; + }); + + await harness.promptStarted; + harness.emit(makeLargeAgentEnd(payload)); + harness.resolvePrompt(); + + // Drain to quiescence: every step runPrintMode can complete without the + // deferred write is microtask-driven, so one macrotask boundary flushes + // them all. The pre-fix fire-and-forget path settles and disposes here; + // the fix must still be blocked on the undrained agent_end write. + await agentEndWriteIssued; + await new Promise(resolve => setImmediate(resolve)); + expect(releaseAgentEnd).toBeDefined(); + expect(settled).toBe(false); + expect(harness.disposed()).toBe(false); + + releaseAgentEnd?.(); + await run; + + expect(settled).toBe(true); + expect(harness.disposed()).toBe(true); + + const agentEndLine = writes.find(line => line.includes('"type":"agent_end"')); + expect(agentEndLine).toBeDefined(); + expect(agentEndLine?.endsWith("\n")).toBe(true); + // The complete payload survives — not a pipe-buffer-sized prefix. + expect(agentEndLine).toContain(payload); + expect(JSON.parse(agentEndLine as string)).toMatchObject({ type: "agent_end" }); + }); +}); From 954894f1d49b25604250b5a1a5384536739d5c95 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:14:36 +0900 Subject: [PATCH 49/97] refactor: centralize version comparison in pi-utils --- packages/coding-agent/src/cli/update-cli.ts | 20 +--- packages/coding-agent/src/utils/changelog.ts | 9 +- .../coding-agent/src/web/scrapers/hackage.ts | 13 +-- packages/utils/src/index.ts | 1 + packages/utils/src/version.ts | 99 +++++++++++++++++++ packages/utils/test/version.test.ts | 72 ++++++++++++++ scripts/ci-release-notes.ts | 18 +--- scripts/release.ts | 9 +- 8 files changed, 182 insertions(+), 59 deletions(-) create mode 100644 packages/utils/src/version.ts create mode 100644 packages/utils/test/version.test.ts diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index f106e72d1..ac2e2cd60 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -10,7 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { $env, $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; +import { $env, $which, APP_NAME, compareVersions, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; @@ -498,24 +498,6 @@ async function getLatestRelease(): Promise { }; } -/** - * Compare semver versions. Returns: - * - negative if a < b - * - 0 if a == b - * - positive if a > b - */ -function compareVersions(a: string, b: string): number { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const na = pa[i] || 0; - const nb = pb[i] || 0; - if (na !== nb) return na - nb; - } - return 0; -} - interface BunInstallCachePruneResult { scannedPackages: number; removedEntries: number; diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 456fc23f2..b64431dae 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -198,9 +198,10 @@ function parseChangelogContent(content: string): ChangelogEntry[] { } /** - * Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 + * Compare changelog entries by their parsed version parts. + * Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 */ -export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number { +export function compareChangelogEntries(v1: ChangelogEntry, v2: ChangelogEntry): number { if (v1.major !== v2.major) return v1.major - v2.major; if (v1.minor !== v2.minor) return v1.minor - v2.minor; return v1.patch - v2.patch; @@ -232,7 +233,7 @@ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): C return []; } - return entries.filter(entry => compareVersions(entry, parsedLastVersion) > 0); + return entries.filter(entry => compareChangelogEntries(entry, parsedLastVersion) > 0); } /** @@ -328,7 +329,7 @@ export async function resolveStartupChangelogForDisplay(options: { } if (options.mode === "hidden") { const currentVersion = parseChangelogVersion(options.currentVersion); - if (currentVersion && compareVersions(currentVersion, parsedLastVersion) > 0) { + if (currentVersion && compareChangelogEntries(currentVersion, parsedLastVersion) > 0) { await writeLastChangelogVersion(options.currentVersion, options.agentDir); } return undefined; diff --git a/packages/coding-agent/src/web/scrapers/hackage.ts b/packages/coding-agent/src/web/scrapers/hackage.ts index 997e90a69..339c2178a 100644 --- a/packages/coding-agent/src/web/scrapers/hackage.ts +++ b/packages/coding-agent/src/web/scrapers/hackage.ts @@ -1,4 +1,4 @@ -import { tryParseJson } from "@oh-my-pi/pi-utils"; +import { compareVersions, tryParseJson } from "@oh-my-pi/pi-utils"; import type { RenderResult, SpecialHandler } from "./types"; import { buildResult, loadPage } from "./types"; @@ -20,17 +20,6 @@ interface ParsedCabal { stability?: string; } -function compareVersions(a: string, b: string): number { - const aParts = a.split(".").map(part => Number.parseInt(part, 10) || 0); - const bParts = b.split(".").map(part => Number.parseInt(part, 10) || 0); - const max = Math.max(aParts.length, bParts.length); - for (let i = 0; i < max; i++) { - const delta = (aParts[i] || 0) - (bParts[i] || 0); - if (delta !== 0) return delta; - } - return 0; -} - function extractCabalField(content: string, fieldName: string): string | undefined { const pattern = new RegExp(`^${fieldName}:\\s*(.*)$`, "im"); const match = content.match(pattern); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8a2ba3b8c..7d1363b7d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -34,6 +34,7 @@ export * from "./tab-spacing"; export * from "./temp"; export * from "./tls-fetch"; export * from "./type-guards"; +export * from "./version"; export * from "./which"; function isPlainObject(val: object): val is Record { diff --git a/packages/utils/src/version.ts b/packages/utils/src/version.ts new file mode 100644 index 000000000..15088117e --- /dev/null +++ b/packages/utils/src/version.ts @@ -0,0 +1,99 @@ +const DIGITS = /^\d+$/; + +/** + * Compare two version strings. + * + * Canonical comparator that supersedes the historical in-repo copies + * (update-cli, hackage scraper, release scripts): + * - inputs are trimmed and at most one leading `v`/`V` is stripped + * - dot-separated segments are compared numerically, missing trailing + * segments count as 0, so `1.2` === `1.2.0` and any segment count works + * - a SemVer-2.0 prerelease suffix sorts before the plain release + * (`1.0.0-beta` < `1.0.0`); prerelease identifiers follow SemVer order + * (numeric < alphanumeric, numeric compared by value, alphanumeric + * compared lexically, longer sets of equal fields win) + * - SemVer build metadata begins at the first `+` and does not participate + * in precedence; it is stripped before core/prerelease parsing + * - malformed numeric segments compare as 0 (`1.2.x` === `1.2.0`) + * - never throws; returns only -1 | 0 | 1 + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + + const core = compareNumericParts(pa.core, pb.core); + if (core !== 0) return core; + + return comparePrerelease(pa.prerelease, pb.prerelease); +} + +interface ParsedVersion { + core: string[]; + prerelease: string[] | null; +} + +function parseVersion(version: string): ParsedVersion { + const trimmed = version.trim(); + const stripped = trimmed.startsWith("v") || trimmed.startsWith("V") ? trimmed.slice(1) : trimmed; + const plusIndex = stripped.indexOf("+"); + const withoutBuild = plusIndex === -1 ? stripped : stripped.slice(0, plusIndex); + const dashIndex = withoutBuild.indexOf("-"); + if (dashIndex === -1) { + return { core: withoutBuild.split("."), prerelease: null }; + } + return { + core: withoutBuild.slice(0, dashIndex).split("."), + prerelease: withoutBuild.slice(dashIndex + 1).split("."), + }; +} + +/** Compare dot-separated numeric segments; missing/malformed segments count as 0. */ +function compareNumericParts(a: string[], b: string[]): number { + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + // Missing or malformed segments compare as 0. + const sa = a[i]; + const sb = b[i]; + const result = compareDigits( + sa !== undefined && DIGITS.test(sa) ? sa : "0", + sb !== undefined && DIGITS.test(sb) ? sb : "0", + ); + if (result !== 0) return result; + } + return 0; +} + +/** Exact integer comparison of digit strings, avoiding float overflow. */ +function compareDigits(a: string, b: string): number { + const na = a.replace(/^0+/, "") || "0"; + const nb = b.replace(/^0+/, "") || "0"; + if (na.length !== nb.length) return na.length < nb.length ? -1 : 1; + if (na < nb) return -1; + if (na > nb) return 1; + return 0; +} + +/** SemVer-2.0 prerelease ordering; null means a plain release, which wins. */ +function comparePrerelease(a: string[] | null, b: string[] | null): number { + if (a === null || b === null) { + return a === b ? 0 : a === null ? 1 : -1; + } + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + const ia = a[i]; + const ib = b[i]; + if (ia === undefined) return -1; + if (ib === undefined) return 1; + const aNumeric = DIGITS.test(ia); + const bNumeric = DIGITS.test(ib); + if (aNumeric && bNumeric) { + const result = compareDigits(ia, ib); + if (result !== 0) return result; + } else if (aNumeric !== bNumeric) { + return aNumeric ? -1 : 1; + } else if (ia !== ib) { + return ia < ib ? -1 : 1; + } + } + return 0; +} diff --git a/packages/utils/test/version.test.ts b/packages/utils/test/version.test.ts new file mode 100644 index 000000000..bb6d485d3 --- /dev/null +++ b/packages/utils/test/version.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { compareVersions } from "../src/version"; + +describe("compareVersions", () => { + it("trims whitespace and strips one leading v/V", () => { + expect(compareVersions(" 1.2.3 ", "1.2.3")).toBe(0); + expect(compareVersions("v1.2.3", "1.2.3")).toBe(0); + expect(compareVersions("V1.2.3", "v1.2.3")).toBe(0); + expect(compareVersions(" v1.2.3 ", "1.2.3")).toBe(0); + }); + + it("zero-pads missing trailing segments", () => { + expect(compareVersions("1.2", "1.2.0")).toBe(0); + expect(compareVersions("1", "1.0.0.0")).toBe(0); + expect(compareVersions("1.2", "1.2.1")).toBe(-1); + expect(compareVersions("1.2.3", "1.2")).toBe(1); + }); + + it("supports arbitrary segment counts", () => { + expect(compareVersions("1.2.3.4.5", "1.2.3.4.5")).toBe(0); + expect(compareVersions("1.2.3.4", "1.2.3.5")).toBe(-1); + expect(compareVersions("1.0.0.1", "1.0.0")).toBe(1); + }); + + it("orders SemVer prereleases before the plain release", () => { + expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1); + expect(compareVersions("1.0.0", "1.0.0-rc.1")).toBe(1); + expect(compareVersions("v1.0.0-beta", "1.0.0")).toBe(-1); + expect(compareVersions("1.1.0-alpha", "1.0.0-beta")).toBe(1); + }); + + it("compares prerelease identifiers per SemVer 2.0", () => { + expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBe(-1); + expect(compareVersions("1.0.0-rc.1", "1.0.0-rc.2")).toBe(-1); + // numeric, not lexical: rc.10 > rc.9 + expect(compareVersions("1.0.0-rc.9", "1.0.0-rc.10")).toBe(-1); + // a larger set of equal fields has higher precedence + expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBe(-1); + // numeric identifiers sort before alphanumeric ones + expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBe(-1); + expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1); + }); + + it("strips SemVer build metadata before comparing", () => { + // build metadata does not affect precedence + expect(compareVersions("1.0.1+linux", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0-rc.1+abc", "1.0.0-rc.1+xyz")).toBe(0); + expect(compareVersions("1.0.0+build1", "1.0.0+build2")).toBe(0); + expect(compareVersions("1.0.0+linux", "1.0.0+mac")).toBe(0); + expect(compareVersions("1.0.0+linux", "1.0.1+linux")).toBe(-1); + expect(compareVersions("v1.2.3+meta", "1.2.3")).toBe(0); + expect(compareVersions(" 1.0.0+meta ", "1.0.0")).toBe(0); + }); + + it("compares malformed numeric segments as 0", () => { + expect(compareVersions("1.2.x", "1.2.0")).toBe(0); + expect(compareVersions("1.x", "1.0")).toBe(0); + expect(compareVersions("1.2.x", "1.2.1")).toBe(-1); + }); + + it("never throws and always returns -1, 0, or 1", () => { + expect(compareVersions("not_a_version", "0")).toBe(0); + expect(compareVersions("", "")).toBe(0); + expect(compareVersions("v", "")).toBe(0); + // hyphenated garbage parses as a prerelease suffix and loses to the release + expect(compareVersions("not-a-version", "1.0.0")).toBe(-1); + expect(compareVersions("3.0.0", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0", "3.0.0")).toBe(-1); + // exact numeric comparison beyond float precision + expect(compareVersions("1.2.99999999999999999999", "1.2.100000000000000000000")).toBe(-1); + }); +}); diff --git a/scripts/ci-release-notes.ts b/scripts/ci-release-notes.ts index 1228d39dc..51bc8c9bb 100755 --- a/scripts/ci-release-notes.ts +++ b/scripts/ci-release-notes.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun + /** * Generate aggregated release notes from per-package CHANGELOG.md files. * @@ -28,6 +29,7 @@ * underneath; this only adds curated context. */ +import { compareVersions } from "@oh-my-pi/pi-utils"; import { $, Glob } from "bun"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); @@ -37,22 +39,6 @@ const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/o // alphabetically after these. const CATEGORY_ORDER = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const; -/** Compare two `X.Y.Z` (or `vX.Y.Z`) version strings; non-semver returns 0. */ -export function compareVersions(a: string, b: string): number { - const am = a - .replace(/^v/, "") - .trim() - .match(/^(\d+)\.(\d+)\.(\d+)$/); - const bm = b - .replace(/^v/, "") - .trim() - .match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!am || !bm) return 0; - if (am[1] !== bm[1]) return Number(am[1]) - Number(bm[1]); - if (am[2] !== bm[2]) return Number(am[2]) - Number(bm[2]); - return Number(am[3]) - Number(bm[3]); -} - export interface ChangelogVersionSpan { version: string; /** 0-indexed line of the `## [X.Y.Z]` heading. */ diff --git a/scripts/release.ts b/scripts/release.ts index 0399fce85..790c1f01f 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { compareVersions } from "@oh-my-pi/pi-utils"; /** * Release script for pi-mono * @@ -186,14 +187,6 @@ function bumpVersion(current: string, bump: "major" | "minor" | "patch"): string } } -function compareVersions(a: string, b: string): number { - const [aMajor, aMinor, aPatch] = parseVersion(a); - const [bMajor, bMinor, bPatch] = parseVersion(b); - if (aMajor !== bMajor) return aMajor - bMajor; - if (aMinor !== bMinor) return aMinor - bMinor; - return aPatch - bPatch; -} - async function cmdRelease(versionOrBump: string): Promise { console.log("\n=== Release Script ===\n"); From 726698e2b0d4ffad4401ad135998233f123746db Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:09:31 +0900 Subject: [PATCH 50/97] fix(release): validate explicit versions and drop duplicate comparators Review findings on #7586: - validate an explicit release version before comparing; the shared comparator never throws, so 999.bad previously reached every manifest - ci-release-notes imports the comparator by relative path: the release_github job runs without bun install - route Bun cache pruning through compareVersions and delete compareSemverLikeVersions - regression test for the release-version guard --- packages/coding-agent/CHANGELOG.md | 7 +++ packages/coding-agent/src/cli/update-cli.ts | 38 +------------- packages/utils/CHANGELOG.md | 4 ++ scripts/ci-release-notes.ts | 2 +- scripts/release.test.ts | 36 ++++++++++++++ scripts/release.ts | 55 ++++++++++++++------- 6 files changed, 87 insertions(+), 55 deletions(-) create mode 100644 scripts/release.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 22a72debb..94b193953 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,13 @@ ### Changed - Upgraded the bundled omptype schema engine: intersection and pipe operators, bigint and RegExp literals in the string DSL, Standard Schema V1 interop, JSON Schema import via fromJsonSchema(), and richer union/collection error reporting. +### Breaking Changes + +- Renamed `compareVersions` to `compareChangelogEntries` in `@oh-my-pi/pi-coding-agent/utils/changelog`. The function signature and behavior are unchanged; update imports to use the new name. + +### Changed + +- Routed Bun install-cache pruning in `update-cli` through the shared `compareVersions` utility (`@oh-my-pi/pi-utils`), removing a duplicate local comparator that rounded large numeric version identifiers via `Number`. ## [17.2.7] - 2026-08-03 diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index ac2e2cd60..9cfc95413 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -514,42 +514,6 @@ function stripBunCacheVersionSuffix(name: string): string { return metadataIndex === -1 ? name : name.slice(0, metadataIndex); } -function compareSemverIdentifier(a: string, b: string): number { - const aNumber = /^\d+$/.test(a); - const bNumber = /^\d+$/.test(b); - if (aNumber && bNumber) return Number(a) - Number(b); - if (aNumber) return -1; - if (bNumber) return 1; - return a.localeCompare(b); -} - -function compareSemverLikeVersions(a: string, b: string): number { - const [aCoreWithPrerelease] = a.split("+", 1); - const [bCoreWithPrerelease] = b.split("+", 1); - const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2); - const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2); - const aParts = aCore.split("."); - const bParts = bCore.split("."); - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0); - if (diff !== 0 && Number.isFinite(diff)) return diff; - } - if (!aPrerelease && !bPrerelease) return 0; - if (!aPrerelease) return 1; - if (!bPrerelease) return -1; - const aPrereleaseParts = aPrerelease.split("."); - const bPrereleaseParts = bPrerelease.split("."); - for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) { - const aPart = aPrereleaseParts[i]; - const bPart = bPrereleaseParts[i]; - if (aPart === undefined) return -1; - if (bPart === undefined) return 1; - const diff = compareSemverIdentifier(aPart, bPart); - if (diff !== 0) return diff; - } - return 0; -} - async function readdirIfExists(dir: string): Promise { try { return await fs.promises.readdir(dir, { withFileTypes: true }); @@ -671,7 +635,7 @@ export async function pruneBunInstallCache( scannedPackages++; let latestVersion: string | undefined; for (const version of group.actualDirs.keys()) { - if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version; + if (!latestVersion || compareVersions(version, latestVersion) > 0) latestVersion = version; } if (!latestVersion) continue; for (const [version, paths] of group.actualDirs) { diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c195b1d2c..8761240b5 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a public `compareVersions` utility (`@oh-my-pi/pi-utils`) that compares two version strings with SemVer-2.0 prerelease ordering, build-metadata stripping, and numeric segment comparison without float overflow; never throws. + ## [17.2.6] - 2026-08-03 ### Added diff --git a/scripts/ci-release-notes.ts b/scripts/ci-release-notes.ts index 51bc8c9bb..0f2b7d1fa 100755 --- a/scripts/ci-release-notes.ts +++ b/scripts/ci-release-notes.ts @@ -29,8 +29,8 @@ * underneath; this only adds curated context. */ -import { compareVersions } from "@oh-my-pi/pi-utils"; import { $, Glob } from "bun"; +import { compareVersions } from "../packages/utils/src/version"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/oh-my-pi"; diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 000000000..cc820cb76 --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { isValidExplicitVersion } from "./release"; + +describe("isValidExplicitVersion", () => { + test("rejects malformed versions", () => { + expect(isValidExplicitVersion("999.bad")).toBe(false); + expect(isValidExplicitVersion("17")).toBe(false); + expect(isValidExplicitVersion("17.2")).toBe(false); + expect(isValidExplicitVersion("17.2.8.9")).toBe(false); + expect(isValidExplicitVersion("v17.2.8.9")).toBe(false); + expect(isValidExplicitVersion("abc")).toBe(false); + expect(isValidExplicitVersion("")).toBe(false); + expect(isValidExplicitVersion("v")).toBe(false); + expect(isValidExplicitVersion("17.2.8-")).toBe(false); + }); + + test("accepts valid three-segment numeric versions", () => { + expect(isValidExplicitVersion("17.2.8")).toBe(true); + expect(isValidExplicitVersion("0.0.0")).toBe(true); + expect(isValidExplicitVersion("1.0.0")).toBe(true); + }); + + test("accepts leading v prefix", () => { + expect(isValidExplicitVersion("v17.2.8")).toBe(true); + expect(isValidExplicitVersion("V17.2.8")).toBe(false); + }); + + test("accepts prerelease suffixes", () => { + expect(isValidExplicitVersion("17.2.8-rc.1")).toBe(true); + expect(isValidExplicitVersion("v17.2.8-rc.1")).toBe(true); + expect(isValidExplicitVersion("1.0.0-beta")).toBe(true); + expect(isValidExplicitVersion("1.0.0-alpha.1.2")).toBe(true); + expect(isValidExplicitVersion("1.0.0-0.3.7")).toBe(true); + expect(isValidExplicitVersion("1.0.0-x.7.z.92")).toBe(true); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 790c1f01f..4e79926b8 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -15,6 +15,14 @@ import { runChangelogFixer } from "./fix-changelogs"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); +/** + * Strict explicit-version guard: three numeric dot-segments, optional leading + * `v`, optional SemVer-2.0 prerelease suffix. Bump keywords (major/minor/patch) + * are handled separately and must not be routed through this check. + */ +export function isValidExplicitVersion(version: string): boolean { + return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version); +} function git(args: readonly string[]) { return $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}`; @@ -189,6 +197,17 @@ function bumpVersion(current: string, bump: "major" | "minor" | "patch"): string async function cmdRelease(versionOrBump: string): Promise { console.log("\n=== Release Script ===\n"); + // Validate explicit versions before any compare: the shared compareVersions + // never throws, so without this guard garbage like "999.bad" would be + // accepted and written into every package.json / Cargo.toml / tag. + if (versionOrBump !== "major" && versionOrBump !== "minor" && versionOrBump !== "patch") { + if (!isValidExplicitVersion(versionOrBump)) { + console.error( + `Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8-rc.1, or a bump keyword (major/minor/patch).`, + ); + process.exit(1); + } + } // 1. Pre-flight checks console.log("Pre-flight checks..."); @@ -390,23 +409,25 @@ async function cmdRelease(versionOrBump: string): Promise { // Main // ============================================================================= -const arg = process.argv[2]; +if (import.meta.main) { + const arg = process.argv[2]; -if (!arg) { - console.error("Usage:"); - console.error(" bun scripts/release.ts Full release"); - console.error(" bun scripts/release.ts watch Watch CI for current commit"); - process.exit(1); -} + if (!arg) { + console.error("Usage:"); + console.error(" bun scripts/release.ts Full release"); + console.error(" bun scripts/release.ts watch Watch CI for current commit"); + process.exit(1); + } -if (arg === "watch") { - await cmdWatch(); -} else if (arg === "major" || arg === "minor" || arg === "patch" || /^\d+\.\d+\.\d+$/.test(arg)) { - await cmdRelease(arg); -} else { - console.error(`Unknown command or invalid version: ${arg}`); - console.error("Usage:"); - console.error(" bun scripts/release.ts Full release"); - console.error(" bun scripts/release.ts watch Watch CI for current commit"); - process.exit(1); + if (arg === "watch") { + await cmdWatch(); + } else if (arg === "major" || arg === "minor" || arg === "patch" || isValidExplicitVersion(arg)) { + await cmdRelease(arg); + } else { + console.error(`Unknown command or invalid version: ${arg}`); + console.error("Usage:"); + console.error(" bun scripts/release.ts Full release"); + console.error(" bun scripts/release.ts watch Watch CI for current commit"); + process.exit(1); + } } From df3fa86f7d6396ac4b5353b07078162539c1def2 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:59:08 +0900 Subject: [PATCH 51/97] fix(release): reject prereleases and normalize the version prefix Wave-2 review findings on #7586: - the guard accepted `17.2.8-rc.1`, and the publish step passes no --tag, so a prerelease would have become the npm `latest` every unqualified install and `omp update` resolve - `v17.2.8` passed the guard and reached Cargo.toml verbatim, which cargo rejects only after every manifest was rewritten; validation now returns the normalized version and callers write that --- scripts/release.test.ts | 52 +++++++++++++++++++++-------------------- scripts/release.ts | 32 ++++++++++++++++++------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/scripts/release.test.ts b/scripts/release.test.ts index cc820cb76..c7f640715 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -1,36 +1,38 @@ import { describe, expect, test } from "bun:test"; -import { isValidExplicitVersion } from "./release"; +import { validateExplicitVersion } from "./release"; -describe("isValidExplicitVersion", () => { +describe("validateExplicitVersion", () => { test("rejects malformed versions", () => { - expect(isValidExplicitVersion("999.bad")).toBe(false); - expect(isValidExplicitVersion("17")).toBe(false); - expect(isValidExplicitVersion("17.2")).toBe(false); - expect(isValidExplicitVersion("17.2.8.9")).toBe(false); - expect(isValidExplicitVersion("v17.2.8.9")).toBe(false); - expect(isValidExplicitVersion("abc")).toBe(false); - expect(isValidExplicitVersion("")).toBe(false); - expect(isValidExplicitVersion("v")).toBe(false); - expect(isValidExplicitVersion("17.2.8-")).toBe(false); + expect(validateExplicitVersion("999.bad")).toBe(null); + expect(validateExplicitVersion("17")).toBe(null); + expect(validateExplicitVersion("17.2")).toBe(null); + expect(validateExplicitVersion("17.2.8.9")).toBe(null); + expect(validateExplicitVersion("v17.2.8.9")).toBe(null); + expect(validateExplicitVersion("abc")).toBe(null); + expect(validateExplicitVersion("")).toBe(null); + expect(validateExplicitVersion("v")).toBe(null); + expect(validateExplicitVersion("17.2.8-")).toBe(null); }); - test("accepts valid three-segment numeric versions", () => { - expect(isValidExplicitVersion("17.2.8")).toBe(true); - expect(isValidExplicitVersion("0.0.0")).toBe(true); - expect(isValidExplicitVersion("1.0.0")).toBe(true); + test("rejects prerelease suffixes (not supported by this release path)", () => { + // Prereleases would be published as npm `latest` because the downstream + // publish runs `npm publish` with no `--tag`. + expect(validateExplicitVersion("17.2.8-rc.1")).toBe(null); + expect(validateExplicitVersion("v17.2.8-beta")).toBe(null); + expect(validateExplicitVersion("1.0.0-alpha")).toBe(null); + expect(validateExplicitVersion("1.0.0-alpha.1.2")).toBe(null); + expect(validateExplicitVersion("1.0.0-0.3.7")).toBe(null); + expect(validateExplicitVersion("1.0.0-x.7.z.92")).toBe(null); }); - test("accepts leading v prefix", () => { - expect(isValidExplicitVersion("v17.2.8")).toBe(true); - expect(isValidExplicitVersion("V17.2.8")).toBe(false); + test("accepts bare three-segment numeric versions and returns them unchanged", () => { + expect(validateExplicitVersion("17.2.8")).toBe("17.2.8"); + expect(validateExplicitVersion("0.0.0")).toBe("0.0.0"); + expect(validateExplicitVersion("1.0.0")).toBe("1.0.0"); }); - test("accepts prerelease suffixes", () => { - expect(isValidExplicitVersion("17.2.8-rc.1")).toBe(true); - expect(isValidExplicitVersion("v17.2.8-rc.1")).toBe(true); - expect(isValidExplicitVersion("1.0.0-beta")).toBe(true); - expect(isValidExplicitVersion("1.0.0-alpha.1.2")).toBe(true); - expect(isValidExplicitVersion("1.0.0-0.3.7")).toBe(true); - expect(isValidExplicitVersion("1.0.0-x.7.z.92")).toBe(true); + test("accepts leading v prefix and normalizes to the bare version", () => { + expect(validateExplicitVersion("v17.2.8")).toBe("17.2.8"); + expect(validateExplicitVersion("V17.2.8")).toBe(null); }); }); diff --git a/scripts/release.ts b/scripts/release.ts index 4e79926b8..832c3ab5c 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -16,12 +16,22 @@ const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); /** - * Strict explicit-version guard: three numeric dot-segments, optional leading - * `v`, optional SemVer-2.0 prerelease suffix. Bump keywords (major/minor/patch) - * are handled separately and must not be routed through this check. + * Strict explicit-version guard: three numeric dot-segments with an optional + * leading `v` and NO prerelease suffix. Prereleases are rejected because the + * downstream publish (`scripts/ci-release-publish.ts`) runs `npm publish` with + * no `--tag`, which would promote a prerelease to the npm `latest` dist-tag — + * hitting every unqualified install and the `/latest` endpoint `omp update` + * reads. Bump keywords (major/minor/patch) are handled separately and must not + * be routed through this check. + * + * Returns the normalized bare version (leading `v` stripped) when accepted, or + * `null` when rejected. Callers must use the returned value for all writes so + * no downstream manifest (package.json, Cargo.toml, tag) ever sees a `v` + * prefix — Cargo rejects `version = "v17.2.8"`. */ -export function isValidExplicitVersion(version: string): boolean { - return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version); +export function validateExplicitVersion(version: string): string | null { + const match = /^v?(\d+\.\d+\.\d+)$/.exec(version); + return match ? match[1] : null; } function git(args: readonly string[]) { @@ -199,14 +209,18 @@ async function cmdRelease(versionOrBump: string): Promise { console.log("\n=== Release Script ===\n"); // Validate explicit versions before any compare: the shared compareVersions // never throws, so without this guard garbage like "999.bad" would be - // accepted and written into every package.json / Cargo.toml / tag. + // accepted and written into every package.json / Cargo.toml / tag. The + // validator also normalizes a leading `v` to the bare version so every + // downstream write (manifests, Cargo.toml, tag) uses `17.2.8`, not `v17.2.8`. if (versionOrBump !== "major" && versionOrBump !== "minor" && versionOrBump !== "patch") { - if (!isValidExplicitVersion(versionOrBump)) { + const normalized = validateExplicitVersion(versionOrBump); + if (normalized === null) { console.error( - `Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8-rc.1, or a bump keyword (major/minor/patch).`, + `Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8 (prereleases such as 17.2.8-rc.1 are not supported by this release path), or a bump keyword (major/minor/patch).`, ); process.exit(1); } + versionOrBump = normalized; } // 1. Pre-flight checks @@ -421,7 +435,7 @@ if (import.meta.main) { if (arg === "watch") { await cmdWatch(); - } else if (arg === "major" || arg === "minor" || arg === "patch" || isValidExplicitVersion(arg)) { + } else if (arg === "major" || arg === "minor" || arg === "patch" || validateExplicitVersion(arg) !== null) { await cmdRelease(arg); } else { console.error(`Unknown command or invalid version: ${arg}`); From 1a45730e6fec813e2f74a4dbbe1041fdc02b6ab7 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:35:15 +0900 Subject: [PATCH 52/97] refactor(ai): isolate SQLite credential store --- packages/ai/src/auth-storage.ts | 2036 +--------------- .../ai/src/auth/sqlite-credential-store.ts | 2049 +++++++++++++++++ 2 files changed, 2064 insertions(+), 2021 deletions(-) create mode 100644 packages/ai/src/auth/sqlite-credential-store.ts diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index e1c76bda2..6f8605fcb 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -5,14 +5,17 @@ * This module defines: * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …) * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh - * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation + * - re-exported `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ -import { Database, type Statement } from "bun:sqlite"; import { createHash } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; -import { $env, $envExact, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import { $env, $envExact, getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; +import { + isSqliteCorruptionError, + resolveCredentialIdentityKey, + SqliteAuthCredentialStore, + serializeCredential, + USAGE_REPORT_TTL_MS, +} from "./auth/sqlite-credential-store"; import type { ApiKeyResolver } from "./auth-retry"; import * as AIError from "./error"; import { isUsageLimitOutcome } from "./error/rate-limit"; @@ -28,7 +31,6 @@ import type { import { getEnvApiKey, getEnvApiKeyName } from "./stream"; import type { Provider } from "./types"; import type { - ClientProviderUsage, ClientUsageReport, ClientUsageSummary, CredentialRankingContext, @@ -70,6 +72,12 @@ import { umansUsageProvider } from "./usage/umans"; import { xaiOauthUsageProvider } from "./usage/xai-oauth"; import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai"; +export { + isSqliteBusyError, + isSqliteCorruptionError, + SqliteAuthCredentialStore, +} from "./auth/sqlite-credential-store"; + const USAGE_RANKING_METRIC_EPSILON = 1e-9; /** * Primary (short, e.g. 5h) window used-fraction at or above which a candidate @@ -675,26 +683,8 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map( ); const USAGE_CACHE_PREFIX = "usage_cache:"; -// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP -// level so we can't fetch all N credentials every cycle; with a long cache -// each credential's last-known value sticks visible while peers retry. UI -// data (5h / 7d / monthly limits) is fine being a few minutes stale. -const USAGE_REPORT_TTL_MS = 5 * 60_000; const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000; const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000; -/** - * Downsample usage history to at most one row per hour per account window: a - * snapshot landing in the same hour bucket as the series' latest row - * overwrites it in place. That bound makes further retention pruning - * unnecessary — 1 row/hour is ~9k rows per account window per year. - */ -const USAGE_HISTORY_BUCKET_MS = 60 * 60_000; -/** - * Merge client observed-usage flushes into at most one row per 5 minutes per - * (install, provider, model): ~300 rows/day per active model per client - * instead of one row per 10s flush. - */ -const CLIENT_USAGE_BUCKET_MS = 5 * 60_000; /** * Per-credential cool-down after a usage fetch fails. While this window is * active we serve the last successful value to avoid dropping the credential @@ -6535,1999 +6525,3 @@ export class AuthStorage { return undefined; } } - -// ───────────────────────────────────────────────────────────────────────────── -// SqliteAuthCredentialStore -// ───────────────────────────────────────────────────────────────────────────── - -/** Row shape for auth_credentials table queries */ -type AuthRow = { - id: number; - provider: string; - credential_type: string; - data: string; - disabled_cause: string | null; - identity_key: string | null; -}; - -/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */ -type DisabledAuthRow = AuthRow & { updated_at: number | null }; - -type CredentialBlockRow = { - credential_id: number; - provider_key: string; - block_scope: string; - blocked_until_ms: number; - updated_at: number; -}; - -type SerializedCredentialRecord = { - credentialType: AuthCredential["type"]; - data: string; - identityKey: string | null; -}; - -const AUTH_SCHEMA_VERSION = 7; -const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; -const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth"; -const LEGACY_CODEX_BLOCK_SCOPE = "shared"; -const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const; - -/** - * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended - * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, - * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. - */ -export function isSqliteBusyError(err: unknown): boolean { - if (err === null || typeof err !== "object") return false; - const code = (err as { code?: unknown }).code; - return typeof code === "string" && code.startsWith("SQLITE_BUSY"); -} - -/** - * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family - * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`) - * and `SQLITE_NOTADB` (the file header is not a database). Unlike - * {@link isSqliteBusyError}, these never clear by retrying: the store must be - * repaired or replaced, so callers latch and stop touching it. - */ -export function isSqliteCorruptionError(err: unknown): boolean { - if (err === null || typeof err !== "object" || !("code" in err)) return false; - const code = err.code; - return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB"); -} - -function normalizeStoredAccountId(accountId: string | null | undefined): string | null { - const normalized = accountId?.trim(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeStoredEmail(email: string | null | undefined): string | null { - const normalized = email?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null { - const normalized = identityKey?.trim(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null { - if (credential.type === "api_key") { - const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key }; - return { - credentialType: "api_key", - data: JSON.stringify(data), - identityKey: null, - }; - } - if (credential.type === "oauth") { - const { type: _type, ...rest } = credential; - return { - credentialType: "oauth", - data: JSON.stringify(rest), - identityKey: resolveCredentialIdentityKey(provider, credential), - }; - } - return null; -} - -function deserializeCredential(row: AuthRow): AuthCredential | null { - let parsed: unknown; - try { - parsed = JSON.parse(row.data); - } catch { - return null; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - if (row.credential_type === "api_key") { - const data = parsed as Record; - if (typeof data.key === "string") { - const source = data.source === "login" ? "login" : undefined; - return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key }; - } - } - if (row.credential_type === "oauth") { - return { type: "oauth", ...(parsed as Record) } as AuthCredential; - } - return null; -} - -function normalizeDisabledCause(disabledCause: string): string { - const normalized = disabledCause.trim(); - return normalized.length > 0 ? normalized : "disabled"; -} - -function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential { - return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause }; -} - -function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { - const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); - if (provider === "anthropic" || provider === "openai-codex") { - // One account email can hold several organizations/workspaces (e.g. a - // Team seat plus a personal plan), each with its own org-scoped token - // and limit pools. Scope identity by org so both subscriptions can be - // stored side by side. The qualifier rides on whichever base identity - // is available, so an unqualified account/project fallback would - // still collapse two subscriptions whenever the email could not be - // recovered. Org-less credentials (rows written before org capture - // existed) keep their bare key. - const base = - emailIdentifier ?? - identifiers.find(identifier => identifier.startsWith("account:")) ?? - identifiers.find(identifier => identifier.startsWith("project:")); - const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); - if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; - // No base identity at all: the org alone still distinguishes the row. - return orgIdentifier ?? null; - } - const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); - if (accountIdentifier) return accountIdentifier; - if (emailIdentifier) return emailIdentifier; - const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:")); - if (projectIdentifier) return projectIdentifier; - return null; -} - -function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null { - if (credential.type === "api_key") return null; - return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)); -} - -function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null { - const identityKey = normalizeStoredIdentityKey(row.identity_key); - if (identityKey) return identityKey; - const credential = deserializeCredential(row); - return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null; -} - -function matchesReplacementCredential( - provider: string, - existing: AuthCredential | null, - existingIdentityKey: string | null, - incoming: AuthCredential, -): boolean { - if (!existing || existing.type !== incoming.type) return false; - if (incoming.type === "api_key") { - if (existing.type !== "api_key") return false; - if (existing.key === incoming.key) return true; - if (provider !== "alibaba-token-plan") return false; - const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token; - const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token; - return existingToken !== undefined && existingToken === incomingToken; - } - const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming); - const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers); - if (incomingIdentityKey === null) return false; - if (incomingIdentityKey === existingIdentityKey) return true; - if (existingIdentityKey === null) return false; - // One-way upgrade, applied only when the INCOMING identity key carries the - // org qualifier (only anthropic and openai-codex keys do, so other - // providers never reach the checks below). An org-scoped login `org:` - // claims (and re-keys) any existing row that denotes the same subscription: - // - `org:` — org-only row stored when identity recovery failed, claimed - // once a later same-org login recovers a base identity; - // - `` for any base identity `` (email/account/project) the incoming - // credential carries — a pre-org legacy row, mirroring the pre-org - // replace behavior; - // - `|org:` for any such base — the same subscription keyed by a - // different base, e.g. an account-keyed row stored while the email could - // not be recovered, claimed once a later login recovers the email; - // - any same-org row whose STORED credential shares a base identity with - // the incoming one — a stored credential can retain identifiers its key - // does not use (an email-keyed row also carries the account UUID), so a - // later login that loses the email but keeps the account still updates - // its row instead of duplicating the subscription. - // The reverse stays a non-match: an org-less credential only ever replaces - // via exact key equality above and must never clobber an org-scoped row. - const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:")); - if (orgIdentifier === undefined) return false; - if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false; - if (existingIdentityKey === orgIdentifier) return true; - const existingIdentifiers = - existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`) - ? extractOAuthCredentialIdentifiers(existing) - : null; - // A base identifier that merely repeats the org qualifier's id carries no - // per-user identity (openai-codex stores the ChatGPT workspace id as both - // accountId and orgId, shared by every member) — letting it act as a - // claimable base would re-key another member's same-org row. - const orgQualifierId = orgIdentifier.slice("org:".length); - for (const identifier of incomingIdentifiers) { - const isBase = - identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:"); - if (!isBase) continue; - if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue; - if (existingIdentityKey === identifier) return true; - if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true; - if (existingIdentifiers?.includes(identifier)) return true; - } - return false; -} - -function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { - const identifiers = new Set(); - const accountId = normalizeStoredAccountId(credential.accountId); - if (accountId) identifiers.add(`account:${accountId}`); - const email = normalizeStoredEmail(credential.email); - if (email) identifiers.add(`email:${email}`); - const projectId = normalizeStoredAccountId(credential.projectId); - if (projectId) identifiers.add(`project:${projectId}`); - const orgId = normalizeStoredAccountId(credential.orgId); - if (orgId) identifiers.add(`org:${orgId}`); - const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; - for (const identifier of accessIdentifiers) { - identifiers.add(identifier); - } - const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? []; - for (const identifier of refreshIdentifiers) { - identifiers.add(identifier); - } - return [...identifiers]; -} - -function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined { - if (!token) return undefined; - const parts = token.split("."); - if (parts.length !== 3) return undefined; - try { - const payload = JSON.parse( - new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })), - ) as Record; - const identifiers = new Set(); - const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined); - if (directEmail) identifiers.add(`email:${directEmail}`); - const openAiProfile = payload["https://api.openai.com/profile"]; - if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) { - const claimEmail = normalizeStoredEmail( - (openAiProfile as Record).email as string | undefined, - ); - if (claimEmail) identifiers.add(`email:${claimEmail}`); - } - const openAiAuth = payload["https://api.openai.com/auth"]; - const authClaims = - typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth) - ? (openAiAuth as Record) - : undefined; - const accountId = normalizeStoredAccountId( - typeof payload.account_id === "string" - ? payload.account_id - : typeof payload.accountId === "string" - ? payload.accountId - : typeof payload.user_id === "string" - ? payload.user_id - : typeof payload.sub === "string" - ? payload.sub - : typeof authClaims?.chatgpt_account_id === "string" - ? authClaims.chatgpt_account_id - : undefined, - ); - if (accountId) identifiers.add(`account:${accountId}`); - return identifiers.size > 0 ? [...identifiers] : undefined; - } catch { - return undefined; - } -} -/** - * Default SQLite-backed implementation of {@link AuthCredentialStore}. - * - * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. - * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, - * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly - * without going through `AuthStorage`. - */ -export class SqliteAuthCredentialStore implements AuthCredentialStore { - #db: Database; - #listActiveStmt: Statement; - #listActiveByProviderStmt: Statement; - #listDisabledStmt: Statement; - #listDisabledByProviderStmt: Statement; - #insertStmt: Statement; - #updateStmt: Statement; - #deleteStmt: Statement; - #deleteIfMatchesStmt: Statement; - #updateIfMatchesStmt: Statement; - #deleteByProviderStmt: Statement; - #hardDeleteStmt: Statement; - #getCacheStmt: Statement; - #getCacheIncludingExpiredStmt: Statement; - #upsertCacheStmt: Statement; - #deleteCachePrefixStmt: Statement; - #deleteExpiredCacheStmt: Statement; - #updateIfMatchesWithLeaseStmt: Statement; - #deleteIfMatchesWithLeaseStmt: Statement; - #getCredentialBlockStmt: Statement; - #listCredentialBlocksByCredentialStmt: Statement; - #upsertCredentialBlockStmt: Statement; - #deleteCredentialBlocksStmt: Statement; - #deleteCredentialBlockStmt: Statement; - #deleteExpiredCredentialBlocksStmt: Statement; - #acquireCredentialRefreshLeaseStmt: Statement; - #getCredentialRefreshLeaseStmt: Statement; - #renewCredentialRefreshLeaseStmt: Statement; - #releaseCredentialRefreshLeaseStmt: Statement; - #credentialBlockReconcileAfter: Map = new Map(); - #insertUsageHistoryStmt: Statement; - #insertUsageCostStmt: Statement; - #listUsageCostsStmt: Statement; - #lastUsageHistoryStmt: Statement; - #listUsageHistoryStmt: Statement; - #updateUsageHistoryStmt: Statement; - #dataVersion: number; - #authRevision: number; - #localAuthRevision: number; - #closed = false; - - constructor(db: Database) { - this.#db = db; - this.#initializeSchema(); - this.#dataVersion = this.#readDataVersion(); - this.#authRevision = this.#readAuthRevision(); - this.#localAuthRevision = this.#readLocalAuthRevision(); - - this.#listActiveStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC", - ); - this.#listActiveByProviderStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC", - ); - this.#listDisabledStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC", - ); - this.#listDisabledByProviderStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC", - ); - this.#insertStmt = this.#db.prepare( - `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`, - ); - this.#updateStmt = this.#db.prepare( - `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, - ); - this.#updateIfMatchesStmt = this.#db.prepare( - `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, - ); - this.#updateIfMatchesWithLeaseStmt = this.#db.prepare( - `UPDATE auth_credentials - SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} - WHERE id = ? AND data = ? AND disabled_cause IS NULL - AND EXISTS ( - SELECT 1 FROM auth_credential_refresh_leases - WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? - )`, - ); - this.#deleteStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, - ); - this.#deleteIfMatchesStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, - ); - this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare( - `UPDATE auth_credentials - SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} - WHERE id = ? AND data = ? AND disabled_cause IS NULL - AND EXISTS ( - SELECT 1 FROM auth_credential_refresh_leases - WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? - )`, - ); - this.#deleteByProviderStmt = this.#db.prepare( - `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`, - ); - this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); - this.#getCacheStmt = this.#db.prepare( - `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`, - ); - this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?"); - this.#upsertCacheStmt = this.#db.prepare( - "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", - ); - this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?"); - this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`); - this.#getCredentialBlockStmt = this.#db.prepare( - "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?", - ); - this.#listCredentialBlocksByCredentialStmt = this.#db.prepare( - `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE credential_id = ? AND blocked_until_ms > ? - AND NOT (provider_key = ? AND block_scope = ?) - ORDER BY provider_key ASC, block_scope ASC`, - ); - this.#upsertCredentialBlockStmt = this.#db.prepare( - `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at) - VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms), - updated_at = excluded.updated_at`, - ); - this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?"); - this.#deleteCredentialBlockStmt = this.#db.prepare( - "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?", - ); - this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare( - "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?", - ); - this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare( - `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at) - VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH}) - ON CONFLICT(credential_id) DO UPDATE SET - owner = excluded.owner, - expires_at_ms = excluded.expires_at_ms, - updated_at = excluded.updated_at - WHERE auth_credential_refresh_leases.expires_at_ms <= ?`, - ); - this.#getCredentialRefreshLeaseStmt = this.#db.prepare( - "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?", - ); - this.#renewCredentialRefreshLeaseStmt = this.#db.prepare( - `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`, - ); - this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare( - "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?", - ); - this.#insertUsageHistoryStmt = this.#db.prepare( - "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ); - this.#lastUsageHistoryStmt = this.#db.prepare( - "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1", - ); - this.#updateUsageHistoryStmt = this.#db.prepare( - "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?", - ); - this.#listUsageHistoryStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", - ); - this.#insertUsageCostStmt = this.#db.prepare( - "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", - ); - this.#listUsageCostsStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", - ); - } - - static async open(dbPath: string = getAgentDbPath()): Promise { - const dir = path.dirname(dbPath); - const dirExists = await fs - .stat(dir) - .then(s => s.isDirectory()) - .catch(() => false); - if (!dirExists) { - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - } - - // Concurrent omp startups can race against WAL recovery and the schema - // init's first lock-taking statement. Bun's default `busy_timeout` is 0, - // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded - // exponential backoff before surfacing the failure. See issue #2421. - const maxAttempts = 4; - const baseDelayMs = 100; - let lastBusyError: Error | undefined; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - let db: Database | undefined; - try { - db = new Database(dbPath); - // Install the busy handler BEFORE the first lock-taking statement - // on this connection. The leases DDL below and the constructor's - // schema init both acquire locks during WAL recovery; without a - // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY. - // See issue #2421. - SqliteAuthCredentialStore.#installBusyTimeout(db); - try { - await fs.chmod(dbPath, 0o600); - } catch { - // Ignore chmod failures (e.g., Windows) - } - SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db); - return new SqliteAuthCredentialStore(db); - } catch (err) { - db?.close(); - if (!isSqliteBusyError(err)) { - throw err; - } - lastBusyError = err instanceof Error ? err : new Error(String(err)); - if (attempt < maxAttempts - 1) { - await Bun.sleep(baseDelayMs * 2 ** attempt); - } - } - } - throw new AIError.ConfigurationError( - `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, - { cause: lastBusyError }, - ); - } - - static #ensureAuthCredentialRefreshLeasesTable(db: Database): void { - db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases ( - credential_id INTEGER PRIMARY KEY, - owner TEXT NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms); - `); - } - - /** - * Install the per-connection busy handler so lock-taking statements wait for - * a contended writer instead of failing immediately (Bun defaults - * `busy_timeout` to 0). MUST run before the first lock-taking statement on - * the connection: concurrent omp startups race WAL recovery and the leases - * DDL. Uses the centralized timeout so headless hosts keep their bounded - * busy wait instead of the interactive 5s value. See issues #2421, #7298. - */ - static #installBusyTimeout(db: Database): void { - db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`); - } - - #initializeSchema(): void { - // Install the busy handler BEFORE any lock-taking statement (incl. - // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL - // recovery). Without this, concurrent omp startups can crash here with - // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via - // `open()` (which already installed it) is idempotent. See issue #2421. - SqliteAuthCredentialStore.#installBusyTimeout(this.#db); - this.#db.run(` - PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - CREATE TABLE IF NOT EXISTS auth_schema_version ( - id INTEGER PRIMARY KEY CHECK (id = 1), - version INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS cache ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - expires_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); - CREATE TABLE IF NOT EXISTS usage_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - email TEXT, - account_id TEXT, - limit_id TEXT NOT NULL, - label TEXT NOT NULL, - window_label TEXT, - used_fraction REAL, - status TEXT, - resets_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); - CREATE TABLE IF NOT EXISTS usage_cost_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - cost_usd REAL NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); - CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); - CREATE TABLE IF NOT EXISTS clients ( - install_id TEXT PRIMARY KEY, - hostname TEXT, - first_seen INTEGER NOT NULL, - last_seen INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS client_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - install_id TEXT NOT NULL, - provider TEXT NOT NULL, - model TEXT NOT NULL, - requests INTEGER NOT NULL, - input_tokens INTEGER NOT NULL, - output_tokens INTEGER NOT NULL, - cache_read_tokens INTEGER NOT NULL, - cache_write_tokens INTEGER NOT NULL, - cost_usd REAL NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at); - CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at); - `); - - if (!this.#authCredentialsTableExists()) { - this.#createAuthCredentialsTable(); - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialRefreshLeasesTable(); - this.#createAuthCredentialBlockCompatibilityObjects(); - this.#createAuthChangeTrackingObjects(); - this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); - return; - } - - const recordedVersion = this.#readAuthSchemaVersion(); - const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion(); - if (schemaVersion > AUTH_SCHEMA_VERSION) { - logger.warn("SqliteAuthCredentialStore schema version mismatch", { - current: schemaVersion, - expected: AUTH_SCHEMA_VERSION, - }); - } else if (schemaVersion < AUTH_SCHEMA_VERSION) { - this.#migrateAuthSchema(schemaVersion); - } - - this.#createAuthCredentialIndexes(); - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialRefreshLeasesTable(); - if (schemaVersion <= AUTH_SCHEMA_VERSION) { - this.#createAuthCredentialBlockCompatibilityObjects(); - } - this.#createAuthChangeTrackingObjects(); - this.#backfillCredentialIdentityKeys(); - // Rewriting an already-current version row is a no-op write transaction - // on every boot; only persist when the recorded version actually changes. - if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) { - this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); - } - } - - #authCredentialsTableExists(): boolean { - const stmt = this.#db.prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'", - ); - try { - const row = stmt.get() as { present?: number } | undefined; - return row?.present === 1; - } finally { - stmt.finalize(); - } - } - - #readAuthSchemaVersion(): number | null { - const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1"); - try { - const row = stmt.get() as { version?: number } | undefined; - return typeof row?.version === "number" ? row.version : null; - } finally { - stmt.finalize(); - } - } - - #writeAuthSchemaVersion(version: number): void { - const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)"); - try { - stmt.run(version); - } finally { - stmt.finalize(); - } - } - - #inferAuthSchemaVersion(): number { - const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); - try { - const cols = stmt.all() as Array<{ name?: string }>; - return this.#inferAuthSchemaVersionFromColumns(cols); - } finally { - stmt.finalize(); - } - } - - #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number { - const hasDisabledCause = cols.some(column => column.name === "disabled_cause"); - const hasIdentityKey = cols.some(column => column.name === "identity_key"); - const hasAccountId = cols.some(column => column.name === "account_id"); - const hasEmail = cols.some(column => column.name === "email"); - if (hasIdentityKey) return 3; - if (hasAccountId || hasEmail) return 2; - if (hasDisabledCause) return 1; - return 0; - } - - #createAuthCredentialsTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) - ); - `); - this.#createAuthCredentialIndexes(); - } - - #createAuthCredentialIndexes(): void { - this.#db.run(` - CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); - CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; - `); - } - - #createAuthCredentialBlocksTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_blocks ( - credential_id INTEGER NOT NULL, - provider_key TEXT NOT NULL, - block_scope TEXT NOT NULL DEFAULT '', - blocked_until_ms INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (credential_id, provider_key, block_scope) - ); - CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms); - `); - } - - #createAuthChangeTrackingObjects(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_change_revision ( - id INTEGER PRIMARY KEY CHECK (id = 1), - revision INTEGER NOT NULL - ); - INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0); - CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision ( - id INTEGER PRIMARY KEY CHECK (id = 1), - revision INTEGER NOT NULL - ); - INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0); - `); - for (const table of ["auth_credentials", "auth_credential_blocks"] as const) { - for (const event of ["INSERT", "UPDATE", "DELETE"] as const) { - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()} - AFTER ${event} ON ${table} - BEGIN - UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1; - END; - `); - this.#db.run(` - CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()} - AFTER ${event} ON main.${table} - BEGIN - UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1; - END; - `); - } - } - } - - #createAuthCredentialBlockMirrorGuardTable(): void { - this.#db.run(` - CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard ( - credential_id INTEGER PRIMARY KEY - ) WITHOUT ROWID; - `); - } - - /** - * Keep a physical Codex `shared` row for pre-meter binaries that read this - * database directly. Meter rows are canonical for current code. The guard - * suppresses feedback while triggers update the compatibility projection. - */ - #createAuthCredentialBlockCompatibilityTriggers(): void { - for (const event of ["INSERT", "UPDATE"] as const) { - const eventName = event.toLowerCase(); - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters - AFTER ${event} ON auth_credential_blocks - WHEN NEW.provider_key = 'openai-codex:oauth' - AND NEW.block_scope = 'shared' - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (NEW.credential_id); - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - VALUES ( - NEW.credential_id, - NEW.provider_key, - 'chat', - NEW.blocked_until_ms, - NEW.updated_at - ) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - VALUES ( - NEW.credential_id, - NEW.provider_key, - 'spark', - NEW.blocked_until_ms, - NEW.updated_at - ) - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id; - END; - - CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared - AFTER ${event} ON auth_credential_blocks - WHEN NEW.provider_key = 'openai-codex:oauth' - AND NEW.block_scope IN ('chat', 'spark') - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (NEW.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = NEW.credential_id - AND provider_key = NEW.provider_key - AND block_scope = 'shared'; - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - NEW.credential_id, - NEW.provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE credential_id = NEW.credential_id - AND provider_key = NEW.provider_key - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key; - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = NEW.credential_id; - END; - `); - } - - this.#db.run(` - CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters - AFTER DELETE ON auth_credential_blocks - WHEN OLD.provider_key = 'openai-codex:oauth' - AND OLD.block_scope = 'shared' - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (OLD.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope IN ('chat', 'spark'); - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id; - END; - - CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared - AFTER DELETE ON auth_credential_blocks - WHEN OLD.provider_key = 'openai-codex:oauth' - AND OLD.block_scope IN ('chat', 'spark') - AND NOT EXISTS ( - SELECT 1 FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id - ) - BEGIN - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - VALUES (OLD.credential_id); - DELETE FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope = 'shared'; - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - OLD.credential_id, - OLD.provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE credential_id = OLD.credential_id - AND provider_key = OLD.provider_key - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key; - DELETE FROM auth_credential_block_mirror_guard - WHERE credential_id = OLD.credential_id; - END; - `); - } - - #createAuthCredentialBlockCompatibilityObjects(): void { - this.#createAuthCredentialBlockMirrorGuardTable(); - this.#createAuthCredentialBlockCompatibilityTriggers(); - } - - #createAuthCredentialRefreshLeasesTable(): void { - SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db); - } - - #migrateAuthSchema(fromVersion: number): void { - if (fromVersion < 1) { - this.#migrateAuthSchemaV0ToV1(); - } - if (fromVersion < 3) { - this.#migrateAuthSchemaV1OrV2ToV3(); - } - if (fromVersion < 4) { - this.#migrateAuthSchemaV3ToV4(); - } - if (fromVersion < 5) { - this.#migrateAuthSchemaV4ToV5(); - } - if (fromVersion < 6) { - this.#migrateAuthSchemaV5ToV6(); - } - if (fromVersion < 7) { - this.#migrateAuthSchemaV6ToV7(); - } - } - - #migrateAuthSchemaV0ToV1(): void { - const migrate = this.#db.transaction(() => { - const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); - let hasDisabled = false; - try { - const v0Cols = stmt.all() as Array<{ name?: string }>; - hasDisabled = v0Cols.some(col => col.name === "disabled"); - } finally { - stmt.finalize(); - } - - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); - this.#db.run(` - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) - ); - `); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"}, - created_at, - updated_at - FROM auth_credentials_v0 - `); - this.#db.run("DROP TABLE auth_credentials_v0"); - }); - migrate(); - } - - #migrateAuthSchemaV1OrV2ToV3(): void { - const migrate = this.#db.transaction(() => { - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); - this.#createAuthCredentialsTable(); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - disabled_cause, - NULL, - created_at, - updated_at - FROM auth_credentials_legacy - `); - this.#db.run("DROP TABLE auth_credentials_legacy"); - }); - migrate(); - } - - #migrateAuthSchemaV3ToV4(): void { - const migrate = this.#db.transaction(() => { - this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); - this.#createAuthCredentialsTable(); - this.#db.run(` - INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) - SELECT - id, - provider, - credential_type, - data, - disabled_cause, - identity_key, - created_at, - updated_at - FROM auth_credentials_v3 - `); - this.#db.run("DROP TABLE auth_credentials_v3"); - }); - migrate(); - } - - #migrateAuthSchemaV4ToV5(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialBlocksTable(); - }); - migrate(); - } - - #migrateAuthSchemaV5ToV6(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialRefreshLeasesTable(); - }); - migrate(); - } - - #migrateAuthSchemaV6ToV7(): void { - const migrate = this.#db.transaction(() => { - this.#createAuthCredentialBlocksTable(); - this.#createAuthCredentialBlockMirrorGuardTable(); - this.#db.run(` - DELETE FROM auth_credential_block_mirror_guard; - INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) - SELECT DISTINCT credential_id - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope IN ('chat', 'spark', 'shared'); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope = 'shared' - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope = 'shared' - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), - updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); - - INSERT INTO auth_credential_blocks ( - credential_id, - provider_key, - block_scope, - blocked_until_ms, - updated_at - ) - SELECT - credential_id, - provider_key, - 'shared', - MAX(blocked_until_ms), - MAX(updated_at) - FROM auth_credential_blocks - WHERE provider_key = 'openai-codex:oauth' - AND block_scope IN ('chat', 'spark') - GROUP BY credential_id, provider_key - ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET - blocked_until_ms = excluded.blocked_until_ms, - updated_at = excluded.updated_at; - - DELETE FROM auth_credential_block_mirror_guard; - `); - this.#createAuthCredentialBlockCompatibilityTriggers(); - this.#writeAuthSchemaVersion(7); - }); - migrate.immediate(); - } - - #backfillCredentialIdentityKeys(): void { - const selectRowsStmt = this.#db.prepare( - "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC", - ); - let rows: AuthRow[]; - try { - rows = selectRowsStmt.all() as AuthRow[]; - } finally { - selectRowsStmt.finalize(); - } - if (rows.length === 0) return; - - let updateIdentity: Statement | null = null; - try { - for (const row of rows) { - const identityKey = resolveRowCredentialIdentityKey(row.provider, row); - // Rows whose identity cannot be derived stay NULL; writing NULL over - // NULL would just burn a write transaction on every boot. - if (identityKey === null) continue; - updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?"); - updateIdentity.run(identityKey, row.id); - } - } finally { - updateIdentity?.finalize(); - } - } - - // ─── AuthCredentialStore interface ────────────────────────────────────── - - listAuthCredentials(provider?: string): StoredAuthCredential[] { - const rows = - (provider - ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[]) - : (this.#listActiveStmt.all() as AuthRow[])) ?? []; - - const results: StoredAuthCredential[] = []; - for (const row of rows) { - const credential = deserializeCredential(row); - if (!credential) continue; - results.push(toStoredAuthCredential(row, credential)); - } - return results; - } - - async listDisabledCredentials(provider?: string): Promise { - const rows = - (provider - ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[]) - : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? []; - const results: DisabledCredentialSummary[] = []; - for (const row of rows) { - const credential = deserializeCredential(row); - const summary: DisabledCredentialSummary = { - id: row.id, - provider: row.provider, - type: row.credential_type === "api_key" ? "api_key" : "oauth", - cause: row.disabled_cause ?? "disabled", - }; - if (credential?.type === "oauth") { - if (credential.email) summary.email = credential.email; - if (credential.accountId) summary.accountId = credential.accountId; - if (credential.orgId) summary.orgId = credential.orgId; - if (credential.orgName) summary.orgName = credential.orgName; - } - if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) { - summary.disabledAtMs = row.updated_at * 1000; - } - results.push(summary); - } - return results; - } - - replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { - const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { - const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const existing = existingRows.map(row => ({ - id: row.id, - credential: deserializeCredential(row), - identityKey: resolveRowCredentialIdentityKey(providerName, row), - })); - - const result: StoredAuthCredential[] = []; - const matchedExistingIds = new Set(); - - for (const credential of items) { - const serialized = serializeCredential(providerName, credential); - if (!serialized) continue; - const match = existing.find( - entry => - !matchedExistingIds.has(entry.id) && - matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential), - ); - if (match) { - matchedExistingIds.add(match.id); - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id); - result.push({ id: match.id, provider: providerName, credential, disabledCause: null }); - } else { - const row = this.#insertStmt.get( - providerName, - serialized.credentialType, - serialized.data, - serialized.identityKey, - ) as { id?: number } | undefined; - if (row?.id) { - result.push({ id: row.id, provider: providerName, credential, disabledCause: null }); - } - } - } - - for (const row of existing) { - if (!matchedExistingIds.has(row.id)) { - this.#deleteStmt.run("replaced by newer credential", row.id); - } - } - - return result; - }); - - const result = replace(provider, credentials); - this.#purgeSupersededDisabledRows(provider, result); - return result; - } - - upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] { - const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => { - const serialized = serializeCredential(providerName, item); - if (!serialized) return this.listAuthCredentials(providerName); - const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const existing = existingRows.map(row => ({ - id: row.id, - credential: deserializeCredential(row), - identityKey: resolveRowCredentialIdentityKey(providerName, row), - })); - - if (item.type === "oauth") { - for (const row of existing) { - if (row.credential && row.credential.type === "api_key") { - this.#deleteStmt.run("replaced by oauth login", row.id); - } - } - } - - let targetId: number | null = null; - for (const row of existing) { - if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; - if (targetId === null) { - targetId = row.id; - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id); - continue; - } - this.#deleteStmt.run("replaced by newer credential", row.id); - } - - if (targetId === null) { - const row = this.#insertStmt.get( - providerName, - serialized.credentialType, - serialized.data, - serialized.identityKey, - ) as { id?: number } | undefined; - targetId = row?.id ?? null; - } - - const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; - const result: StoredAuthCredential[] = []; - for (const row of activeRows) { - const activeCredential = deserializeCredential(row); - if (!activeCredential) continue; - result.push(toStoredAuthCredential(row, activeCredential)); - } - return result; - }); - - const result = upsert(provider, credential); - this.#purgeSupersededDisabledRows(provider, result); - return result; - } - - /** - * Hard-deletes disabled rows for a provider when an active replacement exists. - * OAuth credentials match by identity key; API keys match by provider and type. - * Disabled rows without an active same-type replacement remain recoverable. - */ - #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void { - try { - let hasActiveApiKey = false; - const activeIdentityKeys = new Set(); - for (const row of activeRows) { - if (row.credential.type === "api_key") { - hasActiveApiKey = true; - continue; - } - const identityKey = resolveCredentialIdentityKey(provider, row.credential); - if (identityKey) activeIdentityKeys.add(identityKey); - } - if (!hasActiveApiKey && activeIdentityKeys.size === 0) return; - - const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[]; - for (const row of disabledRows) { - if (hasActiveApiKey && row.credential_type === "api_key") { - this.#hardDeleteStmt.run(row.id); - continue; - } - const identityKey = resolveRowCredentialIdentityKey(provider, row); - if (identityKey && activeIdentityKeys.has(identityKey)) { - this.#hardDeleteStmt.run(row.id); - } - } - } catch { - // Best-effort cleanup; don't let it break the main operation - } - } - - updateAuthCredential(id: number, credential: AuthCredential): void { - try { - const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); - let providerRow: { provider?: string } | undefined; - try { - providerRow = providerStmt.get(id) as { provider?: string } | undefined; - } finally { - providerStmt.finalize(); - } - const provider = providerRow?.provider ?? ""; - const serialized = serializeCredential(provider, credential); - if (!serialized) return; - this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id); - if (provider) { - this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); - } - } catch { - // Ignore update failures - } - } - - tryUpdateAuthCredentialIfMatches( - id: number, - expectedData: string, - credential: AuthCredential, - lease?: CredentialRefreshLeaseFence, - ): boolean { - const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); - let providerRow: { provider?: string } | undefined; - try { - providerRow = providerStmt.get(id) as { provider?: string } | undefined; - } finally { - providerStmt.finalize(); - } - const provider = providerRow?.provider ?? ""; - const serialized = serializeCredential(provider, credential); - if (!serialized) return false; - const result = lease - ? (this.#updateIfMatchesWithLeaseStmt.run( - serialized.credentialType, - serialized.data, - serialized.identityKey, - id, - expectedData, - id, - lease.owner, - lease.nowMs, - ) as { changes: number }) - : (this.#updateIfMatchesStmt.run( - serialized.credentialType, - serialized.data, - serialized.identityKey, - id, - expectedData, - ) as { changes: number }); - if (result.changes === 0) return false; - if (provider) { - this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); - } - return true; - } - - deleteAuthCredential(id: number, disabledCause: string): void { - try { - this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id); - } catch { - // Ignore delete failures - } - } - - /** - * CAS-style disable: only soft-deletes the row when its `data` column still - * matches `expectedData` and the row has not already been disabled. Used by - * the OAuth refresh-failure path to avoid clobbering a peer that rotated the - * row between our pre-check and the disable. - */ - tryDisableAuthCredentialIfMatches( - id: number, - expectedData: string, - disabledCause: string, - lease?: CredentialRefreshLeaseFence, - ): boolean { - const result = lease - ? (this.#deleteIfMatchesWithLeaseStmt.run( - normalizeDisabledCause(disabledCause), - id, - expectedData, - id, - lease.owner, - lease.nowMs, - ) as { changes: number }) - : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as { - changes: number; - }); - return result.changes > 0; - } - deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void { - try { - this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider); - } catch { - // Ignore delete failures - } - } - - getCache(key: string, options?: { includeExpired?: boolean }): string | null { - try { - const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt; - const row = stmt.get(key) as { value?: string } | undefined; - return row?.value ?? null; - } catch { - return null; - } - } - - setCache(key: string, value: string, expiresAtSec: number): void { - try { - this.#upsertCacheStmt.run(key, value, expiresAtSec); - } catch { - // Ignore cache set failures - } - } - - /** Drop all cache rows whose keys start with the supplied prefix. */ - deleteCachePrefix(prefix: string): void { - try { - this.#deleteCachePrefixStmt.run(prefix.length, prefix); - } catch { - // Ignore cache delete failures - } - } - - cleanExpiredCache(): void { - try { - this.#deleteExpiredCacheStmt.run(); - } catch { - // Ignore cleanup errors - } - } - - getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { - const nowMs = Date.now(); - const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; - // Current callers use meter scopes. The physical shared row exists only - // for direct SQLite readers from pre-meter releases. - if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { - return undefined; - } - if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as - | { blocked_until_ms?: number; updated_at?: number } - | undefined; - return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined; - } - - getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined { - const nowMs = Date.now(); - const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; - if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { - return undefined; - } - if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as - | { blocked_until_ms?: number; updated_at?: number } - | undefined; - if (typeof row?.blocked_until_ms !== "number") return undefined; - const memoryReconcileAfter = - this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0; - const persistedReconcileAfter = - typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0; - const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter); - return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined; - } - - upsertCredentialBlock(block: StoredCredentialBlock): void { - const isLegacyCodexBlock = - block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE; - const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope]; - const upsert = this.#db.transaction(() => { - for (const blockScope of blockScopes) { - this.#upsertCredentialBlockStmt.run( - block.credentialId, - block.providerKey, - blockScope, - block.blockedUntilMs, - ); - } - }); - upsert.immediate(); - - const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS); - for (const blockScope of blockScopes) { - this.#credentialBlockReconcileAfter.set( - `${block.credentialId}\0${block.providerKey}\0${blockScope}`, - reconcileAfterMs, - ); - } - if (isLegacyCodexBlock) { - this.#credentialBlockReconcileAfter.delete( - `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`, - ); - } - } - - deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void { - this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope); - this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`); - } - - deleteCredentialBlocks(credentialId: number): void { - this.#deleteCredentialBlocksStmt.run(credentialId); - for (const key of this.#credentialBlockReconcileAfter.keys()) { - if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key); - } - } - - cleanExpiredCredentialBlocks(nowMs: number): void { - this.#deleteExpiredCredentialBlocksStmt.run(nowMs); - for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) { - if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key); - } - } - - listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] { - if (credentialIds.length === 0) return []; - const nowMs = Date.now(); - this.cleanExpiredCredentialBlocks(nowMs); - const seenCredentialIds = new Set(); - const blocks: StoredCredentialBlock[] = []; - for (const credentialId of credentialIds) { - if (seenCredentialIds.has(credentialId)) continue; - seenCredentialIds.add(credentialId); - const rows = this.#listCredentialBlocksByCredentialStmt.all( - credentialId, - nowMs, - LEGACY_CODEX_BLOCK_PROVIDER_KEY, - LEGACY_CODEX_BLOCK_SCOPE, - ) as CredentialBlockRow[]; - for (const row of rows) { - blocks.push({ - credentialId: row.credential_id, - providerKey: row.provider_key, - blockScope: row.block_scope, - blockedUntilMs: row.blocked_until_ms, - updatedAtMs: row.updated_at * 1000, - }); - } - } - return blocks; - } - - tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { - const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as { - changes: number; - }; - return result.changes === 1; - } - - getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined { - const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined; - if (typeof row?.expires_at_ms !== "number") return undefined; - if (row.expires_at_ms <= Date.now()) return undefined; - return row.expires_at_ms; - } - - renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { - const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as { - changes: number; - }; - return result.changes === 1; - } - - releaseCredentialRefreshLease(credentialId: number, owner: string): void { - try { - this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner); - } catch { - // Ignore lease release failures; expired leases are stealable. - } - } - - recordUsageSnapshots(entries: UsageHistoryEntry[]): void { - try { - for (const entry of entries) { - const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS); - const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as - | { id: number; recorded_at: number } - | undefined; - if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) { - this.#updateUsageHistoryStmt.run( - entry.recordedAt, - entry.email ?? null, - entry.accountId ?? null, - entry.label, - entry.windowLabel ?? null, - entry.usedFraction ?? null, - entry.status ?? null, - entry.resetsAt ?? null, - last.id, - ); - continue; - } - this.#insertUsageHistoryStmt.run( - entry.recordedAt, - entry.provider, - entry.accountKey, - entry.email ?? null, - entry.accountId ?? null, - entry.limitId, - entry.label, - entry.windowLabel ?? null, - entry.usedFraction ?? null, - entry.status ?? null, - entry.resetsAt ?? null, - ); - } - } catch { - // History is best-effort; never break the usage fetch path. - } - } - - listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - email: string | null; - account_id: string | null; - limit_id: string; - label: string; - window_label: string | null; - used_fraction: number | null; - status: string | null; - resets_at: number | null; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - email: row.email ?? undefined, - accountId: row.account_id ?? undefined, - limitId: row.limit_id, - label: row.label, - windowLabel: row.window_label ?? undefined, - usedFraction: row.used_fraction ?? undefined, - status: (row.status ?? undefined) as UsageHistoryEntry["status"], - resetsAt: row.resets_at ?? undefined, - })); - } catch { - return []; - } - } - recordUsageCosts(entries: UsageCostHistoryEntry[]): void { - try { - for (const entry of entries) { - this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); - } - } catch { - // Cost history is best-effort; never break request persistence. - } - } - - listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const accountKey = query?.accountKey ?? null; - const rows = this.#listUsageCostsStmt.all( - query?.sinceMs ?? 0, - provider, - provider, - accountKey, - accountKey, - ) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - cost_usd: number; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - costUsd: row.cost_usd, - })); - } catch { - return []; - } - } - - recordClientUsage(report: ClientUsageReport): void { - const now = Date.now(); - this.#db - .query( - `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?) - ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`, - ) - .run(report.installId, report.hostname ?? null, now, now); - const findBucket = this.#db.query( - `SELECT id FROM client_usage - WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ? - ORDER BY recorded_at DESC LIMIT 1`, - ); - const merge = this.#db.query( - `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?, - output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, - cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`, - ); - const insert = this.#db.query( - `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const entry of report.entries) { - // Merge into the newest row of the same (install, provider, model) - // bucket so 10s client flushes don't accrete one row apiece forever. - const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS; - const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as { - id: number; - } | null; - if (existing) { - merge.run( - entry.at, - entry.requests, - entry.inputTokens, - entry.outputTokens, - entry.cacheReadTokens, - entry.cacheWriteTokens, - entry.costUsd, - existing.id, - ); - continue; - } - insert.run( - entry.at, - report.installId, - entry.provider, - entry.model, - entry.requests, - entry.inputTokens, - entry.outputTokens, - entry.cacheReadTokens, - entry.cacheWriteTokens, - entry.costUsd, - ); - } - } - - getClientUsageSummary(sinceMs: number): ClientUsageSummary { - const clients = this.#db - .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC") - .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>; - const aggregates = this.#db - .query( - `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens, - SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens, - SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd - FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider - ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`, - ) - .all(sinceMs) as Array<{ - install_id: string; - provider: string; - requests: number; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - cost_usd: number; - }>; - const providersByInstall = new Map(); - for (const row of aggregates) { - let list = providersByInstall.get(row.install_id); - if (!list) { - list = []; - providersByInstall.set(row.install_id, list); - } - list.push({ - provider: row.provider, - requests: row.requests, - inputTokens: row.input_tokens, - outputTokens: row.output_tokens, - cacheReadTokens: row.cache_read_tokens, - cacheWriteTokens: row.cache_write_tokens, - costUsd: row.cost_usd, - }); - } - return { - clients: clients.map(client => ({ - installId: client.install_id, - hostname: client.hostname ?? undefined, - firstSeen: client.first_seen, - lastSeen: client.last_seen, - providers: providersByInstall.get(client.install_id) ?? [], - })), - }; - } - - // ─── Convenience methods for CLI ──────────────────────────────────────── - - /** - * Save OAuth credentials for a provider. - * Preserves unrelated identities and replaces only the matching credential. - */ - saveOAuth(provider: string, credentials: OAuthCredentials): void { - const credential: AuthCredential = { type: "oauth", ...credentials }; - this.upsertAuthCredentialForProvider(provider, credential); - } - - /** - * Get OAuth credentials for a provider. - */ - getOAuth(provider: string): OAuthCredentials | null { - const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; - for (const row of rows) { - const credential = deserializeCredential(row); - if (credential && credential.type === "oauth") { - const { type: _type, ...oauth } = credential; - return oauth as OAuthCredentials; - } - } - return null; - } - - /** - * Save API key for a provider (replaces existing). - */ - saveApiKey(provider: string, apiKey: string): void { - const credential: AuthCredential = { type: "api_key", key: apiKey }; - this.replaceAuthCredentialsForProvider(provider, [credential]); - } - - /** - * Get API key for a provider. - */ - getApiKey(provider: string): string | null { - const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; - for (const row of rows) { - const credential = deserializeCredential(row); - if (credential && credential.type === "api_key") { - return credential.key; - } - } - return null; - } - - /** - * List all providers with credentials. - */ - listProviders(): string[] { - const rows = this.#listActiveStmt.all() as AuthRow[]; - const providers = new Set(); - for (const row of rows) { - providers.add(row.provider); - } - return Array.from(providers); - } - - /** - * Delete all credentials for a provider. - */ - deleteProvider(provider: string): void { - this.deleteAuthCredentialsForProvider(provider, "deleted by user"); - } - - /** - * SQLite increments `data_version` when another connection commits. Own - * writes leave it unchanged and already notify AuthStorage directly. - */ - pollExternalChanges(): boolean { - this.#acknowledgeLocalAuthChanges(); - const dataVersion = this.#readDataVersion(); - if (dataVersion === this.#dataVersion) return false; - this.#dataVersion = dataVersion; - const authRevision = this.#readAuthRevision(); - if (authRevision === this.#authRevision) return false; - this.#authRevision = authRevision; - return true; - } - - acknowledgeLocalChanges(): void { - this.#acknowledgeLocalAuthChanges(); - } - - #acknowledgeLocalAuthChanges(): void { - const localAuthRevision = this.#readLocalAuthRevision(); - this.#authRevision += localAuthRevision - this.#localAuthRevision; - this.#localAuthRevision = localAuthRevision; - } - - #readDataVersion(): number { - const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null; - return row?.data_version ?? 0; - } - - #readAuthRevision(): number { - const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as { - revision?: number; - } | null; - return row?.revision ?? 0; - } - - #readLocalAuthRevision(): number { - const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as { - revision?: number; - } | null; - return row?.revision ?? 0; - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - this.#listActiveStmt.finalize(); - this.#listActiveByProviderStmt.finalize(); - this.#listDisabledStmt.finalize(); - this.#listDisabledByProviderStmt.finalize(); - this.#insertStmt.finalize(); - this.#updateStmt.finalize(); - this.#deleteStmt.finalize(); - this.#deleteIfMatchesStmt.finalize(); - this.#deleteByProviderStmt.finalize(); - this.#hardDeleteStmt.finalize(); - this.#getCacheStmt.finalize(); - this.#getCacheIncludingExpiredStmt.finalize(); - this.#upsertCacheStmt.finalize(); - this.#deleteExpiredCacheStmt.finalize(); - this.#getCredentialBlockStmt.finalize(); - this.#listCredentialBlocksByCredentialStmt.finalize(); - this.#upsertCredentialBlockStmt.finalize(); - this.#deleteCredentialBlocksStmt.finalize(); - this.#deleteCredentialBlockStmt.finalize(); - this.#deleteExpiredCredentialBlocksStmt.finalize(); - this.#insertUsageHistoryStmt.finalize(); - this.#lastUsageHistoryStmt.finalize(); - this.#listUsageHistoryStmt.finalize(); - this.#updateUsageHistoryStmt.finalize(); - this.#insertUsageCostStmt.finalize(); - this.#listUsageCostsStmt.finalize(); - this.#updateIfMatchesStmt.finalize(); - this.#updateIfMatchesWithLeaseStmt.finalize(); - this.#deleteIfMatchesWithLeaseStmt.finalize(); - this.#deleteCachePrefixStmt.finalize(); - this.#acquireCredentialRefreshLeaseStmt.finalize(); - this.#getCredentialRefreshLeaseStmt.finalize(); - this.#renewCredentialRefreshLeaseStmt.finalize(); - this.#releaseCredentialRefreshLeaseStmt.finalize(); - this.#db.close(); - } -} diff --git a/packages/ai/src/auth/sqlite-credential-store.ts b/packages/ai/src/auth/sqlite-credential-store.ts new file mode 100644 index 000000000..0ae14f851 --- /dev/null +++ b/packages/ai/src/auth/sqlite-credential-store.ts @@ -0,0 +1,2049 @@ +/** + * SQLite-backed credential persistence for AuthStorage. + * + * The public AuthCredentialStore interface remains in ../auth-storage so local + * and remote stores share the same contract. + */ +import { Database, type Statement } from "bun:sqlite"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; +import { getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import type { + AuthCredential, + AuthCredentialStore, + CredentialRefreshLeaseFence, + DisabledCredentialSummary, + OAuthCredential, + StoredAuthCredential, + StoredCredentialBlock, +} from "../auth-storage"; +import * as AIError from "../error"; +import type { OAuthCredentials } from "../registry/oauth/types"; +import type { Provider } from "../types"; +import type { + ClientProviderUsage, + ClientUsageReport, + ClientUsageSummary, + UsageCostHistoryEntry, + UsageCostHistoryQuery, + UsageHistoryEntry, + UsageHistoryQuery, +} from "../usage"; + +// 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP +// level so we can't fetch all N credentials every cycle; with a long cache +// each credential's last-known value sticks visible while peers retry. UI +// data (5h / 7d / monthly limits) is fine being a few minutes stale. +export const USAGE_REPORT_TTL_MS = 5 * 60_000; + +/** + * Downsample usage history to at most one row per hour per account window: a + * snapshot landing in the same hour bucket as the series' latest row + * overwrites it in place. That bound makes further retention pruning + * unnecessary — 1 row/hour is ~9k rows per account window per year. + */ +const USAGE_HISTORY_BUCKET_MS = 60 * 60_000; + +/** + * Merge client observed-usage flushes into at most one row per 5 minutes per + * (install, provider, model): ~300 rows/day per active model per client + * instead of one row per 10s flush. + */ +const CLIENT_USAGE_BUCKET_MS = 5 * 60_000; + +// ───────────────────────────────────────────────────────────────────────────── +// SqliteAuthCredentialStore +// ───────────────────────────────────────────────────────────────────────────── + +/** Row shape for auth_credentials table queries */ +type AuthRow = { + id: number; + provider: string; + credential_type: string; + data: string; + disabled_cause: string | null; + identity_key: string | null; +}; + +/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */ +type DisabledAuthRow = AuthRow & { updated_at: number | null }; + +type CredentialBlockRow = { + credential_id: number; + provider_key: string; + block_scope: string; + blocked_until_ms: number; + updated_at: number; +}; + +type SerializedCredentialRecord = { + credentialType: AuthCredential["type"]; + data: string; + identityKey: string | null; +}; + +const AUTH_SCHEMA_VERSION = 7; +const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; +const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth"; +const LEGACY_CODEX_BLOCK_SCOPE = "shared"; +const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const; + +/** + * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended + * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, + * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. + */ +export function isSqliteBusyError(err: unknown): boolean { + if (err === null || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + return typeof code === "string" && code.startsWith("SQLITE_BUSY"); +} + +/** + * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family + * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`) + * and `SQLITE_NOTADB` (the file header is not a database). Unlike + * {@link isSqliteBusyError}, these never clear by retrying: the store must be + * repaired or replaced, so callers latch and stop touching it. + */ +export function isSqliteCorruptionError(err: unknown): boolean { + if (err === null || typeof err !== "object" || !("code" in err)) return false; + const code = err.code; + return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB"); +} + +function normalizeStoredAccountId(accountId: string | null | undefined): string | null { + const normalized = accountId?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeStoredEmail(email: string | null | undefined): string | null { + const normalized = email?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null { + const normalized = identityKey?.trim(); + return normalized && normalized.length > 0 ? normalized : null; +} + +export function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null { + if (credential.type === "api_key") { + const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key }; + return { + credentialType: "api_key", + data: JSON.stringify(data), + identityKey: null, + }; + } + if (credential.type === "oauth") { + const { type: _type, ...rest } = credential; + return { + credentialType: "oauth", + data: JSON.stringify(rest), + identityKey: resolveCredentialIdentityKey(provider, credential), + }; + } + return null; +} + +function deserializeCredential(row: AuthRow): AuthCredential | null { + let parsed: unknown; + try { + parsed = JSON.parse(row.data); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + if (row.credential_type === "api_key") { + const data = parsed as Record; + if (typeof data.key === "string") { + const source = data.source === "login" ? "login" : undefined; + return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key }; + } + } + if (row.credential_type === "oauth") { + return { type: "oauth", ...(parsed as Record) } as AuthCredential; + } + return null; +} + +function normalizeDisabledCause(disabledCause: string): string { + const normalized = disabledCause.trim(); + return normalized.length > 0 ? normalized : "disabled"; +} + +function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential { + return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause }; +} + +function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { + const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); + if (provider === "anthropic" || provider === "openai-codex") { + // One account email can hold several organizations/workspaces (e.g. a + // Team seat plus a personal plan), each with its own org-scoped token + // and limit pools. Scope identity by org so both subscriptions can be + // stored side by side. The qualifier rides on whichever base identity + // is available, so an unqualified account/project fallback would + // still collapse two subscriptions whenever the email could not be + // recovered. Org-less credentials (rows written before org capture + // existed) keep their bare key. + const base = + emailIdentifier ?? + identifiers.find(identifier => identifier.startsWith("account:")) ?? + identifiers.find(identifier => identifier.startsWith("project:")); + const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); + if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; + // No base identity at all: the org alone still distinguishes the row. + return orgIdentifier ?? null; + } + const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); + if (accountIdentifier) return accountIdentifier; + if (emailIdentifier) return emailIdentifier; + const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:")); + if (projectIdentifier) return projectIdentifier; + return null; +} + +export function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null { + if (credential.type === "api_key") return null; + return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential)); +} + +function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null { + const identityKey = normalizeStoredIdentityKey(row.identity_key); + if (identityKey) return identityKey; + const credential = deserializeCredential(row); + return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null; +} + +function matchesReplacementCredential( + provider: string, + existing: AuthCredential | null, + existingIdentityKey: string | null, + incoming: AuthCredential, +): boolean { + if (!existing || existing.type !== incoming.type) return false; + if (incoming.type === "api_key") { + if (existing.type !== "api_key") return false; + if (existing.key === incoming.key) return true; + if (provider !== "alibaba-token-plan") return false; + const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token; + const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token; + return existingToken !== undefined && existingToken === incomingToken; + } + const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming); + const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers); + if (incomingIdentityKey === null) return false; + if (incomingIdentityKey === existingIdentityKey) return true; + if (existingIdentityKey === null) return false; + // One-way upgrade, applied only when the INCOMING identity key carries the + // org qualifier (only anthropic and openai-codex keys do, so other + // providers never reach the checks below). An org-scoped login `org:` + // claims (and re-keys) any existing row that denotes the same subscription: + // - `org:` — org-only row stored when identity recovery failed, claimed + // once a later same-org login recovers a base identity; + // - `` for any base identity `` (email/account/project) the incoming + // credential carries — a pre-org legacy row, mirroring the pre-org + // replace behavior; + // - `|org:` for any such base — the same subscription keyed by a + // different base, e.g. an account-keyed row stored while the email could + // not be recovered, claimed once a later login recovers the email; + // - any same-org row whose STORED credential shares a base identity with + // the incoming one — a stored credential can retain identifiers its key + // does not use (an email-keyed row also carries the account UUID), so a + // later login that loses the email but keeps the account still updates + // its row instead of duplicating the subscription. + // The reverse stays a non-match: an org-less credential only ever replaces + // via exact key equality above and must never clobber an org-scoped row. + const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:")); + if (orgIdentifier === undefined) return false; + if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false; + if (existingIdentityKey === orgIdentifier) return true; + const existingIdentifiers = + existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`) + ? extractOAuthCredentialIdentifiers(existing) + : null; + // A base identifier that merely repeats the org qualifier's id carries no + // per-user identity (openai-codex stores the ChatGPT workspace id as both + // accountId and orgId, shared by every member) — letting it act as a + // claimable base would re-key another member's same-org row. + const orgQualifierId = orgIdentifier.slice("org:".length); + for (const identifier of incomingIdentifiers) { + const isBase = + identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:"); + if (!isBase) continue; + if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue; + if (existingIdentityKey === identifier) return true; + if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true; + if (existingIdentifiers?.includes(identifier)) return true; + } + return false; +} + +function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { + const identifiers = new Set(); + const accountId = normalizeStoredAccountId(credential.accountId); + if (accountId) identifiers.add(`account:${accountId}`); + const email = normalizeStoredEmail(credential.email); + if (email) identifiers.add(`email:${email}`); + const projectId = normalizeStoredAccountId(credential.projectId); + if (projectId) identifiers.add(`project:${projectId}`); + const orgId = normalizeStoredAccountId(credential.orgId); + if (orgId) identifiers.add(`org:${orgId}`); + const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; + for (const identifier of accessIdentifiers) { + identifiers.add(identifier); + } + const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? []; + for (const identifier of refreshIdentifiers) { + identifiers.add(identifier); + } + return [...identifiers]; +} + +function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined { + if (!token) return undefined; + const parts = token.split("."); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse( + new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })), + ) as Record; + const identifiers = new Set(); + const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined); + if (directEmail) identifiers.add(`email:${directEmail}`); + const openAiProfile = payload["https://api.openai.com/profile"]; + if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) { + const claimEmail = normalizeStoredEmail( + (openAiProfile as Record).email as string | undefined, + ); + if (claimEmail) identifiers.add(`email:${claimEmail}`); + } + const openAiAuth = payload["https://api.openai.com/auth"]; + const authClaims = + typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth) + ? (openAiAuth as Record) + : undefined; + const accountId = normalizeStoredAccountId( + typeof payload.account_id === "string" + ? payload.account_id + : typeof payload.accountId === "string" + ? payload.accountId + : typeof payload.user_id === "string" + ? payload.user_id + : typeof payload.sub === "string" + ? payload.sub + : typeof authClaims?.chatgpt_account_id === "string" + ? authClaims.chatgpt_account_id + : undefined, + ); + if (accountId) identifiers.add(`account:${accountId}`); + return identifiers.size > 0 ? [...identifiers] : undefined; + } catch { + return undefined; + } +} +/** + * Default SQLite-backed implementation of {@link AuthCredentialStore}. + * + * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. + * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, + * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly + * without going through `AuthStorage`. + */ +export class SqliteAuthCredentialStore implements AuthCredentialStore { + #db: Database; + #listActiveStmt: Statement; + #listActiveByProviderStmt: Statement; + #listDisabledStmt: Statement; + #listDisabledByProviderStmt: Statement; + #insertStmt: Statement; + #updateStmt: Statement; + #deleteStmt: Statement; + #deleteIfMatchesStmt: Statement; + #updateIfMatchesStmt: Statement; + #deleteByProviderStmt: Statement; + #hardDeleteStmt: Statement; + #getCacheStmt: Statement; + #getCacheIncludingExpiredStmt: Statement; + #upsertCacheStmt: Statement; + #deleteCachePrefixStmt: Statement; + #deleteExpiredCacheStmt: Statement; + #updateIfMatchesWithLeaseStmt: Statement; + #deleteIfMatchesWithLeaseStmt: Statement; + #getCredentialBlockStmt: Statement; + #listCredentialBlocksByCredentialStmt: Statement; + #upsertCredentialBlockStmt: Statement; + #deleteCredentialBlocksStmt: Statement; + #deleteCredentialBlockStmt: Statement; + #deleteExpiredCredentialBlocksStmt: Statement; + #acquireCredentialRefreshLeaseStmt: Statement; + #getCredentialRefreshLeaseStmt: Statement; + #renewCredentialRefreshLeaseStmt: Statement; + #releaseCredentialRefreshLeaseStmt: Statement; + #credentialBlockReconcileAfter: Map = new Map(); + #insertUsageHistoryStmt: Statement; + #insertUsageCostStmt: Statement; + #listUsageCostsStmt: Statement; + #lastUsageHistoryStmt: Statement; + #listUsageHistoryStmt: Statement; + #updateUsageHistoryStmt: Statement; + #dataVersion: number; + #authRevision: number; + #localAuthRevision: number; + #closed = false; + + constructor(db: Database) { + this.#db = db; + this.#initializeSchema(); + this.#dataVersion = this.#readDataVersion(); + this.#authRevision = this.#readAuthRevision(); + this.#localAuthRevision = this.#readLocalAuthRevision(); + + this.#listActiveStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC", + ); + this.#listActiveByProviderStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC", + ); + this.#listDisabledStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC", + ); + this.#listDisabledByProviderStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC", + ); + this.#insertStmt = this.#db.prepare( + `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`, + ); + this.#updateStmt = this.#db.prepare( + `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, + ); + this.#updateIfMatchesStmt = this.#db.prepare( + `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, + ); + this.#updateIfMatchesWithLeaseStmt = this.#db.prepare( + `UPDATE auth_credentials + SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} + WHERE id = ? AND data = ? AND disabled_cause IS NULL + AND EXISTS ( + SELECT 1 FROM auth_credential_refresh_leases + WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? + )`, + ); + this.#deleteStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`, + ); + this.#deleteIfMatchesStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`, + ); + this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare( + `UPDATE auth_credentials + SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} + WHERE id = ? AND data = ? AND disabled_cause IS NULL + AND EXISTS ( + SELECT 1 FROM auth_credential_refresh_leases + WHERE credential_id = ? AND owner = ? AND expires_at_ms > ? + )`, + ); + this.#deleteByProviderStmt = this.#db.prepare( + `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`, + ); + this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); + this.#getCacheStmt = this.#db.prepare( + `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`, + ); + this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?"); + this.#upsertCacheStmt = this.#db.prepare( + "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", + ); + this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?"); + this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`); + this.#getCredentialBlockStmt = this.#db.prepare( + "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?", + ); + this.#listCredentialBlocksByCredentialStmt = this.#db.prepare( + `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE credential_id = ? AND blocked_until_ms > ? + AND NOT (provider_key = ? AND block_scope = ?) + ORDER BY provider_key ASC, block_scope ASC`, + ); + this.#upsertCredentialBlockStmt = this.#db.prepare( + `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at) + VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms), + updated_at = excluded.updated_at`, + ); + this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?"); + this.#deleteCredentialBlockStmt = this.#db.prepare( + "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?", + ); + this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare( + "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?", + ); + this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare( + `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at) + VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH}) + ON CONFLICT(credential_id) DO UPDATE SET + owner = excluded.owner, + expires_at_ms = excluded.expires_at_ms, + updated_at = excluded.updated_at + WHERE auth_credential_refresh_leases.expires_at_ms <= ?`, + ); + this.#getCredentialRefreshLeaseStmt = this.#db.prepare( + "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?", + ); + this.#renewCredentialRefreshLeaseStmt = this.#db.prepare( + `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`, + ); + this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare( + "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?", + ); + this.#insertUsageHistoryStmt = this.#db.prepare( + "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + this.#lastUsageHistoryStmt = this.#db.prepare( + "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1", + ); + this.#updateUsageHistoryStmt = this.#db.prepare( + "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?", + ); + this.#listUsageHistoryStmt = this.#db.prepare( + "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", + ); + this.#insertUsageCostStmt = this.#db.prepare( + "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", + ); + this.#listUsageCostsStmt = this.#db.prepare( + "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", + ); + } + + static async open(dbPath: string = getAgentDbPath()): Promise { + const dir = path.dirname(dbPath); + const dirExists = await fs + .stat(dir) + .then(s => s.isDirectory()) + .catch(() => false); + if (!dirExists) { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + } + + // Concurrent omp startups can race against WAL recovery and the schema + // init's first lock-taking statement. Bun's default `busy_timeout` is 0, + // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded + // exponential backoff before surfacing the failure. See issue #2421. + const maxAttempts = 4; + const baseDelayMs = 100; + let lastBusyError: Error | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let db: Database | undefined; + try { + db = new Database(dbPath); + // Install the busy handler BEFORE the first lock-taking statement + // on this connection. The leases DDL below and the constructor's + // schema init both acquire locks during WAL recovery; without a + // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY. + // See issue #2421. + SqliteAuthCredentialStore.#installBusyTimeout(db); + try { + await fs.chmod(dbPath, 0o600); + } catch { + // Ignore chmod failures (e.g., Windows) + } + SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db); + return new SqliteAuthCredentialStore(db); + } catch (err) { + db?.close(); + if (!isSqliteBusyError(err)) { + throw err; + } + lastBusyError = err instanceof Error ? err : new Error(String(err)); + if (attempt < maxAttempts - 1) { + await Bun.sleep(baseDelayMs * 2 ** attempt); + } + } + } + throw new AIError.ConfigurationError( + `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`, + { cause: lastBusyError }, + ); + } + + static #ensureAuthCredentialRefreshLeasesTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases ( + credential_id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms); + `); + } + + /** + * Install the per-connection busy handler so lock-taking statements wait for + * a contended writer instead of failing immediately (Bun defaults + * `busy_timeout` to 0). MUST run before the first lock-taking statement on + * the connection: concurrent omp startups race WAL recovery and the leases + * DDL. Uses the centralized timeout so headless hosts keep their bounded + * busy wait instead of the interactive 5s value. See issues #2421, #7298. + */ + static #installBusyTimeout(db: Database): void { + db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`); + } + + #initializeSchema(): void { + // Install the busy handler BEFORE any lock-taking statement (incl. + // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL + // recovery). Without this, concurrent omp startups can crash here with + // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via + // `open()` (which already installed it) is idempotent. See issue #2421. + SqliteAuthCredentialStore.#installBusyTimeout(this.#db); + this.#db.run(` + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS auth_schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + provider TEXT NOT NULL, + account_key TEXT NOT NULL, + email TEXT, + account_id TEXT, + limit_id TEXT NOT NULL, + label TEXT NOT NULL, + window_label TEXT, + used_fraction REAL, + status TEXT, + resets_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); + CREATE TABLE IF NOT EXISTS usage_cost_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + provider TEXT NOT NULL, + account_key TEXT NOT NULL, + cost_usd REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); + CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); + CREATE TABLE IF NOT EXISTS clients ( + install_id TEXT PRIMARY KEY, + hostname TEXT, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS client_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + install_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + requests INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + cost_usd REAL NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at); + CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at); + `); + + if (!this.#authCredentialsTableExists()) { + this.#createAuthCredentialsTable(); + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialRefreshLeasesTable(); + this.#createAuthCredentialBlockCompatibilityObjects(); + this.#createAuthChangeTrackingObjects(); + this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); + return; + } + + const recordedVersion = this.#readAuthSchemaVersion(); + const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion(); + if (schemaVersion > AUTH_SCHEMA_VERSION) { + logger.warn("SqliteAuthCredentialStore schema version mismatch", { + current: schemaVersion, + expected: AUTH_SCHEMA_VERSION, + }); + } else if (schemaVersion < AUTH_SCHEMA_VERSION) { + this.#migrateAuthSchema(schemaVersion); + } + + this.#createAuthCredentialIndexes(); + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialRefreshLeasesTable(); + if (schemaVersion <= AUTH_SCHEMA_VERSION) { + this.#createAuthCredentialBlockCompatibilityObjects(); + } + this.#createAuthChangeTrackingObjects(); + this.#backfillCredentialIdentityKeys(); + // Rewriting an already-current version row is a no-op write transaction + // on every boot; only persist when the recorded version actually changes. + if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) { + this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION); + } + } + + #authCredentialsTableExists(): boolean { + const stmt = this.#db.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'", + ); + try { + const row = stmt.get() as { present?: number } | undefined; + return row?.present === 1; + } finally { + stmt.finalize(); + } + } + + #readAuthSchemaVersion(): number | null { + const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1"); + try { + const row = stmt.get() as { version?: number } | undefined; + return typeof row?.version === "number" ? row.version : null; + } finally { + stmt.finalize(); + } + } + + #writeAuthSchemaVersion(version: number): void { + const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)"); + try { + stmt.run(version); + } finally { + stmt.finalize(); + } + } + + #inferAuthSchemaVersion(): number { + const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); + try { + const cols = stmt.all() as Array<{ name?: string }>; + return this.#inferAuthSchemaVersionFromColumns(cols); + } finally { + stmt.finalize(); + } + } + + #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number { + const hasDisabledCause = cols.some(column => column.name === "disabled_cause"); + const hasIdentityKey = cols.some(column => column.name === "identity_key"); + const hasAccountId = cols.some(column => column.name === "account_id"); + const hasEmail = cols.some(column => column.name === "email"); + if (hasIdentityKey) return 3; + if (hasAccountId || hasEmail) return 2; + if (hasDisabledCause) return 1; + return 0; + } + + #createAuthCredentialsTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + identity_key TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) + ); + `); + this.#createAuthCredentialIndexes(); + } + + #createAuthCredentialIndexes(): void { + this.#db.run(` + CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); + CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL; + `); + } + + #createAuthCredentialBlocksTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_blocks ( + credential_id INTEGER NOT NULL, + provider_key TEXT NOT NULL, + block_scope TEXT NOT NULL DEFAULT '', + blocked_until_ms INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (credential_id, provider_key, block_scope) + ); + CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms); + `); + } + + #createAuthChangeTrackingObjects(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_change_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0); + CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0); + `); + for (const table of ["auth_credentials", "auth_credential_blocks"] as const) { + for (const event of ["INSERT", "UPDATE", "DELETE"] as const) { + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()} + AFTER ${event} ON ${table} + BEGIN + UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1; + END; + `); + this.#db.run(` + CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()} + AFTER ${event} ON main.${table} + BEGIN + UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1; + END; + `); + } + } + } + + #createAuthCredentialBlockMirrorGuardTable(): void { + this.#db.run(` + CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard ( + credential_id INTEGER PRIMARY KEY + ) WITHOUT ROWID; + `); + } + + /** + * Keep a physical Codex `shared` row for pre-meter binaries that read this + * database directly. Meter rows are canonical for current code. The guard + * suppresses feedback while triggers update the compatibility projection. + */ + #createAuthCredentialBlockCompatibilityTriggers(): void { + for (const event of ["INSERT", "UPDATE"] as const) { + const eventName = event.toLowerCase(); + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters + AFTER ${event} ON auth_credential_blocks + WHEN NEW.provider_key = 'openai-codex:oauth' + AND NEW.block_scope = 'shared' + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (NEW.credential_id); + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + VALUES ( + NEW.credential_id, + NEW.provider_key, + 'chat', + NEW.blocked_until_ms, + NEW.updated_at + ) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + VALUES ( + NEW.credential_id, + NEW.provider_key, + 'spark', + NEW.blocked_until_ms, + NEW.updated_at + ) + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id; + END; + + CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared + AFTER ${event} ON auth_credential_blocks + WHEN NEW.provider_key = 'openai-codex:oauth' + AND NEW.block_scope IN ('chat', 'spark') + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (NEW.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = NEW.credential_id + AND provider_key = NEW.provider_key + AND block_scope = 'shared'; + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + NEW.credential_id, + NEW.provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE credential_id = NEW.credential_id + AND provider_key = NEW.provider_key + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key; + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = NEW.credential_id; + END; + `); + } + + this.#db.run(` + CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters + AFTER DELETE ON auth_credential_blocks + WHEN OLD.provider_key = 'openai-codex:oauth' + AND OLD.block_scope = 'shared' + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (OLD.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope IN ('chat', 'spark'); + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id; + END; + + CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared + AFTER DELETE ON auth_credential_blocks + WHEN OLD.provider_key = 'openai-codex:oauth' + AND OLD.block_scope IN ('chat', 'spark') + AND NOT EXISTS ( + SELECT 1 FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id + ) + BEGIN + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + VALUES (OLD.credential_id); + DELETE FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope = 'shared'; + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + OLD.credential_id, + OLD.provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE credential_id = OLD.credential_id + AND provider_key = OLD.provider_key + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key; + DELETE FROM auth_credential_block_mirror_guard + WHERE credential_id = OLD.credential_id; + END; + `); + } + + #createAuthCredentialBlockCompatibilityObjects(): void { + this.#createAuthCredentialBlockMirrorGuardTable(); + this.#createAuthCredentialBlockCompatibilityTriggers(); + } + + #createAuthCredentialRefreshLeasesTable(): void { + SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db); + } + + #migrateAuthSchema(fromVersion: number): void { + if (fromVersion < 1) { + this.#migrateAuthSchemaV0ToV1(); + } + if (fromVersion < 3) { + this.#migrateAuthSchemaV1OrV2ToV3(); + } + if (fromVersion < 4) { + this.#migrateAuthSchemaV3ToV4(); + } + if (fromVersion < 5) { + this.#migrateAuthSchemaV4ToV5(); + } + if (fromVersion < 6) { + this.#migrateAuthSchemaV5ToV6(); + } + if (fromVersion < 7) { + this.#migrateAuthSchemaV6ToV7(); + } + } + + #migrateAuthSchemaV0ToV1(): void { + const migrate = this.#db.transaction(() => { + const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)"); + let hasDisabled = false; + try { + const v0Cols = stmt.all() as Array<{ name?: string }>; + hasDisabled = v0Cols.some(col => col.name === "disabled"); + } finally { + stmt.finalize(); + } + + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0"); + this.#db.run(` + CREATE TABLE auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) + ); + `); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"}, + created_at, + updated_at + FROM auth_credentials_v0 + `); + this.#db.run("DROP TABLE auth_credentials_v0"); + }); + migrate(); + } + + #migrateAuthSchemaV1OrV2ToV3(): void { + const migrate = this.#db.transaction(() => { + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy"); + this.#createAuthCredentialsTable(); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + disabled_cause, + NULL, + created_at, + updated_at + FROM auth_credentials_legacy + `); + this.#db.run("DROP TABLE auth_credentials_legacy"); + }); + migrate(); + } + + #migrateAuthSchemaV3ToV4(): void { + const migrate = this.#db.transaction(() => { + this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3"); + this.#createAuthCredentialsTable(); + this.#db.run(` + INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) + SELECT + id, + provider, + credential_type, + data, + disabled_cause, + identity_key, + created_at, + updated_at + FROM auth_credentials_v3 + `); + this.#db.run("DROP TABLE auth_credentials_v3"); + }); + migrate(); + } + + #migrateAuthSchemaV4ToV5(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialBlocksTable(); + }); + migrate(); + } + + #migrateAuthSchemaV5ToV6(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialRefreshLeasesTable(); + }); + migrate(); + } + + #migrateAuthSchemaV6ToV7(): void { + const migrate = this.#db.transaction(() => { + this.#createAuthCredentialBlocksTable(); + this.#createAuthCredentialBlockMirrorGuardTable(); + this.#db.run(` + DELETE FROM auth_credential_block_mirror_guard; + INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id) + SELECT DISTINCT credential_id + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope IN ('chat', 'spark', 'shared'); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope = 'shared' + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope = 'shared' + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms), + updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at); + + INSERT INTO auth_credential_blocks ( + credential_id, + provider_key, + block_scope, + blocked_until_ms, + updated_at + ) + SELECT + credential_id, + provider_key, + 'shared', + MAX(blocked_until_ms), + MAX(updated_at) + FROM auth_credential_blocks + WHERE provider_key = 'openai-codex:oauth' + AND block_scope IN ('chat', 'spark') + GROUP BY credential_id, provider_key + ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET + blocked_until_ms = excluded.blocked_until_ms, + updated_at = excluded.updated_at; + + DELETE FROM auth_credential_block_mirror_guard; + `); + this.#createAuthCredentialBlockCompatibilityTriggers(); + this.#writeAuthSchemaVersion(7); + }); + migrate.immediate(); + } + + #backfillCredentialIdentityKeys(): void { + const selectRowsStmt = this.#db.prepare( + "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC", + ); + let rows: AuthRow[]; + try { + rows = selectRowsStmt.all() as AuthRow[]; + } finally { + selectRowsStmt.finalize(); + } + if (rows.length === 0) return; + + let updateIdentity: Statement | null = null; + try { + for (const row of rows) { + const identityKey = resolveRowCredentialIdentityKey(row.provider, row); + // Rows whose identity cannot be derived stay NULL; writing NULL over + // NULL would just burn a write transaction on every boot. + if (identityKey === null) continue; + updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?"); + updateIdentity.run(identityKey, row.id); + } + } finally { + updateIdentity?.finalize(); + } + } + + // ─── AuthCredentialStore interface ────────────────────────────────────── + + listAuthCredentials(provider?: string): StoredAuthCredential[] { + const rows = + (provider + ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[]) + : (this.#listActiveStmt.all() as AuthRow[])) ?? []; + + const results: StoredAuthCredential[] = []; + for (const row of rows) { + const credential = deserializeCredential(row); + if (!credential) continue; + results.push(toStoredAuthCredential(row, credential)); + } + return results; + } + + async listDisabledCredentials(provider?: string): Promise { + const rows = + (provider + ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[]) + : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? []; + const results: DisabledCredentialSummary[] = []; + for (const row of rows) { + const credential = deserializeCredential(row); + const summary: DisabledCredentialSummary = { + id: row.id, + provider: row.provider, + type: row.credential_type === "api_key" ? "api_key" : "oauth", + cause: row.disabled_cause ?? "disabled", + }; + if (credential?.type === "oauth") { + if (credential.email) summary.email = credential.email; + if (credential.accountId) summary.accountId = credential.accountId; + if (credential.orgId) summary.orgId = credential.orgId; + if (credential.orgName) summary.orgName = credential.orgName; + } + if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) { + summary.disabledAtMs = row.updated_at * 1000; + } + results.push(summary); + } + return results; + } + + replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { + const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { + const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const existing = existingRows.map(row => ({ + id: row.id, + credential: deserializeCredential(row), + identityKey: resolveRowCredentialIdentityKey(providerName, row), + })); + + const result: StoredAuthCredential[] = []; + const matchedExistingIds = new Set(); + + for (const credential of items) { + const serialized = serializeCredential(providerName, credential); + if (!serialized) continue; + const match = existing.find( + entry => + !matchedExistingIds.has(entry.id) && + matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential), + ); + if (match) { + matchedExistingIds.add(match.id); + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id); + result.push({ id: match.id, provider: providerName, credential, disabledCause: null }); + } else { + const row = this.#insertStmt.get( + providerName, + serialized.credentialType, + serialized.data, + serialized.identityKey, + ) as { id?: number } | undefined; + if (row?.id) { + result.push({ id: row.id, provider: providerName, credential, disabledCause: null }); + } + } + } + + for (const row of existing) { + if (!matchedExistingIds.has(row.id)) { + this.#deleteStmt.run("replaced by newer credential", row.id); + } + } + + return result; + }); + + const result = replace(provider, credentials); + this.#purgeSupersededDisabledRows(provider, result); + return result; + } + + upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] { + const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => { + const serialized = serializeCredential(providerName, item); + if (!serialized) return this.listAuthCredentials(providerName); + const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const existing = existingRows.map(row => ({ + id: row.id, + credential: deserializeCredential(row), + identityKey: resolveRowCredentialIdentityKey(providerName, row), + })); + + if (item.type === "oauth") { + for (const row of existing) { + if (row.credential && row.credential.type === "api_key") { + this.#deleteStmt.run("replaced by oauth login", row.id); + } + } + } + + let targetId: number | null = null; + for (const row of existing) { + if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue; + if (targetId === null) { + targetId = row.id; + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id); + continue; + } + this.#deleteStmt.run("replaced by newer credential", row.id); + } + + if (targetId === null) { + const row = this.#insertStmt.get( + providerName, + serialized.credentialType, + serialized.data, + serialized.identityKey, + ) as { id?: number } | undefined; + targetId = row?.id ?? null; + } + + const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[]; + const result: StoredAuthCredential[] = []; + for (const row of activeRows) { + const activeCredential = deserializeCredential(row); + if (!activeCredential) continue; + result.push(toStoredAuthCredential(row, activeCredential)); + } + return result; + }); + + const result = upsert(provider, credential); + this.#purgeSupersededDisabledRows(provider, result); + return result; + } + + /** + * Hard-deletes disabled rows for a provider when an active replacement exists. + * OAuth credentials match by identity key; API keys match by provider and type. + * Disabled rows without an active same-type replacement remain recoverable. + */ + #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void { + try { + let hasActiveApiKey = false; + const activeIdentityKeys = new Set(); + for (const row of activeRows) { + if (row.credential.type === "api_key") { + hasActiveApiKey = true; + continue; + } + const identityKey = resolveCredentialIdentityKey(provider, row.credential); + if (identityKey) activeIdentityKeys.add(identityKey); + } + if (!hasActiveApiKey && activeIdentityKeys.size === 0) return; + + const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[]; + for (const row of disabledRows) { + if (hasActiveApiKey && row.credential_type === "api_key") { + this.#hardDeleteStmt.run(row.id); + continue; + } + const identityKey = resolveRowCredentialIdentityKey(provider, row); + if (identityKey && activeIdentityKeys.has(identityKey)) { + this.#hardDeleteStmt.run(row.id); + } + } + } catch { + // Best-effort cleanup; don't let it break the main operation + } + } + + updateAuthCredential(id: number, credential: AuthCredential): void { + try { + const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); + let providerRow: { provider?: string } | undefined; + try { + providerRow = providerStmt.get(id) as { provider?: string } | undefined; + } finally { + providerStmt.finalize(); + } + const provider = providerRow?.provider ?? ""; + const serialized = serializeCredential(provider, credential); + if (!serialized) return; + this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id); + if (provider) { + this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); + } + } catch { + // Ignore update failures + } + } + + tryUpdateAuthCredentialIfMatches( + id: number, + expectedData: string, + credential: AuthCredential, + lease?: CredentialRefreshLeaseFence, + ): boolean { + const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?"); + let providerRow: { provider?: string } | undefined; + try { + providerRow = providerStmt.get(id) as { provider?: string } | undefined; + } finally { + providerStmt.finalize(); + } + const provider = providerRow?.provider ?? ""; + const serialized = serializeCredential(provider, credential); + if (!serialized) return false; + const result = lease + ? (this.#updateIfMatchesWithLeaseStmt.run( + serialized.credentialType, + serialized.data, + serialized.identityKey, + id, + expectedData, + id, + lease.owner, + lease.nowMs, + ) as { changes: number }) + : (this.#updateIfMatchesStmt.run( + serialized.credentialType, + serialized.data, + serialized.identityKey, + id, + expectedData, + ) as { changes: number }); + if (result.changes === 0) return false; + if (provider) { + this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider)); + } + return true; + } + + deleteAuthCredential(id: number, disabledCause: string): void { + try { + this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id); + } catch { + // Ignore delete failures + } + } + + /** + * CAS-style disable: only soft-deletes the row when its `data` column still + * matches `expectedData` and the row has not already been disabled. Used by + * the OAuth refresh-failure path to avoid clobbering a peer that rotated the + * row between our pre-check and the disable. + */ + tryDisableAuthCredentialIfMatches( + id: number, + expectedData: string, + disabledCause: string, + lease?: CredentialRefreshLeaseFence, + ): boolean { + const result = lease + ? (this.#deleteIfMatchesWithLeaseStmt.run( + normalizeDisabledCause(disabledCause), + id, + expectedData, + id, + lease.owner, + lease.nowMs, + ) as { changes: number }) + : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as { + changes: number; + }); + return result.changes > 0; + } + deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void { + try { + this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider); + } catch { + // Ignore delete failures + } + } + + getCache(key: string, options?: { includeExpired?: boolean }): string | null { + try { + const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt; + const row = stmt.get(key) as { value?: string } | undefined; + return row?.value ?? null; + } catch { + return null; + } + } + + setCache(key: string, value: string, expiresAtSec: number): void { + try { + this.#upsertCacheStmt.run(key, value, expiresAtSec); + } catch { + // Ignore cache set failures + } + } + + /** Drop all cache rows whose keys start with the supplied prefix. */ + deleteCachePrefix(prefix: string): void { + try { + this.#deleteCachePrefixStmt.run(prefix.length, prefix); + } catch { + // Ignore cache delete failures + } + } + + cleanExpiredCache(): void { + try { + this.#deleteExpiredCacheStmt.run(); + } catch { + // Ignore cleanup errors + } + } + + getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { + const nowMs = Date.now(); + const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; + // Current callers use meter scopes. The physical shared row exists only + // for direct SQLite readers from pre-meter releases. + if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { + return undefined; + } + if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as + | { blocked_until_ms?: number; updated_at?: number } + | undefined; + return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined; + } + + getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined { + const nowMs = Date.now(); + const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY; + if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) { + return undefined; + } + if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as + | { blocked_until_ms?: number; updated_at?: number } + | undefined; + if (typeof row?.blocked_until_ms !== "number") return undefined; + const memoryReconcileAfter = + this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0; + const persistedReconcileAfter = + typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0; + const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter); + return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined; + } + + upsertCredentialBlock(block: StoredCredentialBlock): void { + const isLegacyCodexBlock = + block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE; + const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope]; + const upsert = this.#db.transaction(() => { + for (const blockScope of blockScopes) { + this.#upsertCredentialBlockStmt.run( + block.credentialId, + block.providerKey, + blockScope, + block.blockedUntilMs, + ); + } + }); + upsert.immediate(); + + const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS); + for (const blockScope of blockScopes) { + this.#credentialBlockReconcileAfter.set( + `${block.credentialId}\0${block.providerKey}\0${blockScope}`, + reconcileAfterMs, + ); + } + if (isLegacyCodexBlock) { + this.#credentialBlockReconcileAfter.delete( + `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`, + ); + } + } + + deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void { + this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope); + this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`); + } + + deleteCredentialBlocks(credentialId: number): void { + this.#deleteCredentialBlocksStmt.run(credentialId); + for (const key of this.#credentialBlockReconcileAfter.keys()) { + if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key); + } + } + + cleanExpiredCredentialBlocks(nowMs: number): void { + this.#deleteExpiredCredentialBlocksStmt.run(nowMs); + for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) { + if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key); + } + } + + listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] { + if (credentialIds.length === 0) return []; + const nowMs = Date.now(); + this.cleanExpiredCredentialBlocks(nowMs); + const seenCredentialIds = new Set(); + const blocks: StoredCredentialBlock[] = []; + for (const credentialId of credentialIds) { + if (seenCredentialIds.has(credentialId)) continue; + seenCredentialIds.add(credentialId); + const rows = this.#listCredentialBlocksByCredentialStmt.all( + credentialId, + nowMs, + LEGACY_CODEX_BLOCK_PROVIDER_KEY, + LEGACY_CODEX_BLOCK_SCOPE, + ) as CredentialBlockRow[]; + for (const row of rows) { + blocks.push({ + credentialId: row.credential_id, + providerKey: row.provider_key, + blockScope: row.block_scope, + blockedUntilMs: row.blocked_until_ms, + updatedAtMs: row.updated_at * 1000, + }); + } + } + return blocks; + } + + tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { + const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as { + changes: number; + }; + return result.changes === 1; + } + + getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined { + const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined; + if (typeof row?.expires_at_ms !== "number") return undefined; + if (row.expires_at_ms <= Date.now()) return undefined; + return row.expires_at_ms; + } + + renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean { + const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as { + changes: number; + }; + return result.changes === 1; + } + + releaseCredentialRefreshLease(credentialId: number, owner: string): void { + try { + this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner); + } catch { + // Ignore lease release failures; expired leases are stealable. + } + } + + recordUsageSnapshots(entries: UsageHistoryEntry[]): void { + try { + for (const entry of entries) { + const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS); + const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as + | { id: number; recorded_at: number } + | undefined; + if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) { + this.#updateUsageHistoryStmt.run( + entry.recordedAt, + entry.email ?? null, + entry.accountId ?? null, + entry.label, + entry.windowLabel ?? null, + entry.usedFraction ?? null, + entry.status ?? null, + entry.resetsAt ?? null, + last.id, + ); + continue; + } + this.#insertUsageHistoryStmt.run( + entry.recordedAt, + entry.provider, + entry.accountKey, + entry.email ?? null, + entry.accountId ?? null, + entry.limitId, + entry.label, + entry.windowLabel ?? null, + entry.usedFraction ?? null, + entry.status ?? null, + entry.resetsAt ?? null, + ); + } + } catch { + // History is best-effort; never break the usage fetch path. + } + } + + listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] { + try { + const provider = query?.provider ?? null; + const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{ + recorded_at: number; + provider: string; + account_key: string; + email: string | null; + account_id: string | null; + limit_id: string; + label: string; + window_label: string | null; + used_fraction: number | null; + status: string | null; + resets_at: number | null; + }>; + return rows.map(row => ({ + recordedAt: row.recorded_at, + provider: row.provider as Provider, + accountKey: row.account_key, + email: row.email ?? undefined, + accountId: row.account_id ?? undefined, + limitId: row.limit_id, + label: row.label, + windowLabel: row.window_label ?? undefined, + usedFraction: row.used_fraction ?? undefined, + status: (row.status ?? undefined) as UsageHistoryEntry["status"], + resetsAt: row.resets_at ?? undefined, + })); + } catch { + return []; + } + } + recordUsageCosts(entries: UsageCostHistoryEntry[]): void { + try { + for (const entry of entries) { + this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); + } + } catch { + // Cost history is best-effort; never break request persistence. + } + } + + listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { + try { + const provider = query?.provider ?? null; + const accountKey = query?.accountKey ?? null; + const rows = this.#listUsageCostsStmt.all( + query?.sinceMs ?? 0, + provider, + provider, + accountKey, + accountKey, + ) as Array<{ + recorded_at: number; + provider: string; + account_key: string; + cost_usd: number; + }>; + return rows.map(row => ({ + recordedAt: row.recorded_at, + provider: row.provider as Provider, + accountKey: row.account_key, + costUsd: row.cost_usd, + })); + } catch { + return []; + } + } + + recordClientUsage(report: ClientUsageReport): void { + const now = Date.now(); + this.#db + .query( + `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`, + ) + .run(report.installId, report.hostname ?? null, now, now); + const findBucket = this.#db.query( + `SELECT id FROM client_usage + WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ? + ORDER BY recorded_at DESC LIMIT 1`, + ); + const merge = this.#db.query( + `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?, + output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, + cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`, + ); + const insert = this.#db.query( + `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const entry of report.entries) { + // Merge into the newest row of the same (install, provider, model) + // bucket so 10s client flushes don't accrete one row apiece forever. + const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS; + const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as { + id: number; + } | null; + if (existing) { + merge.run( + entry.at, + entry.requests, + entry.inputTokens, + entry.outputTokens, + entry.cacheReadTokens, + entry.cacheWriteTokens, + entry.costUsd, + existing.id, + ); + continue; + } + insert.run( + entry.at, + report.installId, + entry.provider, + entry.model, + entry.requests, + entry.inputTokens, + entry.outputTokens, + entry.cacheReadTokens, + entry.cacheWriteTokens, + entry.costUsd, + ); + } + } + + getClientUsageSummary(sinceMs: number): ClientUsageSummary { + const clients = this.#db + .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC") + .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>; + const aggregates = this.#db + .query( + `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens, + SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens, + SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd + FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider + ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`, + ) + .all(sinceMs) as Array<{ + install_id: string; + provider: string; + requests: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + cost_usd: number; + }>; + const providersByInstall = new Map(); + for (const row of aggregates) { + let list = providersByInstall.get(row.install_id); + if (!list) { + list = []; + providersByInstall.set(row.install_id, list); + } + list.push({ + provider: row.provider, + requests: row.requests, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + cacheReadTokens: row.cache_read_tokens, + cacheWriteTokens: row.cache_write_tokens, + costUsd: row.cost_usd, + }); + } + return { + clients: clients.map(client => ({ + installId: client.install_id, + hostname: client.hostname ?? undefined, + firstSeen: client.first_seen, + lastSeen: client.last_seen, + providers: providersByInstall.get(client.install_id) ?? [], + })), + }; + } + + // ─── Convenience methods for CLI ──────────────────────────────────────── + + /** + * Save OAuth credentials for a provider. + * Preserves unrelated identities and replaces only the matching credential. + */ + saveOAuth(provider: string, credentials: OAuthCredentials): void { + const credential: AuthCredential = { type: "oauth", ...credentials }; + this.upsertAuthCredentialForProvider(provider, credential); + } + + /** + * Get OAuth credentials for a provider. + */ + getOAuth(provider: string): OAuthCredentials | null { + const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; + for (const row of rows) { + const credential = deserializeCredential(row); + if (credential && credential.type === "oauth") { + const { type: _type, ...oauth } = credential; + return oauth as OAuthCredentials; + } + } + return null; + } + + /** + * Save API key for a provider (replaces existing). + */ + saveApiKey(provider: string, apiKey: string): void { + const credential: AuthCredential = { type: "api_key", key: apiKey }; + this.replaceAuthCredentialsForProvider(provider, [credential]); + } + + /** + * Get API key for a provider. + */ + getApiKey(provider: string): string | null { + const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[]; + for (const row of rows) { + const credential = deserializeCredential(row); + if (credential && credential.type === "api_key") { + return credential.key; + } + } + return null; + } + + /** + * List all providers with credentials. + */ + listProviders(): string[] { + const rows = this.#listActiveStmt.all() as AuthRow[]; + const providers = new Set(); + for (const row of rows) { + providers.add(row.provider); + } + return Array.from(providers); + } + + /** + * Delete all credentials for a provider. + */ + deleteProvider(provider: string): void { + this.deleteAuthCredentialsForProvider(provider, "deleted by user"); + } + + /** + * SQLite increments `data_version` when another connection commits. Own + * writes leave it unchanged and already notify AuthStorage directly. + */ + pollExternalChanges(): boolean { + this.#acknowledgeLocalAuthChanges(); + const dataVersion = this.#readDataVersion(); + if (dataVersion === this.#dataVersion) return false; + this.#dataVersion = dataVersion; + const authRevision = this.#readAuthRevision(); + if (authRevision === this.#authRevision) return false; + this.#authRevision = authRevision; + return true; + } + + acknowledgeLocalChanges(): void { + this.#acknowledgeLocalAuthChanges(); + } + + #acknowledgeLocalAuthChanges(): void { + const localAuthRevision = this.#readLocalAuthRevision(); + this.#authRevision += localAuthRevision - this.#localAuthRevision; + this.#localAuthRevision = localAuthRevision; + } + + #readDataVersion(): number { + const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null; + return row?.data_version ?? 0; + } + + #readAuthRevision(): number { + const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as { + revision?: number; + } | null; + return row?.revision ?? 0; + } + + #readLocalAuthRevision(): number { + const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as { + revision?: number; + } | null; + return row?.revision ?? 0; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#listActiveStmt.finalize(); + this.#listActiveByProviderStmt.finalize(); + this.#listDisabledStmt.finalize(); + this.#listDisabledByProviderStmt.finalize(); + this.#insertStmt.finalize(); + this.#updateStmt.finalize(); + this.#deleteStmt.finalize(); + this.#deleteIfMatchesStmt.finalize(); + this.#deleteByProviderStmt.finalize(); + this.#hardDeleteStmt.finalize(); + this.#getCacheStmt.finalize(); + this.#getCacheIncludingExpiredStmt.finalize(); + this.#upsertCacheStmt.finalize(); + this.#deleteExpiredCacheStmt.finalize(); + this.#getCredentialBlockStmt.finalize(); + this.#listCredentialBlocksByCredentialStmt.finalize(); + this.#upsertCredentialBlockStmt.finalize(); + this.#deleteCredentialBlocksStmt.finalize(); + this.#deleteCredentialBlockStmt.finalize(); + this.#deleteExpiredCredentialBlocksStmt.finalize(); + this.#insertUsageHistoryStmt.finalize(); + this.#lastUsageHistoryStmt.finalize(); + this.#listUsageHistoryStmt.finalize(); + this.#updateUsageHistoryStmt.finalize(); + this.#insertUsageCostStmt.finalize(); + this.#listUsageCostsStmt.finalize(); + this.#updateIfMatchesStmt.finalize(); + this.#updateIfMatchesWithLeaseStmt.finalize(); + this.#deleteIfMatchesWithLeaseStmt.finalize(); + this.#deleteCachePrefixStmt.finalize(); + this.#acquireCredentialRefreshLeaseStmt.finalize(); + this.#getCredentialRefreshLeaseStmt.finalize(); + this.#renewCredentialRefreshLeaseStmt.finalize(); + this.#releaseCredentialRefreshLeaseStmt.finalize(); + this.#db.close(); + } +} From 3802d2dd80d57b3a07f6fc69b67dcb824c2c7e42 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:42:14 +0900 Subject: [PATCH 53/97] chore(ts): enforce noImplicitOverride --- packages/agent/src/compaction/errors.ts | 4 +- packages/ai/src/registry/oauth/devin.ts | 2 +- packages/ai/test/openai-codex-stream.test.ts | 70 +++++++++---------- .../coding-agent/src/config/config-file.ts | 4 +- .../coding-agent/src/config/keybindings.ts | 6 +- .../src/edit/hashline/filesystem.ts | 14 ++-- .../src/modes/components/agent-hub.ts | 2 +- .../src/modes/components/bordered-loader.ts | 2 +- .../src/modes/components/custom-editor.ts | 4 +- .../src/modes/components/hook-input.ts | 2 +- .../src/modes/components/hook-selector.ts | 4 +- .../src/modes/components/session-selector.ts | 4 +- packages/coding-agent/src/task/agents.ts | 2 +- packages/coding-agent/src/tiny/worker.ts | 2 +- .../src/web/search/providers/duckduckgo.ts | 2 +- .../src/web/search/providers/ecosia.ts | 2 +- .../src/web/search/providers/exa.ts | 2 +- .../src/web/search/providers/firecrawl.ts | 2 +- .../src/web/search/providers/mojeek.ts | 2 +- .../src/web/search/providers/perplexity.ts | 2 +- .../src/web/search/providers/public.ts | 2 +- .../src/web/search/providers/startpage.ts | 2 +- ...ession-manager-atomic-rewrite-race.test.ts | 5 +- .../large-session-memory.test.ts | 2 +- packages/hashline/src/fs.ts | 16 ++--- packages/hashline/src/snapshots.ts | 2 +- packages/hashline/test/core-contracts.test.ts | 2 +- packages/hashline/test/patcher.test.ts | 2 +- packages/mnemopi/test/recovery.test.ts | 2 +- .../tui/src/components/cancellable-loader.ts | 2 +- packages/tui/src/components/loader.ts | 2 +- packages/tui/src/components/markdown.ts | 4 +- packages/tui/test/render-regressions.test.ts | 10 +-- packages/tui/test/render-stress-harness.ts | 6 +- .../src/mutations.ts | 6 +- packages/utils/src/frontmatter.ts | 2 +- .../fixtures/logger-fixed-date-preload.ts | 2 +- tsconfig.base.json | 1 + 38 files changed, 104 insertions(+), 100 deletions(-) diff --git a/packages/agent/src/compaction/errors.ts b/packages/agent/src/compaction/errors.ts index ed34484dd..6a44a86f5 100644 --- a/packages/agent/src/compaction/errors.ts +++ b/packages/agent/src/compaction/errors.ts @@ -11,7 +11,7 @@ */ export class CompactionCancelledError extends Error { - readonly name = "CompactionCancelledError" as const; + override readonly name = "CompactionCancelledError" as const; constructor(message = "Compaction cancelled") { super(message); @@ -27,7 +27,7 @@ export class CompactionCancelledError extends Error { * ordinary summarization errors and must not fall through to another provider. */ export class NativeCompactionError extends Error { - readonly name = "NativeCompactionError" as const; + override readonly name = "NativeCompactionError" as const; constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }); diff --git a/packages/ai/src/registry/oauth/devin.ts b/packages/ai/src/registry/oauth/devin.ts index b3b8029c5..e8c8c251b 100644 --- a/packages/ai/src/registry/oauth/devin.ts +++ b/packages/ai/src/registry/oauth/devin.ts @@ -33,7 +33,7 @@ class DevinOAuthFlow extends OAuthCallbackFlow { }); } - generateState(): string { + override generateState(): string { return crypto.randomUUID(); } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 4e4c543dd..274cc02f3 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -409,7 +409,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_opaque", responseId: "resp_opaque", text: "pong" }); } } @@ -1316,7 +1316,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { const added = encodeWebSocketMessage({ type: "response.output_item.added", item: { type: "message", id: "msg_ws", role: "assistant", status: "in_progress", content: [] }, @@ -1374,7 +1374,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_obs", responseId: "resp_obs", text: "Observed" }); } } @@ -1431,7 +1431,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.done", response: { @@ -1484,7 +1484,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.done", response: { @@ -1638,7 +1638,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); this.emitCodexResponse({ messageId: "msg_lite", responseId: "resp_lite", text: "Hi" }); } @@ -2876,7 +2876,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { websocketRequestCount += 1; const body: unknown = JSON.parse(data); if (websocketRequestCount === 1) { @@ -3064,7 +3064,7 @@ describe("openai-codex streaming", () => { }); } - send(_data: string): void { + override send(_data: string): void { websocketRequestCount += 1; this.emitCodexResponse({ messageId: `msg_pre_turn_${websocketRequestCount}`, @@ -3184,7 +3184,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); this.sendJson({ type: "response.output_item.added", @@ -3269,7 +3269,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); const responseIndex = sentRequests.length; this.emitCodexResponse({ @@ -3409,7 +3409,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); if (sentRequests.length === 1) { this.sendJson({ @@ -3508,7 +3508,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.completed", response: { @@ -3564,7 +3564,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.#sendCount += 1; if (this.#sendCount === 1) { this.emitCodexResponse({ @@ -3653,7 +3653,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sentRequests.push(JSON.parse(data) as Record); const responseIndex = sentRequests.length; this.emitCodexResponse({ @@ -3770,7 +3770,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as Record; sentRequests.push(request); const requestIndex = sentRequests.length; @@ -3878,7 +3878,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as Record; sentRequests.push(request); const requestIndex = sentRequests.length; @@ -3996,7 +3996,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_v2", responseId: "resp_v2", text: "Hello v2" }); } } @@ -4053,7 +4053,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; } } @@ -4113,7 +4113,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4143,7 +4143,7 @@ describe("openai-codex streaming", () => { }, 2); } - close(): void { + override close(): void { if (interval) clearInterval(interval); super.close(); } @@ -4190,7 +4190,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4213,7 +4213,7 @@ describe("openai-codex streaming", () => { } } - close(): void { + override close(): void { closeCount += 1; super.close(); } @@ -4257,7 +4257,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { if (this.#index === 0) { // First attempt: a function call whose arguments are only whitespace. // A completed reasoning item lands in nativeOutputItems before the @@ -4313,7 +4313,7 @@ describe("openai-codex streaming", () => { }); } - close(): void { + override close(): void { closeCount += 1; super.close(); } @@ -4384,7 +4384,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4436,7 +4436,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { // Every frame lands in the connection queue synchronously, before the // consumer microtask drains any of them; the close event used to wipe // the queued terminal event and turn success into a transport error. @@ -4479,7 +4479,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.output_item.added", item: { type: "function_call", id: "fc_limit", call_id: "call_limit", name: "todo", arguments: "" }, @@ -4534,13 +4534,13 @@ describe("openai-codex streaming", () => { this.emit("open", new Event("open")); } - close(): void { + override close(): void { const wasPending = this.readyState === MockWebSocket.CONNECTING; super.close(); if (wasPending) this.emit("close", { code: 1000 } as unknown as Event); } - send(): void { + override send(): void { this.emitCodexResponse({ messageId: "msg_join", responseId: "resp_join", text: "Joined" }); } } @@ -4597,7 +4597,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { sendCount += 1; this.sendJson({ type: "response.output_item.added", @@ -4669,7 +4669,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { const request = JSON.parse(data) as { type?: string }; const requestType = typeof request.type === "string" ? request.type : ""; sentTypesByConnection[this.#connectionIndex]?.push(requestType); @@ -4796,7 +4796,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(): void { + override send(): void { this.sendJson({ type: "response.output_item.added", item: { @@ -4886,7 +4886,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { this.#sendCount += 1; const request = JSON.parse(data) as { type?: string }; requestTypes.push(typeof request.type === "string" ? request.type : ""); @@ -4979,7 +4979,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(data: string): void { + override send(data: string): void { sendCount += 1; const request = JSON.parse(data) as Record; expect(typeof request.type).toBe("string"); @@ -5272,7 +5272,7 @@ describe("openai-codex streaming", () => { this.scheduleOpen(); } - send(_data: string): void { + override send(_data: string): void { sendCount += 1; if (sendCount === 1) { this.emitCodexResponse({ diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index d36916870..87a4cfe2d 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -109,11 +109,11 @@ export class ConfigError extends Error { this.#message = message; } - get message(): string { + override get message(): string { return this.#message; } - toString(): string { + override toString(): string { return this.message; } } diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 6836f1eda..6408cfad9 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -607,12 +607,12 @@ export class KeybindingsManager extends TuiKeybindingsManager { this.setUserBindings(mergeKeybindingsConfig(inheritedConfig, profileConfig)); } - setUserBindings(userBindings: KeybindingsConfig): void { + override setUserBindings(userBindings: KeybindingsConfig): void { this.#userBindings = userBindings; super.setUserBindings(userBindings); } - getKeys(keybinding: Keybinding): KeyId[] { + override getKeys(keybinding: Keybinding): KeyId[] { const keys = super.getKeys(keybinding); const fallbackKey = getFallbackKey(keybinding); if (fallbackKey === undefined || this.#userBindings[keybinding] !== undefined) return keys; @@ -620,7 +620,7 @@ export class KeybindingsManager extends TuiKeybindingsManager { return removeKey(keys, fallbackKey); } - getResolvedBindings(): KeybindingsConfig { + override getResolvedBindings(): KeybindingsConfig { const resolved = super.getResolvedBindings(); resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING)); return resolved; diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index b8743be2e..10dbdd283 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -87,11 +87,11 @@ export class HashlineFilesystem extends Filesystem { return resolvePlanPath(this.session, relativePath); } - canonicalPath(relativePath: string): string { + override canonicalPath(relativePath: string): string { return canonicalSnapshotKey(this.resolveAbsolute(relativePath)); } - allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean { + override allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean { // Internal-URL authored targets (`local://`, `vault://`, …) are approved // at the lower "read" privilege; never let one redirect onto a "write". if (isInternalUrlPath(authoredPath)) return false; @@ -125,7 +125,7 @@ export class HashlineFilesystem extends Filesystem { return content; } - async readBinary(relativePath: string): Promise { + override async readBinary(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); if (isNotebookPath(absolutePath)) return undefined; try { @@ -136,7 +136,7 @@ export class HashlineFilesystem extends Filesystem { } } - async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { + override async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { const fileOp = options?.fileOp; if (fileOp?.kind === "rem") { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); @@ -149,7 +149,7 @@ export class HashlineFilesystem extends Filesystem { enforcePlanModeWrite(this.session, relativePath, { op: "update" }); } - async delete(relativePath: string): Promise { + override async delete(relativePath: string): Promise { enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); const absolutePath = this.resolveAbsolute(relativePath); try { @@ -168,7 +168,7 @@ export class HashlineFilesystem extends Filesystem { invalidateFsScanAfterWrite(absolutePath); } - async move(fromRelative: string, toRelative: string, content?: string): Promise { + override async move(fromRelative: string, toRelative: string, content?: string): Promise { enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative }); const fromAbsolute = this.resolveAbsolute(fromRelative); const toAbsolute = this.resolveAbsolute(toRelative); @@ -240,7 +240,7 @@ export class HashlineFilesystem extends Filesystem { return { text: content }; } - async exists(relativePath: string): Promise { + override async exists(relativePath: string): Promise { const absolutePath = this.resolveAbsolute(relativePath); return Bun.file(absolutePath).exists(); } diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index a5a706c6e..0b20c0c4e 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -258,7 +258,7 @@ export class AgentHubOverlayComponent extends Container { } /** Tear down every subscription and timer. Called by the overlay owner on close. */ - dispose(): void { + override dispose(): void { for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe(); if (this.#ageTimer) { clearInterval(this.#ageTimer); diff --git a/packages/coding-agent/src/modes/components/bordered-loader.ts b/packages/coding-agent/src/modes/components/bordered-loader.ts index a3aaa81bf..861cbbf54 100644 --- a/packages/coding-agent/src/modes/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/components/bordered-loader.ts @@ -35,7 +35,7 @@ export class BorderedLoader extends Container { this.#loader.handleInput(data); } - dispose(): void { + override dispose(): void { this.#loader.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 315c58245..9110fc6fe 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -466,7 +466,7 @@ export class CustomEditor extends Editor { /** Decorate magic keywords, attachments, and the queue-composer header/list markers. * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential * item markers use the accent color so separate follow-ups remain visible while composing. */ - decorateText = (text: string): string => { + override decorateText = (text: string): string => { const editorText = this.getText(); const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText); const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0; @@ -775,7 +775,7 @@ export class CustomEditor extends Editor { void promise.then(this.#onPasteSettled, this.#onPasteSettled); } - handleInput(data: string): void { + override handleInput(data: string): void { // Serialize behind any in-flight async paste so a trailing Enter / follow-up key can't // submit before the clipboard image reaches `pendingImages` (Codex PR #3602 review). if (this.#pasteInFlight > 0) { diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index e1fc3a930..203c13b1f 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -81,7 +81,7 @@ export class HookInputComponent extends Container { this.#input.pasteText(text); } - dispose(): void { + override dispose(): void { this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index fa91932ef..2833cb99a 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -135,7 +135,7 @@ class OutlinedList extends Container { this.invalidate(); } - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const borderColor = (text: string) => theme.fg("border", text); const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); @@ -685,7 +685,7 @@ export class HookSelectorComponent extends Container { return super.render(renderWidth); } - dispose(): void { + override dispose(): void { this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index e0d555882..ccad9309c 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -905,7 +905,7 @@ export class SessionSelectorComponent extends Container { * is mounted the list is detached from the child tree, so Container's * child-walking dispose would miss its pending history-merge timer. */ - dispose(): void { + override dispose(): void { this.#sessionList.dispose(); super.dispose(); } @@ -971,7 +971,7 @@ export class SessionSelectorComponent extends Container { * footer is always visible and never drifts as the list window resizes. The * in-editor selector just appends the footer directly. */ - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const lines: string[] = []; for (const child of this.children) { const childLines = child.render(width); diff --git a/packages/coding-agent/src/task/agents.ts b/packages/coding-agent/src/task/agents.ts index 1585b38bc..1d88ec726 100644 --- a/packages/coding-agent/src/task/agents.ts +++ b/packages/coding-agent/src/task/agents.ts @@ -85,7 +85,7 @@ export class AgentParsingError extends Error { this.name = "AgentParsingError"; } - toString(): string { + override toString(): string { const details: string[] = [this.message]; if (this.source !== undefined) { details.push(`Source: ${JSON.stringify(this.source)}`); diff --git a/packages/coding-agent/src/tiny/worker.ts b/packages/coding-agent/src/tiny/worker.ts index 0f60183ea..ca056baa6 100644 --- a/packages/coding-agent/src/tiny/worker.ts +++ b/packages/coding-agent/src/tiny/worker.ts @@ -94,7 +94,7 @@ function createStopOnTextCriteria( this.#text = text; } - _call(inputIds: number[][]): boolean[] { + override _call(inputIds: number[][]): boolean[] { return inputIds.map(ids => { const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS); const decoded = this.#tokenizer.decode(tail, { diff --git a/packages/coding-agent/src/web/search/providers/duckduckgo.ts b/packages/coding-agent/src/web/search/providers/duckduckgo.ts index 8116dbc75..54cb8d2ad 100644 --- a/packages/coding-agent/src/web/search/providers/duckduckgo.ts +++ b/packages/coding-agent/src/web/search/providers/duckduckgo.ts @@ -372,7 +372,7 @@ export class DuckDuckGoProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/ecosia.ts b/packages/coding-agent/src/web/search/providers/ecosia.ts index 562effa71..374c575ff 100644 --- a/packages/coding-agent/src/web/search/providers/ecosia.ts +++ b/packages/coding-agent/src/web/search/providers/ecosia.ts @@ -173,7 +173,7 @@ export class EcosiaProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index e745f8ab9..9c81d5317 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -458,7 +458,7 @@ export class ExaProvider extends SearchProvider { * still uses {@link isAvailable} so an unrelated configured provider * keeps priority over the public fallback. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return this.#settingsAllowSearch(); } diff --git a/packages/coding-agent/src/web/search/providers/firecrawl.ts b/packages/coding-agent/src/web/search/providers/firecrawl.ts index 3f701b02c..e3bb15071 100644 --- a/packages/coding-agent/src/web/search/providers/firecrawl.ts +++ b/packages/coding-agent/src/web/search/providers/firecrawl.ts @@ -203,7 +203,7 @@ export class FirecrawlProvider extends SearchProvider { * Firecrawl supports keyless mode, so an explicit user selection * (`webSearch: firecrawl`) works without any credential configured. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/mojeek.ts b/packages/coding-agent/src/web/search/providers/mojeek.ts index 3fe6ee71d..1272c3f75 100644 --- a/packages/coding-agent/src/web/search/providers/mojeek.ts +++ b/packages/coding-agent/src/web/search/providers/mojeek.ts @@ -210,7 +210,7 @@ export class MojeekProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index b0742b97d..1839a52d4 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -973,7 +973,7 @@ export class PerplexityProvider extends SearchProvider { * configured provider keeps priority over the anonymous/OpenRouter * fallbacks. */ - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/public.ts b/packages/coding-agent/src/web/search/providers/public.ts index c68c1cf28..5e21ae36b 100644 --- a/packages/coding-agent/src/web/search/providers/public.ts +++ b/packages/coding-agent/src/web/search/providers/public.ts @@ -189,7 +189,7 @@ export class PublicWebProvider extends SearchProvider { return false; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/src/web/search/providers/startpage.ts b/packages/coding-agent/src/web/search/providers/startpage.ts index 58f192b58..0fd8c60b4 100644 --- a/packages/coding-agent/src/web/search/providers/startpage.ts +++ b/packages/coding-agent/src/web/search/providers/startpage.ts @@ -215,7 +215,7 @@ export class StartpageProvider extends SearchProvider { return true; } - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + override isExplicitlyAvailable(_authStorage: AuthStorage): boolean { return true; } diff --git a/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts b/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts index a82c94e9e..6a4d5729b 100644 --- a/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts +++ b/packages/coding-agent/test/session-manager-atomic-rewrite-race.test.ts @@ -27,7 +27,10 @@ class DetachingRewriteStorage extends MemorySessionStorage { guardRejections = 0; readonly #writers = new Set(); - openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { + override openWriter( + path: string, + options?: { flags?: "a" | "w"; onError?: (err: Error) => void }, + ): SessionStorageWriter { const inner = super.openWriter(path, options); const writers = this.#writers; const detachedLines = this.detachedLines; diff --git a/packages/coding-agent/test/session-manager/large-session-memory.test.ts b/packages/coding-agent/test/session-manager/large-session-memory.test.ts index 591187a2a..3d5df8523 100644 --- a/packages/coding-agent/test/session-manager/large-session-memory.test.ts +++ b/packages/coding-agent/test/session-manager/large-session-memory.test.ts @@ -12,7 +12,7 @@ import * as snapcompact from "@oh-my-pi/snapcompact"; class CountingMemorySessionStorage extends MemorySessionStorage { writeTextSyncCalls = 0; - writeTextSync(filePath: string, content: string): void { + override writeTextSync(filePath: string, content: string): void { this.writeTextSyncCalls++; super.writeTextSync(filePath, content); } diff --git a/packages/hashline/src/fs.ts b/packages/hashline/src/fs.ts index bca93496a..6294fadc3 100644 --- a/packages/hashline/src/fs.ts +++ b/packages/hashline/src/fs.ts @@ -150,11 +150,11 @@ export class InMemoryFilesystem extends Filesystem { return { text: content }; } - async delete(path: string): Promise { + override async delete(path: string): Promise { if (!this.#files.delete(path)) throw new NotFoundError(path); } - async move(from: string, to: string, content?: string): Promise { + override async move(from: string, to: string, content?: string): Promise { const existing = this.#files.get(from); if (existing === undefined) throw new NotFoundError(from); const finalContent = content ?? existing; @@ -162,7 +162,7 @@ export class InMemoryFilesystem extends Filesystem { this.#files.delete(from); } - async exists(path: string): Promise { + override async exists(path: string): Promise { return this.#files.has(path); } @@ -199,7 +199,7 @@ export class NodeFilesystem extends Filesystem { return file.text(); } - async readBinary(path: string): Promise { + override async readBinary(path: string): Promise { try { return await fs.readFile(path); } catch (error) { @@ -213,7 +213,7 @@ export class NodeFilesystem extends Filesystem { return { text: content }; } - async delete(path: string): Promise { + override async delete(path: string): Promise { try { await fs.rm(path); } catch (error) { @@ -222,7 +222,7 @@ export class NodeFilesystem extends Filesystem { } } - async move(from: string, to: string, content?: string): Promise { + override async move(from: string, to: string, content?: string): Promise { if (content !== undefined) { await Bun.write(to, content); await this.delete(from); @@ -236,11 +236,11 @@ export class NodeFilesystem extends Filesystem { } } - canonicalPath(path: string): string { + override canonicalPath(path: string): string { return pathModule.resolve(path); } - async exists(path: string): Promise { + override async exists(path: string): Promise { return Bun.file(path).exists(); } } diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index 874433b2b..f755cfd93 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -180,7 +180,7 @@ export class InMemorySnapshotStore extends SnapshotStore { return history?.find(version => version.text === fullText) ?? null; } - findByHash(hash: string): Snapshot[] { + override findByHash(hash: string): Snapshot[] { const matches: Snapshot[] = []; for (const history of this.#versions.values()) { for (const version of history) { diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 817469925..bdb343ccf 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -71,7 +71,7 @@ class BlockingFilesystem extends InMemoryFilesystem { for (const filePath of blocked) this.#blocked.add(filePath); } - async preflightWrite(filePath: string): Promise { + override async preflightWrite(filePath: string): Promise { if (this.#blocked.has(filePath)) throw new Error(`blocked write: ${filePath}`); } } diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 67b8478d4..452d91c60 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -158,7 +158,7 @@ describe("Patcher snapshot tag integrity", () => { // call returned), but `writeText` echoes back a *reformatted* copy — spaces // turned into tabs, exactly the corruption reported against the ACP bridge. class DriftingFilesystem extends InMemoryFilesystem { - async writeText(path: string, content: string): Promise { + override async writeText(path: string, content: string): Promise { const drifted = content.replace(/^ {4}/gm, "\t"); await super.writeText(path, drifted); return { text: drifted }; diff --git a/packages/mnemopi/test/recovery.test.ts b/packages/mnemopi/test/recovery.test.ts index aff4ec8ee..d6c772f0f 100644 --- a/packages/mnemopi/test/recovery.test.ts +++ b/packages/mnemopi/test/recovery.test.ts @@ -52,7 +52,7 @@ function withFrozenNow(iso: string, fn: () => T): T { else super(value); } - static now(): number { + static override now(): number { return fixedMs; } } diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index c82bfd574..447f053ae 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -34,7 +34,7 @@ export class CancellableLoader extends Loader { } } - dispose(): void { + override dispose(): void { this.stop(); } } diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 935b3cb49..8c5c0daaf 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -55,7 +55,7 @@ export class Loader extends Text { this.start(); } - render(width: number): readonly string[] { + override render(width: number): readonly string[] { const source = super.render(width); if (source !== this.#layoutSource) { const paddingX = getPaddingX(1); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a5dec34bd..93ff282e4 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -804,11 +804,11 @@ markdownParser.use({ // (no `m` flag), and stickiness only removes the futile later attempts. The // flags/anchor guard below skips any rule a future marked version changes. class AnchoredAtZero extends RegExp { - exec(str: string): RegExpExecArray | null { + override exec(str: string): RegExpExecArray | null { this.lastIndex = 0; // sticky matches set lastIndex; rules are shared return super.exec(str); } - test(str: string): boolean { + override test(str: string): boolean { this.lastIndex = 0; return super.test(str); } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 364b1b660..6ec257d96 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -87,7 +87,7 @@ class WrappingLinesComponent implements Component { } class UnknownViewportTerminal extends VirtualTerminal { - isNativeViewportAtBottom(): undefined { + override isNativeViewportAtBottom(): undefined { return undefined; } } @@ -96,7 +96,7 @@ class StaleBottomViewportTerminal extends VirtualTerminal { #previous: boolean | undefined; #returnStale = false; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { const current = super.isNativeViewportAtBottom(); if (this.#returnStale) { this.#returnStale = false; @@ -113,18 +113,18 @@ class StaleBottomViewportTerminal extends VirtualTerminal { class CountingViewportTerminal extends VirtualTerminal { viewportProbeCount = 0; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { this.viewportProbeCount += 1; return super.isNativeViewportAtBottom(); } } class LegacyKeyboardVirtualTerminal extends VirtualTerminal { - get keyboardEnhancementEnterSequence(): string | null { + override get keyboardEnhancementEnterSequence(): string | null { return undefined as unknown as string | null; } - get keyboardEnhancementExitSequence(): string | null { + override get keyboardEnhancementExitSequence(): string | null { return undefined as unknown as string | null; } } diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 7962af8e2..cd64b950a 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -425,7 +425,7 @@ const BURST_STEP_METADATA = { } satisfies Record; class UnknownViewportTerminal extends VirtualTerminal { - isNativeViewportAtBottom(): undefined { + override isNativeViewportAtBottom(): undefined { return undefined; } } @@ -433,7 +433,7 @@ class UnknownViewportTerminal extends VirtualTerminal { class IntermittentUnknownViewportTerminal extends VirtualTerminal { #probeCount = 0; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { this.#probeCount += 1; return this.#probeCount % 3 === 0 ? undefined : super.isNativeViewportAtBottom(); } @@ -443,7 +443,7 @@ class StaleBottomTerminal extends VirtualTerminal { #previous: boolean | undefined; #returnStale = false; - isNativeViewportAtBottom(): boolean | undefined { + override isNativeViewportAtBottom(): boolean | undefined { const current = super.isNativeViewportAtBottom(); if (this.#returnStale) { this.#returnStale = false; diff --git a/packages/typescript-edit-benchmark/src/mutations.ts b/packages/typescript-edit-benchmark/src/mutations.ts index 27d308e55..9f1fecb96 100644 --- a/packages/typescript-edit-benchmark/src/mutations.ts +++ b/packages/typescript-edit-benchmark/src/mutations.ts @@ -489,7 +489,7 @@ class CallArgumentSwapMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); @@ -722,7 +722,7 @@ class IdentifierMultiEditMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); @@ -1036,7 +1036,7 @@ class SwapAdjacentLinesMutation extends BaseAstMutation { return out; } - mutate(content: string, rng: () => number): [string, MutationInfo] { + override mutate(content: string, rng: () => number): [string, MutationInfo] { const parsed = parseCode(content); if (!parsed) return [content, noopInfo()]; const candidates = this.collectCandidates(parsed); diff --git a/packages/utils/src/frontmatter.ts b/packages/utils/src/frontmatter.ts index 8178172f1..0aad83593 100644 --- a/packages/utils/src/frontmatter.ts +++ b/packages/utils/src/frontmatter.ts @@ -71,7 +71,7 @@ export class FrontmatterError extends Error { this.name = "FrontmatterError"; } - toString(): string { + override toString(): string { // Format the error with stack and detail, including the error message, stack, and source if present const details: string[] = [this.message]; if (this.source !== undefined) { diff --git a/packages/utils/test/fixtures/logger-fixed-date-preload.ts b/packages/utils/test/fixtures/logger-fixed-date-preload.ts index 987b09f4b..314e9bac6 100644 --- a/packages/utils/test/fixtures/logger-fixed-date-preload.ts +++ b/packages/utils/test/fixtures/logger-fixed-date-preload.ts @@ -13,7 +13,7 @@ class FixedDate extends NativeDate { super(value === undefined ? fixtureNow() : value); } - static now(): number { + static override now(): number { return fixtureNow(); } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 0639244e8..f8afdab2a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "moduleDetection": "force", "strict": true, + "noImplicitOverride": true, "skipLibCheck": true, "allowArbitraryExtensions": true, "verbatimModuleSyntax": true, From 33e84e3ca4f543a7585ec320f5a3fa4dae6fede0 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:26 +0900 Subject: [PATCH 54/97] chore(ts): enforce switch non-fallthrough --- tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index 0639244e8..cc75a1c5a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "moduleDetection": "force", "strict": true, + "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "allowArbitraryExtensions": true, "verbatimModuleSyntax": true, From ee492dbe7518c72ca904b66144e7404a8cb6341a Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:45:42 +0900 Subject: [PATCH 55/97] fix(ci): run the release version guard in CI Wave-3 review finding on #7586: - scripts/release.test.ts ran in neither CI nor the documented root command, so the release-version regression assertions guarded nothing - add it to test:scripts and invoke that file from the workspace job - the job deliberately does not run all of test:scripts: musl-release fails on main, so the whole group would red this job on an unrelated break --- .github/workflows/ci.yml | 7 ++++++- package.json | 2 +- packages/coding-agent/CHANGELOG.md | 11 ++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e5e6a24..a82dbc0fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,7 +307,12 @@ jobs: - name: Test workspace packages and repo scripts (TS) env: OMP_TEST_CONCURRENCY: "4" - run: bun run ci:test:ts:workspace + run: | + bun run ci:test:ts:workspace + # Not `test:scripts`: scripts/musl-release.test.ts fails on main + # (its install.sh smoke-check executes a fake binary), so running + # the whole group here would red this job on an unrelated break. + bun test scripts/release.test.ts test_coding_agent_singleton: name: Test coding-agent singleton/global-state (TS) diff --git a/package.json b/package.json index e763563f7..c3f0dc7a2 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "build:native": "bun --cwd=packages/natives run build", "test": "bun scripts/ci-test-ts.ts local", "test:ts": "bun scripts/ci-test-ts.ts local-ts", - "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts", + "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts scripts/release.test.ts", "test:rs": "bun scripts/run-rs-task.ts test:rs", "check": "bun run --parallel check:ts check:rs", "check:ts": "bun run check:tools && bun run --workspaces --if-present check", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 94b193953..0b32d4c82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,11 +2,6 @@ ## [Unreleased] -## [17.2.8] - 2026-08-04 - -### Changed - -- Upgraded the bundled omptype schema engine: intersection and pipe operators, bigint and RegExp literals in the string DSL, Standard Schema V1 interop, JSON Schema import via fromJsonSchema(), and richer union/collection error reporting. ### Breaking Changes - Renamed `compareVersions` to `compareChangelogEntries` in `@oh-my-pi/pi-coding-agent/utils/changelog`. The function signature and behavior are unchanged; update imports to use the new name. @@ -15,6 +10,12 @@ - Routed Bun install-cache pruning in `update-cli` through the shared `compareVersions` utility (`@oh-my-pi/pi-utils`), removing a duplicate local comparator that rounded large numeric version identifiers via `Number`. +## [17.2.8] - 2026-08-04 + +### Changed + +- Upgraded the bundled omptype schema engine: intersection and pipe operators, bigint and RegExp literals in the string DSL, Standard Schema V1 interop, JSON Schema import via fromJsonSchema(), and richer union/collection error reporting. + ## [17.2.7] - 2026-08-03 ### Changed From 3a9b823630ab230897f10447364309dbb1528135 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:50:17 +0900 Subject: [PATCH 56/97] fix(release): import the comparator without loading native bindings Wave-4 review finding on #7586: - release.ts took compareVersions from the pi-utils barrel, which pulls file-lock and procmgr into the graph; those import pi-natives, whose entrypoint calls loadNative() and throws in a checkout without the built addon - that also meant scripts/release.test.ts never executed under test:scripts, so the guard wired into CI last round proved nothing - take the dependency-free version subpath instead --- scripts/release.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.ts b/scripts/release.ts index 832c3ab5c..156ac11b7 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { compareVersions } from "@oh-my-pi/pi-utils"; +import { compareVersions } from "@oh-my-pi/pi-utils/version"; /** * Release script for pi-mono * From 0a9ff29b17267b3184521e12ed2e125aff10f2ec Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 20:18:52 +0000 Subject: [PATCH 57/97] fix(omptype): emit postfix bound for min-only numeric typebox schemas tNumber rendered a min-only bound as the left-bound-only DSL "1 <= number.integer", which parseBounded rejects because that range spelling requires a trailing right bound. Standard TypeBox schemas like Type.Integer({ minimum: 1 }) therefore failed to build, breaking extension tool loading. Emit the postfix "number.integer >= 1" form when there is no upper bound instead. Fixes #7648 --- packages/omptype/CHANGELOG.md | 4 ++++ packages/omptype/src/typebox.ts | 16 +++++++++++++--- packages/omptype/test/typebox.test.ts | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md index 9c6e03621..555d1af30 100644 --- a/packages/omptype/CHANGELOG.md +++ b/packages/omptype/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the TypeBox adapter emitting an invalid left-bound-only DSL for min-only numeric schemas (e.g. `Type.Integer({ minimum: 1 })`), which threw `left bound requires a corresponding right bound` and broke extension tool loading ([#7648](https://github.com/can1357/oh-my-pi/issues/7648)). + ## [17.2.8] - 2026-08-04 ### Added diff --git a/packages/omptype/src/typebox.ts b/packages/omptype/src/typebox.ts index 2654f5372..7e252a556 100644 --- a/packages/omptype/src/typebox.ts +++ b/packages/omptype/src/typebox.ts @@ -267,9 +267,19 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber { upper = { value: opts.exclusiveMaximum, exclusive: true }; } const keyword = integer ? "number.integer" : "number"; - const lowerDsl = lower ? `${lower.value} ${lower.exclusive ? "<" : "<="} ` : ""; - const upperDsl = upper ? ` ${upper.exclusive ? "<" : "<="} ${upper.value}` : ""; - let schema = asRuntime(type.raw(`${lowerDsl}${keyword}${upperDsl}`)); + // The `LO <= TYPE <= HI` range spelling requires both bounds; a min-only + // bound must use the postfix `TYPE >= LO` form (see parseBounded in ir.ts). + let src: string; + if (lower && upper) { + src = `${lower.value} ${lower.exclusive ? "<" : "<="} ${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`; + } else if (lower) { + src = `${keyword} ${lower.exclusive ? ">" : ">="} ${lower.value}`; + } else if (upper) { + src = `${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`; + } else { + src = keyword; + } + let schema = asRuntime(type.raw(src)); if (opts?.multipleOf !== undefined) { const divisor = opts.multipleOf; schema = schema.narrow((value, ctx) => { diff --git a/packages/omptype/test/typebox.test.ts b/packages/omptype/test/typebox.test.ts index f41e7911f..0ded2d33f 100644 --- a/packages/omptype/test/typebox.test.ts +++ b/packages/omptype/test/typebox.test.ts @@ -56,6 +56,22 @@ describe("TypeBox adapter", () => { expect(valid(exclusive, 1)).toBe(false); expect(valid(exclusive, 3)).toBe(false); expect(exclusive.toJsonSchema()).toEqual({ type: "number", exclusiveMinimum: 1, exclusiveMaximum: 3 }); + + const minOnly = Type.Integer({ minimum: 1 }); + expect(valid(minOnly, 1)).toBe(true); + expect(valid(minOnly, 0)).toBe(false); + expect(valid(minOnly, 1.5)).toBe(false); + expect(minOnly.toJsonSchema()).toEqual({ type: "integer", minimum: 1 }); + const exclusiveMinOnly = Type.Number({ exclusiveMinimum: 0 }); + expect(valid(exclusiveMinOnly, 0)).toBe(false); + expect(valid(exclusiveMinOnly, 0.5)).toBe(true); + const maxOnly = Type.Number({ maximum: 5 }); + expect(valid(maxOnly, 5)).toBe(true); + expect(valid(maxOnly, 6)).toBe(false); + const minWithMultiple = Type.Integer({ minimum: 1, multipleOf: 2 }); + expect(valid(minWithMultiple, 4)).toBe(true); + expect(valid(minWithMultiple, 3)).toBe(false); + expect(valid(minWithMultiple, 0)).toBe(false); }); test("arrays, tuples, objects, records and intersections validate", () => { From 50ee839c5167452526a6e5cdb916af42b00b6e1e Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Tue, 4 Aug 2026 16:35:56 -0400 Subject: [PATCH 58/97] fix(modes): serialize dispatch so a stream tail cannot overtake the coalesced flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentSession.#emit fires listeners fire-and-forget, and the coalesced message_update flush fires from its own 33ms timer — neither path awaited the other. A rapid stream tail (message_update -> message_end -> agent_end) could therefore run the end handlers while the flush was suspended mid-await, agent_end removing streamingComponent before #handleMessageEnd finalizes and records the final message (issue #7443 follow-up). - #runSerialized chains listener dispatch and the timer flush through one promise chain; an in-flight run holds later events until it completes. Idle dispatch stays synchronous (no added microtask), preserving the timing the coalescing tests assert on. - Regression test: a message_end landing while the window flush is suspended on init is queued behind it (initCalls 1 while suspended, then 2), where the pristine code ran both concurrently (2 while suspended). Fails without the fix. --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/controllers/event-controller.ts | 59 ++++++++++++++++++- ...controller-message-update-coalesce.test.ts | 34 +++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 02f11b7e5..c86721b25 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) +- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) ## [17.2.8] - 2026-08-04 ### Changed diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 38352f4fe..9b9b2ad27 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -180,6 +180,10 @@ export class EventController { // delta before the snapshot is coalesced away. #pendingMessageUpdate: Extract | undefined = undefined; #messageUpdateTimer: NodeJS.Timeout | undefined = undefined; + /** Tail of the serialized dispatch chain; see #runSerialized. */ + #dispatchTail: Promise = Promise.resolve(); + /** Whether a chained run is currently in flight (awaiting its own awaits). */ + #dispatchInFlight = false; // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate` // also vocalizes so the direct `handleEvent` path (tests, session focus replay) // keeps working — the WeakSet makes the coalesced path speak each delta exactly @@ -446,6 +450,17 @@ export class EventController { } subscribeToAgent(): void { + // Serialize non-update dispatch behind any in-flight handler run: + // AgentSession.#emit fires listeners fire-and-forget (it does not await + // listener promises), so without this a rapid stream tail + // (message_update → message_end → agent_end) could let a later callback + // overtake the coalesced flush's handler mid-await — agent_end removing + // `streamingComponent` before #handleMessageEnd finalizes and records + // the final message (issue #7443 follow-up). When the tail has settled, + // dispatch stays synchronous: the flush's streaming rebuild runs before + // the listener's first await, preserving the timing the coalescing + // tests assert on. `message_update` enqueue is itself synchronous and + // needs no serialization. this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => { // Coalesce the cumulative `message_update` deltas of a streaming turn // into at most one handler run per window. `#handleMessageUpdate` is @@ -460,11 +475,44 @@ export class EventController { this.#enqueueMessageUpdate(event); return; } - await this.#flushPendingMessageUpdate(); - await this.handleEvent(event); + await this.#runSerialized(async () => { + await this.#flushPendingMessageUpdate(); + await this.handleEvent(event); + }); }); } + /** + * Run `run` in the serialized dispatch chain: when another run is already + * in flight (awaiting its own awaits), wait for it first, so a rapid + * stream tail (message_update → message_end → agent_end) cannot overtake + * the coalesced flush mid-await — agent_end removing `streamingComponent` + * before #handleMessageEnd finalizes and records the final message (issue + * #7443 follow-up). The timer-based flush uses the same chain, closing the + * same race for the ~33ms window path. When the chain is idle, `run` + * starts synchronously (no intermediate microtask), preserving the + * synchronous-flush timing the coalescing tests assert on. A rejection + * propagates to the caller (the session's fire-and-forget emit) and the + * next event starts a fresh chain link instead of being dropped. + */ + async #runSerialized(run: () => Promise): Promise { + if (this.#dispatchInFlight) await this.#dispatchTail.catch(() => {}); + const link = run(); + if (!this.#dispatchInFlight) { + this.#dispatchInFlight = true; + void link.then( + () => { + this.#dispatchInFlight = false; + }, + () => { + this.#dispatchInFlight = false; + }, + ); + } + this.#dispatchTail = link; + await link; + } + /** * Queue a streaming `message_update` for the next coalesced handler run. * Speech is per-delta, so the delta is vocalized at arrival before the @@ -482,7 +530,12 @@ export class EventController { // Mirror AgentSession.#emit: attach a catch so a streaming rebuild // failure surfaces as a logged warning instead of a process-level // unhandled rejection (the timer path has no listener to attach one). - void this.#flushPendingMessageUpdate().catch(err => { + // Runs inside the serialized dispatch chain so a message_end / + // agent_end landing mid-window cannot overtake this flush (issue + // #7443 follow-up). + void this.#runSerialized(async () => { + await this.#flushPendingMessageUpdate(); + }).catch(err => { logger.warn("Message update flush rejected", { error: err instanceof Error ? err.message : String(err), }); diff --git a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts index cc3b5dafe..3bbc71779 100644 --- a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts +++ b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts @@ -138,4 +138,38 @@ describe("EventController message_update coalescing", () => { expect(pushDelta).toHaveBeenNthCalledWith(2, "one two "); expect(pushDelta).toHaveBeenNthCalledWith(3, "one two three "); }); + + it("serializes a tail event behind an in-flight window flush", async () => { + // The coalesced flush fires from a 33ms timer, NOT from the listener + // path, so AgentSession's fire-and-forget dispatch cannot serialize it: + // a message_end landing mid-flush used to run its handler concurrently, + // both calling init while the flush was suspended. The dispatch chain + // must hold the tail event until the window flush completed. + const { ctx, emit } = createStreamingFixture(); + ctx.isInitialized = false; + const initGate = Promise.withResolvers(); + let initCalls = 0; + ctx.init = vi.fn(async () => { + initCalls += 1; + if (initCalls === 1) await initGate.promise; + }); + + emit(messageUpdate("tok1 tok2")); + await Bun.sleep(45); // window fires; flush suspends on init (call 1) + + emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + await Bun.sleep(0); + + // The end handler must be queued behind the suspended flush, not + // running alongside it (which would double-init). + expect(initCalls).toBe(1); + initGate.resolve(); + await Bun.sleep(0); + + // Flush completed, then the end handler ran to completion. + expect(initCalls).toBe(2); + }); }); From b872e51e6fa4db1c4c0599ce5d60c90e500b4583 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 20:57:47 +0000 Subject: [PATCH 59/97] fix(mcp): propagate enabled flag from translated tool configs The Claude Code, Cursor, Gemini CLI, Windsurf, and VS Code importers built their canonical MCPServer object without mapping serverConfig.enabled, so a server declared with "enabled": false stayed undefined and the central suppressServer filter never fired. Only disabledServers masked the gap. Map the field in each importer, mirroring opencode.ts and codex.ts, and add a table-driven regression test across all five importers. Fixes #7652 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/discovery/claude.ts | 1 + packages/coding-agent/src/discovery/cursor.ts | 1 + packages/coding-agent/src/discovery/gemini.ts | 1 + packages/coding-agent/src/discovery/vscode.ts | 1 + .../coding-agent/src/discovery/windsurf.ts | 1 + .../test/discovery/mcp-enabled-import.test.ts | 91 +++++++++++++++++++ 7 files changed, 100 insertions(+) create mode 100644 packages/coding-agent/test/discovery/mcp-enabled-import.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 22a72debb..fa528eb2a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json` ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). + ## [17.2.8] - 2026-08-04 ### Changed diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index ab4637cf1..b03527587 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -90,6 +90,7 @@ async function loadMCPServers(ctx: LoadContext): Promise> const serverConfig = config as Record; return { name, + enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined, timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, diff --git a/packages/coding-agent/src/discovery/cursor.ts b/packages/coding-agent/src/discovery/cursor.ts index 2dcc23e1c..3fb4a8c7d 100644 --- a/packages/coding-agent/src/discovery/cursor.ts +++ b/packages/coding-agent/src/discovery/cursor.ts @@ -57,6 +57,7 @@ function parseMCPServers( const serverConfig = config as Record; items.push({ name, + enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined, command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, env: serverConfig.env as Record | undefined, diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index c7333ba20..fa8290ade 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -102,6 +102,7 @@ async function loadMCPFromSettings( items.push({ name, + enabled: typeof raw.enabled === "boolean" ? raw.enabled : undefined, command: typeof raw.command === "string" ? raw.command : undefined, args: Array.isArray(raw.args) ? (raw.args as string[]) : undefined, env: raw.env && typeof raw.env === "object" ? (raw.env as Record) : undefined, diff --git a/packages/coding-agent/src/discovery/vscode.ts b/packages/coding-agent/src/discovery/vscode.ts index 9239e953b..36116b3a1 100644 --- a/packages/coding-agent/src/discovery/vscode.ts +++ b/packages/coding-agent/src/discovery/vscode.ts @@ -83,6 +83,7 @@ async function loadMCPConfig( const server: MCPServer = { name, + enabled: typeof expanded.enabled === "boolean" ? expanded.enabled : undefined, command: typeof expanded.command === "string" ? expanded.command : undefined, args: Array.isArray(expanded.args) ? (expanded.args as string[]) : undefined, env: expanded.env && typeof expanded.env === "object" ? (expanded.env as Record) : undefined, diff --git a/packages/coding-agent/src/discovery/windsurf.ts b/packages/coding-agent/src/discovery/windsurf.ts index 0034467e1..44e16ff2e 100644 --- a/packages/coding-agent/src/discovery/windsurf.ts +++ b/packages/coding-agent/src/discovery/windsurf.ts @@ -48,6 +48,7 @@ function parseServerConfig( return { server: { name, + enabled: typeof server.enabled === "boolean" ? server.enabled : undefined, command: server.command as string | undefined, args: server.args as string[] | undefined, env: server.env as Record | undefined, diff --git a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts new file mode 100644 index 000000000..a911a7479 --- /dev/null +++ b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type MCPServer, mcpCapability } from "@oh-my-pi/pi-coding-agent/capability/mcp"; +import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { removeWithRetries } from "@oh-my-pi/pi-utils"; + +async function loadMcp(cwd: string, provider: string): Promise { + const result = await loadCapability(mcpCapability.id, { + cwd, + providers: [provider], + }); + return result.items; +} + +interface Fixture { + /** Discovery provider id passed to `loadCapability`. */ + provider: string; + /** Project-relative config file the importer reads. */ + file: string; + /** File body carrying a single server with `enabled: false`. */ + content: string; +} + +// Project-scoped config for each translated importer that previously dropped the +// per-server `enabled` flag (issue #7652). Codex/OpenCode/native already +// propagate it and are covered elsewhere. +const FIXTURES: Fixture[] = [ + { + provider: "claude", + file: ".claude/.mcp.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "cursor", + file: ".cursor/mcp.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "gemini", + file: ".gemini/settings.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "windsurf", + file: ".windsurf/mcp_config.json", + content: JSON.stringify({ + mcpServers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } }, + }), + }, + { + provider: "vscode", + file: ".vscode/mcp.json", + content: JSON.stringify({ + mcp: { servers: { markitdown: { command: "uvx", args: ["markitdown-mcp"], type: "stdio", enabled: false } } }, + }), + }, +]; + +describe("translated MCP importers propagate enabled: false", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-")); + }); + + afterEach(async () => { + await removeWithRetries(tempDir); + }); + + for (const { provider, file, content } of FIXTURES) { + test(`${provider} carries enabled: false`, async () => { + const filePath = path.join(tempDir, file); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + + const servers = await loadMcp(tempDir, provider); + const server = servers.find(item => item.name === "markitdown"); + + expect(server).toBeDefined(); + expect(server?.enabled).toBe(false); + }); + } +}); From 76f04c4c77a773a45ba5fa7b81e9083c0b541538 Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Tue, 4 Aug 2026 17:03:04 -0400 Subject: [PATCH 60/97] fix(coding-agent/eval): reject legacy model kwarg in Julia agent() prelude Python/Ruby raise on the removed keyword via their keyword-only signatures; the Julia helper's kwargs... catch-all silently swallowed agent("..."; model="default") and forwarded it to the bridge, where the "+": "delete" strip discarded it. A caller could keep thinking a model override was honored. Reject model loudly, matching the strict removal decision on #7621. --- packages/coding-agent/src/eval/jl/prelude.jl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/coding-agent/src/eval/jl/prelude.jl b/packages/coding-agent/src/eval/jl/prelude.jl index c90481222..1adcdbcf7 100644 --- a/packages/coding-agent/src/eval/jl/prelude.jl +++ b/packages/coding-agent/src/eval/jl/prelude.jl @@ -542,6 +542,9 @@ function agent(prompt::String; agent="task", label=nothing, schema=nothing, sche if merge !== nothing args_dict["merge"] = Bool(merge) end + if haskey(kwargs, :model) + error("agent() no longer accepts a per-call model override; the selected agent's frontmatter model is used") + end handle_result = handle for (k, v) in kwargs args_dict[string(k)] = v From 73d3076d146a4087b1234c1f874877911354dfc2 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 21:03:19 +0000 Subject: [PATCH 61/97] test(mcp): isolated translated importer home configs Set HOME and os.homedir() to a dedicated temporary directory for each translated-provider fixture, then restore both after every test. This prevents real user MCP configs from shadowing the project fixture through capability deduplication. --- .../test/discovery/mcp-enabled-import.test.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts index a911a7479..9f4b0eb87 100644 --- a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts +++ b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -65,23 +65,33 @@ const FIXTURES: Fixture[] = [ ]; describe("translated MCP importers propagate enabled: false", () => { - let tempDir = ""; + let tempCwd = ""; + let tempHome = ""; + let originalHome: string | undefined; beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-")); + originalHome = process.env.HOME; + tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-cwd-")); + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-enabled-home-")); + process.env.HOME = tempHome; + vi.spyOn(os, "homedir").mockReturnValue(tempHome); }); afterEach(async () => { - await removeWithRetries(tempDir); + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await removeWithRetries(tempCwd); + await removeWithRetries(tempHome); }); for (const { provider, file, content } of FIXTURES) { test(`${provider} carries enabled: false`, async () => { - const filePath = path.join(tempDir, file); + const filePath = path.join(tempCwd, file); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content); - const servers = await loadMcp(tempDir, provider); + const servers = await loadMcp(tempCwd, provider); const server = servers.find(item => item.name === "markitdown"); expect(server).toBeDefined(); From 9f92d36425769808bc502837cee1f9dd98c03e7e Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 21:14:11 +0000 Subject: [PATCH 62/97] fix(mcp): ordered project entries before user in translated importers The Claude/Cursor/Gemini/Windsurf importers appended user entries before project entries, so a project `enabled: false` could not claim its dedupe key ahead of a same-named user server and the disable was silently ignored. Load project entries first, matching the native/Codex loaders, so a project disable suppresses a same-named user server. Updated docs/mcp-config.md to reflect the project-first precedence and added compound regression coverage. Fixes #7652 --- docs/mcp-config.md | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/discovery/claude.ts | 12 ++--- packages/coding-agent/src/discovery/cursor.ts | 12 ++--- packages/coding-agent/src/discovery/gemini.ts | 18 ++++---- .../coding-agent/src/discovery/windsurf.ts | 5 ++- .../test/discovery/mcp-enabled-import.test.ts | 45 +++++++++++++++++++ 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/docs/mcp-config.md b/docs/mcp-config.md index c76b4badb..3acc371d3 100644 --- a/docs/mcp-config.md +++ b/docs/mcp-config.md @@ -39,7 +39,7 @@ OMP also translates these current tool-native sources: - VS Code: project-only `.vscode/mcp.json` using `mcp.servers` - installed Claude marketplace plugins and OMP extension packages that declare MCP servers -For translated providers with both scopes, a same-named user entry is encountered before its project entry. OMP-native config is the exception: its project entry precedes its active-profile user entry. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). +For translated providers with both scopes, the project entry is encountered before its same-named user entry — matching OMP-native config, whose project entry precedes its active-profile user entry — so a project `enabled: false` suppresses a same-named user server. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). ### Profiles diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fa528eb2a..fd6f8ed6e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json` ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). +- Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). ## [17.2.8] - 2026-08-04 diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index b03527587..df7f16881 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -103,17 +103,19 @@ async function loadMCPServers(ctx: LoadContext): Promise> }); }; - for (let i = 0; i < userPaths.length; i++) { - const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level); + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). + const projectOffset = userPaths.length; + for (let i = 0; i < projectPaths.length; i++) { + const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level); if (servers.length > 0) { items.push(...servers); break; } } - const projectOffset = userPaths.length; - for (let i = 0; i < projectPaths.length; i++) { - const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level); + for (let i = 0; i < userPaths.length; i++) { + const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level); if (servers.length > 0) { items.push(...servers); break; diff --git a/packages/coding-agent/src/discovery/cursor.ts b/packages/coding-agent/src/discovery/cursor.ts index 3fb4a8c7d..840f8d478 100644 --- a/packages/coding-agent/src/discovery/cursor.ts +++ b/packages/coding-agent/src/discovery/cursor.ts @@ -87,15 +87,17 @@ async function loadMCPServers(ctx: LoadContext): Promise> const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null); - if (userContent && userPath) { - const result = parseMCPServers(userContent, userPath, "user"); + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). + const projectContent = await projectContentPromise; + if (projectContent && projectPath) { + const result = parseMCPServers(projectContent, projectPath, "project"); items.push(...result.items); if (result.warning) warnings.push(result.warning); } - const projectContent = await projectContentPromise; - if (projectContent && projectPath) { - const result = parseMCPServers(projectContent, projectPath, "project"); + if (userContent && userPath) { + const result = parseMCPServers(userContent, userPath, "user"); items.push(...result.items); if (result.warning) warnings.push(result.warning); } diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index fa8290ade..dcb64ec24 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -48,14 +48,8 @@ async function loadMCPServers(ctx: LoadContext): Promise> const items: MCPServer[] = []; const warnings: string[] = []; - // User-level: ~/.gemini/settings.json → mcpServers - const userPath = getUserPath(ctx, "gemini", "settings.json"); - if (userPath) { - const result = await loadMCPFromSettings(ctx, userPath, "user"); - items.push(...result.items); - if (result.warnings) warnings.push(...result.warnings); - } - + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). // Project-level: .gemini/settings.json → mcpServers const projectPath = getProjectPath(ctx, "gemini", "settings.json"); if (projectPath) { @@ -64,6 +58,14 @@ async function loadMCPServers(ctx: LoadContext): Promise> if (result.warnings) warnings.push(...result.warnings); } + // User-level: ~/.gemini/settings.json → mcpServers + const userPath = getUserPath(ctx, "gemini", "settings.json"); + if (userPath) { + const result = await loadMCPFromSettings(ctx, userPath, "user"); + items.push(...result.items); + if (result.warnings) warnings.push(...result.warnings); + } + return { items, warnings }; } diff --git a/packages/coding-agent/src/discovery/windsurf.ts b/packages/coding-agent/src/discovery/windsurf.ts index 44e16ff2e..2672cb0e6 100644 --- a/packages/coding-agent/src/discovery/windsurf.ts +++ b/packages/coding-agent/src/discovery/windsurf.ts @@ -72,10 +72,11 @@ async function loadMCPServers(ctx: LoadContext): Promise> ]); const projectContent = projectPath ? await readFile(projectPath) : null; - + // Load project entries before user entries so a project `enabled: false` + // claims its dedupe key before a same-named user server can survive (#7654). const configs: Array<{ content: string | null; path: string | null; scope: "user" | "project" }> = [ - { content: userContent, path: userPath, scope: "user" }, { content: projectContent, path: projectPath, scope: "project" }, + { content: userContent, path: userPath, scope: "user" }, ]; for (const { content, path, scope } of configs) { diff --git a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts index 9f4b0eb87..6595c45c3 100644 --- a/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts +++ b/packages/coding-agent/test/discovery/mcp-enabled-import.test.ts @@ -64,6 +64,32 @@ const FIXTURES: Fixture[] = [ }, ]; +interface CompoundFixture { + /** Discovery provider id passed to `loadCapability`. */ + provider: string; + /** User-scope config file, relative to the temp HOME. */ + userFile: string; + /** Project-scope config file, relative to the temp cwd. */ + projectFile: string; +} + +// Providers exposing both a user and a project MCP scope. A project +// `enabled: false` must claim the dedupe key ahead of the same-named user +// server so the disable actually suppresses it (#7654). VS Code MCP is +// project-only, so it has no user/project compound case. +const COMPOUND_FIXTURES: CompoundFixture[] = [ + { provider: "claude", userFile: ".claude.json", projectFile: ".claude/.mcp.json" }, + { provider: "cursor", userFile: ".cursor/mcp.json", projectFile: ".cursor/mcp.json" }, + { provider: "gemini", userFile: ".gemini/settings.json", projectFile: ".gemini/settings.json" }, + { provider: "windsurf", userFile: ".codeium/windsurf/mcp_config.json", projectFile: ".windsurf/mcp_config.json" }, +]; + +function mcpServersJson(enabled: boolean, command: string): string { + return JSON.stringify({ + mcpServers: { markitdown: { command, args: ["markitdown-mcp"], type: "stdio", enabled } }, + }); +} + describe("translated MCP importers propagate enabled: false", () => { let tempCwd = ""; let tempHome = ""; @@ -98,4 +124,23 @@ describe("translated MCP importers propagate enabled: false", () => { expect(server?.enabled).toBe(false); }); } + + for (const { provider, userFile, projectFile } of COMPOUND_FIXTURES) { + test(`${provider} project enabled: false suppresses a same-named user server`, async () => { + const userPath = path.join(tempHome, userFile); + const projectPath = path.join(tempCwd, projectFile); + await fs.mkdir(path.dirname(userPath), { recursive: true }); + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + await fs.writeFile(userPath, mcpServersJson(true, "user-markitdown")); + await fs.writeFile(projectPath, mcpServersJson(false, "project-markitdown")); + + const result = await loadCapability(mcpCapability.id, { + cwd: tempCwd, + providers: [provider], + suppress: server => server.enabled === false, + }); + + expect(result.items.find(server => server.name === "markitdown")).toBeUndefined(); + }); + } }); From 326fb2436719101d5a1b4981f215965cf20c77ae Mon Sep 17 00:00:00 2001 From: Slava Zavadsky Date: Tue, 4 Aug 2026 17:42:54 -0400 Subject: [PATCH 63/97] fix(coding-agent): chain every queued dispatch onto the current tail The previous #runSerialized waited on the shared #dispatchTail, then ran unconditionally: when two or more events queued behind an in-flight run, each resumed from the same settled await and started its own run in parallel, defeating the ordering guarantee for a burst landing in one coalescing window (message_end + agent_end behind a suspended flush). Each waiter now chains its own link onto the current tail (tail.then(run, run)), so queued runs start strictly one after another; the idle path still runs synchronously, preserving the flush timing the coalescing tests assert on. The in-flight flag clears only when the settling link is still the tail, so a later chained link's settle does not clear it early. Regression test: two message_end events queued behind a suspended window flush stay serialized (init call count steps 1 -> 2 -> 3 as each gate opens); fails on the previous implementation. --- .../src/modes/controllers/event-controller.ts | 55 +++++++++++++------ ...controller-message-update-coalesce.test.ts | 45 +++++++++++++++ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 9b9b2ad27..0be56fdaf 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -483,33 +483,54 @@ export class EventController { } /** - * Run `run` in the serialized dispatch chain: when another run is already - * in flight (awaiting its own awaits), wait for it first, so a rapid - * stream tail (message_update → message_end → agent_end) cannot overtake - * the coalesced flush mid-await — agent_end removing `streamingComponent` - * before #handleMessageEnd finalizes and records the final message (issue - * #7443 follow-up). The timer-based flush uses the same chain, closing the - * same race for the ~33ms window path. When the chain is idle, `run` - * starts synchronously (no intermediate microtask), preserving the - * synchronous-flush timing the coalescing tests assert on. A rejection - * propagates to the caller (the session's fire-and-forget emit) and the - * next event starts a fresh chain link instead of being dropped. + * Run `run` in the serialized dispatch chain: every run is its own link on + * the tail, so a burst of events queued behind an in-flight run start one + * after the other, never concurrently. This closes two races (issue #7443 + * follow-up): a rapid stream tail (message_update → message_end → + * agent_end) cannot overtake the coalesced flush mid-await — agent_end + * removing `streamingComponent` before #handleMessageEnd finalizes and + * records the final message — and two+ events landing in the same window + * cannot all resume from one shared await and dispatch in parallel. When + * the chain is drained, `run` starts synchronously (no intermediate + * microtask), preserving the synchronous-flush timing the coalescing + * tests assert on. A rejection propagates to the caller (the session's + * fire-and-forget emit) and the next event starts a fresh chain link + * instead of being dropped. */ async #runSerialized(run: () => Promise): Promise { - if (this.#dispatchInFlight) await this.#dispatchTail.catch(() => {}); - const link = run(); - if (!this.#dispatchInFlight) { - this.#dispatchInFlight = true; + if (this.#dispatchInFlight) { + // Queue behind the CURRENT tail: the next run starts only after + // the previous one settles. Each waiter gets its own link, so a + // burst cannot fan out from the same shared await. + const link = this.#dispatchTail.then( + () => run(), + () => run(), + ); + this.#dispatchTail = link; void link.then( () => { - this.#dispatchInFlight = false; + // Only the tail owner clears the flag: a later chained + // link clears it when it settles as the tail. + if (this.#dispatchTail === link) this.#dispatchInFlight = false; }, () => { - this.#dispatchInFlight = false; + if (this.#dispatchTail === link) this.#dispatchInFlight = false; }, ); + await link; + return; } + this.#dispatchInFlight = true; + const link = run(); this.#dispatchTail = link; + void link.then( + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + () => { + if (this.#dispatchTail === link) this.#dispatchInFlight = false; + }, + ); await link; } diff --git a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts index 3bbc71779..714938d2c 100644 --- a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts +++ b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts @@ -172,4 +172,49 @@ describe("EventController message_update coalescing", () => { // Flush completed, then the end handler ran to completion. expect(initCalls).toBe(2); }); + + it("does not run two events queued in the same window concurrently", async () => { + // A burst that lands while a run is in flight must dispatch strictly + // one after the other: each waiter is chained onto the current tail, + // so two events sharing one suspended handler cannot both resume into + // parallel dispatch after the gate opens (regression: the shared + // `await this.#dispatchTail` let every queued callback start its own + // run once the tail settled). + const { ctx, emit } = createStreamingFixture(); + ctx.isInitialized = false; + const gates = [Promise.withResolvers(), Promise.withResolvers()]; + let initCalls = 0; + ctx.init = vi.fn(async () => { + initCalls += 1; + if (initCalls <= 2) await gates[initCalls - 1]!.promise; // first two runs each suspend on their own gate + }); + + emit(messageUpdate("tok1")); + await Bun.sleep(45); // window fires; flush run 1 suspends on gate 1 + + // Two non-update events land while the flush is still suspended. + emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + await Bun.sleep(0); + + // Neither queued handler has started yet — both are chained behind + // the suspended flush. + expect(initCalls).toBe(1); + + // Release run 1: run 2 starts and suspends on gate 2; run 3 is queued. + gates[0]!.resolve(); + await Bun.sleep(0); + expect(initCalls).toBe(2); + + // Release run 2: run 3 finally runs to completion. + gates[1]!.resolve(); + await Bun.sleep(0); + expect(initCalls).toBe(3); + }); }); From 43642a1a00c9992ea08667e84adac7229677983c Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 00:58:45 +0200 Subject: [PATCH 64/97] docs(utils): note PATH-aware lookup caching --- packages/utils/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index c195b1d2c..6ac5592ee 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -17,6 +17,9 @@ ### Changed - Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations. +### Fixed + +- Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads. ## [17.2.4] - 2026-08-01 From 2605243f38ac291c5ae25166d264592343751d44 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:05:45 +0200 Subject: [PATCH 65/97] fix: complete version comparator centralization --- packages/coding-agent/test/update-cli.test.ts | 24 +++++++++++++++++++ scripts/release.test.ts | 7 ++++++ scripts/release.ts | 4 ++-- scripts/setup-npm-trust.ts | 14 ++--------- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 4bf380ce0..4d731d4cc 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -380,6 +380,30 @@ describe("update-cli bun cache pruning", () => { expect(await Bun.file(path.join(dir, "pkg", "1.0.0@@@1")).exists()).toBe(true); expect(await Bun.file(path.join(dir, "pkg@1.0.0@@@1", "package.json")).exists()).toBe(true); }); + + it("compares numeric version segments without precision loss", async () => { + const dir = await makeTempDir(); + const older = "1.0.99999999999999999999"; + const newer = "1.0.100000000000000000000"; + await Bun.write(path.join(dir, "pkg", `${older}@@@1`), ""); + await Bun.write(path.join(dir, "pkg", `${newer}@@@1`), ""); + await Bun.write( + path.join(dir, `pkg@${older}@@@1`, "package.json"), + JSON.stringify({ name: "pkg", version: older }), + ); + await Bun.write( + path.join(dir, `pkg@${newer}@@@1`, "package.json"), + JSON.stringify({ name: "pkg", version: newer }), + ); + + const result = await pruneBunInstallCache(dir, new Set(["pkg"])); + + expect(result).toEqual({ scannedPackages: 1, removedEntries: 2 }); + expect(await Bun.file(path.join(dir, "pkg", `${older}@@@1`)).exists()).toBe(false); + expect(await Bun.file(path.join(dir, `pkg@${older}@@@1`, "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg", `${newer}@@@1`)).exists()).toBe(true); + expect(await Bun.file(path.join(dir, `pkg@${newer}@@@1`, "package.json")).exists()).toBe(true); + }); }); describe("update-cli release binary integrity", () => { diff --git a/scripts/release.test.ts b/scripts/release.test.ts index c7f640715..bc37ffc8b 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -14,6 +14,13 @@ describe("validateExplicitVersion", () => { expect(validateExplicitVersion("17.2.8-")).toBe(null); }); + test("rejects leading zeroes in numeric segments", () => { + expect(validateExplicitVersion("018.0.0")).toBe(null); + expect(validateExplicitVersion("v018.0.0")).toBe(null); + expect(validateExplicitVersion("18.00.0")).toBe(null); + expect(validateExplicitVersion("18.0.00")).toBe(null); + }); + test("rejects prerelease suffixes (not supported by this release path)", () => { // Prereleases would be published as npm `latest` because the downstream // publish runs `npm publish` with no `--tag`. diff --git a/scripts/release.ts b/scripts/release.ts index 156ac11b7..b29ef8abb 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { compareVersions } from "@oh-my-pi/pi-utils/version"; +import { compareVersions } from "../packages/utils/src/version.ts"; /** * Release script for pi-mono * @@ -30,7 +30,7 @@ const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); * prefix — Cargo rejects `version = "v17.2.8"`. */ export function validateExplicitVersion(version: string): string | null { - const match = /^v?(\d+\.\d+\.\d+)$/.exec(version); + const match = /^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/.exec(version); return match ? match[1] : null; } diff --git a/scripts/setup-npm-trust.ts b/scripts/setup-npm-trust.ts index f4959c53c..336ae5e15 100755 --- a/scripts/setup-npm-trust.ts +++ b/scripts/setup-npm-trust.ts @@ -35,6 +35,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { $ } from "bun"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { LEAF_TARGETS } from "../packages/natives/scripts/gen-npm-packages.ts"; import { packages } from "./ci-release-publish.ts"; @@ -179,17 +180,6 @@ async function collectTargets(): Promise<{ names: string[]; repoFromManifest: st return { names, repoFromManifest }; } -/** Compare dotted version numbers; true when `version` >= `minimum`. */ -function meetsMinimum(version: string, minimum: string): boolean { - const a = version.split(".").map(Number); - const b = minimum.split(".").map(Number); - for (let i = 0; i < Math.max(a.length, b.length); i++) { - const diff = (a[i] ?? 0) - (b[i] ?? 0); - if (diff !== 0) return diff > 0; - } - return true; -} - /** Run npm with the terminal attached so the web 2FA flow stays interactive. */ function npmInteractive(args: readonly string[]): Promise { return Bun.spawn(["npm", ...args], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exited; @@ -321,7 +311,7 @@ async function main(): Promise { console.error("Could not determine npm version. Is npm installed and on PATH?"); process.exit(1); } - if (!meetsMinimum(npmVersion, MIN_NPM)) { + if (compareVersions(npmVersion, MIN_NPM) < 0) { console.error(`npm ${MIN_NPM}+ is required for trusted publishing (found ${npmVersion}).`); console.error("Upgrade with: npm install -g npm@latest"); process.exit(1); From 46a03326149cd9f1a7c90a27a25fea4644e5c261 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 00:58:04 +0200 Subject: [PATCH 66/97] docs(coding-agent): note Herdr launcher fix --- packages/coding-agent/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3cebc8e60..fc9798466 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -34,6 +34,11 @@ - Fixed install.sh falsely reporting success on musl-based systems (such as Alpine Linux) when the binary fails to start; the installer now smoke-tests the binary, exits non-zero on failure, and provides remediation steps. - Fixed Codex config.toml discovery incorrectly importing MCP servers that are configured with enabled = false. - Fixed bash.patterns allow rules rejecting valid commands when quoted arguments contained shell metacharacters (such as Cargo benchmark regex filters). +- Fixed extension/custom/hook tool wrappers stripping schema methods off `parameters`: `applyToolProxy` bound every callable property, and binding a schema (a plain function carrying `toJsonSchema`/`assert`) dropped those properties, breaking wire-schema detection and crashing the status-line token estimator with `JSON.stringify(schema) === undefined`. Prototype methods are still bound; own data properties and schema callables now pass through untouched. +- Fixed bug where `agent()` calls in eval cells ignored turn cancellation and continued running indefinitely +- Fixed the built-in `tail` printing `tail: Broken pipe` and failing when a downstream pipeline reader exited early (e.g. `tail -c N file.jsonl | jq …` with jq aborting on a parse error); it now exits silently with 141 (128+SIGPIPE) like a real tail, in every output path including `--follow`. +- Fixed the in-process ps shell builtin rejecting common procps/BSD format specifiers (`ps -o tpgid,...` failed with `unknown output format specifier`); added `tpgid`, `pri`, `flags`, real/effective user and group columns, `wchan`, fault counters, `sz`, and the STAT `+` foreground flag. +- Fixed Herdr rejecting the macOS development launcher because its foreground process was reported as `bun` instead of `omp`. ## [17.2.6] - 2026-08-03 From 507360297725a3843c9df41847046b8b42c88c21 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:00:19 +0200 Subject: [PATCH 67/97] docs(mcp): clarify translated config precedence --- docs/mcp-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp-config.md b/docs/mcp-config.md index 3acc371d3..806d41118 100644 --- a/docs/mcp-config.md +++ b/docs/mcp-config.md @@ -39,7 +39,7 @@ OMP also translates these current tool-native sources: - VS Code: project-only `.vscode/mcp.json` using `mcp.servers` - installed Claude marketplace plugins and OMP extension packages that declare MCP servers -For translated providers with both scopes, the project entry is encountered before its same-named user entry — matching OMP-native config, whose project entry precedes its active-profile user entry — so a project `enabled: false` suppresses a same-named user server. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). +For Claude Code, Codex, Gemini CLI, Cursor, and Windsurf, the project entry is encountered before its same-named user entry — matching OMP-native config, whose project entry precedes its active-profile user entry — so a project `enabled: false` suppresses a same-named user server. OpenCode currently encounters the user entry first. Cross-provider priority is listed in [Discovery and precedence](#discovery-and-precedence). ### Profiles From 191d7f4051427d5f5226f591f77d6b58e3449b68 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:04:16 +0200 Subject: [PATCH 68/97] fix(stats): reclaim insecure wildcard dashboards --- packages/stats/src/port-conflict.ts | 32 ++++++++++++---- packages/stats/src/server.ts | 5 +++ .../stats/test/server-port-conflict.test.ts | 37 +++++++++++++++---- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/stats/src/port-conflict.ts b/packages/stats/src/port-conflict.ts index 015ab23a9..74b2273a1 100644 --- a/packages/stats/src/port-conflict.ts +++ b/packages/stats/src/port-conflict.ts @@ -24,7 +24,9 @@ export const STATS_DASHBOARD_SECURITY_VERSION = "2"; /** IPv4 loopback address shared by the dashboard server and reuse probe. */ export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1"; -async function probeReusableStatsDashboard(port: number): Promise { +type StatsDashboardProbe = "reusable" | "occupied" | "unreachable"; + +async function probeStatsDashboard(port: number): Promise { try { const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, { signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS), @@ -34,9 +36,9 @@ async function probeReusableStatsDashboard(port: number): Promise { response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION && !response.headers.has("Access-Control-Allow-Origin"); await response.body?.cancel(); - return reusable; + return reusable ? "reusable" : "occupied"; } catch { - return false; + return "unreachable"; } } @@ -211,10 +213,7 @@ async function terminatePortHolder(holder: PortHolder): Promise { await Bun.sleep(PROCESS_EXIT_POLL_MS); } -/** Reuse a securely configured dashboard or reclaim the port from an older omp runtime. */ -export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { - if (await probeReusableStatsDashboard(port)) return "reuse"; - +async function reclaimStatsPort(port: number): Promise<"retry"> { const holder = await findPortHolder(port); if (!holder) { throw new Error(`Port ${port} is in use, but the listening process could not be identified.`); @@ -243,3 +242,22 @@ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> await terminatePortHolder(holder); return "retry"; } + +/** + * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding. + * The preflight is needed on platforms that permit wildcard and loopback-specific + * listeners to coexist on one port. + */ +export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> { + if (port === 0) return "retry"; + const probe = await probeStatsDashboard(port); + if (probe === "reusable") return "reuse"; + if (probe === "occupied") return reclaimStatsPort(port); + return "retry"; +} + +/** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */ +export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> { + if ((await probeStatsDashboard(port)) === "reusable") return "reuse"; + return reclaimStatsPort(port); +} diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index b08051e74..33dd1097d 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -22,6 +22,7 @@ import { decodeEmbeddedClientArchive } from "./embedded-client"; import embeddedClientArchiveTxt from "./embedded-client.generated.txt"; import { getGainDashboardStats } from "./gain-aggregator"; import { + prepareStatsPort, recoverStatsPort, STATS_DASHBOARD_HEADER, STATS_DASHBOARD_HOSTNAME, @@ -359,6 +360,10 @@ function createDashboardServer(port: number) { */ export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> { await ensureClientBuild(); + const preparation = await prepareStatsPort(port); + if (preparation === "reuse") { + return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} }; + } try { const server = createDashboardServer(port); diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 2bd749620..7715b7bb2 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,3 +1,4 @@ +import { networkInterfaces } from "node:os"; import { afterEach, describe, expect, it } from "bun:test"; import { connect, type Subprocess } from "bun"; import { @@ -24,9 +25,11 @@ async function tcpConnects(hostname: string, port: number): Promise { const holderProcesses: Array> = []; -async function startBunHolder(responseExpr: string, options?: { statsOwned?: boolean }) { - // Bind the same loopback address as `startServer` so macOS cannot let a - // wildcard and address-specific listener coexist on the reserved port. +async function startBunHolder( + responseExpr: string, + options?: { hostname?: string; statsOwned?: boolean }, +) { + const hostname = options?.hostname ?? STATS_DASHBOARD_HOSTNAME; const reservation = Bun.serve({ port: 0, hostname: STATS_DASHBOARD_HOSTNAME, @@ -35,7 +38,7 @@ async function startBunHolder(responseExpr: string, options?: { statsOwned?: boo const port = reservation.port; reservation.stop(true); - const source = `Bun.serve({ port: ${port}, hostname: "${STATS_DASHBOARD_HOSTNAME}", fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; + const source = `Bun.serve({ port: ${port}, hostname: "${hostname}", fetch: () => ${responseExpr} }); process.stdout.write("ready"); await Promise.withResolvers().promise;`; const args = [process.execPath, "-e", source]; if (options?.statsOwned) args.push("omp-stats"); const child = Bun.spawn(args, { @@ -77,10 +80,23 @@ describe("startServer access", () => { expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); await response.body?.cancel(); - // 127.0.0.2 also routes to the loopback interface, but the server bound - // only 127.0.0.1, so a direct connection there must be refused. + let nonLoopbackHostname: string | undefined; + const interfaces = networkInterfaces(); + for (const name in interfaces) { + const addresses = interfaces[name] ?? []; + for (const address of addresses) { + if (address.family === "IPv4" && !address.internal) { + nonLoopbackHostname = address.address; + break; + } + } + if (nonLoopbackHostname) break; + } + expect(nonLoopbackHostname).toBeDefined(); expect(await tcpConnects(server.hostname, server.port)).toBe(true); - expect(await tcpConnects("127.0.0.2", server.port)).toBe(false); + if (nonLoopbackHostname) { + expect(await tcpConnects(nonLoopbackHostname, server.port)).toBe(false); + } } finally { server.stop(); } @@ -117,14 +133,19 @@ describe("startServer port conflicts", () => { { name: "reclaims a version 1 dashboard with wildcard CORS", response: `Response.json([], { headers: { "${STATS_DASHBOARD_HEADER}": "1", "Access-Control-Allow-Origin": "*" } })`, + hostname: "0.0.0.0", }, { name: "reclaims a headerless legacy dashboard", response: "Response.json([])", + hostname: STATS_DASHBOARD_HOSTNAME, }, ]) { it(fixture.name, async () => { - const holder = await startBunHolder(fixture.response, { statsOwned: true }); + const holder = await startBunHolder(fixture.response, { + hostname: fixture.hostname, + statsOwned: true, + }); const server = await startServer(holder.port); try { From f3cf3b926d3155cfbbf01dfb67fb2039288123f8 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:01:13 +0200 Subject: [PATCH 69/97] test(fuzzy-find): prove bounded large-corpus ranking --- crates/pi-natives/src/fd.rs | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/pi-natives/src/fd.rs b/crates/pi-natives/src/fd.rs index 1f9ec8dc9..4272e94e9 100644 --- a/crates/pi-natives/src/fd.rs +++ b/crates/pi-natives/src/fd.rs @@ -560,6 +560,49 @@ mod tests { } } + #[test] + fn bounded_retention_matches_full_sort_on_large_corpus() { + use super::{FuzzyFindMatch, TopMatches, path_depth}; + + const CANDIDATE_COUNT: usize = 100_000; + const MAX_RESULTS: usize = 128; + + let mut reference = Vec::with_capacity(CANDIDATE_COUNT); + let mut bounded = TopMatches::new(MAX_RESULTS); + for index in 0..CANDIDATE_COUNT { + let depth = index % 7; + let path = format!("{}{index:06}-item.txt", "nested/".repeat(depth)); + let score = 50 + (index % 83) as u32; + reference.push((score, path_depth(&path), path.clone())); + bounded.push(FuzzyFindMatch { path, is_directory: false, score }); + assert!( + bounded.heap.len() <= MAX_RESULTS, + "retention exceeded maxResults after candidate {index}" + ); + } + assert_eq!(reference.len(), CANDIDATE_COUNT); + assert_eq!(bounded.heap.len(), MAX_RESULTS); + assert_eq!(bounded.total_matches(), CANDIDATE_COUNT as u32); + + reference.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + let expected: Vec = reference + .into_iter() + .take(MAX_RESULTS) + .map(|(_, _, path)| path) + .collect(); + let actual: Vec = bounded + .into_sorted_matches() + .into_iter() + .map(|entry| entry.path) + .collect(); + + assert_eq!(actual, expected, "bounded top-K must match the complete baseline sort"); + } + #[test] fn bounded_retention_counts_hits_with_zero_capacity() { use super::{FuzzyFindMatch, TopMatches}; From abc628b63ca2feacee67072b08a2cf01023eb633 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:01:01 +0200 Subject: [PATCH 70/97] fix(read): expose artifact source line totals --- packages/coding-agent/src/tools/read.ts | 1 + .../coding-agent/test/tools/read-artifact-large.test.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 454aa828d..1a9140064 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -3244,6 +3244,7 @@ export class ReadTool implements AgentTool { if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) { outputText += `\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`; } + if (reachedEof) details.totalLines = totalFileLines; if (displayContent) details.displayContent = displayContent; if (truncationInfo) details.truncation = truncationInfo.result; const resultBuilder = toolResult(details) diff --git a/packages/coding-agent/test/tools/read-artifact-large.test.ts b/packages/coding-agent/test/tools/read-artifact-large.test.ts index 46831a30f..389bebc2b 100644 --- a/packages/coding-agent/test/tools/read-artifact-large.test.ts +++ b/packages/coding-agent/test/tools/read-artifact-large.test.ts @@ -104,6 +104,12 @@ describe("read tool large artifact handling", () => { expect(output).not.toContain("line-032"); }); + it("records the source line count for an open-ended artifact range that reaches EOF", async () => { + const result = await tool.execute("call-raw-tail", { path: "artifact://0:raw:301-" }); + + expect(result.details?.totalLines).toBe(400); + }); + it("shortens artifact paths under the user's home dir instead of leaking the absolute path", async () => { const homeSpy = spyOn(os, "homedir").mockReturnValue(testDir); try { From 130d3318c8e245eda7a56a373afbd0f3c95882dd Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:00:00 +0200 Subject: [PATCH 71/97] test(coding-agent): update observer registry stubs --- packages/coding-agent/test/silent-abort-overlay-render.test.ts | 1 + packages/coding-agent/test/tools/grep-path-lists.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index 7ef4de7ee..e07706b23 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -33,6 +33,7 @@ function makeJsonlSessionFile(dirPath: string, entries: object[]): string { function makeSubagentRegistry(sessions: ObservableSession[]) { return { getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id), onChange: () => () => {}, setMainSession: () => {}, getActiveSubagentCount: () => sessions.filter(s => s.status === "active").length, diff --git a/packages/coding-agent/test/tools/grep-path-lists.test.ts b/packages/coding-agent/test/tools/grep-path-lists.test.ts index 17ab6597f..adbe10449 100644 --- a/packages/coding-agent/test/tools/grep-path-lists.test.ts +++ b/packages/coding-agent/test/tools/grep-path-lists.test.ts @@ -93,6 +93,7 @@ async function makeJsonlSessionFile(dirPath: string, entries: object[]): Promise function makeSubagentRegistry(sessions: ObservableSession[]): SessionObserverRegistry { return { getSessions: () => sessions, + getSession: (id: string) => sessions.find(session => session.id === id), onChange: () => () => {}, setMainSession: () => {}, getActiveSubagentCount: () => sessions.filter(session => session.status === "active").length, From 43666b3b1aa7b93cf059fd91820c02e52b0f9160 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:00:51 +0200 Subject: [PATCH 72/97] fix(cli): follow promise resolver convention --- packages/coding-agent/src/modes/print-mode.ts | 17 ++++++++--------- .../test/print-mode-json-flush.test.ts | 4 +++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 7c0efb10b..54425749e 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -99,15 +99,14 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // tail before dispose/exit. Same truncation class as issue #5309 (issue #7635). let stdoutTail: Promise = Promise.resolve(); const writeStdoutLine = (text: string): void => { - stdoutTail = stdoutTail.then( - () => - new Promise(resolve => { - process.stdout.write(text, err => { - if (err) logger.warn("print-mode stdout write failed", { error: err.message }); - resolve(); - }); - }), - ); + stdoutTail = stdoutTail.then(() => { + const { promise, resolve, reject } = Promise.withResolvers(); + process.stdout.write(text, err => { + if (err) reject(err); + else resolve(); + }); + return promise; + }); }; // Emit session header for JSON mode diff --git a/packages/coding-agent/test/print-mode-json-flush.test.ts b/packages/coding-agent/test/print-mode-json-flush.test.ts index a21e8cd40..0e5d4d398 100644 --- a/packages/coding-agent/test/print-mode-json-flush.test.ts +++ b/packages/coding-agent/test/print-mode-json-flush.test.ts @@ -127,7 +127,9 @@ describe("print-mode JSON flush (#7635)", () => { // them all. The pre-fix fire-and-forget path settles and disposes here; // the fix must still be blocked on the undrained agent_end write. await agentEndWriteIssued; - await new Promise(resolve => setImmediate(resolve)); + const { promise: nextTask, resolve: resolveNextTask } = Promise.withResolvers(); + setImmediate(resolveNextTask); + await nextTask; expect(releaseAgentEnd).toBeDefined(); expect(settled).toBe(false); expect(harness.disposed()).toBe(false); From d53d5c003568aae7f6080ae0d91d6b914806f09f Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:02:08 +0200 Subject: [PATCH 73/97] fix(coding-agent): scope hindsight memory errors to caller --- .../src/internal-urls/memory-protocol.ts | 33 ++++++++++++++----- .../internal-urls/memory-protocol.test.ts | 12 +++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/internal-urls/memory-protocol.ts b/packages/coding-agent/src/internal-urls/memory-protocol.ts index 2917f63cb..36e8315bb 100644 --- a/packages/coding-agent/src/internal-urls/memory-protocol.ts +++ b/packages/coding-agent/src/internal-urls/memory-protocol.ts @@ -222,6 +222,18 @@ function mnemopiSessionStatesFromRegistry(): MnemopiSessionState[] { return states; } +function memoryBackendFromContext(context?: ResolveContext): string | undefined { + if (!context?.settings || typeof context.settings !== "object") return undefined; + try { + const get = Reflect.get(context.settings, "get"); + if (typeof get !== "function") return undefined; + const backend = Reflect.apply(get, context.settings, ["memory.backend"]); + return typeof backend === "string" ? backend : undefined; + } catch { + return undefined; + } +} + /** * Look up a mnemopi memory row by id across every live session's scoped banks. * First hit wins; returns `null` when the id is not stored anywhere in scope. @@ -290,21 +302,24 @@ export class MemoryProtocolHandler implements ProtocolHandler { // clipped recall preview before overwriting it (issue #4443). if (namespace !== MEMORY_NAMESPACE) { const mnemopiStates = mnemopiSessionStatesFromRegistry(); - if (mnemopiStates.length === 0) { + const hindsightActive = + memoryBackendFromContext(context) === "hindsight" || + (mnemopiStates.length === 0 && + AgentRegistry.global() + .list() + .some(ref => ref.session?.getHindsightSessionState?.())); + if (hindsightActive) { // Hindsight keeps memories server-side and exposes no // `memory://` addressing, yet the shared `recall` tool // description still steers a follow-up `read memory://`. // Return a corrective pointer so that stray read self-corrects in // one turn instead of derailing on the generic namespace error // (issue #7587). - const hindsightActive = AgentRegistry.global() - .list() - .some(ref => ref.session?.getHindsightSessionState?.()); - if (hindsightActive) { - throw new Error( - "Hindsight memories are not addressable via memory://. Recall results are final — use `recall` to search or `reflect` to synthesize. `read memory://` is only available with memory.backend=mnemopi.", - ); - } + throw new Error( + "Hindsight memories are not addressable via memory://. Recall results are final — use `recall` to search or `reflect` to synthesize. `read memory://` is only available with memory.backend=mnemopi.", + ); + } + if (mnemopiStates.length === 0) { throw new Error( `Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE} (file-backed memory summary), or a mnemopi memory id when memory.backend=mnemopi is active.`, ); diff --git a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts index b4a3e2d93..8fec71f8f 100644 --- a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts @@ -564,6 +564,18 @@ describe("MemoryProtocolHandler — hindsight (issue #7587)", () => { }); }); + it("uses the calling session backend when hindsight and mnemopi sessions coexist", async () => { + await withMnemopiSession(async () => { + await withHindsightSession(async () => { + const router = InternalUrlRouter.instance(); + const settings = Settings.isolated({ "memory.backend": "hindsight" }); + await expect(router.resolve("memory://a1b2c3d4e5f6", { settings })).rejects.toThrow( + /Hindsight memories are not addressable via memory:\/\//, + ); + }); + }); + }); + it("keeps the generic namespace error when no memory backend is active", async () => { const router = InternalUrlRouter.instance(); await expect(router.resolve("memory://a1b2c3d4e5f6")).rejects.toThrow( From ae1c9c981d4166aa79ce2e36c83e14d0a2efabac Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:01:01 +0200 Subject: [PATCH 74/97] test(coding-agent): make coalescing tests deterministic --- ...controller-message-update-coalesce.test.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts index 714938d2c..653f0e849 100644 --- a/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts +++ b/packages/coding-agent/test/event-controller-message-update-coalesce.test.ts @@ -76,14 +76,19 @@ function createStreamingFixture() { }; return { controller, ctx, ui, emit }; } +async function flushMicrotasks(): Promise { + for (let i = 0; i < 12; i++) await Promise.resolve(); +} describe("EventController message_update coalescing", () => { beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { "display.smoothStreaming": false } }); + vi.useFakeTimers(); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); resetSettingsForTest(); }); @@ -97,13 +102,25 @@ describe("EventController message_update coalescing", () => { emit(messageUpdate("tok1 tok2 tok3 tok4")); emit(messageUpdate("tok1 tok2 tok3 tok4 tok5")); - // The coalescing window is 33ms; give the trailing flush a chance to fire. - await Bun.sleep(60); + vi.advanceTimersByTime(32); + expect(ui.requestRender).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + await flushMicrotasks(); expect(ui.requestRender).toHaveBeenCalledTimes(1); expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ { type: "text", text: "tok1 tok2 tok3 tok4 tok5" }, ]); + + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5 tok6")); + emit(messageUpdate("tok1 tok2 tok3 tok4 tok5 tok6 tok7")); + vi.advanceTimersByTime(33); + await flushMicrotasks(); + + expect(ui.requestRender).toHaveBeenCalledTimes(2); + expect((ctx.streamingMessage as AssistantMessage | undefined)?.content).toEqual([ + { type: "text", text: "tok1 tok2 tok3 tok4 tok5 tok6 tok7" }, + ]); }); it("flushes the pending snapshot before a subsequent non-update event", async () => { @@ -131,7 +148,8 @@ describe("EventController message_update coalescing", () => { emit(messageUpdate("one two ")); emit(messageUpdate("one two three ")); - await Bun.sleep(60); + vi.advanceTimersByTime(33); + await flushMicrotasks(); expect(pushDelta).toHaveBeenCalledTimes(3); expect(pushDelta).toHaveBeenNthCalledWith(1, "one "); @@ -155,19 +173,19 @@ describe("EventController message_update coalescing", () => { }); emit(messageUpdate("tok1 tok2")); - await Bun.sleep(45); // window fires; flush suspends on init (call 1) + vi.advanceTimersByTime(33); // window fires; flush suspends on init (call 1) emit({ type: "message_end", message: assistantMessage("tok1 tok2") } as Extract< AgentSessionEvent, { type: "message_end" } >); - await Bun.sleep(0); + await flushMicrotasks(); // The end handler must be queued behind the suspended flush, not // running alongside it (which would double-init). expect(initCalls).toBe(1); initGate.resolve(); - await Bun.sleep(0); + await flushMicrotasks(); // Flush completed, then the end handler ran to completion. expect(initCalls).toBe(2); @@ -190,7 +208,7 @@ describe("EventController message_update coalescing", () => { }); emit(messageUpdate("tok1")); - await Bun.sleep(45); // window fires; flush run 1 suspends on gate 1 + vi.advanceTimersByTime(33); // window fires; flush run 1 suspends on gate 1 // Two non-update events land while the flush is still suspended. emit({ type: "message_end", message: assistantMessage("tok1") } as Extract< @@ -201,7 +219,7 @@ describe("EventController message_update coalescing", () => { AgentSessionEvent, { type: "message_end" } >); - await Bun.sleep(0); + await flushMicrotasks(); // Neither queued handler has started yet — both are chained behind // the suspended flush. @@ -209,12 +227,12 @@ describe("EventController message_update coalescing", () => { // Release run 1: run 2 starts and suspends on gate 2; run 3 is queued. gates[0]!.resolve(); - await Bun.sleep(0); + await flushMicrotasks(); expect(initCalls).toBe(2); // Release run 2: run 3 finally runs to completion. gates[1]!.resolve(); - await Bun.sleep(0); + await flushMicrotasks(); expect(initCalls).toBe(3); }); }); From 350b85e26cebf4e79894f441cc4a67c73e8392d6 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:01:39 +0200 Subject: [PATCH 75/97] test(coding-agent): drop unrelated schema repro churn --- packages/coding-agent/test/issue-905-repro.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/issue-905-repro.test.ts b/packages/coding-agent/test/issue-905-repro.test.ts index e9901670c..8d7d1aa94 100644 --- a/packages/coding-agent/test/issue-905-repro.test.ts +++ b/packages/coding-agent/test/issue-905-repro.test.ts @@ -208,8 +208,10 @@ test("omp models prints invalid models.yml schema errors before listing output", `providers: myprovider: baseUrl: http://localhost:8000/v1 - api: invalid-api + api: openai-completions auth: none + compat: + thinkingFormat: deepseek models: - id: my-model name: My Model @@ -246,8 +248,8 @@ test("omp models prints invalid models.yml schema errors before listing output", const output = captured.join(""); expect(output).toContain("Warning: models.yml validation failed — custom providers disabled"); - expect(output).toContain("providers.myprovider.api"); - expect(output).toContain("invalid-api"); + expect(output).toContain("providers.myprovider.compat.thinkingFormat"); + expect(output).toContain("deepseek"); } finally { authStorage.close(); } From 89931e109a4a6a9de70cf2e8ee3d37b1855aa0fd Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 00:58:32 +0200 Subject: [PATCH 76/97] fix(session): preserve colliding legacy sessions --- .../coding-agent/src/session/session-paths.ts | 24 +++++-- .../coding-agent/test/session-paths.test.ts | 64 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/test/session-paths.test.ts diff --git a/packages/coding-agent/src/session/session-paths.ts b/packages/coding-agent/src/session/session-paths.ts index 237aa013e..f76d6e183 100644 --- a/packages/coding-agent/src/session/session-paths.ts +++ b/packages/coding-agent/src/session/session-paths.ts @@ -17,11 +17,13 @@ function migrateSessionDirPath(oldPath: string, newPath: string): void { for (const file of fs.readdirSync(oldPath)) { const src = path.join(oldPath, file); const dst = path.join(newPath, file); - if (!fs.existsSync(dst)) { - fs.renameSync(src, dst); + if (fs.existsSync(dst)) { + logger.warn("Session directory migration collision; preserving legacy entry", { src, dst }); + continue; } + fs.renameSync(src, dst); } - fs.rmSync(oldPath, { recursive: true, force: true }); + fs.rmdirSync(oldPath); return; } if (existing) { @@ -94,8 +96,12 @@ function migrateHomeSessionDirs(sessionsRoot: string): void { try { migrateSessionDirPath(oldPath, newPath); - } catch { - // Best effort + } catch (error) { + logger.warn("Failed to migrate legacy home session directory", { + oldPath, + newPath, + error: String(error), + }); } } } @@ -106,8 +112,12 @@ function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessio try { migrateSessionDirPath(legacyDir, sessionDir); - } catch { - // Best effort + } catch (error) { + logger.warn("Failed to migrate legacy session directory", { + oldPath: legacyDir, + newPath: sessionDir, + error: String(error), + }); } } diff --git a/packages/coding-agent/test/session-paths.test.ts b/packages/coding-agent/test/session-paths.test.ts new file mode 100644 index 000000000..4ad9609b5 --- /dev/null +++ b/packages/coding-agent/test/session-paths.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { computeDefaultSessionDir } from "@oh-my-pi/pi-coding-agent/session/session-paths"; +import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; + +const cleanup: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanup.push(dir); + return dir; +} + +function legacySessionDir(sessionsRoot: string, cwd: string): string { + const name = `--${path.resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + return path.join(sessionsRoot, name); +} + +afterEach(() => { + for (const dir of cleanup.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("legacy session directory migration", () => { + test("keeps a colliding live legacy session reachable through its path", () => { + const sessionsRoot = makeTempDir("omp-session-root-"); + const cwd = makeTempDir("omp-session-cwd-"); + const storage = new FileSessionStorage(); + const canonicalDir = computeDefaultSessionDir(cwd, storage, sessionsRoot); + const legacyDir = legacySessionDir(sessionsRoot, cwd); + const source = path.join(legacyDir, "active.jsonl"); + const destination = path.join(canonicalDir, "active.jsonl"); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(source, "live-before\n"); + fs.writeFileSync(destination, "stale\n"); + const fd = fs.openSync(source, "a"); + + computeDefaultSessionDir(cwd, storage, sessionsRoot); + fs.writeSync(fd, "live-after\n"); + fs.closeSync(fd); + + expect(fs.readFileSync(source, "utf8")).toBe("live-before\nlive-after\n"); + expect(fs.readFileSync(destination, "utf8")).toBe("stale\n"); + }); + + test("preserves writes when an older process recreates its cached legacy directory", () => { + const sessionsRoot = makeTempDir("omp-session-root-"); + const cwd = makeTempDir("omp-session-cwd-"); + const storage = new FileSessionStorage(); + const canonicalDir = computeDefaultSessionDir(cwd, storage, sessionsRoot); + const legacyDir = legacySessionDir(sessionsRoot, cwd); + const destination = path.join(canonicalDir, "active.jsonl"); + fs.writeFileSync(destination, "canonical\n"); + + fs.mkdirSync(legacyDir, { recursive: true }); + const recreated = path.join(legacyDir, "active.jsonl"); + fs.writeFileSync(recreated, "older-process-write\n"); + computeDefaultSessionDir(cwd, storage, sessionsRoot); + + expect(fs.readFileSync(recreated, "utf8")).toBe("older-process-write\n"); + expect(fs.readFileSync(destination, "utf8")).toBe("canonical\n"); + }); +}); From 34ecb4a10764ff60762ba16ed4196177b0a8e086 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:13:13 +0200 Subject: [PATCH 77/97] chore(changelog): normalized unreleased entries after merges --- packages/ai/CHANGELOG.md | 2 +- packages/coding-agent/CHANGELOG.md | 80 ++++++++++-------------------- packages/tui/CHANGELOG.md | 2 +- packages/utils/CHANGELOG.md | 7 +-- 4 files changed, 31 insertions(+), 60 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3e22f7630..c21f35f7d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed GitHub Copilot requests failing with a raw `HTTP 400 model_not_available_for_integrator` on roughly half of all turns for recently rolled-out models. Copilot's fleet is not uniform — part of it rejects models that `/models` advertises on the same host — and the transient classifier matched only the older `model_not_supported` code at a fixed envelope depth, so these rejections surfaced as terminal errors instead of entering the existing retry path. Model-availability 400s are now recognized at any envelope depth and rerolled on a flat delay with a dedicated 8-attempt budget on the OpenAI transports; every other retryable failure keeps its previous backoff and attempt count. - Fixed Cursor reads with inline OMP range selectors reporting the returned slice length as the source file's `totalLines`, which made sequential reads of an unchanged file appear inconsistent ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). +- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. ## [17.2.7] - 2026-08-03 @@ -17,7 +18,6 @@ - Fixed OpenAI-Codex (ChatGPT OAuth) requests failing with an `Unsupported service_tier: auto` error on default or legacy sessions by omitting the implicit `auto` service tier on the wire. - Fixed an issue where Cursor `kimi-k3` sessions would break permanently when a same-model assistant turn was persisted without thinking blocks, replacing hard errors with graceful warnings. -- Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. ## [17.2.6] - 2026-08-03 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 752f310c0..cc310cff1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,56 +2,47 @@ ## [Unreleased] -### Fixed - -- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) -### Changed - -- Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). ### Breaking Changes - Renamed `compareVersions` to `compareChangelogEntries` in `@oh-my-pi/pi-coding-agent/utils/changelog`. The function signature and behavior are unchanged; update imports to use the new name. ### Changed +- Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). - Routed Bun install-cache pruning in `update-cli` through the shared `compareVersions` utility (`@oh-my-pi/pi-utils`), removing a duplicate local comparator that rounded large numeric version identifiers via `Number`. + ### Fixed +- Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) - Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). - - Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). +- Fixed legacy Pi extension validation rejecting plugins such as `remote-pi` that import the package-root `convertToPng` image helper. ([#7610](https://github.com/can1357/oh-my-pi/issues/7610)) +- Fixed `PUPPETEER_EXECUTABLE_PATH` being ignored when a system Chrome installation was detected, preventing Windows users from selecting a compatible headless browser for the shared browser daemon ([#7601](https://github.com/can1357/oh-my-pi/issues/7601)). +- Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)). +- Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). +- Fixed `grep`/`glob` silently collapsing a semicolon-delimited `path` list to one literal path when the joined string was too long for the OS to name (`ENAMETOOLONG`) — a list of bare filenames past `NAME_MAX` or absolute paths past `PATH_MAX` failed with `Path not found: ` even though every entry existed. The multipath probe now treats `ENAMETOOLONG` as a definitively non-existent single path so the split proceeds, and `glob` surfaces a clean `Path not found` instead of leaking the raw errno ([#7597](https://github.com/can1357/oh-my-pi/issues/7597)). +- Fixed `--mode json` (and text) print mode truncating a large final record (e.g. a multi-MB `agent_end`) when the process exited before stdout drained, while still exiting 0. Per-event writes are now serialized on their own completion callbacks and shutdown blocks on the last one, so the terminal record is delivered in full ([#7635](https://github.com/can1357/oh-my-pi/issues/7635)). +- Fixed text print mode treating buffered partial responses as replay-unsafe, allowing transient mid-stream connection failures to retry without exposing duplicated output ([#7625](https://github.com/can1357/oh-my-pi/issues/7625)). +- Fixed Hindsight `autoRecall` intermittently not reaching the model: two recall paths shared the `hasRecalledForFirstTurn` flag, and the `agent_start` event path could consume it first and inject only via an unawaited background prompt rebuild that a fast turn outran. `beforeAgentStartPrompt` (awaited before the turn builds) is now the sole injection path ([#7568](https://github.com/can1357/oh-my-pi/issues/7568)). +- Fixed `read memory://` returning a confusing "Unknown memory namespace" error under `memory.backend=hindsight` (Hindsight stores memories server-side and has no `memory://` addressing); the handler now returns a corrective pointer to `recall`/`reflect` so a stray read — steered by the shared `recall` tool description — self-corrects in one turn ([#7587](https://github.com/can1357/oh-my-pi/issues/7587)). +- Fixed extension/custom/hook tool wrappers stripping schema methods off `parameters`: `applyToolProxy` bound every callable property, and binding a schema (a plain function carrying `toJsonSchema`/`assert`) dropped those properties, breaking wire-schema detection and crashing the status-line token estimator with `JSON.stringify(schema) === undefined`. Prototype methods are still bound; own data properties and schema callables now pass through untouched. +- Fixed bug where `agent()` calls in eval cells ignored turn cancellation and continued running indefinitely +- Fixed the built-in `tail` printing `tail: Broken pipe` and failing when a downstream pipeline reader exited early (e.g. `tail -c N file.jsonl | jq …` with jq aborting on a parse error); it now exits silently with 141 (128+SIGPIPE) like a real tail, in every output path including `--follow`. +- Fixed the in-process ps shell builtin rejecting common procps/BSD format specifiers (`ps -o tpgid,...` failed with `unknown output format specifier`); added `tpgid`, `pri`, `flags`, real/effective user and group columns, `wchan`, fault counters, `sz`, and the STAT `+` foreground flag. +- Fixed Herdr rejecting the macOS development launcher because its foreground process was reported as `bun` instead of `omp`. +- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. +- Fixed Agent Hub opening and selection becoming O(all rows) on large rosters: row rendering is now lazy around the selected viewport, and observer lookup is O(1) by id instead of copy-sorting every session per row. +- Fixed the bash interceptor blocking `grep`/`cat`/`find` used as a downstream pipeline stage (e.g. `printf 'x\n' | grep x`); a stage consuming piped stdin cannot be replaced by a path-based dedicated tool, so it is no longer matched, while standalone and first-stage searches stay intercepted ([#7496](https://github.com/can1357/oh-my-pi/issues/7496)). +- Fixed floating rejections from cmux browser guest JavaScript terminating the main process and every active session; attributable rejections now fail the browser run as tool errors while unrelated process rejections retain the fatal path ([#7365](https://github.com/can1357/oh-my-pi/issues/7365)). +- Fixed the Windows bash tool silently taking down the whole omp process when a command blocked until its timeout: cancelling a timed-out run walked the spawned child's descendant tree from raw `th32ParentProcessID` links, and a recycled pid matching the harness's stale recorded parent pid could enumerate omp as a false descendant and `TerminateProcess` it, killing the session with no `session_exit` record. Run-cancellation sweeps now refuse to signal the harness or any process collected beneath it, while still reaping the timed-out target when it owns a recycled ancestor pid ([#7452](https://github.com/can1357/oh-my-pi/issues/7452)). +- Fixed the unexpected-stop guard (`features.unexpectedStopDetection`) never firing for thinking-only stops: `isUnexpectedStopCandidate` only counted non-whitespace `text` blocks, so a `stopReason: "stop"` turn whose sole content was a signed `thinking` block (a trapped response or a truncated reasoning fragment from reasoning models) bypassed classification and silently ended the turn mid-task. Such stops are now candidates and are classified on their thinking text ([#7499](https://github.com/can1357/oh-my-pi/issues/7499)). +- Fixed Task cancellation hanging forever when a child ignored abort or stalled during cleanup ([#7483](https://github.com/can1357/oh-my-pi/issues/7483)). + ## [17.2.8] - 2026-08-04 ### Changed - Upgraded the bundled omptype schema engine: intersection and pipe operators, bigint and RegExp literals in the string DSL, Standard Schema V1 interop, JSON Schema import via fromJsonSchema(), and richer union/collection error reporting. -### Fixed - -- Fixed legacy Pi extension validation rejecting plugins such as `remote-pi` that import the package-root `convertToPng` image helper. ([#7610](https://github.com/can1357/oh-my-pi/issues/7610)) -### Fixed - -- Fixed `PUPPETEER_EXECUTABLE_PATH` being ignored when a system Chrome installation was detected, preventing Windows users from selecting a compatible headless browser for the shared browser daemon ([#7601](https://github.com/can1357/oh-my-pi/issues/7601)). -### Fixed - -- Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)). -### Fixed - -- Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). -### Fixed - -- Fixed `grep`/`glob` silently collapsing a semicolon-delimited `path` list to one literal path when the joined string was too long for the OS to name (`ENAMETOOLONG`) — a list of bare filenames past `NAME_MAX` or absolute paths past `PATH_MAX` failed with `Path not found: ` even though every entry existed. The multipath probe now treats `ENAMETOOLONG` as a definitively non-existent single path so the split proceeds, and `glob` surfaces a clean `Path not found` instead of leaking the raw errno ([#7597](https://github.com/can1357/oh-my-pi/issues/7597)). -### Fixed - -- Fixed `--mode json` (and text) print mode truncating a large final record (e.g. a multi-MB `agent_end`) when the process exited before stdout drained, while still exiting 0. Per-event writes are now serialized on their own completion callbacks and shutdown blocks on the last one, so the terminal record is delivered in full ([#7635](https://github.com/can1357/oh-my-pi/issues/7635)). -### Fixed - -- Fixed text print mode treating buffered partial responses as replay-unsafe, allowing transient mid-stream connection failures to retry without exposing duplicated output ([#7625](https://github.com/can1357/oh-my-pi/issues/7625)). -### Fixed - -- Fixed Hindsight `autoRecall` intermittently not reaching the model: two recall paths shared the `hasRecalledForFirstTurn` flag, and the `agent_start` event path could consume it first and inject only via an unawaited background prompt rebuild that a fast turn outran. `beforeAgentStartPrompt` (awaited before the turn builds) is now the sole injection path ([#7568](https://github.com/can1357/oh-my-pi/issues/7568)). -### Fixed - -- Fixed `read memory://` returning a confusing "Unknown memory namespace" error under `memory.backend=hindsight` (Hindsight stores memories server-side and has no `memory://` addressing); the handler now returns a corrective pointer to `recall`/`reflect` so a stray read — steered by the shared `recall` tool description — self-corrects in one turn ([#7587](https://github.com/can1357/oh-my-pi/issues/7587)). ## [17.2.7] - 2026-08-03 @@ -68,12 +59,6 @@ - Fixed install.sh falsely reporting success on musl-based systems (such as Alpine Linux) when the binary fails to start; the installer now smoke-tests the binary, exits non-zero on failure, and provides remediation steps. - Fixed Codex config.toml discovery incorrectly importing MCP servers that are configured with enabled = false. - Fixed bash.patterns allow rules rejecting valid commands when quoted arguments contained shell metacharacters (such as Cargo benchmark regex filters). -- Fixed extension/custom/hook tool wrappers stripping schema methods off `parameters`: `applyToolProxy` bound every callable property, and binding a schema (a plain function carrying `toJsonSchema`/`assert`) dropped those properties, breaking wire-schema detection and crashing the status-line token estimator with `JSON.stringify(schema) === undefined`. Prototype methods are still bound; own data properties and schema callables now pass through untouched. -- Fixed bug where `agent()` calls in eval cells ignored turn cancellation and continued running indefinitely -- Fixed the built-in `tail` printing `tail: Broken pipe` and failing when a downstream pipeline reader exited early (e.g. `tail -c N file.jsonl | jq …` with jq aborting on a parse error); it now exits silently with 141 (128+SIGPIPE) like a real tail, in every output path including `--follow`. -- Fixed the in-process ps shell builtin rejecting common procps/BSD format specifiers (`ps -o tpgid,...` failed with `unknown output format specifier`); added `tpgid`, `pri`, `flags`, real/effective user and group columns, `wchan`, fault counters, `sz`, and the STAT `+` foreground flag. -- Fixed Herdr rejecting the macOS development launcher because its foreground process was reported as `bun` instead of `omp`. -- Completed usage-aware model fallback across startup, queued turns, same-turn tool continuations, ACP/TUI confirmation cancellation, eligible account reselection, cooldown restoration, and isolated subagent settings so low-usage handoffs remain lossless and cannot consume cancelled queued work. ## [17.2.6] - 2026-08-03 @@ -83,7 +68,6 @@ ### Fixed -- Fixed Agent Hub opening and selection becoming O(all rows) on large rosters: row rendering is now lazy around the selected viewport, and observer lookup is O(1) by id instead of copy-sorting every session per row. - Fixed extension slash commands appearing as user prompts after being handled locally. - Preserved explicit session titles when branching from an earlier conversation turn. - Fixed an issue where unhandled JavaScript rejections in the browser guest could crash the main process and active sessions, converting them into tool errors instead. @@ -93,13 +77,6 @@ - Fixed issues with `/btw` branch promotion where branches could park behind active turns, cut from outdated session leaves, or leave rejected branch keys indistinguishable from composer input. - Fixed database bloat by ensuring archived main and nested session rows are properly cleaned up from `stats.db` during garbage collection. - Fixed startup hanging during local model discovery when a timed-out transport left its request pending, which blocked the CLI before OAuth login could finish ([#7482](https://github.com/can1357/oh-my-pi/issues/7482)). -### Fixed - -- Fixed the bash interceptor blocking `grep`/`cat`/`find` used as a downstream pipeline stage (e.g. `printf 'x\n' | grep x`); a stage consuming piped stdin cannot be replaced by a path-based dedicated tool, so it is no longer matched, while standalone and first-stage searches stay intercepted ([#7496](https://github.com/can1357/oh-my-pi/issues/7496)). -- Fixed floating rejections from cmux browser guest JavaScript terminating the main process and every active session; attributable rejections now fail the browser run as tool errors while unrelated process rejections retain the fatal path ([#7365](https://github.com/can1357/oh-my-pi/issues/7365)). -- Fixed the Windows bash tool silently taking down the whole omp process when a command blocked until its timeout: cancelling a timed-out run walked the spawned child's descendant tree from raw `th32ParentProcessID` links, and a recycled pid matching the harness's stale recorded parent pid could enumerate omp as a false descendant and `TerminateProcess` it, killing the session with no `session_exit` record. Run-cancellation sweeps now refuse to signal the harness or any process collected beneath it, while still reaping the timed-out target when it owns a recycled ancestor pid ([#7452](https://github.com/can1357/oh-my-pi/issues/7452)). -- Fixed the unexpected-stop guard (`features.unexpectedStopDetection`) never firing for thinking-only stops: `isUnexpectedStopCandidate` only counted non-whitespace `text` blocks, so a `stopReason: "stop"` turn whose sole content was a signed `thinking` block (a trapped response or a truncated reasoning fragment from reasoning models) bypassed classification and silently ended the turn mid-task. Such stops are now candidates and are classified on their thinking text ([#7499](https://github.com/can1357/oh-my-pi/issues/7499)). -- Fixed Task cancellation hanging forever when a child ignored abort or stalled during cleanup ([#7483](https://github.com/can1357/oh-my-pi/issues/7483)). ## [17.2.5] - 2026-08-03 @@ -152,14 +129,7 @@ - Fixed plugin installation validation failures for legacy compatibility shims. - Removed hard-coded references to disabled or absent agents in system and tool prompts. - Fixed `omp setup python` to validate the same configured or discovered interpreter used by the Python eval runtime. - - -### Fixed - - Fixed self-update misclassifying glibc Linux hosts with an installed musl loader as musl hosts, which could download an unusable musl binary instead of the glibc release. - -### Fixed - - Fixed a crash where opening the Agent Hub after a resume and moving the selection triggered an unbounded `ExtensionExitError` unhandled-rejection storm and exit 129. The postmortem module bound the native hard-exit at first evaluation; when the bundler deferred that evaluation into a `withHostGuard` window it froze the guard's throwing replacement, poisoning every later signal/fatal exit. The native exit is now resolved per call, and the guard stamps its replacement with the native primitive it shadows so mid-guard signals still exit ([#7393](https://github.com/can1357/oh-my-pi/issues/7393)). ## [17.2.4] - 2026-08-01 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4d7d57d52..603cdd528 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed table borders (and adjacent cells) inheriting an open inline-code color when a cell's content wraps mid-code-span, by terminating each wrapped cell line's SGR state before the border glyphs ([#7575](https://github.com/can1357/oh-my-pi/issues/7575)). +- Fixed inline images not rendering under WSL + Windows Terminal: the SIXEL capability probe gated on `process.platform === "win32"`, but WSL reports `linux`, so the probe never ran and images fell back to the text placeholder even on Sixel-capable Windows Terminal. The probe now runs on any ConPTY host (native win32 or WSL) ([#6009](https://github.com/can1357/oh-my-pi/issues/6009)). ## [17.2.5] - 2026-08-03 @@ -135,7 +136,6 @@ - Prevented temporary dashboard frame updates from cluttering the terminal's native scrollback history. - Added support for cleaning up tracked Kitty graphics, allowing inline images to be properly deleted before falling back to text. - Fixed an issue where resizing or growing a multiplexer pane would incorrectly overwrite newly exposed rows with blank padding. -- Fixed inline images not rendering under WSL + Windows Terminal: the SIXEL capability probe gated on `process.platform === "win32"`, but WSL reports `linux`, so the probe never ran and images fell back to the text placeholder even on Sixel-capable Windows Terminal. The probe now runs on any ConPTY host (native win32 or WSL) ([#6009](https://github.com/can1357/oh-my-pi/issues/6009)). ## [17.0.3] - 2026-07-17 diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index f1bc24b4e..14f518d3c 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -6,6 +6,10 @@ - Added a public `compareVersions` utility (`@oh-my-pi/pi-utils`) that compares two version strings with SemVer-2.0 prerelease ordering, build-metadata stripping, and numeric segment comparison without float overflow; never throws. +### Fixed + +- Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads. + ## [17.2.6] - 2026-08-03 ### Added @@ -21,9 +25,6 @@ ### Changed - Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations. -### Fixed - -- Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads. ## [17.2.4] - 2026-08-01 From e824d06dd6c64c738009cc1055cf410cbe94a25e Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:15:31 +0200 Subject: [PATCH 78/97] docs(changelog): added entries for #7593 session-migration fix and #7415 fuzzy-find bound --- packages/coding-agent/CHANGELOG.md | 1 + packages/natives/CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cc310cff1..17ac9bddb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). - Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). - Fixed legacy Pi extension validation rejecting plugins such as `remote-pi` that import the package-root `convertToPng` image helper. ([#7610](https://github.com/can1357/oh-my-pi/issues/7610)) +- Fixed the legacy session-directory migration silently deleting a live session's transcript when its filename collided with an existing entry in the destination: colliding entries are now preserved in place, the legacy directory is only removed when empty, and collisions/migration failures are logged ([#7593](https://github.com/can1357/oh-my-pi/issues/7593)). - Fixed `PUPPETEER_EXECUTABLE_PATH` being ignored when a system Chrome installation was detected, preventing Windows users from selecting a compatible headless browser for the shared browser daemon ([#7601](https://github.com/can1357/oh-my-pi/issues/7601)). - Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)). - Exposed exact source line counts in read results when selector-based reads reach EOF, allowing protocol bridges to distinguish a returned slice from the complete file ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 5f4c8f991..12fcdbb37 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Bounded fuzzy-find scored-match retention to the top-K results (worst-first heap) instead of collecting and fully sorting every hit; ranking and totals are unchanged ([#7415](https://github.com/can1357/oh-my-pi/issues/7415)). + ## [17.2.7] - 2026-08-03 ### Added From 6baaeec7850b5da6d082ff18acbed3badb685626 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:15:43 +0200 Subject: [PATCH 79/97] style: organized imports in release tooling (biome) --- scripts/release.ts | 2 +- scripts/setup-npm-trust.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index b29ef8abb..00188044e 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,5 +1,4 @@ #!/usr/bin/env bun -import { compareVersions } from "../packages/utils/src/version.ts"; /** * Release script for pi-mono * @@ -10,6 +9,7 @@ import { compareVersions } from "../packages/utils/src/version.ts"; * Example: bun scripts/release.ts minor */ import { $, Glob } from "bun"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { runChangelogFixer } from "./fix-changelogs"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); diff --git a/scripts/setup-npm-trust.ts b/scripts/setup-npm-trust.ts index 336ae5e15..238369de3 100755 --- a/scripts/setup-npm-trust.ts +++ b/scripts/setup-npm-trust.ts @@ -35,8 +35,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { $ } from "bun"; -import { compareVersions } from "../packages/utils/src/version.ts"; import { LEAF_TARGETS } from "../packages/natives/scripts/gen-npm-packages.ts"; +import { compareVersions } from "../packages/utils/src/version.ts"; import { packages } from "./ci-release-publish.ts"; const repoRoot = path.join(import.meta.dir, ".."); From ece9245a0a5010311d602eb9bbb4c79755dbe06b Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:15:59 +0200 Subject: [PATCH 80/97] style: applied biome formatting to merged changes --- packages/coding-agent/test/session-paths.test.ts | 5 ++++- packages/stats/test/server-port-conflict.test.ts | 7 ++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/test/session-paths.test.ts b/packages/coding-agent/test/session-paths.test.ts index 4ad9609b5..f437f5851 100644 --- a/packages/coding-agent/test/session-paths.test.ts +++ b/packages/coding-agent/test/session-paths.test.ts @@ -14,7 +14,10 @@ function makeTempDir(prefix: string): string { } function legacySessionDir(sessionsRoot: string, cwd: string): string { - const name = `--${path.resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const name = `--${path + .resolve(cwd) + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-")}--`; return path.join(sessionsRoot, name); } diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index 7715b7bb2..d5d99bd13 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -1,5 +1,5 @@ -import { networkInterfaces } from "node:os"; import { afterEach, describe, expect, it } from "bun:test"; +import { networkInterfaces } from "node:os"; import { connect, type Subprocess } from "bun"; import { STATS_DASHBOARD_HEADER, @@ -25,10 +25,7 @@ async function tcpConnects(hostname: string, port: number): Promise { const holderProcesses: Array> = []; -async function startBunHolder( - responseExpr: string, - options?: { hostname?: string; statsOwned?: boolean }, -) { +async function startBunHolder(responseExpr: string, options?: { hostname?: string; statsOwned?: boolean }) { const hostname = options?.hostname ?? STATS_DASHBOARD_HOSTNAME; const reservation = Bun.serve({ port: 0, From cab9f6240085ea8f28d2026a586a53efa2f69644 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:22:36 +0200 Subject: [PATCH 81/97] test(stats): isolated port-conflict suite from the real stats db - server-port-conflict opened the module-global db against the live agent stats.db, poisoning later files (sync-serial read 2.28M real rows); installStatsTestIsolation gives it a temp agent dir and closes the handle in teardown --- packages/stats/test/server-port-conflict.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/stats/test/server-port-conflict.test.ts b/packages/stats/test/server-port-conflict.test.ts index d5d99bd13..ae92163bc 100644 --- a/packages/stats/test/server-port-conflict.test.ts +++ b/packages/stats/test/server-port-conflict.test.ts @@ -7,6 +7,9 @@ import { STATS_DASHBOARD_SECURITY_VERSION, } from "../src/port-conflict"; import { startServer } from "../src/server"; +import { installStatsTestIsolation } from "./helpers/temp-agent"; + +installStatsTestIsolation("@pi-stats-port-conflict-"); /** * Directly probe a TCP endpoint, bypassing any configured HTTP proxy so the From 8b120cecc567ae27adce464a694f3fd56cfc4604 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 23:51:32 +0000 Subject: [PATCH 82/97] fix(lsp): normalized published diagnostic uri keys Canonicalized file URI keys at the diagnostics map boundary so server and client spellings match across percent encoding and Windows casing. Added regression coverage for equivalent percent-encoded URIs and the marksman Windows drive-letter form. Fixes #7662 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/lsp/client.ts | 4 +- packages/coding-agent/src/lsp/utils.ts | 29 +++++++++++ .../tools/lsp-diagnostics-freshness.test.ts | 49 ++++++++++++++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00b5106e4..ea8b1a407 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). +### Fixed + +- Fixed LSP diagnostics being dropped when servers normalize file URI percent-encoding or Windows path casing. + ## [17.2.8] - 2026-08-04 ### Changed diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index f4df6cb7b..a90b4fd02 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -17,7 +17,7 @@ import type { ServerConfig, WorkspaceEdit, } from "./types"; -import { detectLanguageId, fileToUri } from "./utils"; +import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils"; // ============================================================================= // Client State @@ -787,7 +787,7 @@ export async function getOrCreateClient( proc, config, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, dynamicCapabilityRegistrations: new Map(), openFiles: new Map(), diff --git a/packages/coding-agent/src/lsp/utils.ts b/packages/coding-agent/src/lsp/utils.ts index d9eeac7ac..d96f7f97b 100644 --- a/packages/coding-agent/src/lsp/utils.ts +++ b/packages/coding-agent/src/lsp/utils.ts @@ -78,6 +78,35 @@ function laxUriToFile(uri: string): string { return filePath; } +/** Map that treats equivalent file URI spellings as the same key. */ +export class EquivalentUriMap extends Map { + #key(uri: string): string { + if (!uri.startsWith("file://")) return uri; + const filePath = path.normalize(uriToFile(uri)); + return process.platform === "win32" ? filePath.toLowerCase() : filePath; + } + + override delete(uri: string): boolean { + const key = this.#key(uri); + return super.delete(key); + } + + override get(uri: string): Value | undefined { + const key = this.#key(uri); + return super.get(key); + } + + override has(uri: string): boolean { + const key = this.#key(uri); + return super.has(key); + } + + override set(uri: string, value: Value): this { + const key = this.#key(uri); + return super.set(key, value); + } +} + // ============================================================================= // Diagnostic Formatting // ============================================================================= diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 13ed0a57c..4f967c1ad 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -5,7 +5,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, FileFormatResult } f import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; import type { Diagnostic, LinterClient, LspClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types"; -import { fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; +import { EquivalentUriMap, fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; import type { DeferredDiagnosticsEntry, ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; import { type ptree, TempDir } from "@oh-my-pi/pi-utils"; @@ -47,7 +47,7 @@ function createClient(cwd: string, config: ServerConfig): LspClient { config, proc: {} as ptree.ChildProcess<"pipe">, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, openFiles: new Map(), pendingRequests: new Map(), @@ -438,6 +438,51 @@ describe("LSP diagnostics freshness", () => { expect(result?.messages.some(m => m.includes("stale error"))).toBe(false); }); + it("matches published diagnostics when the server renormalizes the document URI", async () => { + const filePath = path.join(tempDir.path(), "renormalized.ts"); + const uri = fileToUri(filePath); + const serverUri = uri.replace("/renormalized.ts", "/%72enormalized.ts"); + const client = createClient(tempDir.path(), TEST_SERVER); + const clock = new VirtualClock(Date.now()); + installVirtualTime(clock); + + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]); + vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); + vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => { + const syncedUri = fileToUri(syncedFilePath); + mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" }); + }); + vi.spyOn(lspClient, "notifySaved").mockImplementation(async mockClient => { + clock.in(10, () => { + publishDiagnostics(mockClient, serverUri, [createDiagnostic("renormalized URI error")], 1); + }); + }); + + const writethrough = createLspWritethrough(tempDir.path(), { + enableFormat: false, + enableDiagnostics: true, + }); + const result = await writethrough(filePath, "export const value = missing;\n"); + + expect(result?.errored).toBe(true); + expect(result?.messages.some(message => message.includes("renormalized URI error"))).toBe(true); + }); + + it("matches Windows drive-letter case and percent-encoding differences", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!platformDescriptor) throw new Error("process.platform descriptor is unavailable"); + Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" }); + try { + const diagnostics = new EquivalentUriMap(); + diagnostics.set("file:///c%3A/Users/serge/doc.md", "published"); + + expect(diagnostics.get("file:///C:/Users/serge/doc.md")).toBe("published"); + } finally { + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it("returns completed pull diagnostics inside the inline write window", async () => { const filePath = path.join(tempDir.path(), "pull-only.ts"); const uri = fileToUri(filePath); From f687a074d8fbd4622eaeca4d4b758ad1f8dc525d Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:27:08 +0200 Subject: [PATCH 83/97] fix(natives): refresh active cache directories (cherry picked from commit c62702b89d1f29bc48067d64575c739c65456bc8) --- packages/natives/native/loader-state.d.ts | 2 ++ packages/natives/native/loader-state.js | 18 ++++++++++++++++-- packages/natives/test/windows-staging.test.ts | 3 +++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/natives/native/loader-state.d.ts b/packages/natives/native/loader-state.d.ts index 63a042cd7..e61d2da21 100644 --- a/packages/natives/native/loader-state.d.ts +++ b/packages/natives/native/loader-state.d.ts @@ -88,6 +88,8 @@ export interface CleanupStaleNativeVersionsInput { export function cleanupStaleNativeVersions(input: CleanupStaleNativeVersionsInput): string[]; +export function prepareNativeVersionDir(versionedDir: string): void; + export interface ExtractEmbeddedAddonArchiveInput { archivePath: string; files: EmbeddedAddonFile[]; diff --git a/packages/natives/native/loader-state.js b/packages/natives/native/loader-state.js index db06d5fd6..385aed862 100644 --- a/packages/natives/native/loader-state.js +++ b/packages/natives/native/loader-state.js @@ -202,6 +202,20 @@ function isOlderReleaseVersion(candidate, current) { // stale. const NATIVE_CACHE_CLEANUP_GRACE_MS = 10 * 60_000; +/** + * Create a version cache directory and refresh its activity timestamp before + * extraction or staging begins. Recursive mkdir does not update the mtime of + * an existing directory, so the explicit touch is what protects interrupted + * or partially populated caches from concurrent cleanup. + * + * @param {string} versionedDir + */ +export function prepareNativeVersionDir(versionedDir) { + fs.mkdirSync(versionedDir, { recursive: true }); + const now = new Date(); + fs.utimesSync(versionedDir, now, now); +} + /** * Remove version-pinned native cache directories older than the loaded package. * Best-effort by design: permission errors and concurrent processes must not @@ -522,7 +536,7 @@ function maybeExtractEmbeddedAddon(ctx, errors) { startupMarker("native:extractEmbeddedAddon:start"); try { - fs.mkdirSync(ctx.versionedDir, { recursive: true }); + prepareNativeVersionDir(ctx.versionedDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`embedded addon dir: ${message}`); @@ -589,7 +603,7 @@ function maybeStageNodeModulesAddon(ctx, errors) { if (!fs.existsSync(sourcePath)) continue; try { - fs.mkdirSync(ctx.versionedDir, { recursive: true }); + prepareNativeVersionDir(ctx.versionedDir); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push(`staged addon dir: ${message}`); diff --git a/packages/natives/test/windows-staging.test.ts b/packages/natives/test/windows-staging.test.ts index 144f8ef6a..a951ba5b7 100644 --- a/packages/natives/test/windows-staging.test.ts +++ b/packages/natives/test/windows-staging.test.ts @@ -27,6 +27,7 @@ import { cleanupStaleNativeVersions, getAddonFilenames, initLoaderContext, + prepareNativeVersionDir, resolveLoaderCandidates, shouldStageNodeModulesAddon, } from "../native/loader-state.js"; @@ -205,6 +206,8 @@ describe("windows native addon staging", () => { await fs.mkdir(path.join(nativesDir, "not-a-version")); await Bun.write(path.join(nativesDir, "README.txt"), "not a version directory"); await fs.utimes(path.join(nativesDir, staleVersion), new Date(0), new Date(0)); + await fs.utimes(path.join(nativesDir, freshVersion), new Date(0), new Date(0)); + prepareNativeVersionDir(path.join(nativesDir, freshVersion)); const removed = cleanupStaleNativeVersions({ nativesDir, currentVersion: packageJson.version }); From 216f8630630d426893532c75ee8ef9ddbe7b9615 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:15:43 +0900 Subject: [PATCH 84/97] fix(ai): classified concurrency limits and rotated on account-scoped 403s (cherry picked from commit 23bf29b62cfbb54757e67020ef45d870c1877b9e) --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/error/rate-limit.ts | 24 ++++++++++++++++++++--- packages/ai/test/rate-limit-utils.test.ts | 21 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 767be5402..ff1e54094 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -149,6 +149,7 @@ - Fixed the Pi exec frames displaying a different operation than the one they run. The provider synthesized its transcript block from a second, hand-rolled translation of the frame args, so `pi_read`'s `offset`/`limit` were shown as a whole-file read, `pi_grep`'s `literal` pattern as an unescaped regex, and `pi_find`'s path/glob join differed from the executed one. Both sides now share a single translation. - Fixed the streamed `pi_*_tool_call` announcements that modern builds send alongside each exec frame being unrecognized. The exec channel already synthesizes those blocks when it runs the tool; the duplicate was avoided only because the decoder recognized none of the variants, which would have started double-rendering as soon as any one was added. - Fixed `pi_bash` results reaching Cursor clipped with no truncation notice. Two truncation records exist locally: `read`/`grep` set `details.truncation`, which carries an explicit `truncated` flag, while `bash` sets `details.meta.truncation`, whose record has no such flag — its presence is the signal. `piTruncation` read only the first shape and required the flag, so every real Bash truncation was dropped and the server was told the clipped output was complete. Both shapes now translate, and an explicit `truncated: false` still suppresses the field. +- Classified concurrent-request caps separately from quota exhaustion so they use a short retry backoff without burning a credential, and rotate credentials for account-scoped 403 caps such as Devin's overall message limit. ## [17.1.8] - 2026-07-28 diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 7252e0dcf..1ca041e74 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -6,12 +6,14 @@ export type RateLimitReason = | "QUOTA_EXHAUSTED" | "RATE_LIMIT_EXCEEDED" + | "CONCURRENT_LIMIT" | "MODEL_CAPACITY_EXHAUSTED" | "SERVER_ERROR" | "UNKNOWN"; const QUOTA_EXHAUSTED_BACKOFF_MS = 30 * 60 * 1000; // 30 min const RATE_LIMIT_EXCEEDED_BACKOFF_MS = 30 * 1000; // 30s +const CONCURRENT_LIMIT_BACKOFF_MS = 5 * 1000; // 5s const MODEL_CAPACITY_BASE_MS = 45 * 1000; // 45s base const MODEL_CAPACITY_JITTER_MS = 30 * 1000; // ±15s const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s @@ -26,12 +28,16 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b // before classifying explicit details; an otherwise opaque status is transient // model capacity, while quota/rate-limit/server wording remains authoritative. const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi; +const CONCURRENT_LIMIT_PATTERN = + /\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|request|invocation|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b/i; +const ACCOUNT_SCOPED_403_PATTERN = + /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\blimit will reset\b|\bwill reset in\b/i; /** * Classify a rate-limit error message into a reason category. * Priority order: explicit details in a resource-exhausted error > QUOTA - * (Antigravity "quota will reset") > MODEL_CAPACITY > QUOTA (account) > - * RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. + * (Antigravity "quota will reset") > CONCURRENT_LIMIT > MODEL_CAPACITY > + * QUOTA (account) > RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN. * * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait). * Explicit details such as "quota exceeded" retain their normal classification. @@ -50,6 +56,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason { return "QUOTA_EXHAUSTED"; } + if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) { + return "CONCURRENT_LIMIT"; + } + if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("529") || lower.includes("503")) { return "MODEL_CAPACITY_EXHAUSTED"; } @@ -111,6 +121,8 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number { return QUOTA_EXHAUSTED_BACKOFF_MS; case "RATE_LIMIT_EXCEEDED": return RATE_LIMIT_EXCEEDED_BACKOFF_MS; + case "CONCURRENT_LIMIT": + return CONCURRENT_LIMIT_BACKOFF_MS; case "MODEL_CAPACITY_EXHAUSTED": return MODEL_CAPACITY_BASE_MS + Math.random() * MODEL_CAPACITY_JITTER_MS; case "SERVER_ERROR": @@ -157,9 +169,15 @@ export function isUsageLimitStatus(status: number | undefined): boolean { */ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean { if (message && matchesUsageLimitText(message)) return true; + // A 403 is normally an auth failure, but several providers deliver an + // account-scoped cap with it (Devin/Codeium Connect `permission_denied`, + // GitHub Copilot). Rotate only when the body names a cap that resets — + // never on a bare 403, which stays an auth failure. + if (status === 403 && message && ACCOUNT_SCOPED_403_PATTERN.test(message)) return true; if (!isUsageLimitStatus(status)) return false; if (!message || isOpaqueStatusBody(message)) return true; - return parseRateLimitReason(message) === "QUOTA_EXHAUSTED"; + const reason = parseRateLimitReason(message); + return reason === "QUOTA_EXHAUSTED"; } /** diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 6f25cad55..ee4393438 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -58,6 +58,13 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("Requests per minute limit reached")).toBe("RATE_LIMIT_EXCEEDED"); }); + it("classifies concurrent request caps separately from rate limits and quota exhaustion", () => { + expect(parseRateLimitReason("Number of concurrent requests exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Maximum concurrent invocation limit reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Rate limit reached for gpt-4o")).toBe("RATE_LIMIT_EXCEEDED"); + expect(parseRateLimitReason("Your quota will reset at 07-28")).toBe("QUOTA_EXHAUSTED"); + }); + it("classifies overloaded 529 as MODEL_CAPACITY_EXHAUSTED", () => { expect(parseRateLimitReason("Service overloaded 529")).toBe("MODEL_CAPACITY_EXHAUSTED"); }); @@ -266,6 +273,16 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(429, message)).toBe(true); }); + it("rotates only account-scoped cap 403s", () => { + expect( + isUsageLimitOutcome( + 403, + "Devin stream error permission_denied: Reached overall message rate limit. Please try again later. Your limit will reset in 13 minutes.", + ), + ).toBe(true); + expect(isUsageLimitOutcome(403, "Forbidden")).toBe(false); + }); + it("rotates on xAI Grok Build 402 usage-balance exhaustion regardless of status", () => { const message = "402 Grok Build usage balance exhausted"; expect(isUsageLimitOutcome(402, message)).toBe(true); @@ -295,4 +312,8 @@ describe("calculateRateLimitBackoffMs", () => { expect(ms).toBeLessThanOrEqual(75_000); } }); + + it("returns a short backoff for CONCURRENT_LIMIT", () => { + expect(calculateRateLimitBackoffMs("CONCURRENT_LIMIT")).toBe(5_000); + }); }); From 9d51aabce946736772ac097fdd388887ae9f41ac Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:51:36 +0900 Subject: [PATCH 85/97] fix(ai): address PR review feedback (#6958) (cherry picked from commit 19327455a482ffdf8dfebcfb47b12fe0d4c888da) --- packages/ai/src/auth-storage.ts | 7 +++++- packages/ai/src/error/flags.ts | 17 +++++++++++-- packages/ai/src/error/rate-limit.ts | 17 ++++++++++--- packages/ai/test/rate-limit-utils.test.ts | 30 +++++++++++++++++++++++ packages/utils/src/fetch-retry.ts | 4 ++- packages/utils/test/fetch-retry.test.ts | 20 ++++++++++++++- 6 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 9e3d4b030..9344aefc4 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -8,7 +8,7 @@ * - re-exported `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ import { createHash } from "node:crypto"; -import { $env, $envExact, getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; +import { $env, $envExact, extractRetryHint, getAgentDbPath, logger } from "@oh-my-pi/pi-utils"; import { isSqliteCorruptionError, resolveCredentialIdentityKey, @@ -6093,8 +6093,13 @@ export class AuthStorage { const status = AIError.status(error); const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) { + // Thread the provider-specified reset window (e.g. Devin "Your limit + // will reset in 13 minutes") into the block duration so the credential + // is not reselected and hammered while the cap remains active. + const retryAfterMs = extractRetryHint(undefined, message); return ( await this.markUsageLimitReached(provider, sessionId, { + retryAfterMs, modelId: options?.modelId, apiKey: options?.apiKey, credentialId: options?.credentialId, diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 4e348493b..2f7ca5108 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -339,9 +339,22 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un const isOpaque = isOpaqueStatusBody(cleanMessage); const isLimitStatus = isUsageLimitStatus(statusClean); + const reason = parseRateLimitReason(cleanMessage); + // Concurrency caps (e.g. Vertex "Online prediction concurrent requests + // quota exceeded") are shed-and-backoff, not credential-rotatable — + // exclude them even when the quota-worded phrasing matches the generic + // usage-limit text matcher, whose `quota.?exceeded` arm would otherwise + // set Flag.UsageLimit and burn a healthy sibling credential. HTTP 402 is + // excluded from this gate: it is categorically an account-billing cap, so + // a 402 whose body merely mentions concurrency still classifies as a + // usage limit, mirroring isUsageLimitOutcome. + const isBillingCapStatus = statusClean === 402; + const concurrencyExcluded = reason === "CONCURRENT_LIMIT" && !isBillingCapStatus; if ( - matchesUsageLimitText(cleanMessage) || - (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED")) + !concurrencyExcluded && + (matchesUsageLimitText(cleanMessage) || + (isLimitStatus && + (isOpaque || reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT")))) ) { kinds |= Flag.UsageLimit; } diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 1ca041e74..f2246435c 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -160,14 +160,23 @@ export function isUsageLimitStatus(status: number | undefined): boolean { * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status, * empty JSON, HTTP framing only) → rotate conservatively: the server * gave us nothing else to go on. - * 4. Body has content → defer to {@link parseRateLimitReason}. Only - * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`, + * 4. Body has content → defer to {@link parseRateLimitReason}. `QUOTA_EXHAUSTED` + * rotates; for the categorical 402 billing cap a `CONCURRENT_LIMIT` body + * also rotates (the cap is concurrent-worded but the status is still an + * exhausted billing cap). `RATE_LIMIT_EXCEEDED` (`Too many requests`, * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`), * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the * provider's own backoff layer so transient 429s don't burn sibling * credentials. */ export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean { + // Concurrency caps are shed-and-backoff, not credential-rotatable — but only + // for quota-worded 429 / other statuses. HTTP 402 is categorically an + // account-billing cap, so a 402 whose body happens to mention concurrency is + // still an exhausted billing cap and must rotate; gate the exclusion on the + // status not being that categorical billing cap. + const isBillingCapStatus = status === 402; + if (message && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && !isBillingCapStatus) return false; if (message && matchesUsageLimitText(message)) return true; // A 403 is normally an auth failure, but several providers deliver an // account-scoped cap with it (Devin/Codeium Connect `permission_denied`, @@ -177,7 +186,9 @@ export function isUsageLimitOutcome(status: number | undefined, message: string if (!isUsageLimitStatus(status)) return false; if (!message || isOpaqueStatusBody(message)) return true; const reason = parseRateLimitReason(message); - return reason === "QUOTA_EXHAUSTED"; + // For the categorical 402 billing cap a concurrency-worded body is still an + // exhausted cap (rotate); for 429 / other only QUOTA_EXHAUSTED rotates. + return reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT"); } /** diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index ee4393438..f648b6eb6 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -302,6 +302,36 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(401, "Invalid API key")).toBe(false); expect(isUsageLimitOutcome(400, "invalid_request_error: model unsupported")).toBe(false); }); + + // Vertex returns "Online prediction concurrent requests quota exceeded" for a + // concurrent-request cap. The generic USAGE_LIMIT_PATTERN matches + // `quota.?exceeded`, but this is a concurrency cap (5s backoff, no rotation), + // not account quota exhaustion. CONCURRENT_LIMIT must take precedence so the + // credential is not burned. + it("does not rotate on Vertex quota-worded concurrency caps", () => { + const message = "Online prediction concurrent requests quota exceeded"; + expect(parseRateLimitReason(message)).toBe("CONCURRENT_LIMIT"); + expect(isUsageLimitOutcome(429, message)).toBe(false); + expect(isUsageLimit(message)).toBe(false); + }); + + // HTTP 402 is categorically an account-billing cap, so a 402 whose body is + // worded as a concurrency cap still rotates — the billing-cap status wins + // over the concurrency exclusion. The identical concurrency wording on a + // quota-worded 429 stays non-rotatable (5s backoff). This pins the + // 402-billing-cap > concurrency-exclusion precedence in both the rotation + // decision (isUsageLimitOutcome) and the Flag.UsageLimit classification + // (isUsageLimit). + it("rotates on 402 concurrency-worded billing caps but not 429 concurrency caps", () => { + const message = "concurrent requests limit reached"; + expect(parseRateLimitReason(message)).toBe("CONCURRENT_LIMIT"); + // 402 billing cap wins: rotate. + expect(isUsageLimitOutcome(402, message)).toBe(true); + expect(isUsageLimit(Object.assign(new Error(message), { status: 402 }))).toBe(true); + // 429 concurrency cap: shed-and-backoff, do not rotate. + expect(isUsageLimitOutcome(429, message)).toBe(false); + expect(isUsageLimit(Object.assign(new Error(message), { status: 429 }))).toBe(false); + }); }); describe("calculateRateLimitBackoffMs", () => { diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts index afe7138e3..cf41ee998 100644 --- a/packages/utils/src/fetch-retry.ts +++ b/packages/utils/src/fetch-retry.ts @@ -10,6 +10,8 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i; // "try again in 5 min" / "try again in ~158 min." / "try again in 2h" / // "try again in 90 minutes" / "try again in 1 hour" const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i; +// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h" +const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i; /** * Server-suggested retry delay extraction. Merges the patterns historically used @@ -83,7 +85,7 @@ export function extractRetryHint(source: Response | Headers | null | undefined, if (totalMs > 0) return totalMs; } } - for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { + for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN, WILL_RESET_IN_PATTERN]) { const match = pattern.exec(body); if (match?.[1]) { const value = Number.parseFloat(match[1]); diff --git a/packages/utils/test/fetch-retry.test.ts b/packages/utils/test/fetch-retry.test.ts index cceaf3649..258ff6c35 100644 --- a/packages/utils/test/fetch-retry.test.ts +++ b/packages/utils/test/fetch-retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { fetchWithRetry } from "@oh-my-pi/pi-utils/fetch-retry"; +import { extractRetryHint, fetchWithRetry } from "@oh-my-pi/pi-utils/fetch-retry"; describe("fetchWithRetry", () => { it("routes requests through the `fetch` override when provided", async () => { @@ -79,3 +79,21 @@ describe("fetchWithRetry", () => { expect(attempt).toBe(1); }); }); + +describe("extractRetryHint", () => { + // Devin returns HTTP 403 with "Your limit will reset in 13 minutes" for an + // account-scoped message rate cap. Without recognizing "will reset in", the + // credential is blocked for the 1-minute default instead of 13 minutes and + // can be reselected and hammered while the cap remains active. + it("parses Devin 'Your limit will reset in 13 minutes' as 13 minutes", () => { + expect(extractRetryHint(undefined, "Your limit will reset in 13 minutes")).toBe(13 * 60_000); + }); + + it("parses bare 'reset in 13 minutes' phrasing", () => { + expect(extractRetryHint(undefined, "reset in 13 minutes")).toBe(13 * 60_000); + }); + + it("parses 'will reset in 2h' phrasing", () => { + expect(extractRetryHint(undefined, "will reset in 2h")).toBe(2 * 60 * 60_000); + }); +}); From 7262d9762e4cc2cb25cbe7afc70ae0af68223b97 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:43:54 +0900 Subject: [PATCH 86/97] fix(ai): honor account reset windows and statusless concurrency caps Account-reset hint evaluated before short retry hints; account-scoped caps rotate on status 403 or undefined (Devin statusless trailer); statusless concurrency caps marked transient; transient same-model retries use the concurrency backoff. Refuted: quota-worded concurrency caps were already excluded from rotation before the usage-limit text match. (cherry picked from commit f2b9a18d715ddbcb6ae703670f2212da36bb2826) --- packages/ai/src/error/flags.ts | 14 ++++++++- packages/ai/src/error/rate-limit.ts | 19 ++++++++++-- packages/ai/test/rate-limit-utils.test.ts | 31 ++++++++++++++----- .../coding-agent/src/session/turn-recovery.ts | 16 ++++++++++ packages/utils/src/fetch-retry.ts | 6 +++- packages/utils/test/fetch-retry.test.ts | 8 +++++ 6 files changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 2f7ca5108..9426e44e7 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -7,7 +7,13 @@ import { ProviderHttpError, STREAM_ENVELOPE_ERROR_PREFIX, } from "./classes"; -import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit"; +import { + isAccountScopedCapText, + isOpaqueStatusBody, + isUsageLimitStatus, + matchesUsageLimitText, + parseRateLimitReason, +} from "./rate-limit"; export const Flag = { Class: 0x1000, @@ -353,6 +359,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un if ( !concurrencyExcluded && (matchesUsageLimitText(cleanMessage) || + ((statusClean === 403 || statusClean === undefined) && isAccountScopedCapText(cleanMessage)) || (isLimitStatus && (isOpaque || reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT")))) ) { @@ -361,6 +368,11 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout; else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient; + // A concurrency cap (e.g. Vertex "Online prediction concurrent requests + // quota exceeded") is transient — shed-and-backoff. The bare wording need + // not match TRANSIENT_TRANSPORT_PATTERN, so flag it explicitly to keep + // AIError.retriable from treating the temporary cap as terminal. + if (reason === "CONCURRENT_LIMIT") kinds |= Flag.Transient; if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) { kinds |= Flag.StaleResponsesItem; } diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index f2246435c..0ae022926 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -180,9 +180,11 @@ export function isUsageLimitOutcome(status: number | undefined, message: string if (message && matchesUsageLimitText(message)) return true; // A 403 is normally an auth failure, but several providers deliver an // account-scoped cap with it (Devin/Codeium Connect `permission_denied`, - // GitHub Copilot). Rotate only when the body names a cap that resets — - // never on a bare 403, which stays an auth failure. - if (status === 403 && message && ACCOUNT_SCOPED_403_PATTERN.test(message)) return true; + // GitHub Copilot). Devin's end-of-stream Connect trailer carries no HTTP + // status at all (it arrives as a `permission_denied` ValidationError), so + // accept an undefined status too — but only when the body names a cap that + // resets, never on a bare 403, which stays an auth failure. + if ((status === 403 || status === undefined) && message && isAccountScopedCapText(message)) return true; if (!isUsageLimitStatus(status)) return false; if (!message || isOpaqueStatusBody(message)) return true; const reason = parseRateLimitReason(message); @@ -219,3 +221,14 @@ export function matchesUsageLimitText(errorMessage: string): boolean { OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage) ); } + +/** + * Account-scoped cap phrasing delivered on a 403 (or a statusless Connect + * trailer): "Reached overall message rate limit", "Your limit will reset in …". + * Kept separate from {@link matchesUsageLimitText} because the bare wording is + * ambiguous without the 403 / statusless-account context; consumed by both + * {@link isUsageLimitOutcome} (rotation decision) and `flags.ts` (Flag.UsageLimit). + */ +export function isAccountScopedCapText(message: string): boolean { + return ACCOUNT_SCOPED_403_PATTERN.test(message); +} diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index f648b6eb6..3330f6682 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; -import { isUsageLimit } from "@oh-my-pi/pi-ai/error/flags"; +import { classify, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; import { calculateRateLimitBackoffMs, isUsageLimitOutcome, @@ -273,13 +273,16 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(429, message)).toBe(true); }); - it("rotates only account-scoped cap 403s", () => { - expect( - isUsageLimitOutcome( - 403, - "Devin stream error permission_denied: Reached overall message rate limit. Please try again later. Your limit will reset in 13 minutes.", - ), - ).toBe(true); + it("rotates only account-scoped cap 403s and statusless trailers", () => { + const devinTrailer = + "Devin stream error permission_denied: Reached overall message rate limit. Please try again later. Your limit will reset in 13 minutes."; + // HTTP 403 with the account-scoped body rotates. + expect(isUsageLimitOutcome(403, devinTrailer)).toBe(true); + // Devin's Connect trailer carries no HTTP status (a permission_denied + // ValidationError), so it must rotate on an undefined status too — + // otherwise the exhausted credential is retried as a transient failure. + expect(isUsageLimitOutcome(undefined, devinTrailer)).toBe(true); + expect(isUsageLimit(devinTrailer)).toBe(true); expect(isUsageLimitOutcome(403, "Forbidden")).toBe(false); }); @@ -315,6 +318,18 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimit(message)).toBe(false); }); + // The same bare concurrency wording can reach turn recovery without a + // preserved HTTP status (Vertex/Bedrock paths that bypass API-key + // resolution). The body misses TRANSIENT_TRANSPORT_PATTERN, so without an + // explicit Flag.Transient the temporary cap classifies as terminal and is + // never retried. It must stay shed-and-backoff (transient/retriable). + it("keeps statusless concurrency caps transient and retriable", () => { + const message = "Online prediction concurrent requests quota exceeded"; + const id = classify(message); + expect(is(id, Flag.Transient)).toBe(true); + expect(retriable(id)).toBe(true); + }); + // HTTP 402 is categorically an account-billing cap, so a 402 whose body is // worded as a concurrency cap still rotates — the billing-cap status wins // over the concurrency exclusion. The identical concurrency wording on a diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 37393ab81..af2a63580 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -1587,6 +1587,22 @@ export class TurnRecovery { let delayMs = staleOpenAIResponsesReplayError ? 0 : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt); + // Concurrency caps shed-and-backoff (5s) rather than burning a sibling + // credential, so the usage-limit rotation branch below is deliberately + // skipped for them. Apply the reason-based backoff to the transient + // same-model retry path too — otherwise the default exponential base + // (≈500ms) re-hits the cap immediately and burns the retry budget while + // the concurrency slot stays occupied. A categorical 402 billing cap whose + // body merely mentions concurrency is still a usage limit (handled below), + // so gate on the flag matching the rotation decision. + if ( + !staleOpenAIResponsesReplayError && + !AIError.is(id, AIError.Flag.UsageLimit) && + parseRateLimitReason(errorMessage) === "CONCURRENT_LIMIT" + ) { + const concurrentBackoffMs = calculateRateLimitBackoffMs("CONCURRENT_LIMIT"); + if (concurrentBackoffMs > delayMs) delayMs = concurrentBackoffMs; + } let switchedCredential = false; let switchedModel = false; // Set when a usage-limit error pinned the wait to credential diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts index cf41ee998..51263af31 100644 --- a/packages/utils/src/fetch-retry.ts +++ b/packages/utils/src/fetch-retry.ts @@ -85,7 +85,11 @@ export function extractRetryHint(source: Response | Headers | null | undefined, if (totalMs > 0) return totalMs; } } - for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN, WILL_RESET_IN_PATTERN]) { + // Account-reset hints ("will reset in …") take precedence over short + // retry hints ("please retry in 5s"): a body carrying both must honour the + // longer account window, not the shorter generic one. QUOTA_RESET_PATTERN + // ("reset after …") above already runs first and stays first. + for (const pattern of [WILL_RESET_IN_PATTERN, PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) { const match = pattern.exec(body); if (match?.[1]) { const value = Number.parseFloat(match[1]); diff --git a/packages/utils/test/fetch-retry.test.ts b/packages/utils/test/fetch-retry.test.ts index 258ff6c35..5c8be8e06 100644 --- a/packages/utils/test/fetch-retry.test.ts +++ b/packages/utils/test/fetch-retry.test.ts @@ -96,4 +96,12 @@ describe("extractRetryHint", () => { it("parses 'will reset in 2h' phrasing", () => { expect(extractRetryHint(undefined, "will reset in 2h")).toBe(2 * 60 * 60_000); }); + + // A quota body can carry both a generic retry hint and the account reset + // window ("Please retry in 5s. Your limit will reset in 13 minutes"). The + // account-reset hint must take precedence so the exhausted credential stays + // blocked for the full stated window instead of the short generic retry. + it("prefers the account reset window over a shorter retry hint", () => { + expect(extractRetryHint(undefined, "Please retry in 5s. Your limit will reset in 13 minutes")).toBe(13 * 60_000); + }); }); From 5726fac646fe643150a2446b232853e3acdb9f40 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:04:11 +0900 Subject: [PATCH 87/97] fix(ai): tighten concurrency-cap classification and account-cap gating Reset-window rotation requires account-specific wording; concurrency caps require an actual cap signal; credential removal gated on AuthFailed without UsageLimit so a valid-but-blocked 403 credential is retained. (cherry picked from commit 2f72752c2586352a4f7e9e814af1cdb0cf192af4) --- packages/ai/src/error/rate-limit.ts | 14 +++++- packages/ai/test/rate-limit-utils.test.ts | 49 ++++++++++++++++++- .../coding-agent/src/session/agent-session.ts | 19 ++++--- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 0ae022926..920e389de 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -29,9 +29,19 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b // model capacity, while quota/rate-limit/server wording remains authoritative. const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi; const CONCURRENT_LIMIT_PATTERN = - /\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|request|invocation|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b/i; + // Require an actual cap signal (limit/quota/exceeded/reached) near "concurrent". + // Bare nouns ("concurrent request is not supported", "only one concurrent + // invocation is supported") are deterministic 4xx feature rejections, not + // transient caps — matching them here would set Flag.Transient and retry the + // rejection instead of surfacing it. + /\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b/i; const ACCOUNT_SCOPED_403_PATTERN = - /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\blimit will reset\b|\bwill reset in\b/i; + // The bare "limit will reset" / "will reset in" phrasing also appears on + // statusless per-minute transients ("Rate limit will reset in 30 seconds"), + // so gate the reset-window alternative on account-specific wording (Devin's + // "Your limit will reset in …"); the overall/account qualifiers arm above + // already covers the rest. + /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i; /** * Classify a rate-limit error message into a reason category. diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 3330f6682..787563b0e 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; -import { classify, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; +import { classify, classifyMessage, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; import { calculateRateLimitBackoffMs, isUsageLimitOutcome, @@ -65,6 +65,18 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("Your quota will reset at 07-28")).toBe("QUOTA_EXHAUSTED"); }); + // Deterministic 4xx feature rejections worded with bare concurrency nouns + // ("concurrent request/invocation is not supported") must not classify as a + // concurrency cap — doing so would set Flag.Transient and retry the rejection + // instead of surfacing it. A cap needs an explicit limit/quota/exceeded/reached + // signal near "concurrent". + it("does not classify bare concurrency feature rejections as CONCURRENT_LIMIT", () => { + expect(parseRateLimitReason("Concurrent invocation is not supported")).not.toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Only one concurrent request is supported")).not.toBe("CONCURRENT_LIMIT"); + // The deterministic rejection must surface as a hard error, not be retried. + expect(is(classify("Concurrent invocation is not supported"), Flag.Transient)).toBe(false); + }); + it("classifies overloaded 529 as MODEL_CAPACITY_EXHAUSTED", () => { expect(parseRateLimitReason("Service overloaded 529")).toBe("MODEL_CAPACITY_EXHAUSTED"); }); @@ -286,6 +298,41 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimitOutcome(403, "Forbidden")).toBe(false); }); + // A statusless per-minute reset-window transient ("Rate limit will reset in + // 30 seconds") is ordinary throttling (RATE_LIMIT_EXCEEDED), not an account + // usage cap. The reset-window alternative is gated on account scope so it stays + // in the backoff lane instead of rotating the credential. + it("does not rotate on a statusless per-minute reset-window transient", () => { + const message = "Rate limit will reset in 30 seconds"; + expect(parseRateLimitReason(message)).toBe("RATE_LIMIT_EXCEEDED"); + expect(isUsageLimitOutcome(undefined, message)).toBe(false); + expect(isUsageLimit(message)).toBe(false); + }); + + // agent-session gates Copilot credential removal on AuthFailed && !UsageLimit. + // A 403 whose body is a recognized account cap carries UsageLimit, so the gate + // suppresses removal and retains the still-valid credential; a plain 401 has no + // UsageLimit, so removal proceeds. Pin the gate condition for both shapes. + it("keeps Copilot credentials for a 403 account cap, removes on a 401", () => { + const cap = new ProviderHttpError( + "Reached overall message rate limit. Your limit will reset in 13 minutes.", + 403, + ); + const capId = classifyMessage({ + errorId: classify(cap), + errorMessage: "GitHub Copilot access denied (HTTP 403).", + errorStatus: 403, + }); + expect(is(capId, Flag.AuthFailed) && !is(capId, Flag.UsageLimit)).toBe(false); + const auth = new ProviderHttpError("401 Unauthorized", 401); + const authId = classifyMessage({ + errorId: classify(auth), + errorMessage: "GitHub Copilot authentication failed (HTTP 401).", + errorStatus: 401, + }); + expect(is(authId, Flag.AuthFailed) && !is(authId, Flag.UsageLimit)).toBe(true); + }); + it("rotates on xAI Grok Build 402 usage-balance exhaustion regardless of status", () => { const message = "402 Grok Build usage balance exhausted"; expect(isUsageLimitOutcome(402, message)).toBe(true); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f9d24773e..d77535165 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2691,14 +2691,17 @@ export class AgentSession { // outside the session transcript (issue #6177). logProviderTurnError(msg); - // Invalidate GitHub Copilot credentials on auth failure so stale tokens - // aren't reused on the next request - if ( - msg.stopReason === "error" && - msg.provider === "github-copilot" && - AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed) - ) { - await this.#modelRegistry.authStorage.remove("github-copilot"); + // Invalidate GitHub Copilot credentials on a hard auth failure (401, or an + // expired/revoked token) so stale tokens aren't reused on the next request. + // A 403 whose body is a recognized account usage cap is NOT an auth failure — + // the credential is valid, only temporarily blocked until its reset window — + // so gate the removal on the absence of Flag.UsageLimit and keep the + // still-valid credential around until the cap resets. + if (msg.stopReason === "error" && msg.provider === "github-copilot") { + const errorId = AIError.classifyMessage(msg); + if (AIError.is(errorId, AIError.Flag.AuthFailed) && !AIError.is(errorId, AIError.Flag.UsageLimit)) { + await this.#modelRegistry.authStorage.remove("github-copilot"); + } } if (this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) { From e322f28fcc0244bbd0e8be9eafad878738520b89 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:23:05 +0900 Subject: [PATCH 88/97] fix(ai): exclude concurrency caps from 403 rotation and test the Copilot gate 403 concurrency caps bypass credential rotation in stream and auth-retry paths; snake-case concurrency codes classify; a session-level test covers the Copilot credential-removal gate. (cherry picked from commit aca348e797ed987750a14ecf625952b6b971f7a5) --- packages/ai/src/auth-retry.ts | 13 ++-- packages/ai/src/error/rate-limit.ts | 22 ++++-- packages/ai/src/stream.ts | 4 +- packages/ai/test/auth-retry.test.ts | 26 +++++++ packages/ai/test/rate-limit-utils.test.ts | 38 ++++------ packages/ai/test/stream-auth-retry.test.ts | 35 +++++++++ ...session-copilot-credential-removal.test.ts | 72 +++++++++++++++++++ 7 files changed, 172 insertions(+), 38 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts diff --git a/packages/ai/src/auth-retry.ts b/packages/ai/src/auth-retry.ts index 2e4bda417..972448b2e 100644 --- a/packages/ai/src/auth-retry.ts +++ b/packages/ai/src/auth-retry.ts @@ -3,7 +3,7 @@ import type { OAuthAccess } from "./auth-storage"; import * as AIError from "./error"; import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify"; import { isUsageLimit } from "./error/flags"; -import { isUsageLimitOutcome } from "./error/rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit"; /** * Context passed to an {@link ApiKeyResolver} on each resolution attempt. @@ -93,11 +93,14 @@ export const AUTH_RETRY_MAX_ATTEMPTS = 64; function isDirectCredentialRotationError(error: unknown): boolean { if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true; const status = AIError.status(error); - // 403: the token is valid but access was denied, so refreshing the same - // credential can't help — rotate straight through the sibling pool. - if (status === 403) return true; const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; - if (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403) return true; + // A 403 normally means a valid token lacks access, so rotate through + // siblings. A concurrency-cap 403 is transient instead; do not burn a + // sibling before the caller's backoff layer can retry it. + const isForbidden = + status === 403 || + (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403); + if (isForbidden && !isConcurrencyCapExclusion(status, message)) return true; return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 920e389de..7be4e71d9 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -30,11 +30,12 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi; const CONCURRENT_LIMIT_PATTERN = // Require an actual cap signal (limit/quota/exceeded/reached) near "concurrent". - // Bare nouns ("concurrent request is not supported", "only one concurrent - // invocation is supported") are deterministic 4xx feature rejections, not - // transient caps — matching them here would set Flag.Transient and retry the - // rejection instead of surfacing it. - /\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b/i; + // The first two alternatives rely on `\b`, which treats `_` as a word char, so + // structured snake_case codes ("concurrent_limit_exceeded", + // "concurrent_requests_limit_reached", "concurrency_quota_exceeded") need the + // third alternative. Bare space-separated concurrency feature rejections stay + // excluded because they neither use `[-_]` nor carry a cap keyword. + /\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b|\bconcurren[a-z]*[-_](?:[a-z]+[_-])*(?:limit|quota|exceed\w*|reach\w*)/i; const ACCOUNT_SCOPED_403_PATTERN = // The bare "limit will reset" / "will reset in" phrasing also appears on // statusless per-minute transients ("Rate limit will reset in 30 seconds"), @@ -186,7 +187,7 @@ export function isUsageLimitOutcome(status: number | undefined, message: string // still an exhausted billing cap and must rotate; gate the exclusion on the // status not being that categorical billing cap. const isBillingCapStatus = status === 402; - if (message && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && !isBillingCapStatus) return false; + if (isConcurrencyCapExclusion(status, message)) return false; if (message && matchesUsageLimitText(message)) return true; // A 403 is normally an auth failure, but several providers deliver an // account-scoped cap with it (Devin/Codeium Connect `permission_denied`, @@ -242,3 +243,12 @@ export function matchesUsageLimitText(errorMessage: string): boolean { export function isAccountScopedCapText(message: string): boolean { return ACCOUNT_SCOPED_403_PATTERN.test(message); } + +/** + * A concurrency cap on a non-billing status is shed-and-backoff, not + * credential-rotatable. This mirrors the exclusion in {@link isUsageLimitOutcome} + * for the 403 auth-retry entry points. A 402 remains a categorical billing cap. + */ +export function isConcurrencyCapExclusion(status: number | undefined, message: string | undefined): boolean { + return message !== undefined && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && status !== 402; +} diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index aff282f07..d8c3e1a28 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -21,7 +21,7 @@ import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } fr import * as AIError from "./error"; import { ProviderHttpError } from "./error"; import { isInvalidatedOAuthTokenError } from "./error/auth-classify"; -import { isUsageLimitOutcome } from "./error/rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; import { coworkFetch } from "./providers/cowork-fetch"; @@ -995,7 +995,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me // instead of burning siblings. if (AIError.isUsageLimit(error)) return true; if (isInvalidatedOAuthTokenError(error)) return true; - if (status === 401 || status === 403) return true; + if (status === 401 || (status === 403 && !isConcurrencyCapExclusion(status, message))) return true; return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/test/auth-retry.test.ts b/packages/ai/test/auth-retry.test.ts index e4fbdb652..ad8e9bcb7 100644 --- a/packages/ai/test/auth-retry.test.ts +++ b/packages/ai/test/auth-retry.test.ts @@ -275,6 +275,32 @@ describe("withAuth", () => { expect(contexts.map(ctx => ctx.lastChance)).toEqual([false, true, true, true]); }); + it("does not directly rotate through every sibling on a 403 concurrency cap", async () => { + const keys: string[] = []; + const contexts: ApiKeyResolveContext[] = []; + const pool = ["k0", "k1", "k2", "k3"]; + let resolveIndex = 0; + const concurrencyCap = Object.assign(new Error("concurrent requests limit reached"), { status: 403 }); + + await expect( + withAuth( + ctx => { + contexts.push(ctx); + return ctx.error === undefined ? pool[0] : pool[++resolveIndex]; + }, + async key => { + keys.push(key); + throw concurrencyCap; + }, + ), + ).rejects.toBe(concurrencyCap); + + // The concurrency classification takes precedence over plain-403 direct + // rotation: refresh once, then take only the legacy sibling switch. + expect(keys).toEqual(["k0", "k1", "k2"]); + expect(contexts.map(ctx => ctx.lastChance)).toEqual([false, false, true]); + }); + it("surfaces the last 403 when every sibling is denied", async () => { const errors = [authError(403), authError(403)]; const resolved = ["k0", "k1", "k0"]; diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 787563b0e..8d2feb84c 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; -import { classify, classifyMessage, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; +import { classify, Flag, is, isUsageLimit, retriable } from "@oh-my-pi/pi-ai/error/flags"; import { calculateRateLimitBackoffMs, + isConcurrencyCapExclusion, isUsageLimitOutcome, isUsageLimitStatus, parseRateLimitReason, @@ -61,6 +62,9 @@ describe("parseRateLimitReason", () => { it("classifies concurrent request caps separately from rate limits and quota exhaustion", () => { expect(parseRateLimitReason("Number of concurrent requests exceeded")).toBe("CONCURRENT_LIMIT"); expect(parseRateLimitReason("Maximum concurrent invocation limit reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrent_limit_exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrent_requests_limit_reached")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("concurrency_quota_exceeded")).toBe("CONCURRENT_LIMIT"); expect(parseRateLimitReason("Rate limit reached for gpt-4o")).toBe("RATE_LIMIT_EXCEEDED"); expect(parseRateLimitReason("Your quota will reset at 07-28")).toBe("QUOTA_EXHAUSTED"); }); @@ -309,30 +313,6 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimit(message)).toBe(false); }); - // agent-session gates Copilot credential removal on AuthFailed && !UsageLimit. - // A 403 whose body is a recognized account cap carries UsageLimit, so the gate - // suppresses removal and retains the still-valid credential; a plain 401 has no - // UsageLimit, so removal proceeds. Pin the gate condition for both shapes. - it("keeps Copilot credentials for a 403 account cap, removes on a 401", () => { - const cap = new ProviderHttpError( - "Reached overall message rate limit. Your limit will reset in 13 minutes.", - 403, - ); - const capId = classifyMessage({ - errorId: classify(cap), - errorMessage: "GitHub Copilot access denied (HTTP 403).", - errorStatus: 403, - }); - expect(is(capId, Flag.AuthFailed) && !is(capId, Flag.UsageLimit)).toBe(false); - const auth = new ProviderHttpError("401 Unauthorized", 401); - const authId = classifyMessage({ - errorId: classify(auth), - errorMessage: "GitHub Copilot authentication failed (HTTP 401).", - errorStatus: 401, - }); - expect(is(authId, Flag.AuthFailed) && !is(authId, Flag.UsageLimit)).toBe(true); - }); - it("rotates on xAI Grok Build 402 usage-balance exhaustion regardless of status", () => { const message = "402 Grok Build usage balance exhausted"; expect(isUsageLimitOutcome(402, message)).toBe(true); @@ -365,6 +345,14 @@ describe("isUsageLimitOutcome", () => { expect(isUsageLimit(message)).toBe(false); }); + it("excludes non-billing concurrency caps from credential rotation", () => { + const message = "concurrent requests limit reached"; + expect(isConcurrencyCapExclusion(403, message)).toBe(true); + expect(isConcurrencyCapExclusion(undefined, message)).toBe(true); + expect(isConcurrencyCapExclusion(402, message)).toBe(false); + expect(isConcurrencyCapExclusion(403, "Forbidden")).toBe(false); + }); + // The same bare concurrency wording can reach turn recovery without a // preserved HTTP status (Vertex/Bedrock paths that bypass API-key // resolution). The body misses TRANSIENT_TRANSPORT_PATTERN, so without an diff --git a/packages/ai/test/stream-auth-retry.test.ts b/packages/ai/test/stream-auth-retry.test.ts index 83e06c66d..90e782f35 100644 --- a/packages/ai/test/stream-auth-retry.test.ts +++ b/packages/ai/test/stream-auth-retry.test.ts @@ -120,6 +120,41 @@ describe("streamSimple resolver auth retry", () => { expect((contexts[1]!.error as { status?: number }).status).toBe(401); }); + it("surfaces a 403 concurrency cap for transient backoff without rotating credentials", async () => { + const keys: unknown[] = []; + const contexts: ApiKeyResolveContext[] = []; + const concurrencyCap = Object.assign(new Error("concurrent requests limit reached"), { status: 403 }); + registerCustomApi( + API, + (_model: Model, _context: Context, options?: SimpleStreamOptions) => { + pushKey(keys, options); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => stream.fail(concurrencyCap)); + return stream; + }, + SOURCE_ID, + ); + + const stream = streamSimple(model(), context, { + apiKey: async ctx => { + contexts.push(ctx); + return ctx.error === undefined ? "old-key" : ctx.lastChance ? "sibling-key" : "refresh-key"; + }, + }); + await expect( + (async () => { + for await (const _event of stream) { + // drain + } + })(), + ).rejects.toBe(concurrencyCap); + + expect(keys).toEqual(["old-key"]); + expect(contexts.map(ctx => ({ lastChance: ctx.lastChance, hasError: ctx.error !== undefined }))).toEqual([ + { lastChance: false, hasError: false }, + ]); + }); + it("buffers the start event and retries on a 401 error event before content", async () => { const keys: unknown[] = []; const eventTypes: string[] = []; diff --git a/packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts b/packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts new file mode 100644 index 000000000..24547b579 --- /dev/null +++ b/packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts @@ -0,0 +1,72 @@ +import { expect, it, spyOn } from "bun:test"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +it("removes a Copilot credential for 401 but retains it for a 403 account cap", async () => { + const tempDir = TempDir.createSync("@pi-copilot-credential-removal-"); + const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled Anthropic test model to exist"); + + const agent = new Agent({ + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + const session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry, + }); + const removeSpy = spyOn(authStorage, "remove").mockResolvedValue(undefined); + + try { + const unauthorized: AssistantMessage = { + role: "assistant", + content: [], + api: "openai-responses", + provider: "github-copilot", + model: "gpt-5-mini", + stopReason: "error", + errorMessage: "GitHub Copilot authentication failed (HTTP 401).", + errorStatus: 401, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + agent.emitExternalEvent({ type: "message_end", message: unauthorized }); + agent.emitExternalEvent({ type: "agent_end", messages: [unauthorized] }); + await session.waitForIdle(); + expect(removeSpy).toHaveBeenCalledWith("github-copilot"); + + removeSpy.mockClear(); + const accountCap: AssistantMessage = { + ...unauthorized, + errorMessage: "Reached overall message rate limit. Your limit will reset in 13 minutes.", + errorStatus: 403, + timestamp: Date.now(), + }; + agent.emitExternalEvent({ type: "message_end", message: accountCap }); + agent.emitExternalEvent({ type: "agent_end", messages: [accountCap] }); + await session.waitForIdle(); + expect(removeSpy).not.toHaveBeenCalled(); + } finally { + await session.dispose(); + removeSpy.mockRestore(); + authStorage.close(); + tempDir.removeSync(); + } +}); From a6eafcbf6bc92d4f0c8e7f4d271d7f6b5be96b87 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:30:15 +0200 Subject: [PATCH 89/97] fix(ai): keep concurrency caps out of auth rotation (cherry picked from commit bd6285ad9b16ae6f0a75e336a1c4bf961d3e43c7) --- packages/ai/src/error/auth-classify.ts | 9 +-- packages/ai/src/error/flags.ts | 5 +- packages/ai/src/error/rate-limit.ts | 11 ++- packages/ai/test/auth-retry.test.ts | 10 +-- packages/ai/test/rate-limit-utils.test.ts | 5 ++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/session/agent-session.ts | 15 ++-- ...session-copilot-credential-removal.test.ts | 72 ------------------- packages/utils/CHANGELOG.md | 1 + 9 files changed, 35 insertions(+), 94 deletions(-) delete mode 100644 packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts diff --git a/packages/ai/src/error/auth-classify.ts b/packages/ai/src/error/auth-classify.ts index 575bcc1a1..a253b0ad8 100644 --- a/packages/ai/src/error/auth-classify.ts +++ b/packages/ai/src/error/auth-classify.ts @@ -1,6 +1,6 @@ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { isOAuthExpiry, isUsageLimit } from "./flags"; -import { isUsageLimitOutcome } from "./rate-limit"; +import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit"; /** * Whether an OAuth refresh failure is definitive (the credential must be @@ -38,9 +38,10 @@ export function isAuthRetryableError(error: unknown): boolean { if (isUsageLimit(error)) return true; if (isInvalidatedOAuthTokenError(error)) return true; const httpStatus = extractHttpStatusFromError(error); - if (httpStatus === 401 || httpStatus === 403) return true; const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined; const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined; - if (embeddedStatus === 401 || embeddedStatus === 403) return true; - return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message); + const status = httpStatus ?? embeddedStatus; + if (isConcurrencyCapExclusion(status, message)) return false; + if (status === 401 || status === 403) return true; + return isUsageLimitOutcome(status, message); } diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 9426e44e7..c72ba9074 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -430,7 +430,10 @@ export function classify(error: unknown, api?: Api): number { if (code === "overloaded_error" || code === "rate_limit_error") { linkKinds |= Flag.Transient; } - if (codeStatus === 401 || codeStatus === 403) { + if ( + (codeStatus === 401 || codeStatus === 403) && + !(codeStatus === 403 && parseRateLimitReason(link.message) === "CONCURRENT_LIMIT") + ) { linkKinds |= Flag.AuthFailed; } else if (codeStatus === 429) { if ((linkKinds & Flag.UsageLimit) === 0) { diff --git a/packages/ai/src/error/rate-limit.ts b/packages/ai/src/error/rate-limit.ts index 7be4e71d9..972a533ec 100644 --- a/packages/ai/src/error/rate-limit.ts +++ b/packages/ai/src/error/rate-limit.ts @@ -29,13 +29,10 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b // model capacity, while quota/rate-limit/server wording remains authoritative. const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi; const CONCURRENT_LIMIT_PATTERN = - // Require an actual cap signal (limit/quota/exceeded/reached) near "concurrent". - // The first two alternatives rely on `\b`, which treats `_` as a word char, so - // structured snake_case codes ("concurrent_limit_exceeded", - // "concurrent_requests_limit_reached", "concurrency_quota_exceeded") need the - // third alternative. Bare space-separated concurrency feature rejections stay - // excluded because they neither use `[-_]` nor carry a cap keyword. - /\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b|\bconcurren[a-z]*[-_](?:[a-z]+[_-])*(?:limit|quota|exceed\w*|reach\w*)/i; + // Require an actual cap signal near "concurrent". "Too many concurrent + // requests" is itself a cap signal; bare feature rejections such as + // "concurrent invocation is not supported" remain excluded. + /\btoo many\s+concurren\w*\s+(?:requests?|invocations?)\b|\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b|\bconcurren[a-z]*[-_](?:[a-z]+[_-])*(?:limit|quota|exceed\w*|reach\w*)/i; const ACCOUNT_SCOPED_403_PATTERN = // The bare "limit will reset" / "will reset in" phrasing also appears on // statusless per-minute transients ("Rate limit will reset in 30 seconds"), diff --git a/packages/ai/test/auth-retry.test.ts b/packages/ai/test/auth-retry.test.ts index ad8e9bcb7..e438dcf04 100644 --- a/packages/ai/test/auth-retry.test.ts +++ b/packages/ai/test/auth-retry.test.ts @@ -275,7 +275,7 @@ describe("withAuth", () => { expect(contexts.map(ctx => ctx.lastChance)).toEqual([false, true, true, true]); }); - it("does not directly rotate through every sibling on a 403 concurrency cap", async () => { + it("leaves a 403 concurrency cap to the transient retry layer", async () => { const keys: string[] = []; const contexts: ApiKeyResolveContext[] = []; const pool = ["k0", "k1", "k2", "k3"]; @@ -295,10 +295,10 @@ describe("withAuth", () => { ), ).rejects.toBe(concurrencyCap); - // The concurrency classification takes precedence over plain-403 direct - // rotation: refresh once, then take only the legacy sibling switch. - expect(keys).toEqual(["k0", "k1", "k2"]); - expect(contexts.map(ctx => ctx.lastChance)).toEqual([false, false, true]); + // The outer transient retry/backoff layer owns concurrency caps. The auth + // retry layer must not refresh or select a sibling credential. + expect(keys).toEqual(["k0"]); + expect(contexts.map(ctx => ctx.lastChance)).toEqual([false]); }); it("surfaces the last 403 when every sibling is denied", async () => { diff --git a/packages/ai/test/rate-limit-utils.test.ts b/packages/ai/test/rate-limit-utils.test.ts index 8d2feb84c..15e5b9bab 100644 --- a/packages/ai/test/rate-limit-utils.test.ts +++ b/packages/ai/test/rate-limit-utils.test.ts @@ -65,6 +65,8 @@ describe("parseRateLimitReason", () => { expect(parseRateLimitReason("concurrent_limit_exceeded")).toBe("CONCURRENT_LIMIT"); expect(parseRateLimitReason("concurrent_requests_limit_reached")).toBe("CONCURRENT_LIMIT"); expect(parseRateLimitReason("concurrency_quota_exceeded")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Too many concurrent requests")).toBe("CONCURRENT_LIMIT"); + expect(parseRateLimitReason("Too many concurrent invocations")).toBe("CONCURRENT_LIMIT"); expect(parseRateLimitReason("Rate limit reached for gpt-4o")).toBe("RATE_LIMIT_EXCEEDED"); expect(parseRateLimitReason("Your quota will reset at 07-28")).toBe("QUOTA_EXHAUSTED"); }); @@ -351,6 +353,9 @@ describe("isUsageLimitOutcome", () => { expect(isConcurrencyCapExclusion(undefined, message)).toBe(true); expect(isConcurrencyCapExclusion(402, message)).toBe(false); expect(isConcurrencyCapExclusion(403, "Forbidden")).toBe(false); + const classified = classify(new ProviderHttpError(message, 403)); + expect(is(classified, Flag.AuthFailed)).toBe(false); + expect(is(classified, Flag.Transient)).toBe(true); }); // The same bare concurrency wording can reach turn recovery without a diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e018a8f43..45ea58480 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Retried concurrent-request caps with a short backoff without deleting valid Copilot credentials or rotating through sibling accounts. - Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) - Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). - Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index d77535165..5cfd1169f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2693,13 +2693,18 @@ export class AgentSession { // Invalidate GitHub Copilot credentials on a hard auth failure (401, or an // expired/revoked token) so stale tokens aren't reused on the next request. - // A 403 whose body is a recognized account usage cap is NOT an auth failure — - // the credential is valid, only temporarily blocked until its reset window — - // so gate the removal on the absence of Flag.UsageLimit and keep the - // still-valid credential around until the cap resets. + // Account usage caps and concurrency caps leave the credential valid: the + // former rotates until its reset window, while the latter is retried after + // a short backoff without touching the credential pool. if (msg.stopReason === "error" && msg.provider === "github-copilot") { const errorId = AIError.classifyMessage(msg); - if (AIError.is(errorId, AIError.Flag.AuthFailed) && !AIError.is(errorId, AIError.Flag.UsageLimit)) { + const isConcurrencyCap = + AIError.parseRateLimitReason(msg.errorMessage ?? "") === "CONCURRENT_LIMIT"; + if ( + AIError.is(errorId, AIError.Flag.AuthFailed) && + !AIError.is(errorId, AIError.Flag.UsageLimit) && + !isConcurrencyCap + ) { await this.#modelRegistry.authStorage.remove("github-copilot"); } } diff --git a/packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts b/packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts deleted file mode 100644 index 24547b579..000000000 --- a/packages/coding-agent/test/agent-session-copilot-credential-removal.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expect, it, spyOn } from "bun:test"; -import { Agent } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; -import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; - -it("removes a Copilot credential for 401 but retains it for a 403 account cap", async () => { - const tempDir = TempDir.createSync("@pi-copilot-credential-removal-"); - const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); - const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); - const model = getBundledModel("anthropic", "claude-sonnet-4-5"); - if (!model) throw new Error("Expected bundled Anthropic test model to exist"); - - const agent = new Agent({ - initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, - }); - const session = new AgentSession({ - agent, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ "compaction.enabled": false }), - modelRegistry, - }); - const removeSpy = spyOn(authStorage, "remove").mockResolvedValue(undefined); - - try { - const unauthorized: AssistantMessage = { - role: "assistant", - content: [], - api: "openai-responses", - provider: "github-copilot", - model: "gpt-5-mini", - stopReason: "error", - errorMessage: "GitHub Copilot authentication failed (HTTP 401).", - errorStatus: 401, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }; - agent.emitExternalEvent({ type: "message_end", message: unauthorized }); - agent.emitExternalEvent({ type: "agent_end", messages: [unauthorized] }); - await session.waitForIdle(); - expect(removeSpy).toHaveBeenCalledWith("github-copilot"); - - removeSpy.mockClear(); - const accountCap: AssistantMessage = { - ...unauthorized, - errorMessage: "Reached overall message rate limit. Your limit will reset in 13 minutes.", - errorStatus: 403, - timestamp: Date.now(), - }; - agent.emitExternalEvent({ type: "message_end", message: accountCap }); - agent.emitExternalEvent({ type: "agent_end", messages: [accountCap] }); - await session.waitForIdle(); - expect(removeSpy).not.toHaveBeenCalled(); - } finally { - await session.dispose(); - removeSpy.mockRestore(); - authStorage.close(); - tempDir.removeSync(); - } -}); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 14f518d3c..f970f939f 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads. +- Parsed account-cap reset windows such as “Your limit will reset in 13 minutes” so credential backoff honors the provider's full reset duration. ## [17.2.6] - 2026-08-03 From 99748bbe6156e4880a239ab5d17276cb15f28b27 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 9 Jul 2026 16:58:52 +0000 Subject: [PATCH 90/97] fix(ai): omitted codex optional response defaults Stopped OpenAI Codex requests from sending reasoning.summary, reasoning.context, and text.verbosity unless explicitly configured, matching the safer native Codex request shape for GPT-5.x models. Preserved explicit overrides and added regression coverage for transformer and settings-aware stream behavior. Fixes #4949 (cherry picked from commit 15d86667d6572faf453e74b922eed33dd2d3f3c9) --- packages/ai/CHANGELOG.md | 1 + .../src/providers/openai-codex-responses.ts | 4 +- .../openai-codex/request-transformer.ts | 48 +++--- packages/ai/src/stream.ts | 2 +- .../test/openai-codex-responses-lite.test.ts | 138 +++++++----------- packages/ai/test/openai-codex-stream.test.ts | 26 ++++ packages/coding-agent/CHANGELOG.md | 1 + .../src/session/settings-stream-fn.ts | 10 +- .../test/settings-stream-fn.test.ts | 12 +- 9 files changed, 119 insertions(+), 123 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ff1e54094..4a89ed9d7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed Cursor reads with inline OMP range selectors reporting the returned slice length as the source file's `totalLines`, which made sequential reads of an unchanged file appear inconsistent ([#7590](https://github.com/can1357/oh-my-pi/issues/7590)). - Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. - Fixed OpenAI Codex usage telemetry blocking explicitly allowed ChatGPT Team credentials when a weekly `used_percent` rounded to 100, which could route multi-account sessions to an actually exhausted sibling instead ([#7617](https://github.com/can1357/oh-my-pi/issues/7617)). +- Fixed OpenAI Codex GPT-5.x requests sending optional `reasoning.summary`, `reasoning.context`, and `text.verbosity` controls by default, reducing Codex `server_error` disconnects from unsupported request shapes. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) ## [17.2.7] - 2026-08-03 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 8c27fe700..9e320b70c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -128,7 +128,7 @@ import { redactSensitiveInObject, transformMessages } from "./transform-messages export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "concise" | "detailed" | null; - /** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */ + /** Explicit `reasoning.context` replay scope. Omitted by default so Codex applies its native request policy. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; codexMode?: boolean; @@ -1530,7 +1530,7 @@ export async function buildTransformedCodexRequestBody( } const codexOptions: CodexRequestOptions = { reasoningEffort: options?.reasoning, - reasoningSummary: options?.reasoningSummary === undefined ? "auto" : options.reasoningSummary, + reasoningSummary: options?.reasoningSummary, reasoningContext: options?.reasoningContext, textVerbosity: options?.textVerbosity, include: options?.include, diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index b0b157e9c..13a2cc7f9 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -33,7 +33,7 @@ export interface CodexRequestOptions { /** User-facing effort; maps 1:1 onto the wire tier of the same name. */ reasoningEffort?: CodexCallerEffort | "none"; reasoningSummary?: ReasoningConfig["summary"] | null; - /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. Gated to gpt-5.4+ Codex models (older ids reject it, so it is suppressed and `context` omitted). Note that under Responses Lite (`responsesLite`), the server strictly requires `reasoning.context` to be `all_turns`, which overrides this option and forces `all_turns`. */ + /** Explicit `reasoning.context` override. Omitted by default; Responses Lite forces `all_turns` as required by that transport. */ reasoningContext?: CodexReasoningContext; textVerbosity?: "low" | "medium" | "high"; include?: string[]; @@ -145,13 +145,12 @@ function getReasoningConfig( const config: ReasoningConfig = { effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort), }; - // `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids - // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with - // "Unsupported parameter: 'reasoning.summary' is not supported with this model". - // Mirrors the all_turns gate: an explicit summary is suppressed on unsupported - // ids, letting the server skip the human-readable summary stream. - if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) { - config.summary = options.reasoningSummary ?? "detailed"; + if ( + options.reasoningSummary !== undefined && + options.reasoningSummary !== null && + supportsCodexReasoningSummary(model.id) + ) { + config.summary = options.reasoningSummary; } return config; } @@ -444,21 +443,14 @@ export async function transformRequestBody( ...body.reasoning, ...reasoningConfig, }; - // Default reasoning replay to `all_turns`, mirroring codex-rs; an - // explicit `reasoningContext` overrides the default. The `all_turns` - // value is only accepted from gpt-5.4 onward — earlier Codex ids - // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with - // "Unsupported value: 'all_turns' is not supported with this model". - // For those, drop `context` so the server applies its `current_turn` - // default. The version gate is authoritative: even an explicit - // `all_turns` override is suppressed on unsupported models, while - // `current_turn`/`auto` (universally supported) always pass through. - // Note: Responses Lite forces `all_turns` to satisfy the transport's server invariant. - const context = responsesLite ? "all_turns" : (options.reasoningContext ?? "all_turns"); - if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { - delete body.reasoning.context; - } else { - body.reasoning.context = context; + // Responses Lite requires `all_turns`; the full transport leaves context to the server unless explicitly set. + const context = responsesLite ? "all_turns" : options.reasoningContext; + if (context !== undefined) { + if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) { + delete body.reasoning.context; + } else { + body.reasoning.context = context; + } } } else { delete body.reasoning; @@ -481,10 +473,12 @@ export async function transformRequestBody( delete body.stream_options; } - body.text = { - ...body.text, - verbosity: options.textVerbosity || "medium", - }; + if (options.textVerbosity !== undefined) { + body.text = { + ...body.text, + verbosity: options.textVerbosity, + }; + } const include = Array.isArray(options.include) ? [...options.include] : []; include.push("reasoning.encrypted_content"); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index d8c3e1a28..f89dfd0e3 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1705,7 +1705,7 @@ function mapOptionsForApi( serviceTier: options?.serviceTier, preferWebsockets: options?.preferWebsockets, codexCompaction: options?.codexCompaction, - reasoningSummary: options?.hideThinkingSummary ? null : "detailed", + reasoningSummary: options?.hideThinkingSummary ? null : undefined, textVerbosity: options?.textVerbosity, }); diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 3f4ff7a74..1db6daf4b 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -128,71 +128,58 @@ function createCodexFetchMock(sse: string, onRequest: (captured: CapturedCodexRe }) as FetchImpl; } -describe("openai-codex reasoning.context", () => { - it("defaults to all_turns on gpt-5.4+ models and forwards explicit overrides", async () => { - const model = createCodexModel("gpt-5.4"); +describe("openai-codex optional response controls", () => { + it("omits optional controls on full requests and forwards explicit controls", async () => { + const model = createCodexModel("gpt-5.5"); const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.context).toBe("all_turns"); + expect(defaulted.reasoning).toEqual({ effort: "medium" }); + expect("summary" in (defaulted.reasoning ?? {})).toBe(false); + expect("context" in (defaulted.reasoning ?? {})).toBe(false); + expect("text" in defaulted).toBe(false); + expect("stream_options" in defaulted).toBe(false); const explicit = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", - reasoningContext: "current_turn", + reasoningSummary: "concise", + reasoningContext: "all_turns", + textVerbosity: "low", }); - expect(explicit.reasoning?.context).toBe("current_turn"); + expect(explicit.reasoning).toEqual({ + effort: "medium", + summary: "concise", + context: "all_turns", + }); + expect(explicit.text).toEqual({ verbosity: "low" }); + expect(explicit.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); }); - it("keeps the all_turns default for the lite transport on supported models", async () => { + it("omits reasoning.summary when explicitly suppressed", async () => { const model = createCodexModel("gpt-5.5"); - - const lite = await transformRequestBody({ model: model.id }, model, { + const suppressed = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", - responsesLite: true, + reasoningSummary: null, }); - expect(lite.reasoning?.context).toBe("all_turns"); - - const overridden = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - responsesLite: true, - reasoningContext: "auto", - }); - expect(overridden.reasoning?.context).toBe("all_turns"); + expect(suppressed.reasoning).toEqual({ effort: "medium" }); + expect("summary" in (suppressed.reasoning ?? {})).toBe(false); + expect("stream_options" in suppressed).toBe(false); }); - it("enforces reasoning.context to be all_turns for the lite transport even when effort is unset or none", async () => { + it("forces reasoning.context to all_turns for Responses Lite", async () => { const model = createCodexModel("gpt-5.5"); - // Case 1: reasoningEffort is undefined (missing effort) const missingEffort = await transformRequestBody({ model: model.id }, model, { responsesLite: true, }); - expect(missingEffort.reasoning?.context).toBe("all_turns"); - expect(missingEffort.reasoning?.effort).toBeUndefined(); + expect(missingEffort.reasoning).toEqual({ context: "all_turns" }); - // Case 2: reasoningEffort is explicitly "none" (effort set to off) const noneEffort = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "none", responsesLite: true, - }); - expect(noneEffort.reasoning?.context).toBe("all_turns"); - expect(noneEffort.reasoning?.effort).toBe("none"); - - // Case 3: Conflicting explicit reasoningContext with missing effort under Lite - const conflictingUnsetEffort = await transformRequestBody({ model: model.id }, model, { - responsesLite: true, reasoningContext: "current_turn", }); - expect(conflictingUnsetEffort.reasoning?.context).toBe("all_turns"); + expect(noneEffort.reasoning).toEqual({ effort: "none", context: "all_turns" }); - // Case 4: Conflicting explicit reasoningContext with "none" effort under Lite - const conflictingNoneEffort = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "none", - responsesLite: true, - reasoningContext: "current_turn", - }); - expect(conflictingNoneEffort.reasoning?.context).toBe("all_turns"); - - // Case 5: responsesLite is false and reasoningEffort is undefined (regular request with no effort) const plainRequest = await transformRequestBody({ model: model.id }, model, { responsesLite: false, }); @@ -202,73 +189,35 @@ describe("openai-codex reasoning.context", () => { // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `all_turns` // ("Unsupported value: 'all_turns' is not supported with this model"). it.each(["gpt-5.1-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark"])( - "omits the all_turns default for pre-5.4 model %s", + "omits unsupported all_turns context for pre-5.4 model %s", async modelId => { const model = createCodexModel(modelId); + const forced = await transformRequestBody({ model: model.id }, model, { + reasoningEffort: "medium", + reasoningContext: "all_turns", + }); + expect(forced.reasoning).toEqual({ effort: "medium" }); - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning).toBeDefined(); - expect(defaulted.reasoning?.context).toBeUndefined(); - expect("context" in (defaulted.reasoning ?? {})).toBe(false); - - // A supported override (current_turn/auto) is still honored. const overridden = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningContext: "current_turn", }); - expect(overridden.reasoning?.context).toBe("current_turn"); + expect(overridden.reasoning).toEqual({ effort: "medium", context: "current_turn" }); }, ); - it("suppresses an explicit all_turns override on a pre-5.4 model", async () => { - const model = createCodexModel("gpt-5.3-codex-spark"); - - const forced = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningContext: "all_turns", - }); - expect(forced.reasoning).toBeDefined(); - expect(forced.reasoning?.context).toBeUndefined(); - }); -}); - -describe("openai-codex reasoning.summary", () => { - it("sends summary on gpt-5.4+ models and honors explicit levels", async () => { - const model = createCodexModel("gpt-5.4"); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning?.summary).toBe("detailed"); - - const explicit = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningSummary: "concise", - }); - expect(explicit.reasoning?.summary).toBe("concise"); - - const suppressed = await transformRequestBody({ model: model.id }, model, { - reasoningEffort: "medium", - reasoningSummary: null, - }); - expect("summary" in (suppressed.reasoning ?? {})).toBe(false); - }); - // gpt-5.1-codex / gpt-5.3-codex / gpt-5.3-codex-spark reject `reasoning.summary` // ("Unsupported parameter: 'reasoning.summary' is not supported with this model"). it.each(["gpt-5.1-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark"])( "omits reasoning.summary for pre-5.4 model %s", async modelId => { const model = createCodexModel(modelId); - - const defaulted = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium" }); - expect(defaulted.reasoning).toBeDefined(); - expect("summary" in (defaulted.reasoning ?? {})).toBe(false); - - // Even an explicit summary level is suppressed on unsupported ids. const forced = await transformRequestBody({ model: model.id }, model, { reasoningEffort: "medium", reasoningSummary: "detailed", }); - expect("summary" in (forced.reasoning ?? {})).toBe(false); + expect(forced.reasoning).toEqual({ effort: "medium" }); + expect("stream_options" in forced).toBe(false); }, ); }); @@ -816,7 +765,11 @@ describe("openai-codex concurrent reasoning summaries", () => { it("sends stream_options only when a summary is requested and supported", async () => { const terra = createCodexModel("gpt-5.6-terra"); - const withSummary = await transformRequestBody({ model: terra.id }, terra, { reasoningEffort: "medium" }); + const withSummary = await transformRequestBody( + { model: terra.id }, + terra, + { reasoningEffort: "medium", reasoningSummary: "detailed" }, + ); expect(withSummary.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); expect(withSummary.reasoning?.summary).toBe("detailed"); @@ -830,7 +783,11 @@ describe("openai-codex concurrent reasoning summaries", () => { expect(noReasoning.stream_options).toBeUndefined(); const legacy = createCodexModel("gpt-5.1-codex"); - const unsupported = await transformRequestBody({ model: legacy.id }, legacy, { reasoningEffort: "medium" }); + const unsupported = await transformRequestBody( + { model: legacy.id }, + legacy, + { reasoningEffort: "medium", reasoningSummary: "detailed" }, + ); expect(unsupported.stream_options).toBeUndefined(); }); @@ -889,6 +846,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const thinkingDeltas: string[] = []; for await (const event of stream) { @@ -1060,6 +1018,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const thinkingDeltas: string[] = []; for await (const event of stream) { @@ -1215,6 +1174,7 @@ describe("openai-codex concurrent reasoning summaries", () => { apiKey: createCodexTestToken(), fetch: fetchMock, reasoning: "medium", + reasoningSummary: "detailed", }); const deltasByBlock = new Map(); for await (const event of stream) { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 274cc02f3..a650a3e43 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -501,6 +501,32 @@ describe("openai-codex streaming", () => { expect(capturedText).toEqual({ verbosity: "low" }); }); + it("omits optional response controls from default SimpleStreamOptions", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const token = createCodexTestToken(); + const context = createCodexTestContext(); + const model = { ...createCodexTestModel("https://chatgpt.com/backend-api"), preferWebsockets: false }; + let capturedBody: Record | undefined; + const fetchMock: FetchImpl = async (_input, init) => { + capturedBody = JSON.parse(decodeCodexRequestBody(init?.body)) as Record; + return new Response(createCompletedCodexSse("Hello"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + const result = await streamSimple(model, context, { + apiKey: token, + fetch: fetchMock, + reasoning: "medium", + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(capturedBody?.reasoning).toEqual({ effort: "medium" }); + expect(capturedBody?.text).toBeUndefined(); + }); + async function runCodexSseEvents(events: unknown[]) { const token = createCodexTestToken(); const context = createCodexTestContext(); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 45ea58480..8fe24bd59 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed - Retried concurrent-request caps with a short backoff without deleting valid Copilot credentials or rotating through sibling accounts. +- Fixed the default `textVerbosity` setting being forwarded to OpenAI Codex requests unless the user explicitly configures it, preserving Codex's native response-control defaults. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) - Reduced streaming CPU usage by coalescing the cumulative `message_update` deltas of a turn at the event-controller dispatch boundary: at most one streaming-state rebuild runs per ~33ms window instead of one per token, cutting the per-token handler work that dominated the CPU profile of streaming sessions (especially at high token rates) while preserving per-delta speech output. Subscriber dispatch is serialized so a rapid stream tail (`message_update` → `message_end` → `agent_end`) cannot overtake the coalesced flush. ([#7443](https://github.com/can1357/oh-my-pi/issues/7443)) - Fixed translated MCP importers (Claude Code, Cursor, Gemini CLI, Windsurf, VS Code) silently dropping a server's `enabled: false` flag, so a server disabled at the source config stayed mounted; the flag is now propagated and honored like Codex, OpenCode, and native `mcp.json`. These importers now also load project entries before same-named user entries (matching native/Codex) so a project `enabled: false` suppresses a same-named user server ([#7652](https://github.com/can1357/oh-my-pi/issues/7652)). - Removed the per-call `model` override from the eval `agent()` helper (all runtimes), completing the earlier task-tool removal (`9f8aa87dbf`). Subagents always use their selected agent's frontmatter model and settings; a legacy `model` argument is silently ignored, so an explicit `model: "default"` can no longer route children onto the parent session model ([#6438](https://github.com/can1357/oh-my-pi/issues/6438)). diff --git a/packages/coding-agent/src/session/settings-stream-fn.ts b/packages/coding-agent/src/session/settings-stream-fn.ts index be58243ed..41dcdc386 100644 --- a/packages/coding-agent/src/session/settings-stream-fn.ts +++ b/packages/coding-agent/src/session/settings-stream-fn.ts @@ -34,9 +34,13 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn = openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined; const antigravityEndpointMode = settings.get("providers.antigravityEndpoint"); const textVerbosity = - model.api === "openai-codex-responses" || model.api === "openai-responses" - ? settings.get("textVerbosity") - : undefined; + model.api === "openai-codex-responses" + ? settings.isConfigured("textVerbosity") + ? settings.get("textVerbosity") + : undefined + : model.api === "openai-responses" + ? settings.get("textVerbosity") + : undefined; const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds")); const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds")); // Server-side fallback (opt-in): when the user enables it AND the diff --git a/packages/coding-agent/test/settings-stream-fn.test.ts b/packages/coding-agent/test/settings-stream-fn.test.ts index 424889447..44be30735 100644 --- a/packages/coding-agent/test/settings-stream-fn.test.ts +++ b/packages/coding-agent/test/settings-stream-fn.test.ts @@ -81,7 +81,17 @@ describe("createSettingsAwareStreamFn", () => { expect(calls[0]?.options?.hideThinkingSummary).toBe(true); }); - it("applies Responses-family text verbosity from settings while preserving caller overrides", () => { + it("applies Codex text verbosity only when settings or caller options configure it", () => { + const unconfiguredSettings = Settings.isolated({}); + const { fn: unconfiguredBase, calls: unconfiguredCalls } = captureBase(); + const unconfiguredWrapped = createSettingsAwareStreamFn(unconfiguredSettings, unconfiguredBase); + + unconfiguredWrapped(stubCodexModel, stubContext, undefined); + unconfiguredWrapped(stubCodexModel, stubContext, { textVerbosity: "medium" }); + + expect(unconfiguredCalls[0]?.options?.textVerbosity).toBeUndefined(); + expect(unconfiguredCalls[1]?.options?.textVerbosity).toBe("medium"); + const settings = Settings.isolated({ textVerbosity: "low" }); const { fn: base, calls } = captureBase(); const wrapped = createSettingsAwareStreamFn(settings, base); From fe9bd27fc994ec58b2cac7f1c90bd9b00fc991de Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 29 Jun 2026 03:06:48 +0000 Subject: [PATCH 91/97] fix(agent): loaded WSL host agents skills Scanned the Windows host USERPROFILE .agents directory when running under WSL so globally installed Agent Skills are available alongside Linux-home skills. Fixes #3779 (cherry picked from commit c3468dae4f9b91646bf50f2cf4ded9075572290e) --- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/discovery/agents.ts | 59 ++++++++++++++++++- packages/coding-agent/test/skills.test.ts | 50 ++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8fe24bd59..000560995 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2059,6 +2059,9 @@ - Fixed the DuckDuckGo web_search provider returning empty results for non-encyclopedic queries by switching from the Instant Answer API to parsing the HTML frontend, and added clear error handling for bot-challenge throttling. - Fixed Windows --extension paths with spaces or \\?\ prefixes being truncated or incorrectly passed to Bun import/spawn APIs. - Fixed /mcp reauth compatibility with Cloudflare by aligning OAuth prompt behavior with the reference MCP SDK and updating the client label to oh-my-pi. +### Fixed + +- Fixed WSL sessions missing Agent Skills stored in the Windows host profile's `.agents/skills` directory. ([#3779](https://github.com/can1357/oh-my-pi/issues/3779)) ## [16.2.5] - 2026-06-28 diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 4496b87c0..16eb6205f 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -28,9 +28,62 @@ const DISPLAY_NAME = "Agent Dirs (.agent/.agents)"; const PRIORITY = 70; const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; -/** User-level paths: ~/.agent/ and ~/.agents/. */ -function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { - return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments)); +interface UserPathCandidateOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + wslPath?: (windowsPath: string) => string | undefined; +} + +const WINDOWS_DRIVE_PROFILE_PATTERN = /^([A-Za-z]):[\\/](.*)$/; + +function isWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean { + return platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP); +} + +function convertWindowsPathToDefaultWslMount(windowsPath: string): string | undefined { + const trimmed = windowsPath.trim(); + if (trimmed.length === 0) return undefined; + if (path.isAbsolute(trimmed)) return path.normalize(trimmed); + const match = WINDOWS_DRIVE_PROFILE_PATTERN.exec(trimmed); + if (!match) return undefined; + const [, drive, rest] = match; + const segments = rest.replace(/\\/g, "/").split("/").filter(Boolean); + return path.join("/mnt", drive.toLowerCase(), ...segments); +} + +function resolveWithWslPath(windowsPath: string): string | undefined { + try { + const result = Bun.spawnSync(["wslpath", "-u", windowsPath], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + const resolved = result.stdout.toString().trim(); + return resolved.length > 0 ? resolved : undefined; + } catch { + return undefined; + } +} + +/** Resolve the Windows host profile home exposed to WSL, if available. */ +export function getWslWindowsHomeCandidate(options: UserPathCandidateOptions = {}): string | undefined { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + if (!isWsl(platform, env)) return undefined; + const userProfile = env.USERPROFILE; + if (!userProfile) return undefined; + return (options.wslPath ?? resolveWithWslPath)(userProfile) ?? convertWindowsPathToDefaultWslMount(userProfile); +} + +function getUserHomeCandidates(ctx: LoadContext): string[] { + const homes = [ctx.home]; + const wslHome = getWslWindowsHomeCandidate(); + if (wslHome && !homes.includes(wslHome)) homes.push(wslHome); + return homes; +} + +/** User-level paths: ~/.agent[s]/, plus the Windows host profile under WSL. */ +export function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] { + return getUserHomeCandidates(ctx).flatMap(home => + AGENT_DIR_CANDIDATES.map(baseDir => path.join(home, baseDir, ...segments)), + ); } /** diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 5b8c43e6b..14cda65ad 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { type Skill as CapabilitySkill, skillCapability } from "@oh-my-pi/pi-coding-agent/capability/skill"; import { getCapability } from "@oh-my-pi/pi-coding-agent/discovery"; +import { getWslWindowsHomeCandidate } from "@oh-my-pi/pi-coding-agent/discovery/agents"; import { loadSkills, loadSkillsFromDir, @@ -234,6 +235,55 @@ describe("skills", () => { } }); + it("should load Windows host ~/.agents/skills when running under WSL (#3779)", async () => { + const tempHostHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-wsl-host-")); + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-wsl-cwd-")); + const skillDir = path.join(tempHostHome, ".agents", "skills", "wsl-host-skill"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", "description: Loaded from WSL host USERPROFILE", "---", "", "# wsl-host-skill"].join("\n"), + ); + const previousWslDistroName = process.env.WSL_DISTRO_NAME; + const previousWslInterop = process.env.WSL_INTEROP; + const previousUserProfile = process.env.USERPROFILE; + process.env.WSL_DISTRO_NAME = "Ubuntu"; + delete process.env.WSL_INTEROP; + process.env.USERPROFILE = tempHostHome; + try { + const { skills } = await loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + cwd: tempCwd, + }); + const skill = skills.find(s => s.name === "wsl-host-skill"); + expect(skill?.source).toBe("agents:user"); + expect(skill?.filePath).toBe(path.join(skillDir, "SKILL.md")); + } finally { + if (previousWslDistroName === undefined) delete process.env.WSL_DISTRO_NAME; + else process.env.WSL_DISTRO_NAME = previousWslDistroName; + if (previousWslInterop === undefined) delete process.env.WSL_INTEROP; + else process.env.WSL_INTEROP = previousWslInterop; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + await removeWithRetries(tempHostHome); + await removeWithRetries(tempCwd); + } + }); + + it("converts Windows USERPROFILE paths to the default WSL mount (#3779)", () => { + const resolved = getWslWindowsHomeCandidate({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu", USERPROFILE: "C:\\Users\\alice" }, + wslPath: () => undefined, + }); + + expect(resolved).toBe(path.join("/mnt", "c", "Users", "alice")); + }); + it("respects an explicit enableAgentsUser: false (#2401)", async () => { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-home-off-")); const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-cwd-off-")); From 5ed9ffadcee4ad406c29152e846bec6113b85d46 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:29:54 +0200 Subject: [PATCH 92/97] fix(agent): resolve WSL host profile through interop (cherry picked from commit bd06a333e30510c71ca1bd2a55f79e87227a829b) --- packages/coding-agent/src/discovery/agents.ts | 17 ++++++++++++++++- packages/coding-agent/test/skills.test.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 16eb6205f..27d5f3880 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -31,6 +31,7 @@ const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const; interface UserPathCandidateOptions { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; + windowsUserProfile?: () => string | undefined; wslPath?: (windowsPath: string) => string | undefined; } @@ -62,12 +63,26 @@ function resolveWithWslPath(windowsPath: string): string | undefined { } } +function resolveWindowsUserProfile(): string | undefined { + try { + const result = Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"], { + stdout: "pipe", + stderr: "ignore", + }); + if (result.exitCode !== 0) return undefined; + const resolved = result.stdout.toString().trim(); + return resolved.length > 0 && resolved !== "%USERPROFILE%" ? resolved : undefined; + } catch { + return undefined; + } +} + /** Resolve the Windows host profile home exposed to WSL, if available. */ export function getWslWindowsHomeCandidate(options: UserPathCandidateOptions = {}): string | undefined { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; if (!isWsl(platform, env)) return undefined; - const userProfile = env.USERPROFILE; + const userProfile = env.USERPROFILE ?? (options.windowsUserProfile ?? resolveWindowsUserProfile)(); if (!userProfile) return undefined; return (options.wslPath ?? resolveWithWslPath)(userProfile) ?? convertWindowsPathToDefaultWslMount(userProfile); } diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 14cda65ad..148615b79 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -247,6 +247,8 @@ describe("skills", () => { const previousWslDistroName = process.env.WSL_DISTRO_NAME; const previousWslInterop = process.env.WSL_INTEROP; const previousUserProfile = process.env.USERPROFILE; + const previousPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); process.env.WSL_DISTRO_NAME = "Ubuntu"; delete process.env.WSL_INTEROP; process.env.USERPROFILE = tempHostHome; @@ -269,6 +271,7 @@ describe("skills", () => { else process.env.WSL_INTEROP = previousWslInterop; if (previousUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = previousUserProfile; + Object.defineProperty(process, "platform", { value: previousPlatform }); await removeWithRetries(tempHostHome); await removeWithRetries(tempCwd); } @@ -284,6 +287,17 @@ describe("skills", () => { expect(resolved).toBe(path.join("/mnt", "c", "Users", "alice")); }); + it("resolves the Windows profile through interop when USERPROFILE is not exported (#3779)", () => { + const resolved = getWslWindowsHomeCandidate({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + windowsUserProfile: () => "C:\\Users\\alice", + wslPath: () => "/mnt/c/Users/alice", + }); + + expect(resolved).toBe("/mnt/c/Users/alice"); + }); + it("respects an explicit enableAgentsUser: false (#2401)", async () => { const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-home-off-")); const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pi-agents-cwd-off-")); From c4570378163c08e340863ad26bb63c9180c985b9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:30:11 +0200 Subject: [PATCH 93/97] test(coding-agent): cover bounded Vibe suspension (cherry picked from commit 30fd0cc7bbcb427c9ae7a8ef6b801545c6aa1c5b) --- .../test/vibe/vibe-runtime.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/coding-agent/test/vibe/vibe-runtime.test.ts b/packages/coding-agent/test/vibe/vibe-runtime.test.ts index 288d5595a..c285d5582 100644 --- a/packages/coding-agent/test/vibe/vibe-runtime.test.ts +++ b/packages/coding-agent/test/vibe/vibe-runtime.test.ts @@ -2168,4 +2168,49 @@ describe("vibe session registry", () => { expect(AgentRegistry.global().get("One")).toBeUndefined(); expect(AgentRegistry.global().get("Two")).toBeUndefined(); }); + it("bounds suspension when worker disposal does not settle", async () => { + const disposeGate = deferred(); + const worker = createFakeWorkerSession({ onDispose: () => disposeGate.promise }); + const ref = AgentRegistry.global().register({ + id: "bounded-suspend", + displayName: "bounded-suspend", + kind: "sub", + parentId: "Main", + session: worker.session, + status: "idle", + }); + AgentLifecycleManager.global().adopt(ref.id, { + idleTtlMs: 0, + revive: async () => worker.session, + }); + const registry = VibeSessionRegistry.global(); + registry.registerRecordForTests({ id: ref.id, ownerId: "Main", state: "idle" }); + registry.setTeardownGraceForTesting(20); + + vi.useFakeTimers(); + let settled = false; + const suspension = registry + .suspendScope({ + ownerId: "Main", + parentSessionId: "test-parent-session", + parentSessionFile: null, + }) + .finally(() => { + settled = true; + }); + try { + await flushMicrotasks(); + vi.advanceTimersByTime(19); + await flushMicrotasks(); + expect(settled).toBe(false); + vi.advanceTimersByTime(1); + await flushMicrotasks(); + expect(settled).toBe(true); + expect(AgentRegistry.global().get(ref.id)).toBeUndefined(); + expect(await suspension).toBe(1); + } finally { + disposeGate.resolve(); + vi.useRealTimers(); + } + }); }); From dd78873661f2cade81fa9d5663b5ccdf51db35d4 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:30:11 +0200 Subject: [PATCH 94/97] fix(coding-agent): bound Vibe teardown waits (cherry picked from commit cf43a416d7cac3ce79f01de571161952865cf03c) --- packages/coding-agent/src/vibe/runtime.ts | 255 +++++++++++++++++----- 1 file changed, 199 insertions(+), 56 deletions(-) diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index e77c9ef8a..745a3f63c 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -68,10 +68,8 @@ const TURN_TRACE_CAP = 40; const TRACE_LINE_MAX = 120; /** Default `vibe_wait` window when no timeout was given (ms). */ const DEFAULT_WAIT_TIMEOUT_MS = 30_000; -/** Response text cap inside a delivered turn result; full output stays at agent://. */ -const RESPONSE_PREVIEW_MAX = 6000; -/** Grace period for abort-aware turns before teardown detaches a stuck provider/tool call. */ -const CANCELLED_TURN_SETTLE_GRACE_MS = 250; +/** Grace period for Vibe cancellation/release cleanup before teardown detaches (ms). */ +const VIBE_TEARDOWN_GRACE_MS = 5_000; const VIBE_LIFECYCLE_CUSTOM_TYPE = "vibe-session-lifecycle"; const VIBE_LIFECYCLE_VERSION = 1; @@ -252,6 +250,48 @@ export interface VibeWaitOutcome { timedOut: boolean; } +type VibeTeardownStatus = "pending" | "settled" | "failed"; + +interface TrackedVibeTeardown { + promise: Promise; + status: () => VibeTeardownStatus; +} + +/** Observe cleanup without propagating a detached late rejection. */ +function trackVibeTeardown(promise: Promise, onError: (error: unknown) => void): TrackedVibeTeardown { + let status: VibeTeardownStatus = "pending"; + return { + promise: promise.then( + () => { + status = "settled"; + }, + error => { + status = "failed"; + onError(error); + }, + ), + status: () => status, + }; +} + +/** Wait for cleanup only until the caller's shared absolute deadline. */ +async function waitForVibeTeardown(tasks: readonly TrackedVibeTeardown[], deadline: number): Promise { + if (tasks.length === 0 || tasks.every(task => task.status() !== "pending")) return true; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return false; + const timeout = Promise.withResolvers(); + const timer = setTimeout(timeout.resolve, remainingMs); + timer.unref?.(); + try { + return await Promise.race([ + Promise.allSettled(tasks.map(task => task.promise)).then(() => true), + timeout.promise.then(() => false), + ]); + } finally { + clearTimeout(timer); + } +} + /** Normalize a text fragment to one bounded roster/trace line. */ function firstLine(text: string, max = 100): string { return oneLineLabel(text, max); @@ -354,23 +394,6 @@ function mergeTrace(turn: VibeTurn, progress: AgentProgress): void { /** Thrown from a turn job body so the job manager marks the job failed while carrying the formatted result. */ export class VibeTurnError extends Error {} -async function awaitCancelledTurnJobs(jobs: ReadonlySet): Promise { - if (jobs.size === 0) return; - const settled = Promise.allSettled([...jobs].map(job => job.promise)).then(() => true); - const timeout = Promise.withResolvers(); - const timer = setTimeout(() => timeout.resolve(false), CANCELLED_TURN_SETTLE_GRACE_MS); - timer.unref(); - try { - if (!(await Promise.race([settled, timeout.promise]))) { - logger.warn("vibe: detached cancelled turn that did not settle within teardown grace period", { - jobCount: jobs.size, - graceMs: CANCELLED_TURN_SETTLE_GRACE_MS, - }); - } - } finally { - clearTimeout(timer); - } -} /** * Process-global registry of vibe worker sessions, scoped by both owner agent @@ -419,6 +442,12 @@ export class VibeSessionRegistry { readonly #records = new Map(); readonly #terminationTails = new Map>(); readonly #terminatedScopes = new Set(); + #teardownGraceMs = VIBE_TEARDOWN_GRACE_MS; + + /** Override the teardown grace period for deterministic lifecycle tests. */ + setTeardownGraceForTesting(timeoutMs: number): void { + this.#teardownGraceMs = Math.max(1, timeoutMs); + } ownerScope(session: VibeParentSession): VibeOwnerScope { const parentSessionId = session.getSessionId?.(); @@ -709,11 +738,50 @@ export class VibeSessionRegistry { } } + #trackAgentRelease(id: string, ref: AgentRef, action: "detach" | "release"): TrackedVibeTeardown { + return trackVibeTeardown(AgentLifecycleManager.global().release(id, ref), error => { + logger.warn(`vibe: failed to ${action} worker session`, { + id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + #finishAgentRelease(id: string, ref: AgentRef, task: TrackedVibeTeardown, action: "detach" | "release"): void { + if (task.status() === "settled") return; + if (task.status() === "pending") { + logger.warn(`vibe: timed out waiting to ${action} worker session; detaching registry ref`, { id }); + } + AgentRegistry.global().unregister(id, ref); + } + + async #releaseRefWithinDeadline( + id: string, + ref: AgentRef, + deadline: number, + action: "detach" | "release", + ): Promise { + const task = this.#trackAgentRelease(id, ref, action); + await waitForVibeTeardown([task], deadline); + this.#finishAgentRelease(id, ref, task, action); + } + + #trackJobSettlement(record: VibeRecord, job: AsyncJob): TrackedVibeTeardown { + return trackVibeTeardown(job.promise, error => { + logger.warn("vibe: cancelled worker turn cleanup failed", { + id: record.id, + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + async #markTerminalRef( id: string, ownerId: string, childSessionFile: string, expected?: AgentRef | null, + teardownDeadline?: number, ): Promise { const registry = AgentRegistry.global(); const existing = registry.get(id); @@ -726,7 +794,9 @@ export class VibeSessionRegistry { } if (existing?.status === "aborted" && !existing.session) return; if (existing && !registry.setStatus(id, "aborted", existing)) return; - if (existing && AgentLifecycleManager.global().has(id, existing)) { + if (existing && teardownDeadline !== undefined) { + await this.#releaseRefWithinDeadline(id, existing, teardownDeadline, "release"); + } else if (existing && AgentLifecycleManager.global().has(id, existing)) { await AgentLifecycleManager.global().release(id, existing); } else if (existing?.session) { await existing.session.dispose(); @@ -1086,19 +1156,48 @@ export class VibeSessionRegistry { this.#records.delete(scopeKey(scope, record.id)); if (record.turn && manager) manager.cancel(record.turn.jobId, { ownerId: record.ownerId }); } - for (const { record, ref } of teardown) { - if (!ref) continue; - try { - await AgentLifecycleManager.global().release(record.id, ref); - } catch (error) { - logger.warn("vibe: failed to detach worker session", { + const deadline = Date.now() + this.#teardownGraceMs; + const cleanup = teardown.map(entry => ({ + ...entry, + releaseTask: entry.ref ? this.#trackAgentRelease(entry.record.id, entry.ref, "detach") : undefined, + jobTask: entry.job ? this.#trackJobSettlement(entry.record, entry.job) : undefined, + })); + await waitForVibeTeardown( + cleanup.flatMap(entry => [entry.releaseTask, entry.jobTask].filter(task => task !== undefined)), + deadline, + ); + for (const { record, ref, releaseTask, job, jobTask } of cleanup) { + if (ref && releaseTask) this.#finishAgentRelease(record.id, ref, releaseTask, "detach"); + if (job && jobTask?.status() === "pending") { + logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", { + id: record.id, + jobId: job.id, + }); + this.#continueSuspendedCleanup(scope, record, jobTask); + } + if (this.#records.has(scopeKey(scope, record.id))) continue; + const lateRef = this.#registeredAgent(record); + if (lateRef && lateRef !== ref) { + await this.#releaseRefWithinDeadline(record.id, lateRef, deadline, "detach"); + } + } + return records.length; + } + + #continueSuspendedCleanup(scope: VibeOwnerScope, record: VibeRecord, jobTask: TrackedVibeTeardown): void { + void jobTask.promise + .then(async () => { + if (this.#records.has(scopeKey(scope, record.id))) return; + const lateRef = this.#registeredAgent(record); + if (!lateRef) return; + await this.#releaseRefWithinDeadline(record.id, lateRef, Date.now() + this.#teardownGraceMs, "detach"); + }) + .catch(error => { + logger.warn("vibe: failed to finish suspended worker cleanup", { id: record.id, error: error instanceof Error ? error.message : String(error), }); - } - } - await awaitCancelledTurnJobs(new Set(teardown.flatMap(entry => (entry.job ? [entry.job] : [])))); - return records.length; + }); } /** Terminate one worker; a tombstone failure still tears it down before reconciliation and error delivery. */ @@ -1144,15 +1243,21 @@ export class VibeSessionRegistry { await this.#persistModeExit(session, scope, records); } catch (error) { if (error instanceof SessionPersistenceIndeterminateError) { - for (const record of records) { - await this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false); - } + const teardownDeadline = Date.now() + this.#teardownGraceMs; + await Promise.all( + records.map(record => + this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false, teardownDeadline), + ), + ); } throw error; } - for (const record of records) { - await this.#killRecord(record, session.asyncJobManager, session, "mode-exit"); - } + const teardownDeadline = Date.now() + this.#teardownGraceMs; + await Promise.all( + records.map(record => + this.#killRecord(record, session.asyncJobManager, session, "mode-exit", true, teardownDeadline), + ), + ); return records.length; } @@ -1162,6 +1267,7 @@ export class VibeSessionRegistry { session: VibeParentSession, reason: VibeTombstoneReason, persistTerminal = true, + teardownDeadline?: number, ): Promise { const registered = this.#registeredAgent(record); const settlingJobs = new Set(); @@ -1198,26 +1304,29 @@ export class VibeSessionRegistry { record.state = "dead"; record.lastActivityAt = Date.now(); record.lastActivity = "killed"; - if (registered) { - try { - await AgentLifecycleManager.global().release(record.id, registered); - } catch (error) { - logger.warn("vibe: failed to release worker session", { - id: record.id, - error: error instanceof Error ? error.message : String(error), - }); - } + const deadline = teardownDeadline ?? Date.now() + this.#teardownGraceMs; + const releaseTask = registered ? this.#trackAgentRelease(record.id, registered, "release") : undefined; + const jobCleanup = [...settlingJobs].map(job => ({ job, task: this.#trackJobSettlement(record, job) })); + await waitForVibeTeardown( + [releaseTask, ...jobCleanup.map(entry => entry.task)].filter(task => task !== undefined), + deadline, + ); + if (registered && releaseTask) this.#finishAgentRelease(record.id, registered, releaseTask, "release"); + const pendingJobs = jobCleanup.filter(entry => entry.task.status() === "pending"); + for (const { job } of pendingJobs) { + logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", { + id: record.id, + jobId: job.id, + }); } - await awaitCancelledTurnJobs(settlingJobs); const terminalRef = registered ?? this.#registeredAgent(record) ?? null; - if (record.childSessionFile) { - try { - const persisted = await SessionManager.peekSessionInit(record.childSessionFile); - if (persisted?.init) - await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, terminalRef); - } catch { - // A turn killed before session initialization has no readable history to retain. - } + await this.#markTerminalRecord(record, terminalRef, deadline); + if (pendingJobs.length > 0) { + this.#continueKilledCleanup( + record, + pendingJobs.map(entry => entry.task), + registered, + ); } if (persistenceError) { let finalPersistenceError = persistenceError; @@ -1246,6 +1355,40 @@ export class VibeSessionRegistry { return { id: record.id, cancelledTurn }; } + async #markTerminalRecord( + record: VibeRecord, + expected: AgentRef | null | undefined, + teardownDeadline: number, + ): Promise { + if (!record.childSessionFile) return; + try { + const persisted = await SessionManager.peekSessionInit(record.childSessionFile); + if (persisted?.init) { + await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, expected, teardownDeadline); + } + } catch (error) { + logger.warn("vibe: failed to retain terminal worker transcript", { + id: record.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + #continueKilledCleanup( + record: VibeRecord, + jobTasks: readonly TrackedVibeTeardown[], + expected: AgentRef | undefined, + ): void { + void Promise.allSettled(jobTasks.map(task => task.promise)) + .then(() => this.#markTerminalRecord(record, expected, Date.now() + this.#teardownGraceMs)) + .catch(error => { + logger.warn("vibe: failed to finish killed worker cleanup", { + id: record.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + /** Build the ExecutorOptions for a first spawn, mirroring the `task`/eval-bridge plumbing. */ async #buildSpawnOptions( session: ToolSession, From eb56330c9cedc726ce5449ef5c0f291671687e7f Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:32:48 +0200 Subject: [PATCH 95/97] chore(changelog): normalized unreleased entries after merges --- packages/ai/CHANGELOG.md | 2 +- .../test/openai-codex-responses-lite.test.ts | 18 ++++++++---------- packages/coding-agent/CHANGELOG.md | 7 +------ .../coding-agent/src/session/agent-session.ts | 3 +-- packages/coding-agent/src/vibe/runtime.ts | 1 - packages/natives/CHANGELOG.md | 7 ++++--- 6 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4a89ed9d7..04a42d13b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ - Made model-scoped usage health ignore Codex accounts that cannot use the requested plan-gated model while retaining conservative unknown-state handling and independent usage-window resets. - Fixed OpenAI Codex usage telemetry blocking explicitly allowed ChatGPT Team credentials when a weekly `used_percent` rounded to 100, which could route multi-account sessions to an actually exhausted sibling instead ([#7617](https://github.com/can1357/oh-my-pi/issues/7617)). - Fixed OpenAI Codex GPT-5.x requests sending optional `reasoning.summary`, `reasoning.context`, and `text.verbosity` controls by default, reducing Codex `server_error` disconnects from unsupported request shapes. ([#4949](https://github.com/can1357/oh-my-pi/issues/4949)) +- Classified concurrent-request caps separately from quota exhaustion so they use a short retry backoff without burning a credential, and rotate credentials for account-scoped 403 caps such as Devin's overall message limit. ## [17.2.7] - 2026-08-03 @@ -150,7 +151,6 @@ - Fixed the Pi exec frames displaying a different operation than the one they run. The provider synthesized its transcript block from a second, hand-rolled translation of the frame args, so `pi_read`'s `offset`/`limit` were shown as a whole-file read, `pi_grep`'s `literal` pattern as an unescaped regex, and `pi_find`'s path/glob join differed from the executed one. Both sides now share a single translation. - Fixed the streamed `pi_*_tool_call` announcements that modern builds send alongside each exec frame being unrecognized. The exec channel already synthesizes those blocks when it runs the tool; the duplicate was avoided only because the decoder recognized none of the variants, which would have started double-rendering as soon as any one was added. - Fixed `pi_bash` results reaching Cursor clipped with no truncation notice. Two truncation records exist locally: `read`/`grep` set `details.truncation`, which carries an explicit `truncated` flag, while `bash` sets `details.meta.truncation`, whose record has no such flag — its presence is the signal. `piTruncation` read only the first shape and required the flag, so every real Bash truncation was dropped and the server was told the clipped output was complete. Both shapes now translate, and an explicit `truncated: false` still suppresses the field. -- Classified concurrent-request caps separately from quota exhaustion so they use a short retry backoff without burning a credential, and rotate credentials for account-scoped 403 caps such as Devin's overall message limit. ## [17.1.8] - 2026-07-28 diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index 1db6daf4b..222bd511b 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -765,11 +765,10 @@ describe("openai-codex concurrent reasoning summaries", () => { it("sends stream_options only when a summary is requested and supported", async () => { const terra = createCodexModel("gpt-5.6-terra"); - const withSummary = await transformRequestBody( - { model: terra.id }, - terra, - { reasoningEffort: "medium", reasoningSummary: "detailed" }, - ); + const withSummary = await transformRequestBody({ model: terra.id }, terra, { + reasoningEffort: "medium", + reasoningSummary: "detailed", + }); expect(withSummary.stream_options).toEqual({ reasoning_summary_delivery: "sequential_cutoff" }); expect(withSummary.reasoning?.summary).toBe("detailed"); @@ -783,11 +782,10 @@ describe("openai-codex concurrent reasoning summaries", () => { expect(noReasoning.stream_options).toBeUndefined(); const legacy = createCodexModel("gpt-5.1-codex"); - const unsupported = await transformRequestBody( - { model: legacy.id }, - legacy, - { reasoningEffort: "medium", reasoningSummary: "detailed" }, - ); + const unsupported = await transformRequestBody({ model: legacy.id }, legacy, { + reasoningEffort: "medium", + reasoningSummary: "detailed", + }); expect(unsupported.stream_options).toBeUndefined(); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 000560995..8e2c07b03 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,10 +40,8 @@ - Fixed the Windows bash tool silently taking down the whole omp process when a command blocked until its timeout: cancelling a timed-out run walked the spawned child's descendant tree from raw `th32ParentProcessID` links, and a recycled pid matching the harness's stale recorded parent pid could enumerate omp as a false descendant and `TerminateProcess` it, killing the session with no `session_exit` record. Run-cancellation sweeps now refuse to signal the harness or any process collected beneath it, while still reaping the timed-out target when it owns a recycled ancestor pid ([#7452](https://github.com/can1357/oh-my-pi/issues/7452)). - Fixed the unexpected-stop guard (`features.unexpectedStopDetection`) never firing for thinking-only stops: `isUnexpectedStopCandidate` only counted non-whitespace `text` blocks, so a `stopReason: "stop"` turn whose sole content was a signed `thinking` block (a trapped response or a truncated reasoning fragment from reasoning models) bypassed classification and silently ended the turn mid-task. Such stops are now candidates and are classified on their thinking text ([#7499](https://github.com/can1357/oh-my-pi/issues/7499)). - Fixed Task cancellation hanging forever when a child ignored abort or stalled during cleanup ([#7483](https://github.com/can1357/oh-my-pi/issues/7483)). - -### Fixed - - Fixed LSP diagnostics being dropped when servers normalize file URI percent-encoding or Windows path casing. +- Fixed WSL sessions missing Agent Skills stored in the Windows host profile's `.agents/skills` directory. ([#3779](https://github.com/can1357/oh-my-pi/issues/3779)) ## [17.2.8] - 2026-08-04 @@ -2059,9 +2057,6 @@ - Fixed the DuckDuckGo web_search provider returning empty results for non-encyclopedic queries by switching from the Instant Answer API to parsing the HTML frontend, and added clear error handling for bot-challenge throttling. - Fixed Windows --extension paths with spaces or \\?\ prefixes being truncated or incorrectly passed to Bun import/spawn APIs. - Fixed /mcp reauth compatibility with Cloudflare by aligning OAuth prompt behavior with the reference MCP SDK and updating the client label to oh-my-pi. -### Fixed - -- Fixed WSL sessions missing Agent Skills stored in the Windows host profile's `.agents/skills` directory. ([#3779](https://github.com/can1357/oh-my-pi/issues/3779)) ## [16.2.5] - 2026-06-28 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5cfd1169f..7573608a9 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2698,8 +2698,7 @@ export class AgentSession { // a short backoff without touching the credential pool. if (msg.stopReason === "error" && msg.provider === "github-copilot") { const errorId = AIError.classifyMessage(msg); - const isConcurrencyCap = - AIError.parseRateLimitReason(msg.errorMessage ?? "") === "CONCURRENT_LIMIT"; + const isConcurrencyCap = AIError.parseRateLimitReason(msg.errorMessage ?? "") === "CONCURRENT_LIMIT"; if ( AIError.is(errorId, AIError.Flag.AuthFailed) && !AIError.is(errorId, AIError.Flag.UsageLimit) && diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index 745a3f63c..03b119f47 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -394,7 +394,6 @@ function mergeTrace(turn: VibeTurn, progress: AgentProgress): void { /** Thrown from a turn job body so the job manager marks the job failed while carrying the formatted result. */ export class VibeTurnError extends Error {} - /** * Process-global registry of vibe worker sessions, scoped by both owner agent * id and stable parent session id. Persisted lifecycle events rebuild idle diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index f7eef15f7..c9966b01f 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -6,6 +6,10 @@ - Bounded fuzzy-find scored-match retention to the top-K results (worst-first heap) instead of collecting and fully sorting every hit; ranking and totals are unchanged ([#7415](https://github.com/can1357/oh-my-pi/issues/7415)). +### Fixed + +- Fixed newer OMP versions deleting a freshly created older native addon cache directory during concurrent startup, which could interrupt extraction with `ENOENT`. + ## [17.2.7] - 2026-08-03 ### Added @@ -35,9 +39,6 @@ - Fixed accessibility snapshots incorrectly marking a window root as focused based on its app-local AXFocused attribute when another application held global focus; the root annotation now correctly reflects the global window-roster focus flag. - Improved coordinate-frame error messages for pointer input before capture, out-of-frame coordinates, and between-display points to clearly explain the capture-frame contract and remedy instead of throwing a generic bounds check. - Fixed duplicated characters in AppKit targets on macOS caused by background keyboard events being posted through both CoreGraphics and SkyLight; events are now delivered once via the authenticated SkyLight route. -### Fixed - -- Fixed newer OMP versions deleting a freshly created older native addon cache directory during concurrent startup, which could interrupt extraction with `ENOENT`. ## [17.2.2] - 2026-07-31 From e9c5bc91302ebe97e58531559fb4e9206bbbc911 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 01:03:40 +0200 Subject: [PATCH 96/97] fix(browser): preserve Chromium detection precedence (cherry picked from commit 028c4d3588879306ab208a1e0b5c913a1004651f) --- packages/coding-agent/CHANGELOG.md | 3 ++ .../coding-agent/src/tools/browser/launch.ts | 51 ++++++++++--------- .../test/tools/browser-launch.test.ts | 36 +++++++------ 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8e2c07b03..e43ea1f0f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -64,6 +64,9 @@ - Fixed install.sh falsely reporting success on musl-based systems (such as Alpine Linux) when the binary fails to start; the installer now smoke-tests the binary, exits non-zero on failure, and provides remediation steps. - Fixed Codex config.toml discovery incorrectly importing MCP servers that are configured with enabled = false. - Fixed bash.patterns allow rules rejecting valid commands when quoted arguments contained shell metacharacters (such as Cargo benchmark regex filters). +### Added + +- Added automatic detection of common Ungoogled Chromium Linux installations for the browser tool. ## [17.2.6] - 2026-08-03 diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index a3e7c1f5b..76cccf66b 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -202,10 +202,13 @@ function isExecutableFile(p: string): boolean { /** Flatpak application id published by the Ungoogled Chromium project. */ const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; -function systemChromiumCandidates(): string[] { - const home = os.homedir(); +function systemChromiumCandidates( + platform: NodeJS.Platform = process.platform, + home = os.homedir(), + which: (name: string) => string | undefined = $which, +): string[] { const candidates: string[] = []; - switch (process.platform) { + switch (platform) { case "darwin": { for (const root of ["/Applications", path.join(home, "Applications")]) { candidates.push( @@ -220,17 +223,9 @@ function systemChromiumCandidates(): string[] { break; } case "linux": { - const names = [ - "google-chrome-stable", - "google-chrome", - "chromium", - "chromium-browser", - "chrome", - "ungoogled-chromium", - "ungoogled-chromium-browser", - ]; + const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]; for (const name of names) { - const found = $which(name); + const found = which(name); if (found) candidates.push(found); } candidates.push( @@ -241,13 +236,6 @@ function systemChromiumCandidates(): string[] { "/snap/bin/chromium", "/var/lib/flatpak/exports/bin/com.google.Chrome", "/var/lib/flatpak/exports/bin/org.chromium.Chromium", - // Ungoogled Chromium. Distro and AUR packages that keep the plain - // `chromium` name are already covered above; these are the paths - // unique to it, including the system and per-user Flatpak shims. - "/usr/bin/ungoogled-chromium", - "/usr/bin/ungoogled-chromium-browser", - `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, - path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID), ); let onNixos = false; try { @@ -256,6 +244,19 @@ function systemChromiumCandidates(): string[] { if (onNixos) { candidates.push(path.join(home, ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium"); } + for (const name of ["ungoogled-chromium", "ungoogled-chromium-browser"]) { + const found = which(name); + if (found) candidates.push(found); + } + candidates.push( + // Ungoogled Chromium. Distro and AUR packages that keep the plain + // `chromium` name are already covered above; these are the paths + // unique to it, including the system and per-user Flatpak shims. + "/usr/bin/ungoogled-chromium", + "/usr/bin/ungoogled-chromium-browser", + `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, + path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID), + ); break; } case "win32": { @@ -884,9 +885,13 @@ export async function applyStealthPatches( await injectStealthScripts(page); } -/** Exposes the resolved executable candidate list for detection tests. */ -export function systemChromiumCandidatesForTest(): string[] { - return systemChromiumCandidates(); +/** Exposes executable candidates for detection tests. */ +export function systemChromiumCandidatesForTest( + platform: NodeJS.Platform = process.platform, + home?: string, + which?: (name: string) => string | undefined, +): string[] { + return systemChromiumCandidates(platform, home, which); } export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] { diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index f7e0948e4..c545fed31 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -45,24 +45,23 @@ describe("browser launch stealth defaults", () => { const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; -describe("system Chromium candidates on Linux", () => { - it("offers Ungoogled Chromium executables", () => { - if (process.platform !== "linux") return; - const candidates = systemChromiumCandidatesForTest(); +describe("system Chromium candidates", () => { + const linuxCandidates = (which: (name: string) => string | undefined = () => undefined) => + systemChromiumCandidatesForTest("linux", "/home/test", which); + + it("offers Ungoogled Chromium executables on Linux", () => { + const candidates = linuxCandidates(); expect(candidates).toContain("/usr/bin/ungoogled-chromium"); expect(candidates).toContain("/usr/bin/ungoogled-chromium-browser"); expect(candidates).toContain(`/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); - - const perUserFlatpak = candidates.find(candidate => - candidate.endsWith(`/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`), + expect(candidates).toContain( + `/home/test/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, ); - expect(perUserFlatpak).toBeDefined(); }); - it("keeps the previously supported executables", () => { - if (process.platform !== "linux") return; - const candidates = systemChromiumCandidatesForTest(); + it("keeps the previously supported Linux executables", () => { + const candidates = linuxCandidates(); for (const executablePath of [ "/usr/bin/google-chrome-stable", @@ -77,10 +76,10 @@ describe("system Chromium candidates on Linux", () => { } }); - it("ranks Ungoogled Chromium below the stock builds", () => { - if (process.platform !== "linux") return; - const candidates = systemChromiumCandidatesForTest(); - const ungoogled = candidates.indexOf("/usr/bin/ungoogled-chromium"); + it("ranks PATH-resolved Ungoogled Chromium below stock builds", () => { + const ungoogledPath = "/custom/bin/ungoogled-chromium"; + const candidates = linuxCandidates(name => (name === "ungoogled-chromium" ? ungoogledPath : undefined)); + const ungoogled = candidates.indexOf(ungoogledPath); for (const executablePath of [ "/usr/bin/google-chrome-stable", @@ -91,6 +90,13 @@ describe("system Chromium candidates on Linux", () => { expect(ungoogled).toBeGreaterThan(candidates.indexOf(executablePath)); } }); + + it("does not add Ungoogled Chromium candidates on macOS or Windows", () => { + for (const platform of ["darwin", "win32"] as const) { + const candidates = systemChromiumCandidatesForTest(platform, "/home/test", () => "/custom/ungoogled"); + expect(candidates.some(candidate => candidate.toLowerCase().includes("ungoogled"))).toBeFalse(); + } + }); }); describe("browser executable selection", () => { From b361bbde20b45493cfb9a6fbb2c27c9f4e0341b3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:40:47 +0200 Subject: [PATCH 97/97] chore(changelog): normalized unreleased entries --- packages/coding-agent/CHANGELOG.md | 7 +- .../test/agent-session-handoff.test.ts | 24 - .../test/tools/browser-launch.test.ts | 5 +- .../test/vibe/vibe-runtime.test.ts | 2216 ----------------- 4 files changed, 5 insertions(+), 2247 deletions(-) delete mode 100644 packages/coding-agent/test/vibe/vibe-runtime.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e43ea1f0f..7faf470d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Renamed `compareVersions` to `compareChangelogEntries` in `@oh-my-pi/pi-coding-agent/utils/changelog`. The function signature and behavior are unchanged; update imports to use the new name. +### Added + +- Added automatic detection of common Ungoogled Chromium Linux installations for the browser tool. + ### Changed - Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). @@ -64,9 +68,6 @@ - Fixed install.sh falsely reporting success on musl-based systems (such as Alpine Linux) when the binary fails to start; the installer now smoke-tests the binary, exits non-zero on failure, and provides remediation steps. - Fixed Codex config.toml discovery incorrectly importing MCP servers that are configured with enabled = false. - Fixed bash.patterns allow rules rejecting valid commands when quoted arguments contained shell metacharacters (such as Cargo benchmark regex filters). -### Added - -- Added automatic detection of common Ungoogled Chromium Linux installations for the browser tool. ## [17.2.6] - 2026-08-03 diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 5bb139086..c630dc93d 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -850,30 +850,6 @@ describe("AgentSession handoff", () => { expect(promptSpy).toHaveBeenCalledTimes(1); }); - it("does not switch providers after provider-native auto-compaction fails", async () => { - session.settings.set("compaction.strategy", "context-full"); - session.settings.set("compaction.thresholdTokens", 50); - session.settings.set("compaction.keepRecentTokens", 1); - session.settings.set("contextPromotion.enabled", false); - - const attemptedCandidates: string[] = []; - vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, candidate) => { - attemptedCandidates.push(`${candidate.provider}/${candidate.id}`); - throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); - }); - - await session.prompt("pending prompt ".repeat(120)); - await waitFor(() => - events.some( - event => - event.type === "auto_compaction_end" && - event.errorMessage?.includes("native compaction transport failed") === true, - ), - ); - - expect(attemptedCandidates.length).toBeGreaterThan(0); - expect(new Set(attemptedCandidates.map(candidate => candidate.split("/", 1)[0]))).toHaveLength(1); - }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); diff --git a/packages/coding-agent/test/tools/browser-launch.test.ts b/packages/coding-agent/test/tools/browser-launch.test.ts index c545fed31..0026a43fc 100644 --- a/packages/coding-agent/test/tools/browser-launch.test.ts +++ b/packages/coding-agent/test/tools/browser-launch.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; - import * as path from "node:path"; import { stealthIgnoreDefaultArgsForTest, @@ -55,9 +54,7 @@ describe("system Chromium candidates", () => { expect(candidates).toContain("/usr/bin/ungoogled-chromium"); expect(candidates).toContain("/usr/bin/ungoogled-chromium-browser"); expect(candidates).toContain(`/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); - expect(candidates).toContain( - `/home/test/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, - ); + expect(candidates).toContain(`/home/test/.local/share/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`); }); it("keeps the previously supported Linux executables", () => { diff --git a/packages/coding-agent/test/vibe/vibe-runtime.test.ts b/packages/coding-agent/test/vibe/vibe-runtime.test.ts deleted file mode 100644 index c285d5582..000000000 --- a/packages/coding-agent/test/vibe/vibe-runtime.test.ts +++ /dev/null @@ -1,2216 +0,0 @@ -/** - * Contracts: vibe worker-session registry lifecycle. - * - * 1. `spawn` returns immediately (session id + turn job id) while the turn - * runs in the background; the settled turn self-delivers a result carrying - * the activity trace AND the worker's response, and the session stays - * addressable (idle) afterwards. - * 2. `send` routes by state: steering into a streaming mid-turn worker, - * queueing when the worker is mid-turn but not steerable (drained into the - * next turn automatically), and starting a follow-up turn on the SAME - * worker id when idle. - * 3. `runSubagentFollowUpTurn` continues a live session in place: consecutive - * turns hit the same AgentSession instance (context retained) and the - * finalized result carries the yield payload + tool trace. - * 4. `wait` wakes on the FIRST settling turn among concurrent sessions and - * acknowledges its delivery so the result is not delivered twice. - * 5. `kill` cancels the in-flight turn job and releases the worker session. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; -import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; -import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { - SessionManager, - SessionPersistenceIndeterminateError, -} from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { - FileSessionStorage, - type SessionStorage, - type SessionStorageWriter, - type WriteTextAtomicOptions, -} from "@oh-my-pi/pi-coding-agent/session/session-storage"; -import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; -import type { AgentProgress, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; - -const PERSISTED_WORKER_SYSTEM_PROMPT = "Persisted vibe worker"; -const PERSISTED_WORKER_TOOLS = ["read", "yield"]; -const INITIAL_VIBE_TASK = "Complete the first persisted turn."; -const FOLLOW_UP_VIBE_TASK = "Continue from the persisted conversation."; -const RESTORED_VIBE_RESPONSE = "Continued from persisted context."; - -async function fileExists(file: string): Promise { - try { - return (await fs.stat(file)).isFile(); - } catch { - return false; - } -} - -type AtomicWriteHook = (commit: () => Promise) => Promise; - -class FaultInjectingSessionStorage extends FileSessionStorage { - readonly atomicWriteHooks: AtomicWriteHook[] = []; - atomicWriteAttempts = 0; - failedWriterClosed = false; - #appendFault: { error: Error; prefixBytes: number } | undefined; - - failNextAppendWithPrefix(error: Error, prefixBytes: number): void { - this.#appendFault = { error, prefixBytes }; - } - - override async writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise { - this.atomicWriteAttempts++; - const hook = this.atomicWriteHooks.shift(); - const commit = () => super.writeTextAtomic(filePath, content, options); - if (hook) await hook(commit); - else await commit(); - } - - override openWriter( - filePath: string, - options?: { flags?: "a" | "w"; onError?: (error: Error) => void }, - ): SessionStorageWriter { - const inner = super.openWriter(filePath, options); - let injectedError: Error | undefined; - let pendingFault: Promise = Promise.resolve(); - let faulted = false; - return { - append: line => { - const fault = this.#appendFault; - if (!fault) return inner.append(line); - this.#appendFault = undefined; - faulted = true; - const prefix = Buffer.from(line).subarray(0, fault.prefixBytes); - const operation = fs.appendFile(filePath, prefix).then(() => { - injectedError = fault.error; - options?.onError?.(fault.error); - throw fault.error; - }); - pendingFault = operation.catch(() => {}); - return operation; - }, - flush: async () => { - await pendingFault; - if (injectedError) throw injectedError; - await inner.flush(); - }, - isOpen: () => inner.isOpen(), - close: async () => { - await pendingFault; - await inner.close(); - if (faulted) this.failedWriterClosed = true; - }, - getError: () => injectedError ?? inner.getError(), - }; - } -} - -class SwitchGatedSessionStorage extends FaultInjectingSessionStorage { - #readGate: - | { - filePath: string; - started: ReturnType>; - release: ReturnType>; - } - | undefined; - - gateNextRead(filePath: string): { started: Promise; release: () => void } { - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - this.#readGate = { filePath, started, release }; - return { started: started.promise, release: release.resolve }; - } - - override async readTextSlices( - filePath: string, - prefixBytes: number, - suffixBytes: number, - ): Promise<[string, string]> { - const gate = this.#readGate; - if (gate?.filePath === filePath) { - this.#readGate = undefined; - gate.started.resolve(); - await gate.release.promise; - } - return super.readTextSlices(filePath, prefixBytes, suffixBytes); - } -} - -interface TestSessionOptions { - manager?: AsyncJobManager; - sessionManager?: SessionManager; - ownerId?: string; - parentSessionId?: string; -} - -function createSession(options: TestSessionOptions = {}): ToolSession { - const sessionManager = options.sessionManager; - return { - cwd: sessionManager?.getCwd() ?? "/tmp", - hasUI: false, - settings: Settings.isolated({}), - getSessionFile: () => sessionManager?.getSessionFile() ?? null, - getSessionId: () => options.parentSessionId ?? sessionManager?.getSessionId() ?? "vibe-test-parent", - getAgentId: () => options.ownerId ?? "Main", - getArtifactsDir: () => sessionManager?.getArtifactsDir() ?? null, - getSessionSpawns: () => "*", - sessionManager, - asyncJobManager: options.manager, - }; -} - -interface PersistWorkerOptions { - cwd: string; - artifactsDir: string; - id: string; - task: string; -} - -async function persistWorkerSession(options: PersistWorkerOptions): Promise { - const childSessionFile = path.join(options.artifactsDir, `${options.id}.jsonl`); - const childManager = SessionManager.create(options.cwd, options.artifactsDir); - await childManager.setSessionFile(childSessionFile); - childManager.appendSessionInit({ - systemPrompt: PERSISTED_WORKER_SYSTEM_PROMPT, - task: options.task, - tools: [...PERSISTED_WORKER_TOOLS], - spawns: "", - }); - await childManager.flush(); - await childManager.close(); - return childSessionFile; -} - -function makeResult(id: string, overrides: Partial = {}): SingleResult { - return { - index: 0, - id, - agent: "task", - agentSource: "bundled", - task: "prompt", - exitCode: 0, - output: "All done.", - stderr: "", - truncated: false, - durationMs: 5, - tokens: 0, - requests: 1, - ...overrides, - }; -} - -interface Deferred { - promise: Promise; - resolve: () => void; -} - -function deferred(): Deferred { - const { promise, resolve } = Promise.withResolvers(); - return { promise, resolve }; -} - -async function flushMicrotasks(): Promise { - for (let index = 0; index < 5; index++) await Promise.resolve(); -} - -async function pollUntil(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now(); - while (!predicate()) { - if (Date.now() - start > timeoutMs) throw new Error("pollUntil timed out"); - await Bun.sleep(5); - } -} - -/** - * Minimal stand-in for a worker AgentSession: records prompts/steers, replays - * a scripted event stream through subscribed listeners on each prompt, and - * reports a final assistant message — enough surface for the executor's run - * monitor + driveSessionToYield. - */ -function createFakeWorkerSession(options: { streaming?: boolean; onDispose?: () => void | Promise } = {}) { - const listeners = new Set<(event: unknown) => void>(); - const prompts: string[] = []; - const steers: string[] = []; - let disposed = false; - let lastAssistant: { stopReason: string; content: Array<{ type: string; text: string }> } | undefined; - let script: { events: unknown[]; responseText: string } | undefined; - const fake = { - isStreaming: options.streaming ?? false, - model: undefined, - subscribe(listener: (event: unknown) => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async prompt(text: string): Promise { - prompts.push(text); - const active = script; - script = undefined; - if (active) { - for (const event of active.events) { - for (const listener of [...listeners]) listener(event); - } - lastAssistant = { stopReason: "stop", content: [{ type: "text", text: active.responseText }] }; - const end = { type: "message_end", message: { role: "assistant", content: lastAssistant.content } }; - for (const listener of [...listeners]) listener(end); - } - return true; - }, - async steer(text: string): Promise { - steers.push(text); - }, - async waitForIdle(): Promise {}, - getLastAssistantMessage() { - return lastAssistant; - }, - async abort(): Promise {}, - async dispose(): Promise { - disposed = true; - await options.onDispose?.(); - }, - }; - return { - session: fake as unknown as AgentSession, - prompts, - steers, - isDisposed: () => disposed, - setStreaming(value: boolean) { - fake.isStreaming = value; - }, - setScript(next: { events: unknown[]; responseText: string }) { - script = next; - }, - }; -} - -/** Scripted turn: one `read` tool call, then a successful `yield` carrying `data`. */ -function yieldTurnEvents(data: unknown): unknown[] { - return [ - { type: "tool_execution_start", toolName: "read", args: { path: "src/foo.ts" }, intent: "Reading foo" }, - { type: "tool_execution_end", toolName: "read", result: {}, isError: false }, - { type: "tool_execution_start", toolName: "yield", args: {} }, - { - type: "tool_execution_end", - toolName: "yield", - result: { details: { status: "success", data } }, - isError: false, - }, - ]; -} - -/** Progress snapshot in the shape the executor's run monitor emits. */ -function progressSnapshot(id: string, overrides: Partial = {}): AgentProgress { - return { - index: 0, - id, - agent: "task", - agentSource: "bundled", - status: "running", - task: "prompt", - recentTools: [], - recentOutput: [], - toolCount: 0, - requests: 0, - tokens: 0, - cost: 0, - durationMs: 0, - ...overrides, - }; -} - -describe("vibe session registry", () => { - const managers: AsyncJobManager[] = []; - const persistedManagers: SessionManager[] = []; - const tempRoots: string[] = []; - - async function createPersistedParent(storage?: SessionStorage): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vibe-resume-")); - tempRoots.push(root); - const cwd = path.join(root, "workspace"); - await fs.mkdir(cwd, { recursive: true }); - const manager = SessionManager.create(cwd, path.join(root, "sessions"), storage); - persistedManagers.push(manager); - return manager; - } - - function createManager(): AsyncJobManager { - const manager = new AsyncJobManager({ onJobComplete: () => {} }); - managers.push(manager); - return manager; - } - - function installPersistedSpawnMock(): void { - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const parentSessionFile = options.sessionFile; - if (!parentSessionFile) throw new Error("Persisted vibe test requires a parent session file"); - const snapshot = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - try { - const actions = snapshot.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return data.id === options.id && typeof data.action === "string" ? [data.action] : []; - }); - expect(actions).toEqual(["spawn", "turn-started"]); - } finally { - await snapshot.close(); - } - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - AgentRegistry.global().setStatus(options.id, "idle"); - AgentLifecycleManager.global().adopt(options.id, { - idleTtlMs: 0, - revive: async () => worker.session, - }); - return makeResult(options.id, { output: "Persisted first turn." }); - }); - } - - function installPersistedReviver(capture: { sessionFile?: string; prompts?: string[] }): void { - AgentLifecycleManager.global().setPersistedSubagentReviverFactory(async ref => { - if (!ref.sessionFile) return undefined; - const persisted = await SessionManager.peekSessionInit(ref.sessionFile); - if (!persisted?.init) return undefined; - const worker = createFakeWorkerSession(); - worker.prompts.push(persisted.init.task); - worker.setScript({ - events: yieldTurnEvents({ report: RESTORED_VIBE_RESPONSE }), - responseText: RESTORED_VIBE_RESPONSE, - }); - capture.sessionFile = ref.sessionFile; - capture.prompts = worker.prompts; - return async () => worker.session; - }, 0); - } - - async function simulateProcessBoundary(): Promise { - await AgentLifecycleManager.global().dispose(); - VibeSessionRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - AgentRegistry.resetGlobalForTests(); - } - - beforeEach(() => { - AgentRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - VibeSessionRegistry.resetGlobalForTests(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - for (const manager of managers.splice(0)) { - await manager.dispose({ timeoutMs: 1000 }); - } - for (const manager of persistedManagers.splice(0)) { - await manager.close(); - } - for (const root of tempRoots.splice(0)) { - await fs.rm(root, { recursive: true, force: true }); - } - VibeSessionRegistry.resetGlobalForTests(); - AgentLifecycleManager.resetGlobalForTests(); - AgentRegistry.resetGlobalForTests(); - }); - - it("spawn returns immediately and self-delivers a turn result with activity trace + response", async () => { - const gate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - options.onProgress?.( - progressSnapshot(options.id, { - toolCount: 2, - recentTools: [ - { tool: "bash", args: "bun test", endMs: 2 }, - { tool: "read", args: "src/foo.ts", endMs: 1 }, - ], - lastIntent: "Running tests", - resolvedModel: "prov/fast-model", - }), - ); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "Implemented the widget.", requests: 3 }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - - const { id, jobId } = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Build the widget." }); - expect(id).toBe("Fast"); - - // Ack is immediate: the job is still running behind the gate. - const job = manager.getJob(jobId)!; - expect(job.status).toBe("running"); - expect(registry.screens(session)[0]?.cli).toBe("fast"); - - gate.resolve(); - await job.promise; - - expect(job.status).toBe("completed"); - const text = job.resultText ?? ""; - // Envelope + summarized activity (compressed tool trace, oldest first) + response. - expect(text).toContain(' { - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const originalFlush = parentManager.flush.bind(parentManager); - const flush = vi - .spyOn(parentManager, "flush") - .mockImplementationOnce(originalFlush) - .mockRejectedValueOnce(new Error("tombstone write failed")); - const register = vi.spyOn(jobs, "register").mockImplementation(() => { - throw new Error("job registry failed"); - }); - - await expect( - VibeSessionRegistry.global().spawn(session, { - cli: "fast", - name: "failed-spawn", - prompt: INITIAL_VIBE_TASK, - }), - ).rejects.toThrow("tombstone write failed"); - - expect(VibeSessionRegistry.global().screens(session)[0]).toMatchObject({ - id: "failed-spawn", - state: "dead", - lastActivity: "spawn failed", - }); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - flush.mockRestore(); - register.mockRestore(); - expect(await VibeSessionRegistry.global().killAll(session)).toBe(1); - expect(VibeSessionRegistry.global().listIds(session)).toEqual([]); - expect(parentManager.buildSessionContext().mode).toBe("none"); - }); - - it("send steers a streaming mid-turn worker and queues for a non-steerable one", async () => { - const gate = deferred(); - const fake = createFakeWorkerSession({ streaming: true }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id); - }); - const followUps: Array<{ id: string; message: string }> = []; - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - followUps.push({ id: options.id, message: options.message }); - return makeResult(options.id, { output: "queued work done" }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { cli: "good", name: "Good", prompt: "Design it." }); - await pollUntil(() => AgentRegistry.global().get("Good") !== undefined); - - // Streaming worker → steering. - const steered = await registry.send(session, { session: "Good", message: "Focus on the API first." }); - expect(steered.mode).toBe("steered"); - expect(fake.steers).toEqual(["Focus on the API first."]); - - // Not streaming → queued for the next turn. - fake.setStreaming(false); - const queued = await registry.send(session, { session: "Good", message: "Then write tests." }); - expect(queued.mode).toBe("queued"); - expect(registry.screens(session)[0]?.queued).toBe(1); - - // Settling the turn drains the queue into an automatic follow-up turn. - gate.resolve(); - await manager.getJob(jobId)!.promise; - await pollUntil(() => followUps.length === 1); - expect(followUps[0]).toEqual({ id: "Good", message: "Then write tests." }); - }); - - it("send to an idle session starts a follow-up turn on the same worker", async () => { - const gate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id); - }); - const followUps: Array<{ id: string; message: string }> = []; - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - followUps.push({ id: options.id, message: options.message }); - options.onProgress?.( - progressSnapshot(options.id, { - toolCount: 1, - recentTools: [{ tool: "edit", args: "src/foo.ts", endMs: 1 }], - }), - ); - return makeResult(options.id, { output: "Renamed everything." }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const spawn = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "First task." }); - gate.resolve(); - await manager.getJob(spawn.jobId)!.promise; - - const outcome = await registry.send(session, { session: "Fast", message: "Now rename the helpers." }); - expect(outcome.mode).toBe("turn"); - const turnJob = manager.getJob(outcome.jobId!)!; - await turnJob.promise; - - expect(followUps).toEqual([{ id: "Fast", message: "Now rename the helpers." }]); - const text = turnJob.resultText ?? ""; - expect(text).toContain('turn="2"'); - expect(text).toContain("edit(src/foo.ts)"); - expect(text).toContain("Renamed everything."); - expect(registry.screens(session)[0]?.turns).toBe(2); - }); - - it("rehydrates an idle worker after a process boundary and continues turn two with prior context", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - expect(await fileExists(parentSessionFile)).toBe(false); - const firstManager = createManager(); - const firstSession = createSession({ manager: firstManager, sessionManager: parentManager }); - const firstRegistry = VibeSessionRegistry.global(); - const spawned = await firstRegistry.spawn(firstSession, { - cli: "fast", - name: "push-fixes", - prompt: INITIAL_VIBE_TASK, - }); - expect(await fileExists(parentSessionFile)).toBe(true); - await firstManager.getJob(spawned.jobId)!.promise; - expect(firstRegistry.screens(firstSession)[0]).toMatchObject({ id: "push-fixes", state: "idle", turns: 1 }); - - const wrongOwner = createSession({ manager: firstManager, sessionManager: parentManager, ownerId: "Other" }); - await expect( - firstRegistry.send(wrongOwner, { session: "push-fixes", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "push-fixes"'); - const wrongParent = createSession({ - manager: firstManager, - sessionManager: parentManager, - parentSessionId: "different-parent", - }); - await expect( - firstRegistry.send(wrongParent, { session: "push-fixes", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "push-fixes"'); - - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: resumedManager }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - - const resumedRegistry = VibeSessionRegistry.global(); - expect(await resumedRegistry.rehydrate(resumedSession)).toBe(1); - expect(AgentRegistry.global().get("push-fixes")?.status).toBe("parked"); - expect(resumedRegistry.screens(resumedSession)[0]).toMatchObject({ - id: "push-fixes", - state: "idle", - turns: 1, - }); - - const outcome = await resumedRegistry.send(resumedSession, { - session: "push-fixes", - message: FOLLOW_UP_VIBE_TASK, - }); - expect(outcome.mode).toBe("turn"); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.sessionFile).toBe(path.join(parentSessionFile.slice(0, -6), "push-fixes.jsonl")); - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - expect(turnJob.resultText).toContain(RESTORED_VIBE_RESPONSE); - }); - - it("suspends an idle persisted worker for same-process disposal and resumes turn two", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(firstSession, { - cli: "fast", - name: "idle-dispose", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(spawned.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(firstSession), firstJobs)).toBe(1); - expect(AgentRegistry.global().get("idle-dispose")).toBeUndefined(); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).action === "tombstone"; - }), - ).toBe(false); - await parentManager.close(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: reopened }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - expect(await registry.rehydrate(resumedSession)).toBe(1); - const outcome = await registry.send(resumedSession, { - session: "idle-dispose", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("suspends a blocked in-flight worker for fresh-process disposal without tombstoning it", async () => { - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Interrupted by disposal.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(firstSession, { - cli: "fast", - name: "running-dispose", - prompt: INITIAL_VIBE_TASK, - }); - await pollUntil(() => AgentRegistry.global().get("running-dispose")?.status === "running"); - expect(await registry.suspendScope(registry.ownerScope(firstSession), firstJobs)).toBe(1); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).action === "tombstone"; - }), - ).toBe(false); - await parentManager.close(); - await simulateProcessBoundary(); - - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: reopened }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - const freshRegistry = VibeSessionRegistry.global(); - expect(await freshRegistry.rehydrate(resumedSession)).toBe(1); - expect(freshRegistry.screens(resumedSession)[0]).toMatchObject({ - id: "running-dispose", - state: "idle", - turns: 1, - lastActivity: "turn 1 interrupted by process restart", - }); - const outcome = await freshRegistry.send(resumedSession, { - session: "running-dispose", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("bounds parent-session suspension when a cancelled turn ignores abort and settles late", async () => { - const gate = deferred(); - const started = deferred(); - const disposed = deferred(); - const fake = createFakeWorkerSession({ onDispose: disposed.resolve }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - started.resolve(); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { - cli: "fast", - name: "IgnoresSuspendAbort", - prompt: "Keep working through a parent-session switch.", - }); - await started.promise; - - vi.useFakeTimers(); - try { - const suspension = registry.suspendScope(registry.ownerScope(session), manager); - await disposed.promise; - await flushMicrotasks(); - expect(vi.getTimerCount()).toBeGreaterThan(0); - vi.advanceTimersByTime(250); - - expect(await suspension).toBe(1); - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresSuspendAbort")).toBeUndefined(); - expect(registry.listIds(session)).toEqual([]); - - gate.resolve(); - await manager.getJob(jobId)!.promise; - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresSuspendAbort")).toBeUndefined(); - expect(registry.listIds(session)).toEqual([]); - } finally { - gate.resolve(); - vi.useRealTimers(); - } - }); - - it("restores an interrupted turn as idle without replay and continues only after send", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const spawned = await VibeSessionRegistry.global().spawn(firstSession, { - cli: "fast", - name: "interrupted", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(spawned.jobId)!.promise; - const turnStarted = parentManager.getEntries().find(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "interrupted" && data.action === "turn-started"; - }); - if (!turnStarted) throw new Error("Expected a persisted turn-started lifecycle event"); - parentManager.branch(turnStarted.id); - parentManager.appendModeChange("vibe"); - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: resumedManager }); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - const registry = VibeSessionRegistry.global(); - expect(await registry.rehydrate(resumedSession)).toBe(1); - expect(registry.screens(resumedSession)[0]).toMatchObject({ - id: "interrupted", - state: "idle", - turns: 1, - lastActivity: "turn 1 interrupted by process restart", - }); - expect(revived).toEqual({}); - - const outcome = await registry.send(resumedSession, { - session: "interrupted", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = resumedJobs.getJob(outcome.jobId!)!; - await turnJob.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - expect(turnJob.resultText).toContain('turn="2"'); - }); - - it("never lets an old lifecycle append cross into a target session during its load window", async () => { - installPersistedSpawnMock(); - const storage = new SwitchGatedSessionStorage(); - const sourceManager = await createPersistedParent(storage); - sourceManager.appendModeChange("vibe"); - const sourceSessionId = sourceManager.getSessionId(); - const sourceSessionFile = sourceManager.getSessionFile(); - if (!sourceSessionFile) throw new Error("Persisted source session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: sourceManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "switch-race", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const targetManager = await createPersistedParent(storage); - targetManager.appendModeChange("goal"); - targetManager.appendCustomEntry("target-only"); - await targetManager.ensureOnDisk(); - const targetSessionFile = targetManager.getSessionFile(); - if (!targetSessionFile) throw new Error("Persisted target session file was not created"); - await targetManager.close(); - - const lifecycleAppendStarted = Promise.withResolvers(); - const releaseLifecycleAppend = Promise.withResolvers(); - const originalEnsureOnDisk = sourceManager.ensureOnDisk.bind(sourceManager); - let gateLifecycleAppend = true; - const ensureOnDisk = vi.spyOn(sourceManager, "ensureOnDisk").mockImplementation(async () => { - if (gateLifecycleAppend) { - gateLifecycleAppend = false; - lifecycleAppendStarted.resolve(); - await releaseLifecycleAppend.promise; - } - await originalEnsureOnDisk(); - }); - const followUp = await registry.send(session, { - session: "switch-race", - message: FOLLOW_UP_VIBE_TASK, - }); - const turnJob = jobs.getJob(followUp.jobId!)!; - await lifecycleAppendStarted.promise; - - const targetRead = storage.gateNextRead(targetSessionFile); - const switching = sourceManager.setSessionFile(targetSessionFile); - await targetRead.started; - releaseLifecycleAppend.resolve(); - await turnJob.promise.catch(() => undefined); - targetRead.release(); - await switching; - ensureOnDisk.mockRestore(); - - expect(turnJob.status).toBe("failed"); - expect(sourceManager.buildSessionContext().mode).toBe("goal"); - expect( - sourceManager - .getBranch() - .map(entry => - entry.type === "mode_change" - ? `mode:${entry.mode}` - : `custom:${entry.type === "custom" ? entry.customType : entry.type}`, - ), - ).toEqual(["mode:goal", "custom:target-only"]); - expect( - sourceManager.getEntries().some(entry => { - if (entry.type !== "custom" || entry.customType !== "vibe-session-lifecycle") return false; - if (typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).parentSessionId === sourceSessionId; - }), - ).toBe(false); - expect(await fs.readFile(targetSessionFile, "utf8")).not.toContain(sourceSessionId); - }); - - it("never sends through or releases a registry ref with the wrong child session file", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "exact-ref", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - await AgentLifecycleManager.global().release("exact-ref"); - const mismatchedFile = path.join(parentSessionFile.slice(0, -6), "another-parent.jsonl"); - AgentRegistry.global().register({ - id: "exact-ref", - displayName: "exact-ref", - kind: "sub", - parentId: "Main", - session: null, - sessionFile: mismatchedFile, - status: "parked", - }); - - await expect(registry.send(session, { session: "exact-ref", message: FOLLOW_UP_VIBE_TASK })).rejects.toThrow( - "no longer resolves to this parent session", - ); - await registry.kill(session, "exact-ref"); - expect(AgentRegistry.global().get("exact-ref")).toMatchObject({ - status: "parked", - sessionFile: mismatchedFile, - }); - }); - - it("suspends a parent without tombstones so the same worker id can move between isolated scopes", async () => { - installPersistedSpawnMock(); - const parentA = await createPersistedParent(); - const parentB = await createPersistedParent(); - parentA.appendModeChange("vibe"); - parentB.appendModeChange("vibe"); - const jobsA = createManager(); - const jobsB = createManager(); - const sessionA = createSession({ manager: jobsA, sessionManager: parentA }); - const sessionB = createSession({ manager: jobsB, sessionManager: parentB }); - const registry = VibeSessionRegistry.global(); - const workerA = await registry.spawn(sessionA, { - cli: "fast", - name: "shared-name", - prompt: INITIAL_VIBE_TASK, - }); - await jobsA.getJob(workerA.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(sessionA), jobsA)).toBe(1); - expect(AgentRegistry.global().get("shared-name")).toBeUndefined(); - - const workerB = await registry.spawn(sessionB, { - cli: "fast", - name: "shared-name", - prompt: INITIAL_VIBE_TASK, - }); - expect(workerB.id).toBe("shared-name"); - await jobsB.getJob(workerB.jobId)!.promise; - expect(await registry.suspendScope(registry.ownerScope(sessionB), jobsB)).toBe(1); - const revived: { sessionFile?: string; prompts?: string[] } = {}; - installPersistedReviver(revived); - expect(await registry.rehydrate(sessionA)).toBe(1); - expect(registry.listIds(sessionA)).toEqual(["shared-name"]); - await expect(registry.send(sessionB, { session: "shared-name", message: FOLLOW_UP_VIBE_TASK })).rejects.toThrow( - 'Unknown vibe session "shared-name"', - ); - - const outcome = await registry.send(sessionA, { - session: "shared-name", - message: FOLLOW_UP_VIBE_TASK, - }); - await jobsA.getJob(outcome.jobId!)!.promise; - expect(revived.prompts).toEqual([INITIAL_VIBE_TASK, FOLLOW_UP_VIBE_TASK]); - }); - - it("does not let late teardown from a suspended parent mutate a new same-id worker", async () => { - const parentA = await createPersistedParent(); - const parentB = await createPersistedParent(); - parentA.appendModeChange("vibe"); - parentB.appendModeChange("vibe"); - const parentAFile = parentA.getSessionFile(); - if (!parentAFile) throw new Error("Persisted parent A session file was not created"); - let workerA: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - const worker = createFakeWorkerSession(); - if (options.sessionFile === parentAFile) workerA = worker; - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - if (options.sessionFile === parentAFile) { - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Parent A suspended.", aborted: true }); - } - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "Parent B finished." }); - }); - const jobsA = createManager(); - const jobsB = createManager(); - const sessionA = createSession({ manager: jobsA, sessionManager: parentA }); - const sessionB = createSession({ manager: jobsB, sessionManager: parentB }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(sessionA, { cli: "fast", name: "reused", prompt: INITIAL_VIBE_TASK }); - await pollUntil(() => workerA !== undefined); - const oldRef = AgentRegistry.global().get("reused"); - if (!oldRef) throw new Error("Expected parent A worker ref"); - expect(await registry.suspendScope(registry.ownerScope(sessionA), jobsA)).toBe(1); - - const second = await registry.spawn(sessionB, { cli: "fast", name: "reused", prompt: INITIAL_VIBE_TASK }); - await jobsB.getJob(second.jobId)!.promise; - const replacement = AgentRegistry.global().get("reused"); - expect(replacement).toMatchObject({ status: "idle" }); - expect(replacement).not.toBe(oldRef); - - await executorModule.finalizeSubagentLifecycle({ - id: "reused", - session: workerA!.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - await AgentLifecycleManager.global().release("reused", oldRef); - expect(AgentRegistry.global().get("reused")).toBe(replacement); - expect(registry.screens(sessionB)[0]).toMatchObject({ id: "reused", state: "idle" }); - }); - - it("rejects escaped child paths and JSONL files without persisted worker init", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const spawned = await VibeSessionRegistry.global().spawn(session, { - cli: "fast", - name: "valid-worker", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const lifecycleEntries = parentManager.getEntries().filter(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - return (entry.data as Record).id === "valid-worker"; - }); - if (lifecycleEntries.length < 3) throw new Error("Expected complete persisted worker lifecycle events"); - const artifactsDir = parentSessionFile.slice(0, -6); - const invalidManager = SessionManager.create(parentManager.getCwd(), artifactsDir); - persistedManagers.push(invalidManager); - await invalidManager.ensureOnDisk(); - const invalidSource = invalidManager.getSessionFile(); - if (!invalidSource) throw new Error("Expected an invalid child fixture path"); - await invalidManager.close(); - await fs.rename(invalidSource, path.join(artifactsDir, "invalid-init.jsonl")); - - for (const { id, childSessionFile } of [ - { id: "escaped", childSessionFile: "../escaped.jsonl" }, - { id: "invalid-init", childSessionFile: "invalid-init.jsonl" }, - ]) { - for (const entry of lifecycleEntries) { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) continue; - const data: Record = { ...(entry.data as Record), id }; - if (data.action === "spawn") data.childSessionFile = childSessionFile; - parentManager.appendCustomEntry(entry.customType, data); - } - } - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - const resumedSession = createSession({ manager: createManager(), sessionManager: resumedManager }); - const registry = VibeSessionRegistry.global(); - expect(await registry.rehydrate(resumedSession)).toBe(1); - expect(registry.listIds(resumedSession)).toEqual(["valid-worker"]); - expect(AgentRegistry.global().get("escaped")).toBeUndefined(); - expect(AgentRegistry.global().get("invalid-init")).toBeUndefined(); - }); - - it("reserves orphan JSONL and lifecycle-known ids before allocating a worker name", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const artifactsDir = parentSessionFile.slice(0, -6); - await fs.mkdir(artifactsDir, { recursive: true }); - await fs.writeFile(path.join(artifactsDir, "orphan.jsonl"), "orphaned transcript"); - const firstJobs = createManager(); - const firstSession = createSession({ manager: firstJobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const orphanCollision = await registry.spawn(firstSession, { - cli: "fast", - name: "orphan", - prompt: INITIAL_VIBE_TASK, - }); - expect(orphanCollision.id).toBe("orphan-2"); - await firstJobs.getJob(orphanCollision.jobId)!.promise; - - const metadataOnly = await registry.spawn(firstSession, { - cli: "fast", - name: "metadata-only", - prompt: INITIAL_VIBE_TASK, - }); - await firstJobs.getJob(metadataOnly.jobId)!.promise; - await registry.kill(firstSession, metadataOnly.id); - await fs.rm(path.join(artifactsDir, "metadata-only.jsonl"), { force: true }); - await simulateProcessBoundary(); - - const resumedJobs = createManager(); - const resumedSession = createSession({ manager: resumedJobs, sessionManager: parentManager }); - const metadataCollision = await VibeSessionRegistry.global().spawn(resumedSession, { - cli: "fast", - name: "metadata-only", - prompt: INITIAL_VIBE_TASK, - }); - expect(metadataCollision.id).toBe("metadata-only-2"); - await resumedJobs.getJob(metadataCollision.jobId)!.promise; - }); - - it("retains a cold candidate blocked by another parent id collision so mode exit can tombstone it", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "collision", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - await registry.suspendScope(registry.ownerScope(session), jobs); - const otherWorker = createFakeWorkerSession(); - const otherSessionFile = path.join(path.dirname(parentSessionFile), "other-parent", "collision.jsonl"); - const otherRef = AgentRegistry.global().register({ - id: "collision", - displayName: "collision", - kind: "sub", - parentId: "Main", - session: otherWorker.session, - sessionFile: otherSessionFile, - status: "idle", - }); - - expect(await registry.rehydrate(session)).toBe(1); - expect(registry.screens(session)[0]?.lastActivity).toBe("blocked by an agent id collision"); - expect(await registry.killAll(session)).toBe(1); - expect(AgentRegistry.global().get("collision")).toBe(otherRef); - expect(otherWorker.isDisposed()).toBe(false); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "collision" && data.action === "tombstone"; - }), - ).toBe(true); - - AgentRegistry.global().unregister("collision", otherRef); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("collision")).toMatchObject({ status: "aborted", session: null }); - }); - - it("rejects a spawn queued behind mode exit and leaves no live or untombstoned worker", async () => { - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - await parentManager.ensureOnDisk(); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const exitWriteStarted = Promise.withResolvers(); - const releaseExitWrite = Promise.withResolvers(); - storage.atomicWriteHooks.push(async commit => { - exitWriteStarted.resolve(); - await releaseExitWrite.promise; - await commit(); - }); - - const exiting = registry.killAll(session); - await exitWriteStarted.promise; - let spawnSettled = false; - const lateSpawn = registry - .spawn(session, { cli: "fast", name: "late-after-exit", prompt: INITIAL_VIBE_TASK }) - .then( - () => ({ error: undefined }), - error => ({ error }), - ) - .finally(() => { - spawnSettled = true; - }); - await flushMicrotasks(); - expect(spawnSettled).toBe(false); - - releaseExitWrite.resolve(); - expect(await exiting).toBe(0); - const { error } = await lateSpawn; - expect(error).toBeInstanceOf(Error); - expect(String(error)).toContain("Vibe mode has exited"); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("late-after-exit")).toBeUndefined(); - expect(parentManager.buildSessionContext().mode).toBe("none"); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "late-after-exit" && data.action === "spawn"; - }), - ).toBe(false); - }); - - it("keeps mode and workers live after a real atomic mode-exit failure, then retries cleanly", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const first = await registry.spawn(session, { - cli: "fast", - name: "retry-exit-one", - prompt: INITIAL_VIBE_TASK, - }); - const second = await registry.spawn(session, { - cli: "good", - name: "retry-exit-two", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([jobs.getJob(first.jobId)!.promise, jobs.getJob(second.jobId)!.promise]); - const firstRef = AgentRegistry.global().get("retry-exit-one"); - const secondRef = AgentRegistry.global().get("retry-exit-two"); - if (!firstRef || !secondRef) throw new Error("Expected both live worker refs"); - await parentManager.flush(); - const beforeBytes = await fs.readFile(parentSessionFile); - const beforeSize = (await fs.stat(parentSessionFile)).size; - storage.atomicWriteHooks.push(async () => { - throw Object.assign(new Error("atomic publish unavailable"), { code: "ENOSPC" }); - }); - - await expect(registry.killAll(session)).rejects.toThrow("atomic publish unavailable"); - - expect((await fs.stat(parentSessionFile)).size).toBe(beforeSize); - expect(await fs.readFile(parentSessionFile)).toEqual(beforeBytes); - expect(registry.screens(session).map(screen => [screen.id, screen.state])).toEqual([ - ["retry-exit-one", "idle"], - ["retry-exit-two", "idle"], - ]); - expect(AgentRegistry.global().get("retry-exit-one")?.status).toBe("idle"); - expect(AgentRegistry.global().get("retry-exit-two")?.status).toBe("idle"); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - const failedExitActions = parentManager.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return typeof data.id === "string" && data.id.startsWith("retry-exit-") && data.action === "tombstone" - ? [data.action] - : []; - }); - expect(failedExitActions).toEqual([]); - - VibeSessionRegistry.resetGlobalForTests(); - const reloadedRegistry = VibeSessionRegistry.global(); - expect(await reloadedRegistry.rehydrate(session)).toBe(2); - expect(reloadedRegistry.listIds(session)).toEqual(["retry-exit-one", "retry-exit-two"]); - expect(AgentRegistry.global().get("retry-exit-one")).toBe(firstRef); - expect(AgentRegistry.global().get("retry-exit-two")).toBe(secondRef); - - expect(await reloadedRegistry.killAll(session)).toBe(2); - expect(reloadedRegistry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("retry-exit-one")?.status).toBe("aborted"); - expect(AgentRegistry.global().get("retry-exit-two")?.status).toBe("aborted"); - expect(parentManager.buildSessionContext().mode).toBe("none"); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(reopened.buildSessionContext().mode).toBe("none"); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("retry-exit-one")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("retry-exit-two")).toMatchObject({ status: "aborted", session: null }); - }); - - it("fail-closes workers when mode-exit rollback durability is indeterminate", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "indeterminate-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - storage.atomicWriteHooks.push( - async commit => { - await commit(); - throw new Error("mode exit committed but acknowledgement failed"); - }, - async () => { - throw new Error("authoritative rollback publish failed"); - }, - ); - - const failure = await registry.killAll(session).catch(error => error); - - expect(failure).toBeInstanceOf(SessionPersistenceIndeterminateError); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("indeterminate-exit")).toMatchObject({ status: "aborted", session: null }); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - const durableText = await fs.readFile(parentSessionFile, "utf8"); - expect(durableText).toContain('"reason":"mode-exit"'); - expect(durableText).toContain('"mode":"none"'); - - await parentManager.recoverPersistenceFromCurrentState(); - expect(await registry.killAll(session)).toBe(1); - expect(parentManager.buildSessionContext().mode).toBe("none"); - }); - - it("tears down and repairs a partial writer failure while persisting an explicit tombstone", async () => { - installPersistedSpawnMock(); - const storage = new FaultInjectingSessionStorage(); - const parentManager = await createPersistedParent(storage); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "explicit-io-failure", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - const liveRef = AgentRegistry.global().get("explicit-io-failure"); - if (!liveRef?.session) throw new Error("Expected a live worker ref"); - const dispose = vi.spyOn(liveRef.session, "dispose"); - await parentManager.flush(); - const beforeSize = (await fs.stat(parentSessionFile)).size; - storage.failNextAppendWithPrefix(Object.assign(new Error("explicit tombstone ENOSPC"), { code: "ENOSPC" }), 23); - - await expect(registry.kill(session, "explicit-io-failure")).rejects.toThrow("explicit tombstone ENOSPC"); - - expect(dispose).toHaveBeenCalled(); - expect(storage.failedWriterClosed).toBe(true); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("explicit-io-failure")).toMatchObject({ status: "aborted", session: null }); - await parentManager.flush(); - expect((await fs.stat(parentSessionFile)).size).toBeGreaterThan(beforeSize); - const repairedLines = (await fs.readFile(parentSessionFile, "utf8")).trimEnd().split("\n"); - expect(repairedLines.every(line => Boolean(JSON.parse(line)))).toBe(true); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "explicit-io-failure" && data.action === "tombstone" && data.reason === "explicit-kill"; - }), - ).toBe(true); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("explicit-io-failure")).toMatchObject({ status: "aborted", session: null }); - }); - - it("serializes explicit kill ahead of a failing mode exit so rollback cannot erase it", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const explicit = await registry.spawn(session, { - cli: "fast", - name: "overlap-explicit", - prompt: INITIAL_VIBE_TASK, - }); - const survivor = await registry.spawn(session, { - cli: "good", - name: "overlap-survivor", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([jobs.getJob(explicit.jobId)!.promise, jobs.getJob(survivor.jobId)!.promise]); - - const originalFlush = parentManager.flush.bind(parentManager); - const explicitFlushStarted = Promise.withResolvers(); - const releaseExplicitFlush = Promise.withResolvers(); - const explicitTeardownStarted = Promise.withResolvers(); - const releaseExplicitTeardown = Promise.withResolvers(); - const lifecycle = AgentLifecycleManager.global(); - const originalRelease = lifecycle.release.bind(lifecycle); - const releaseSpy = vi.spyOn(lifecycle, "release").mockImplementation(async (id, expected) => { - if (id === "overlap-explicit") { - explicitTeardownStarted.resolve(); - await releaseExplicitTeardown.promise; - } - return originalRelease(id, expected); - }); - let flushCalls = 0; - const flush = vi.spyOn(parentManager, "flush").mockImplementation(async () => { - flushCalls++; - if (flushCalls === 1) { - explicitFlushStarted.resolve(); - await releaseExplicitFlush.promise; - await originalFlush(); - return; - } - if (flushCalls === 2) throw new Error("overlapping mode exit failed"); - await originalFlush(); - }); - - const explicitKill = registry.kill(session, "overlap-explicit"); - await explicitFlushStarted.promise; - const failedExit = registry.killAll(session).catch(error => error); - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseExplicitFlush.resolve(); - await explicitTeardownStarted.promise; - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseExplicitTeardown.resolve(); - expect((await explicitKill).id).toBe("overlap-explicit"); - const exitError = await failedExit; - expect(exitError).toBeInstanceOf(Error); - expect(String(exitError)).toContain("overlapping mode exit failed"); - expect(flushCalls).toBe(2); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - expect(AgentRegistry.global().get("overlap-explicit")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("overlap-survivor")?.status).toBe("idle"); - - flush.mockRestore(); - releaseSpy.mockRestore(); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(1); - expect(AgentRegistry.global().get("overlap-explicit")).toMatchObject({ status: "aborted", session: null }); - expect(AgentRegistry.global().get("overlap-survivor")).toMatchObject({ status: "parked", session: null }); - }); - - it("serializes overlapping mode exits so a later success cannot be revoked by an earlier failure", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "overlap-mode-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - - const originalFlush = parentManager.flush.bind(parentManager); - const firstExitFlushStarted = Promise.withResolvers(); - const releaseFirstExitFlush = Promise.withResolvers(); - let flushCalls = 0; - const flush = vi.spyOn(parentManager, "flush").mockImplementation(async () => { - flushCalls++; - if (flushCalls === 1) { - firstExitFlushStarted.resolve(); - await releaseFirstExitFlush.promise; - throw new Error("first mode exit failed"); - } - await originalFlush(); - }); - - const firstExit = registry.killAll(session).catch(error => error); - await firstExitFlushStarted.promise; - const successfulExit = registry.killAll(session); - await flushMicrotasks(); - expect(flushCalls).toBe(1); - releaseFirstExitFlush.resolve(); - const firstError = await firstExit; - expect(firstError).toBeInstanceOf(Error); - expect(String(firstError)).toContain("first mode exit failed"); - expect(await successfulExit).toBe(1); - expect(flushCalls).toBe(2); - expect(parentManager.buildSessionContext().mode).toBe("none"); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("overlap-mode-exit")).toMatchObject({ status: "aborted", session: null }); - expect(await registry.killAll(session)).toBe(0); - expect(flushCalls).toBe(2); - - flush.mockRestore(); - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("overlap-mode-exit")).toMatchObject({ status: "aborted", session: null }); - }); - - it("persists mode none when re-exiting a rewound pre-exit vibe branch", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const spawned = await registry.spawn(session, { - cli: "fast", - name: "rewound-mode-exit", - prompt: INITIAL_VIBE_TASK, - }); - await jobs.getJob(spawned.jobId)!.promise; - const preExitLeaf = parentManager.getLeafId(); - if (!preExitLeaf) throw new Error("Expected a pre-exit Vibe branch leaf"); - - expect(await registry.killAll(session)).toBe(1); - expect(parentManager.buildSessionContext().mode).toBe("none"); - parentManager.branch(preExitLeaf); - expect(parentManager.buildSessionContext().mode).toBe("vibe"); - expect(await registry.rehydrate(session)).toBe(0); - expect(registry.listIds(session)).toEqual([]); - - expect(await registry.killAll(session)).toBe(0); - expect(parentManager.buildSessionContext().mode).toBe("none"); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - expect(reopened.buildSessionContext().mode).toBe("none"); - }); - - it("does not rehydrate workers tombstoned by explicit kill or deliberate mode exit", async () => { - installPersistedSpawnMock(); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const firstManager = createManager(); - const firstSession = createSession({ manager: firstManager, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - const explicitlyKilled = await registry.spawn(firstSession, { - cli: "fast", - name: "explicitly-killed", - prompt: INITIAL_VIBE_TASK, - }); - const modeExited = await registry.spawn(firstSession, { - cli: "good", - name: "mode-exited", - prompt: INITIAL_VIBE_TASK, - }); - await Promise.all([ - firstManager.getJob(explicitlyKilled.jobId)!.promise, - firstManager.getJob(modeExited.jobId)!.promise, - ]); - const preKillLeaf = parentManager.getLeafId(); - if (!preKillLeaf) throw new Error("Expected a persisted lifecycle leaf before kill"); - expect((await registry.kill(firstSession, "explicitly-killed")).cancelledTurn).toBe(false); - parentManager.appendCustomEntry("vibe-session-lifecycle", { - version: 1, - action: "tombstone-revoked", - id: "explicitly-killed", - ownerId: "Main", - parentSessionId: parentManager.getSessionId(), - reason: "mode-exit", - }); - await parentManager.flush(); - expect(await registry.killAll(firstSession)).toBe(1); - const killedSnapshot = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - try { - const tombstonedIds = killedSnapshot.getEntries().flatMap(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return []; - const data = entry.data as Record; - return data.action === "tombstone" && typeof data.id === "string" ? [data.id] : []; - }); - expect(tombstonedIds.toSorted()).toEqual(["explicitly-killed", "mode-exited"]); - } finally { - await killedSnapshot.close(); - } - parentManager.branch(preKillLeaf); - - parentManager.appendModeChange("none"); - await parentManager.flush(); - await parentManager.close(); - await simulateProcessBoundary(); - const artifactsDir = parentSessionFile.slice(0, -6); - for (const id of ["explicitly-killed", "mode-exited"]) { - AgentRegistry.global().register({ - id, - displayName: id, - kind: "sub", - parentId: "Main", - session: null, - sessionFile: path.join(artifactsDir, `${id}.jsonl`), - status: "parked", - }); - } - const resumedManager = await SessionManager.open(parentSessionFile, undefined, undefined, { - suppressBreadcrumb: true, - }); - persistedManagers.push(resumedManager); - expect(resumedManager.buildSessionContext().mode).toBe("none"); - const resumedSession = createSession({ manager: createManager(), sessionManager: resumedManager }); - const resumedRegistry = VibeSessionRegistry.global(); - expect(await resumedRegistry.rehydrate(resumedSession)).toBe(0); - expect(resumedRegistry.listIds(resumedSession)).toEqual([]); - const explicitRef = AgentRegistry.global().get("explicitly-killed"); - const modeExitRef = AgentRegistry.global().get("mode-exited"); - expect(explicitRef).toMatchObject({ status: "aborted", session: null }); - expect(modeExitRef).toMatchObject({ status: "aborted", session: null }); - if (!explicitRef?.sessionFile || !modeExitRef?.sessionFile) { - throw new Error("Tombstoned workers must retain readable transcript references"); - } - expect((await SessionManager.peekSessionInit(explicitRef.sessionFile))?.init?.task).toBe(INITIAL_VIBE_TASK); - expect((await SessionManager.peekSessionInit(modeExitRef.sessionFile))?.init?.task).toBe(INITIAL_VIBE_TASK); - await expect(AgentLifecycleManager.global().ensureLive("explicitly-killed")).rejects.toThrow( - "aborted and cannot be revived", - ); - await expect( - resumedRegistry.send(resumedSession, { session: "explicitly-killed", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "explicitly-killed"'); - await expect( - resumedRegistry.send(resumedSession, { session: "mode-exited", message: FOLLOW_UP_VIBE_TASK }), - ).rejects.toThrow('Unknown vibe session "mode-exited"'); - }); - - it("runSubagentFollowUpTurn continues the same live session and finalizes trace + yield response", async () => { - const fake = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: "Worker", - displayName: "Worker", - kind: "sub", - parentId: "Main", - session: fake.session, - status: "idle", - }); - const agent = { name: "task", description: "worker", systemPrompt: "sp", source: "bundled" as const }; - - fake.setScript({ events: yieldTurnEvents({ report: "did the first thing" }), responseText: "first summary" }); - const progressSnapshots: AgentProgress[] = []; - const first = await executorModule.runSubagentFollowUpTurn({ - id: "Worker", - agent, - message: "do the first thing", - onProgress: progress => progressSnapshots.push({ ...progress, recentTools: progress.recentTools.slice() }), - }); - expect(first.exitCode).toBe(0); - expect(first.output).toContain("did the first thing"); - expect(progressSnapshots.some(progress => progress.recentTools.some(entry => entry.tool === "read"))).toBe(true); - - // Second turn lands on the SAME session instance — prior context retained. - fake.setScript({ events: yieldTurnEvents({ report: "built on prior work" }), responseText: "second summary" }); - const second = await executorModule.runSubagentFollowUpTurn({ id: "Worker", agent, message: "now extend it" }); - expect(second.exitCode).toBe(0); - expect(second.output).toContain("built on prior work"); - expect(fake.prompts).toEqual(["do the first thing", "now extend it"]); - expect(fake.isDisposed()).toBe(false); - }); - - it("wait wakes on the first settling turn among concurrent sessions and suppresses its re-delivery", async () => { - const gates = new Map(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - const gate = deferred(); - gates.set(options.id, gate); - await gate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: `${options.id} finished.` }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const fast = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Task A." }); - const good = await registry.spawn(session, { cli: "good", name: "Good", prompt: "Task B." }); - await pollUntil(() => gates.size === 2); - - const waitPromise = registry.wait(session, { sessions: ["Fast", "Good"], timeoutMs: 5000 }); - gates.get("Fast")!.resolve(); - const outcome = await waitPromise; - - expect(outcome.timedOut).toBe(false); - expect(outcome.settled.map(entry => entry.id)).toEqual(["Fast"]); - expect(outcome.settled[0]!.resultText).toContain("Fast finished."); - expect(outcome.stillRunning).toEqual(["Good"]); - // The reported result must not be delivered a second time as a follow-up. - expect(manager.isDeliverySuppressed(fast.jobId)).toBe(true); - expect(manager.isDeliverySuppressed(good.jobId)).toBe(false); - - gates.get("Good")!.resolve(); - await manager.getJob(good.jobId)!.promise; - }); - - it("wait reports the settled turn even when a queued follow-up starts immediately", async () => { - const firstGate = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - await firstGate.promise; - AgentRegistry.global().setStatus(options.id, "idle"); - return makeResult(options.id, { output: "First turn done." }); - }); - const followUpGate = deferred(); - vi.spyOn(executorModule, "runSubagentFollowUpTurn").mockImplementation(async options => { - await followUpGate.promise; - return makeResult(options.id, { output: "Follow-up done." }); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { cli: "fast", name: "Fast", prompt: "Task A." }); - await pollUntil(() => AgentRegistry.global().get("Fast") !== undefined); - - // Queued while mid-turn: #finishTurn starts this follow-up turn inside - // the settling job's callback, BEFORE the watched job's promise resolves. - const queued = await registry.send(session, { session: "Fast", message: "Task B." }); - expect(queued.mode).toBe("queued"); - - const waitPromise = registry.wait(session, { sessions: ["Fast"], timeoutMs: 5000 }); - firstGate.resolve(); - const outcome = await waitPromise; - - // The settled first turn is reported (not shadowed by the new in-flight - // turn) and acknowledged so it is not re-delivered … - expect(outcome.settled.map(entry => entry.jobId)).toEqual([jobId]); - expect(outcome.settled[0]!.resultText).toContain("First turn done."); - expect(manager.isDeliverySuppressed(jobId)).toBe(true); - // … while the drained-queue follow-up shows as still running. - expect(outcome.stillRunning).toEqual(["Fast"]); - - followUpGate.resolve(); - await manager.getJob("Fast-t2")!.promise; - }); - - it("bounds kill teardown when a cancelled turn ignores abort and settles late", async () => { - const gate = deferred(); - const started = deferred(); - const disposed = deferred(); - const fake = createFakeWorkerSession({ onDispose: disposed.resolve }); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: fake.session, - status: "running", - }); - started.resolve(); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const { jobId } = await registry.spawn(session, { - cli: "fast", - name: "IgnoresKillAbort", - prompt: "Keep working through explicit termination.", - }); - await started.promise; - - vi.useFakeTimers(); - try { - const kill = registry.kill(session, "IgnoresKillAbort"); - await disposed.promise; - await flushMicrotasks(); - expect(vi.getTimerCount()).toBeGreaterThan(0); - vi.advanceTimersByTime(250); - - const outcome = await kill; - expect(outcome.cancelledTurn).toBe(true); - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresKillAbort")).toBeUndefined(); - expect(registry.screens(session)[0]?.state).toBe("dead"); - await expect(registry.send(session, { session: "IgnoresKillAbort", message: "hello?" })).rejects.toThrow( - "dead", - ); - - gate.resolve(); - await manager.getJob(jobId)!.promise; - expect(manager.getJob(jobId)!.status).toBe("cancelled"); - expect(fake.isDisposed()).toBe(true); - expect(AgentRegistry.global().get("IgnoresKillAbort")).toBeUndefined(); - expect(registry.screens(session)[0]?.state).toBe("dead"); - await expect(registry.send(session, { session: "IgnoresKillAbort", message: "still there?" })).rejects.toThrow( - "dead", - ); - } finally { - gate.resolve(); - vi.useRealTimers(); - } - }); - - it("keeps a persisted in-flight kill terminal when the old executor finalizes late", async () => { - let worker: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - worker = createFakeWorkerSession(); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Killed during work.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "persisted-kill", prompt: "Keep working." }); - await pollUntil(() => worker !== undefined); - - expect((await registry.kill(session, "persisted-kill")).cancelledTurn).toBe(true); - const terminal = AgentRegistry.global().get("persisted-kill"); - expect(terminal).toMatchObject({ status: "aborted", session: null }); - - await executorModule.finalizeSubagentLifecycle({ - id: "persisted-kill", - session: worker!.session, - aborted: true, - keepAlive: true, - isolated: false, - agentIdleTtlMs: 0, - reviveSession: null, - }); - expect(AgentRegistry.global().get("persisted-kill")).toBe(terminal); - expect(AgentRegistry.global().get("persisted-kill")).toMatchObject({ status: "aborted", session: null }); - await expect(AgentLifecycleManager.global().ensureLive("persisted-kill")).rejects.toThrow("cannot be revived"); - }); - - it("does not terminalize a same-path replacement installed while the killed worker disposes", async () => { - let worker: ReturnType | undefined; - let replacement: ReturnType | undefined; - let replacementWorker: ReturnType | undefined; - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - replacementWorker = createFakeWorkerSession(); - worker = createFakeWorkerSession({ - onDispose: () => { - replacement = AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: replacementWorker!.session, - sessionFile: childSessionFile, - status: "idle", - }); - }, - }); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: worker.session, - sessionFile: childSessionFile, - status: "running", - }); - const signal = options.signal; - if (!signal) throw new Error("Persisted blocked worker requires a cancellation signal"); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - return makeResult(options.id, { output: "Old worker killed.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "same-path-replacement", prompt: "Keep working." }); - await pollUntil(() => worker !== undefined); - - expect((await registry.kill(session, "same-path-replacement")).cancelledTurn).toBe(true); - expect(replacement).toBeDefined(); - expect(AgentRegistry.global().get("same-path-replacement")).toBe(replacement); - expect(replacement).toMatchObject({ status: "idle", session: replacementWorker!.session }); - expect(replacementWorker!.isDisposed()).toBe(false); - }); - - it("persists a kill issued before child initialization and terminalizes the worker if it registers late", async () => { - // Handshake so kill() lands while the worker is mid-init, not before its - // job body has started executing: on a loaded runner the job may not have - // dispatched yet when spawn() returns, and kill would then observe no - // registration at all (the mock registers only after the abort lands), - // failing the registry assertion below. Resolving once the body is inside - // the abort-wait keeps the test exercising exactly the late-registration - // path it names. - const workerStarted = deferred(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - const artifactsDir = options.artifactsDir; - if (!artifactsDir) throw new Error("Persisted vibe test requires an artifacts directory"); - const signal = options.signal; - if (!signal) throw new Error("Pre-initialization worker requires a cancellation signal"); - workerStarted.resolve(); - await new Promise(resolve => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - const childSessionFile = await persistWorkerSession({ - cwd: options.cwd, - artifactsDir, - id: options.id, - task: options.task, - }); - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: options.parentAgentId ?? "Main", - session: createFakeWorkerSession().session, - sessionFile: childSessionFile, - status: "idle", - }); - return makeResult(options.id, { output: "Killed before initialization.", aborted: true }); - }); - const parentManager = await createPersistedParent(); - parentManager.appendModeChange("vibe"); - const parentSessionFile = parentManager.getSessionFile(); - if (!parentSessionFile) throw new Error("Persisted parent session file was not created"); - const jobs = createManager(); - const session = createSession({ manager: jobs, sessionManager: parentManager }); - const registry = VibeSessionRegistry.global(); - await registry.spawn(session, { cli: "fast", name: "pre-init-kill", prompt: "Start later." }); - // Wait for the worker to be mid-init (inside the abort-wait) before - // issuing the kill — see the handshake comment at the top of the test. - await workerStarted.promise; - - expect((await registry.kill(session, "pre-init-kill")).cancelledTurn).toBe(true); - expect(AgentRegistry.global().get("pre-init-kill")).toMatchObject({ status: "aborted", session: null }); - expect( - parentManager.getEntries().some(entry => { - if (entry.type !== "custom" || typeof entry.data !== "object" || entry.data === null) return false; - const data = entry.data as Record; - return data.id === "pre-init-kill" && data.action === "tombstone"; - }), - ).toBe(true); - - await parentManager.close(); - await simulateProcessBoundary(); - const reopened = await SessionManager.open(parentSessionFile, undefined, undefined, { suppressBreadcrumb: true }); - persistedManagers.push(reopened); - const resumedSession = createSession({ manager: createManager(), sessionManager: reopened }); - expect(await VibeSessionRegistry.global().rehydrate(resumedSession)).toBe(0); - expect(AgentRegistry.global().get("pre-init-kill")).toMatchObject({ status: "aborted", session: null }); - }); - - it("killAll terminates every session for the owner (mode-exit path)", async () => { - const gates = new Map(); - vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - AgentRegistry.global().register({ - id: options.id, - displayName: options.id, - kind: "sub", - parentId: "Main", - session: createFakeWorkerSession().session, - status: "running", - }); - const gate = deferred(); - gates.set(options.id, gate); - await gate.promise; - return makeResult(options.id); - }); - - const manager = createManager(); - const session = createSession({ manager }); - const registry = VibeSessionRegistry.global(); - const one = await registry.spawn(session, { cli: "fast", name: "One", prompt: "A." }); - const two = await registry.spawn(session, { cli: "good", name: "Two", prompt: "B." }); - await pollUntil(() => gates.size === 2); - - const killPromise = registry.killAll(session); - await pollUntil(() => manager.getJob(one.jobId)?.status === "cancelled"); - gates.get("One")!.resolve(); - await pollUntil(() => manager.getJob(two.jobId)?.status === "cancelled"); - gates.get("Two")!.resolve(); - const killed = await killPromise; - expect(killed).toBe(2); - expect(registry.listIds(session)).toEqual([]); - expect(AgentRegistry.global().get("One")).toBeUndefined(); - expect(AgentRegistry.global().get("Two")).toBeUndefined(); - }); - it("bounds suspension when worker disposal does not settle", async () => { - const disposeGate = deferred(); - const worker = createFakeWorkerSession({ onDispose: () => disposeGate.promise }); - const ref = AgentRegistry.global().register({ - id: "bounded-suspend", - displayName: "bounded-suspend", - kind: "sub", - parentId: "Main", - session: worker.session, - status: "idle", - }); - AgentLifecycleManager.global().adopt(ref.id, { - idleTtlMs: 0, - revive: async () => worker.session, - }); - const registry = VibeSessionRegistry.global(); - registry.registerRecordForTests({ id: ref.id, ownerId: "Main", state: "idle" }); - registry.setTeardownGraceForTesting(20); - - vi.useFakeTimers(); - let settled = false; - const suspension = registry - .suspendScope({ - ownerId: "Main", - parentSessionId: "test-parent-session", - parentSessionFile: null, - }) - .finally(() => { - settled = true; - }); - try { - await flushMicrotasks(); - vi.advanceTimersByTime(19); - await flushMicrotasks(); - expect(settled).toBe(false); - vi.advanceTimersByTime(1); - await flushMicrotasks(); - expect(settled).toBe(true); - expect(AgentRegistry.global().get(ref.id)).toBeUndefined(); - expect(await suspension).toBe(1); - } finally { - disposeGate.resolve(); - vi.useRealTimers(); - } - }); -});