From 0d3be100f7d7ce97af24186a0a5a65132315c5f3 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Mon, 3 Aug 2026 01:02:47 -0700 Subject: [PATCH 001/126] 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 002/126] 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 003/126] 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 004/126] 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 005/126] 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 006/126] 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 007/126] 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 008/126] 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 009/126] 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 010/126] 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 011/126] 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 012/126] 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 013/126] 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 014/126] 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 015/126] 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 016/126] 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 017/126] 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 018/126] 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 019/126] 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 020/126] 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 021/126] 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 022/126] 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 023/126] 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 024/126] 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 025/126] 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 026/126] 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 027/126] 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 028/126] 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 4fb3ecd69a94b012e2f51fe275645d61d2b0a536 Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Mon, 3 Aug 2026 12:25:06 -0700 Subject: [PATCH 029/126] refactor(coding-agent): polish agent hub menu --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/components/agent-hub.ts | 193 +++++++++++++----- .../modes/controllers/selector-controller.ts | 84 ++++---- .../src/registry/persisted-agents.ts | 24 ++- packages/coding-agent/src/vibe/runtime.ts | 10 +- .../test/agent-hub-activate.test.ts | 101 +++++++-- .../test/agent-hub-ordering.test.ts | 133 +++++++++--- 7 files changed, 389 insertions(+), 159 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..f6df79af3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -206,6 +206,9 @@ - Fixed remote or LAN local-engine endpoints being ignored during model discovery: the llama.cpp and Ollama probes used timeouts tuned for loopback, so a host reached over the network could exceed them and return no models, while changing `OLLAMA_BASE_URL`/`OLLAMA_HOST` could keep reusing a fresh cache from the previous endpoint. Non-loopback hosts now get a generous discovery timeout, and Ollama cache rows are scoped to the normalized endpoint ([#7087](https://github.com/can1357/oh-my-pi/issues/7087)). - Fixed `omp install` failing extension validation for pi extensions that import `createEditTool` or `createWriteTool` (e.g. gentle-pi) — the legacy `@oh-my-pi/pi-coding-agent` shim exported the read/bash/grep/find/ls tool factories but omitted the edit and write ones, so a named import threw Bun's static "Export named X not found" error. Added `createEditTool`/`createEditToolDefinition` and `createWriteTool`/`createWriteToolDefinition` to match the upstream pi surface ([#7094](https://github.com/can1357/oh-my-pi/issues/7094)). - Fixed Python eval's loopback tool bridge being routed through macOS system HTTP proxies, which caused `parallel()` tool reads to fail with `ConnectionRefusedError` after a local proxy stopped. +### Changed + +- Reworked the Ctrl+S Agent Hub onto the fullscreen modal lifecycle shared by Settings and Model Hub, with stable agent ordering, bounded rendering for large rosters, responsive persisted-session discovery, and consistent keyboard and mouse navigation. ## [17.2.0] - 2026-07-30 diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index a5a706c6e..9ec081cba 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -4,8 +4,8 @@ * One overlay, two views: * - Table view: every registered agent except Main (Main IS the ambient * chat), live from the global AgentRegistry — status, unread irc count, - * current/last task, last activity. Select with j/k, Enter opens a chat, - * `r` revives a parked agent, `x` aborts + releases one. + * current/last task, last activity. Navigate with keys, wheel, hover, and + * click; `r` revives a parked agent, `x` aborts + releases one. * - Chat view: per-agent transcript (incremental session-file tail, absorbed * from the old session observer overlay) plus an input line. Submitting * revives a parked agent, then prompts/steers it; the message lands in the @@ -14,7 +14,18 @@ * Replaces the old SessionObserverOverlayComponent (ctrl+s observer). */ import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; +import { + Container, + Ellipsis, + matchesKey, + type OverlayHandle, + padding, + routeSelectListMouse, + routeSgrMouseInput, + type SelectListMouseTarget, + type TUI, + visibleWidth, +} from "@oh-my-pi/pi-tui"; import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; @@ -162,11 +173,12 @@ export interface AgentHubDeps { focusAgent?: (id: string) => Promise; /** Current main session file; used to seed parked historical subagents after restart. */ sessionFile?: string | null; + /** Collab guest: route actions/transcripts to the host instead of local sessions. */ remote?: AgentHubRemote; } -export class AgentHubOverlayComponent extends Container { +export class AgentHubOverlayComponent extends Container implements SelectListMouseTarget { #registry: AgentRegistry; #observers: SessionObserverRegistry; #irc: IrcBus; @@ -178,12 +190,16 @@ export class AgentHubOverlayComponent extends Container { #ageTimer: NodeJS.Timeout | undefined; #dataChangeTimer?: NodeJS.Timeout; #remote: AgentHubRemote | undefined; + #disposed = false; /** Resolves after persisted historical subagents have been registered and rows refreshed. */ readonly persistedSubagentsReady: Promise; // Table state #rows: AgentRef[] = []; #selectedRow = 0; + #hoveredRow: number | null = null; + /** Per-render screen-line to agent-row map, shared by click and hover routing. */ + #hitRows: Array = []; #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ #rowOrder: Map | undefined; @@ -238,13 +254,15 @@ export class AgentHubOverlayComponent extends Container { this.persistedSubagentsReady = this.#remote ? Promise.resolve() : registerPersistedSubagents(this.#registry, deps.sessionFile) + .then(() => { + if (!this.#disposed) this.#refreshRows(); + }) .catch((error: unknown) => { logger.warn("Failed to register persisted subagents", { error }); }) - .then(() => { - this.#refreshRows(); - }) - .finally(() => this.#requestRender()); + .finally(() => { + if (!this.#disposed) this.#requestRender(); + }); this.#refreshRows(); } @@ -259,6 +277,8 @@ export class AgentHubOverlayComponent extends Container { /** Tear down every subscription and timer. Called by the overlay owner on close. */ dispose(): void { + if (this.#disposed) return; + this.#disposed = true; for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe(); if (this.#ageTimer) { clearInterval(this.#ageTimer); @@ -272,10 +292,21 @@ export class AgentHubOverlayComponent extends Container { } override render(width: number): readonly string[] { - return this.#renderTable(width).map(line => clampHubLine(line, width)); + const frame = this.#renderTable(width).map(line => clampHubLine(line, width)); + const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; + if (frame.length <= termHeight) return frame; + + // A tiny terminal can leave less room than the fixed chrome needs. Keep + // the header and footer visible rather than allowing the fullscreen modal + // to spill into the terminal's scrollback. + const footerLines = Math.min(3, frame.length); + const bodyEnd = Math.max(0, termHeight - footerLines); + return [...frame.slice(0, bodyEnd), ...frame.slice(-footerLines)].slice(0, termHeight); } handleInput(keyData: string): void { + if (routeSgrMouseInput(keyData, event => routeSelectListMouse(this, event, event.row))) return; + // The hub/observe keys always close the overlay (toggle semantics) for (const key of this.#hubKeys) { if (matchesKey(keyData, key)) { @@ -369,7 +400,8 @@ export class AgentHubOverlayComponent extends Container { const selectedId = this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); - if (!this.#rowOrder) { + const rowOrder = this.#rowOrder; + if (!rowOrder) { // First refresh (usually the constructor): order by status, then recency. this.#rows = refs.sort( (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, @@ -382,14 +414,10 @@ export class AgentHubOverlayComponent extends Container { this.#rows = refs.sort((a, b) => { const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; if (statusDiff !== 0) return statusDiff; - const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return aOrder - bOrder; + return (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER); }); for (const ref of this.#rows) { - if (!this.#rowOrder.has(ref.id)) { - this.#rowOrder.set(ref.id, this.#rowOrder.size); - } + if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, rowOrder.size); } } @@ -406,7 +434,9 @@ export class AgentHubOverlayComponent extends Container { // ======================================================================== #renderTable(width: number): string[] { + this.#hitRows.length = 0; const lines: string[] = []; + const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; lines.push(...new DynamicBorder().render(width)); const counts = this.#statusSummary(); lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`); @@ -415,44 +445,77 @@ export class AgentHubOverlayComponent extends Container { if (this.#rows.length === 0) { lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`); } else { - 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. - let start = this.#selectedRow; - let end = this.#selectedRow + 1; - let used = entries[start]?.length ?? 0; - for (let grew = true; grew; ) { - grew = false; - if (end < entries.length && used + entries[end].length <= budget) { - used += entries[end].length; - end++; - grew = true; + // Reserve both possible overflow markers plus the fixed table chrome. + // Rendering only this window keeps a large persisted registry cheap. + const chrome = 3 + 3 + (this.#notice ? 1 : 0); + const budget = Math.max(0, termHeight - chrome - 2); + if (budget > 0) { + const rendered: Array = []; + const entryAt = (index: number): string[] => { + const cached = rendered[index]; + if (cached) return cached; + const entry = this.#renderEntry( + this.#rows[index], + index === this.#selectedRow, + width, + index === this.#hoveredRow, + ); + rendered[index] = entry; + return entry; + }; + const appendEntry = (index: number, entry = entryAt(index)): void => { + for (const line of entry) { + this.#hitRows[lines.length] = index; + lines.push(line); + } + }; + + let start = this.#selectedRow; + let end = this.#selectedRow + 1; + let used = entryAt(this.#selectedRow).length; + if (used > budget) { + appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget)); + } else { + // Grow a window around the selection until the line budget is + // spent, so selection remains visible without rendering every row. + for (let grew = true; grew; ) { + grew = false; + if (end < this.#rows.length) { + const next = entryAt(end); + if (used + next.length <= budget) { + used += next.length; + end++; + 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++) { + appendEntry(i); + } + if (end < this.#rows.length) { + lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); + } } - if (start > 0 && used + entries[start - 1].length <= budget) { - start--; - used += entries[start].length; - grew = true; - } - } - if (start > 0) { - lines.push(` ${theme.fg("dim", `… ${start} more`)}`); - } - for (let i = start; i < end; i++) { - lines.push(...entries[i]); - } - if (end < this.#rows.length) { - lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); } } if (this.#notice) { lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`); } + while (lines.length < termHeight - 3) lines.push(""); lines.push(""); - lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`); + lines.push(` ${theme.fg("dim", "j/k/wheel:select Enter/click:open r:revive x:kill Esc/←←:close")}`); lines.push(...new DynamicBorder().render(width)); return lines; } @@ -476,7 +539,7 @@ export class AgentHubOverlayComponent extends Container { * left, metadata right-aligned (inlined when the terminal is too narrow) — * plus an indented dim task line when the agent's work is known. */ - #renderEntry(ref: AgentRef, selected: boolean, width: number): string[] { + #renderEntry(ref: AgentRef, selected: boolean, width: number, hovered = false): string[] { const max = Math.max(1, width - 2); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`]; @@ -514,7 +577,40 @@ export class AgentHubOverlayComponent extends Container { if (task) { entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`); } - return entry; + if (!hovered) return entry; + return entry.map(row => { + const rowWidth = visibleWidth(row); + return theme.bg("selectedBg", rowWidth < max ? row + padding(max - rowWidth) : row); + }); + } + + handleWheel(delta: -1 | 1): void { + this.#hoveredRow = null; + if (this.#rows.length > 0) { + this.#selectedRow = Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1)); + } + this.#requestRender(); + } + + hitTest(line: number): number | undefined { + return this.#hitRows[line]; + } + + setHoverIndex(index: number | null): void { + if (index === this.#hoveredRow) return; + this.#hoveredRow = index; + this.#requestRender(); + } + + clickItem(index: number): void { + this.#hoveredRow = index; + if (index === this.#selectedRow) { + const selected = this.#rows[index]; + if (selected) this.#activateAgent(selected); + return; + } + this.#selectedRow = index; + this.#requestRender(); } #handleTableInput(keyData: string): void { @@ -532,6 +628,7 @@ export class AgentHubOverlayComponent extends Container { } return; } + this.#hoveredRow = null; if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) { if (this.#rows.length > 0) { this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index c1875a9af..3bc944171 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -110,6 +110,23 @@ const MANUAL_LOGIN_PROMPT = "Paste the authorization code (or full redirect URL) export class SelectorController { constructor(private ctx: InteractiveModeContext) {} + /** + * Mount a primary fullscreen menu through the one polished modal path shared + * by Settings, Model Hub, and Agent Hub. + */ + #showFullscreenMenu(component: Component): OverlayHandle { + const handle = this.ctx.ui.showOverlay(component, { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + fullscreen: true, + }); + this.ctx.ui.setFocus(component); + this.ctx.ui.requestRender(); + return handle; + } + #defaultRoleMutationTail = Promise.resolve(); async #acquireDefaultRoleMutation(): Promise<() => void> { @@ -242,15 +259,7 @@ export class SelectorController { }, }, ); - overlayHandle = this.ctx.ui.showOverlay(selector, { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - fullscreen: true, - }); - this.ctx.ui.setFocus(selector); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(selector); }); } @@ -1023,15 +1032,7 @@ export class SelectorController { initialProviderId: hubOptions.initialProviderId, }, ); - overlayHandle = this.ctx.ui.showOverlay(hub, { - anchor: "bottom-center", - width: "100%", - maxHeight: "100%", - margin: 0, - fullscreen: true, - }); - this.ctx.ui.setFocus(hub); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(hub); } /** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */ @@ -2000,26 +2001,19 @@ export class SelectorController { ...this.ctx.keybindings.getKeys("app.agents.hub"), ...this.ctx.keybindings.getKeys("app.session.observe"), ]; - let hub: AgentHubOverlayComponent | undefined; + let overlayHandle: OverlayHandle | undefined; + let closed = false; - // Render the hub inline in the editor slot — the same anchored region - // every other selector (model, session, tree, the `ask` tool) uses — - // rather than a floating overlay. A non-fullscreen overlay composited over - // a live transcript strands a stale copy in native scrollback every time a - // running subagent's progress grows the frame and scrolls the window; the - // hub is opened mid-run, so those copies stacked into a wall of duplicate - // "Agent Hub" frames bleeding the task tree behind them. As an editor-slot - // component it rides the normal append-only commit path: the transcript - // commits above it exactly once and the hub repaints in place. const done = () => { - hub?.dispose(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + if (closed) return; + closed = true; + hub.dispose(); + overlayHandle?.hide(); + this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; - hub = new AgentHubOverlayComponent({ + const hub = new AgentHubOverlayComponent({ observers, hubKeys, expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"), @@ -2038,30 +2032,24 @@ export class SelectorController { }); const showReadyHub = () => { - // The double-← gesture passes requireContent so it stays inert when - // neither live nor persisted subagents are available. Persisted rows now - // load asynchronously, so defer the gate until that scan has refreshed the - // hub instead of treating the initial empty table as authoritative. + if (closed) return; + // The double-← gesture stays inert when neither live nor persisted + // subagents are available, so wait for discovery before making the gate. if (options?.requireContent && hub.isEmpty) { - hub.dispose(); + done(); return; } - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(hub); - this.ctx.ui.setFocus(hub); - // When the hub was raised by the editor's double-← gesture, prime its own - // close detector so the *next* single ← dismisses it — the two taps that - // opened it were consumed by the editor's detector (issue #4780). + // Prime the detector before the first frame when the editor's double-← + // gesture opened the hub, so the next single ← dismisses it. if (options?.armCloseTap) hub.armCloseTap(); - this.ctx.ui.requestRender(); + overlayHandle = this.#showFullscreenMenu(hub); }; if (options?.requireContent && hub.isEmpty) { void hub.persistedSubagentsReady.then(showReadyHub); - return; + } else { + showReadyHub(); } - - showReadyHub(); } } diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 4804919bc..3aaadada2 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { readLines } from "@oh-my-pi/pi-utils"; import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../advisor/transcript-recorder"; -import { SessionManager } from "../session/session-manager"; import { persistedVibeChildIds } from "../vibe/runtime"; import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; @@ -10,18 +10,26 @@ import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; * workers are revived through the Vibe registry's own journal, so the generic * persisted-subagent scan must not register them as plain `sub` refs. */ +const VIBE_LIFECYCLE_MARKER = Buffer.from('"vibe-session-lifecycle"'); + async function readPersistedVibeChildIds(sessionFile: string): Promise> { - let sessionManager: SessionManager; + const ids = new Set(); try { - sessionManager = await SessionManager.open(sessionFile, undefined, undefined, { suppressBreadcrumb: true }); + for await (const bytes of readLines(Bun.file(sessionFile).stream())) { + const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (line.indexOf(VIBE_LIFECYCLE_MARKER) === -1) continue; + try { + const entry: unknown = JSON.parse(line.toString("utf8")); + for (const id of persistedVibeChildIds([entry])) ids.add(id); + } catch { + // Match lenient session loading: one malformed line must not hide + // valid lifecycle entries later in the transcript. + } + } + return ids; } catch { return new Set(); } - try { - return persistedVibeChildIds(sessionManager.getEntries()); - } finally { - await sessionManager.close(); - } } /** Register persisted subagent and advisor transcripts as parked registry refs. */ diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index e77c9ef8a..eb1983042 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -25,7 +25,6 @@ import { MCPManager } from "../mcp/manager"; import vibeTurnResultTemplate from "../prompts/tools/vibe-turn-result.md" with { type: "text" }; import { AgentLifecycleManager } from "../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; -import type { SessionEntry } from "../session/session-entries"; import { SessionManager, SessionPersistenceIndeterminateError } from "../session/session-manager"; import { getBundledAgent } from "../task/agents"; import { type ExecutorOptions, runSubagentFollowUpTurn, runSubprocess } from "../task/executor"; @@ -321,11 +320,12 @@ function parseLifecycleEvent(value: unknown): VibeLifecycleEvent | undefined { return undefined; } -/** Child ids claimed by any valid Vibe spawn event, independent of current parent scope. */ -export function persistedVibeChildIds(entries: Iterable): Set { +/** Child ids claimed by valid Vibe spawn records from untrusted persisted JSON. */ +export function persistedVibeChildIds(entries: Iterable): Set { const ids = new Set(); - for (const entry of entries) { - if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue; + for (const value of entries) { + const entry = objectRecord(value); + if (entry?.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue; const event = parseLifecycleEvent(entry.data); if ( event?.action === "spawn" && diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 0175d9c19..294b78891 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -116,6 +116,62 @@ describe("Agent hub Enter activation", () => { expect(agents.get("Worker")?.sessionFile).toBe(workerSessionFile); hub.dispose(); }); + it("avoids multi-second event-loop stalls while discovering agents from a large session", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const header = JSON.stringify({ + type: "session", + version: 3, + id: "responsive-test", + timestamp: new Date().toISOString(), + cwd: tempDir.path(), + }); + const payload = "x".repeat(250_000); + const message = JSON.stringify({ + type: "message", + id: "large-message", + parentId: null, + timestamp: new Date().toISOString(), + message: { + role: "user", + content: [{ type: "text", text: payload }], + timestamp: Date.now(), + }, + }); + await Bun.write(sessionFile, `${header}\n${Array.from({ length: 600 }, () => message).join("\n")}\n`); + Bun.gc(true); + + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + sessionFile, + }); + + let maxLagMs = 0; + let previous = performance.now(); + const timer = setInterval(() => { + const now = performance.now(); + maxLagMs = Math.max(maxLagMs, now - previous - 2); + previous = now; + }, 2); + try { + await hub.persistedSubagentsReady; + await Bun.sleep(10); + } finally { + clearInterval(timer); + hub.dispose(); + } + + // Guard the original seconds-long freeze without treating shared-runner + // scheduling jitter as an Agent Hub regression. + expect(maxLagMs).toBeLessThan(500); + }); it("does not generically revive active or tombstoned Vibe children copied by a post-exit fork", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-vibe-fork-"); @@ -191,21 +247,22 @@ describe("Agent hub Enter activation", () => { const editor = {}; let capturedHub: AgentHubOverlayComponent | undefined; - let editorRestoredCount = 0; const focusedIds: string[] = []; const focusResolved = Promise.withResolvers(); const editorFocused = Promise.withResolvers(); const focusTargets: unknown[] = []; const editorContainer = { + children: [editor], clear: () => {}, - addChild: (child: unknown) => { - if (child === editor) editorRestoredCount++; - else capturedHub = child as AgentHubOverlayComponent; - }, + addChild: () => {}, }; const ctx = { keybindings: { getKeys: () => [] }, ui: { + showOverlay: (component: AgentHubOverlayComponent) => { + capturedHub = component; + return { hide: () => {} }; + }, setFocus: (target: unknown) => { focusTargets.push(target); if (target === editor) editorFocused.resolve(); @@ -235,7 +292,6 @@ describe("Agent hub Enter activation", () => { await editorFocused.promise; expect(focusedIds).toEqual([AGENT_ID]); - expect(editorRestoredCount).toBe(1); expect(focusTargets.at(-1)).toBe(editor); capturedHub!.dispose(); }); @@ -252,12 +308,19 @@ describe("Agent hub double-← gating", () => { function setup(agents: AgentRegistry, sessionFile: string | null = null) { let shown: AgentHubOverlayComponent | undefined; + let overlayOptions: Record | undefined; const shownReady = Promise.withResolvers(); const editor = {}; const focusTargets: unknown[] = []; const ctx = { keybindings: { getKeys: () => [] }, ui: { + showOverlay: (component: AgentHubOverlayComponent, options: Record) => { + shown = component; + overlayOptions = options; + shownReady.resolve(component); + return { hide: () => {} }; + }, setFocus: (target: unknown) => { focusTargets.push(target); }, @@ -265,13 +328,9 @@ describe("Agent hub double-← gating", () => { }, editor, editorContainer: { + children: [editor], clear: () => {}, - addChild: (child: unknown) => { - if (child !== editor) { - shown = child as AgentHubOverlayComponent; - shownReady.resolve(shown); - } - }, + addChild: () => {}, }, collabGuest: { agentRegistry: agents, hubRemote: undefined }, focusAgentSession: async () => {}, @@ -285,6 +344,7 @@ describe("Agent hub double-← gating", () => { editor, shown: () => shown, shownReady: shownReady.promise, + overlayOptions: () => overlayOptions, focusTargets, }; } @@ -347,14 +407,23 @@ describe("Agent hub double-← gating", () => { shownHub!.dispose(); }); - it("the explicit hub key opens the empty roster even with no subagents", () => { + it("the explicit hub opens fullscreen before persisted subagents load", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-explicit-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + await Bun.write(sessionFile, ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); const agents = new AgentRegistry(); - const { controller, shown } = setup(agents); + const { controller, shown, overlayOptions } = setup(agents, sessionFile); controller.showAgentHub(new SessionObserverRegistry()); - expect(shown()).toBeDefined(); - shown()!.dispose(); + const hub = shown(); + expect(hub).toBeDefined(); + expect(overlayOptions()).toMatchObject({ width: "100%", maxHeight: "100%", margin: 0, fullscreen: true }); + expect(agents.get("Worker")).toBeUndefined(); + await hub!.persistedSubagentsReady; + expect(agents.get("Worker")?.status).toBe("parked"); + hub!.dispose(); }); it("armCloseTap lets a single ← dismiss the hub the opening ←← raised", () => { diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 2588dd3eb..8be901af1 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -7,7 +7,7 @@ */ import { afterEach, beforeAll, describe, expect, it, setSystemTime, vi } from "bun:test"; import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; -import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { type AgentHubDeps, AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; import { SessionObserverRegistry } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; @@ -40,7 +40,7 @@ function stubStdoutGeometry(cols: number): GeometryStub { }; } -function makeHub(agents: AgentRegistry) { +function makeHub(agents: AgentRegistry, overrides: Partial = {}) { return new AgentHubOverlayComponent({ observers: new SessionObserverRegistry(), hubKeys: [], @@ -49,18 +49,40 @@ function makeHub(agents: AgentRegistry) { registry: agents, irc: new IrcBus(agents), focusAgent: async () => {}, + ...overrides, }); } -function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { +interface RenderedAgentRow { + id: string; + selected: boolean; +} + +function renderedAgentRows(hub: AgentHubOverlayComponent): RenderedAgentRow[] { // Entry first lines are ` …`; task lines are // indented deeper and chrome lines never carry the cursor slot. - const ids: string[] = []; + const rows: RenderedAgentRow[] = []; for (const raw of hub.render(120)) { - const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); - if (match) ids.push(match[2]!); + const match = /^ (❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); + if (match) rows.push({ id: match[3]!, selected: match[1] === "❯" }); } - return ids; + return rows; +} + +function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { + return renderedAgentRows(hub).map(row => row.id); +} + +function selectedAgentId(hub: AgentHubOverlayComponent): string | undefined { + return renderedAgentRows(hub).find(row => row.selected)?.id; +} + +function leftClick(row1Based: number): string { + return `\x1b[<0;4;${row1Based}M`; +} + +function wheel(direction: "up" | "down"): string { + return `\x1b[<${direction === "down" ? 65 : 64};4;4M`; } describe("Agent hub row ordering", () => { @@ -144,15 +166,7 @@ describe("Agent hub row ordering", () => { }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); const lines = hub.render(80); for (const line of lines) { @@ -165,6 +179,73 @@ describe("Agent hub row ordering", () => { hub.dispose(); }); + it("fits the fullscreen table to short terminals and windows large registries", () => { + geometry = stubStdoutGeometry(80); + geometry.setRows(10); + const agents = new AgentRegistry(); + for (let i = 0; i < 50; i++) { + agents.register({ + id: `Agent${i}`, + displayName: `Agent ${i}`, + kind: "sub", + session: {} as AgentSession, + }); + } + + const observers = new SessionObserverRegistry(); + const sessions = vi.spyOn(observers, "getSessions").mockReturnValue([]); + const hub = makeHub(agents, { observers }); + + try { + const lines = hub.render(80); + expect(lines.length).toBe(10); + expect(sessions.mock.calls.length).toBeLessThan(agents.list().length); + expect(Bun.stripANSI(lines.join("\n"))).toContain("…"); + } finally { + hub.dispose(); + } + }); + it("matches fullscreen menu mouse selection, wheel, and activation", async () => { + geometry = stubStdoutGeometry(120); + const agents = new AgentRegistry(); + setSystemTime(1_000); + agents.register({ id: "Alpha", displayName: "Alpha", kind: "sub", session: {} as AgentSession }); + setSystemTime(2_000); + agents.register({ id: "Beta", displayName: "Beta", kind: "sub", session: {} as AgentSession }); + setSystemTime(3_000); + agents.register({ id: "Gamma", displayName: "Gamma", kind: "sub", session: {} as AgentSession }); + + const focused: string[] = []; + const done = vi.fn(); + const hub = makeHub(agents, { + onDone: done, + focusAgent: async id => { + focused.push(id); + }, + }); + + try { + expect(selectedAgentId(hub)).toBe("Gamma"); + hub.handleInput(wheel("down")); + expect(selectedAgentId(hub)).toBe("Beta"); + + const frame = hub.render(120); + const alphaRow = frame.findIndex(line => /^ {3}\S+ Alpha/u.test(Bun.stripANSI(line))); + expect(alphaRow).toBeGreaterThanOrEqual(0); + hub.handleInput(leftClick(alphaRow + 1)); + expect(selectedAgentId(hub)).toBe("Alpha"); + expect(focused).toEqual([]); + + const selectedFrame = hub.render(120); + const selectedAlphaRow = selectedFrame.findIndex(line => /^ ❯ \S+ Alpha/u.test(Bun.stripANSI(line))); + hub.handleInput(leftClick(selectedAlphaRow + 1)); + await Promise.resolve(); + expect(focused).toEqual(["Alpha"]); + expect(done).toHaveBeenCalledTimes(1); + } finally { + hub.dispose(); + } + }); it("flags a fallback badge for observer-only rows with no live session", () => { geometry = stubStdoutGeometry(120); @@ -188,15 +269,7 @@ describe("Agent hub row ordering", () => { }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); try { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("fallback → openai/gpt-4o"); @@ -230,15 +303,7 @@ describe("Agent hub row ordering", () => { }, ]); - const hub = new AgentHubOverlayComponent({ - observers, - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, - }); + const hub = makeHub(agents, { observers }); try { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("fallback → fireworks/kimi-k2"); From bda330bb1315607691132826b764b2d63874ae86 Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Mon, 3 Aug 2026 14:24:42 -0700 Subject: [PATCH 030/126] refactor(coding-agent): add Agent Hub inspector --- packages/coding-agent/CHANGELOG.md | 5 +- .../src/modes/components/agent-hub.ts | 584 ++++++++++++++---- .../test/agent-hub-ordering.test.ts | 158 ++++- 3 files changed, 631 insertions(+), 116 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f6df79af3..8850d31fc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,8 @@ ### Changed - Replaced arktype with @oh-my-pi/omptype for tool parameter and config schemas, significantly improving startup performance with ~100x faster schema construction. Config schema errors are now reported via OmpErrors using the same path/problem structure. +- Replaced arktype with `@oh-my-pi/omptype` across all tool parameter and config schemas: ~100x faster schema construction removes the arktype startup tax (the `scope({}, { jitless: true })` workarounds are gone). Config schema errors now report via `OmpErrors` entries with the same `path`/`problem` shape. +- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, and consistent keyboard and mouse navigation. ### Fixed @@ -206,9 +208,6 @@ - Fixed remote or LAN local-engine endpoints being ignored during model discovery: the llama.cpp and Ollama probes used timeouts tuned for loopback, so a host reached over the network could exceed them and return no models, while changing `OLLAMA_BASE_URL`/`OLLAMA_HOST` could keep reusing a fresh cache from the previous endpoint. Non-loopback hosts now get a generous discovery timeout, and Ollama cache rows are scoped to the normalized endpoint ([#7087](https://github.com/can1357/oh-my-pi/issues/7087)). - Fixed `omp install` failing extension validation for pi extensions that import `createEditTool` or `createWriteTool` (e.g. gentle-pi) — the legacy `@oh-my-pi/pi-coding-agent` shim exported the read/bash/grep/find/ls tool factories but omitted the edit and write ones, so a named import threw Bun's static "Export named X not found" error. Added `createEditTool`/`createEditToolDefinition` and `createWriteTool`/`createWriteToolDefinition` to match the upstream pi surface ([#7094](https://github.com/can1357/oh-my-pi/issues/7094)). - Fixed Python eval's loopback tool bridge being routed through macOS system HTTP proxies, which caused `parallel()` tool reads to fail with `ConnectionRefusedError` after a local proxy stopped. -### Changed - -- Reworked the Ctrl+S Agent Hub onto the fullscreen modal lifecycle shared by Settings and Model Hub, with stable agent ordering, bounded rendering for large rosters, responsive persisted-session discovery, and consistent keyboard and mouse navigation. ## [17.2.0] - 2026-07-30 diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 9ec081cba..21d2887be 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -25,8 +25,9 @@ import { type SelectListMouseTarget, type TUI, visibleWidth, + wrapTextWithAnsi, } from "@oh-my-pi/pi-tui"; -import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { formatAge, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; @@ -40,7 +41,42 @@ import type { ObservableSession, SessionObserverRegistry } from "../session-obse import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; import { AgentTranscriptViewer } from "./agent-transcript-viewer"; -import { DynamicBorder } from "./dynamic-border"; +import { + bottomBorder, + divider, + dividerSplit, + row, + splitBodyWidth, + splitRow, + topBorder, + topBorderSplit, +} from "./overlay-box"; + +/** Two-pane mode needs a useful roster and a readable inspector. */ +const SPLIT_MIN_WIDTH = 96; +const DETAIL_MIN_WIDTH = 34; +const ROSTER_MIN_WIDTH = 48; + +type HubViewMode = "roster" | "tree"; + +interface AgentMetrics { + tokens: number; + requests: number; + tools: number; + cost: number; + durationMs: number; + contextTokens?: number; + contextWindow?: number; +} + +interface AggregateMetrics extends AgentMetrics { + reportedAgents: number; +} + +interface RosterRender { + lines: string[]; + hitRows: Array; +} /** Refresh cadence for the relative-time column */ const AGE_TICK_MS = 5_000; @@ -60,7 +96,7 @@ function sanitizeLine(text: string, maxWidth?: number): string { } function clampHubLine(line: string, width: number): string { - return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width - 2), Ellipsis.Omit); + return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width), Ellipsis.Omit); } const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; @@ -126,6 +162,54 @@ function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): str return resolved ? formatResolvedModelBadge(resolved) : undefined; } +/** Direct usage for one roster entry. Prefer executor progress; fall back to a live session snapshot. */ +function metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined { + const progress = observed?.progress; + if (progress) { + return { + tokens: progress.tokens, + requests: progress.requests, + tools: progress.toolCount, + cost: progress.cost, + durationMs: progress.durationMs, + contextTokens: progress.contextTokens, + contextWindow: progress.contextWindow, + }; + } + const getSessionStats = ref.session?.getSessionStats; + if (typeof getSessionStats !== "function") return undefined; + try { + const stats = getSessionStats.call(ref.session); + return { + // Match AgentProgress lifetime billing volume: cache reads are + // deliberately excluded because every turn rereads cached context. + tokens: stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite, + requests: stats.assistantMessages, + tools: stats.toolCalls, + cost: stats.cost, + durationMs: 0, + contextTokens: stats.contextUsage?.tokens, + contextWindow: stats.contextUsage?.contextWindow, + }; + } catch { + // Render-only test doubles and sessions being torn down may not expose a + // complete statistics host. Missing metrics are preferable to a broken hub. + return undefined; + } +} + +function formatCost(cost: number): string { + if (cost < 0.01) return `$${cost.toFixed(4)}`; + if (cost < 1) return `$${cost.toFixed(3)}`; + return `$${cost.toFixed(2)}`; +} + +function contextGauge(tokens: number, window: number): string { + const ratio = Math.max(0, Math.min(1, tokens / window)); + const filled = Math.round(ratio * 10); + return `${theme.fg("accent", "━".repeat(filled))}${theme.fg("dim", "─".repeat(10 - filled))} ${formatNumber(tokens)}/${formatNumber(window)} ${Math.round(ratio * 100)}%`; +} + /** Result of one host-backed transcript read for the Agent Hub viewer. */ export interface AgentHubRemoteTranscript { text: string; @@ -205,6 +289,12 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #rowOrder: Map | undefined; /** Double-tap window state for the table's left-left "close hub" gesture. */ #lastLeftTap = 0; + /** Operational ordering by default; tree mode groups descendants under their spawner. */ + #viewMode: HubViewMode = "roster"; + #treeDepthById = new Map(); + /** On narrow terminals Tab replaces the roster with the selected-agent inspector. */ + #narrowDetailsOpen = false; + #lastRenderWasSplit = false; // Transcript-viewer launch deps (passed through to AgentTranscriptViewer). #ui: TUI; @@ -292,13 +382,12 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } override render(width: number): readonly string[] { - const frame = this.#renderTable(width).map(line => clampHubLine(line, width)); const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; + const frame = this.#renderTable(width, termHeight).map(line => clampHubLine(line, width)); if (frame.length <= termHeight) return frame; // A tiny terminal can leave less room than the fixed chrome needs. Keep - // the header and footer visible rather than allowing the fullscreen modal - // to spill into the terminal's scrollback. + // the title and footer visible instead of spilling into scrollback. const footerLines = Math.min(3, frame.length); const bodyEnd = Math.max(0, termHeight - footerLines); return [...frame.slice(0, bodyEnd), ...frame.slice(-footerLines)].slice(0, termHeight); @@ -401,130 +490,276 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); const rowOrder = this.#rowOrder; + let rosterRows: AgentRef[]; if (!rowOrder) { // First refresh (usually the constructor): order by status, then recency. - this.#rows = refs.sort( + rosterRows = refs.sort( (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, ); - this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i])); + this.#rowOrder = new Map(rosterRows.map((ref, i) => [ref.id, i])); } else { - // After the hub is open, freeze the relative order so keyboard selection - // does not jump around as agents heartbeat or update activity. New agents - // are appended at the end and then stay put. - this.#rows = refs.sort((a, b) => { + // After the hub is open, freeze relative order within each lifecycle + // group. New agents append instead of moving the user's selection. + rosterRows = refs.sort((a, b) => { const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; if (statusDiff !== 0) return statusDiff; return (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER); }); - for (const ref of this.#rows) { + for (const ref of rosterRows) { if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, rowOrder.size); } } + this.#rows = this.#viewMode === "tree" ? this.#orderAsTree(rosterRows) : rosterRows; + if (this.#viewMode === "roster") this.#treeDepthById.clear(); const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1; this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); } - #observableFor(id: string): ObservableSession | undefined { - return this.#observers.getSessions().find(s => s.id === id); + /** Parent-before-child projection preserving the roster's stable sibling order. */ + #orderAsTree(refs: AgentRef[]): AgentRef[] { + const ids = new Set(refs.map(ref => ref.id)); + const children = new Map(); + for (const ref of refs) { + const parent = + ref.parentId && ref.parentId !== MAIN_AGENT_ID && ids.has(ref.parentId) ? ref.parentId : MAIN_AGENT_ID; + const siblings = children.get(parent); + if (siblings) siblings.push(ref); + else children.set(parent, [ref]); + } + + const ordered: AgentRef[] = []; + const visited = new Set(); + this.#treeDepthById.clear(); + const visit = (ref: AgentRef, depth: number): void => { + if (visited.has(ref.id)) return; + visited.add(ref.id); + this.#treeDepthById.set(ref.id, depth); + ordered.push(ref); + for (const child of children.get(ref.id) ?? []) visit(child, depth + 1); + }; + for (const root of children.get(MAIN_AGENT_ID) ?? []) visit(root, 0); + // Corrupt persisted parent cycles remain visible as roots instead of + // disappearing from the operational surface. + for (const ref of refs) visit(ref, 0); + return ordered; + } + + #observablesById(): Map { + return new Map(this.#observers.getSessions().map(session => [session.id, session])); } // ======================================================================== // Table view // ======================================================================== - #renderTable(width: number): string[] { + #renderTable(width: number, termHeight: number): string[] { this.#hitRows.length = 0; + const contentRows = Math.max(1, termHeight - 4); + const observedById = this.#observablesById(); + const split = this.#splitRosterWidth(width); + this.#lastRenderWasSplit = split !== undefined; + const selected = this.#rows[this.#selectedRow]; const lines: string[] = []; - const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40; - lines.push(...new DynamicBorder().render(width)); - const counts = this.#statusSummary(); - lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`); - lines.push(...new DynamicBorder().render(width)); - if (this.#rows.length === 0) { - lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`); + if (split !== undefined) { + const detailWidth = splitBodyWidth(width, split); + const roster = this.#renderRosterPanel(split, contentRows, observedById); + const details = this.#renderDetailPanel(selected, detailWidth, contentRows, observedById); + lines.push(topBorderSplit(width, "Agent Hub", split)); + for (let i = 0; i < contentRows; i++) { + const hit = roster.hitRows[i]; + if (hit !== undefined) this.#hitRows[lines.length] = hit; + lines.push(splitRow(roster.lines[i] ?? "", details[i] ?? "", width, split)); + } + lines.push(dividerSplit(width, split)); + lines.push(row(this.#footer(false, Math.max(1, width - 4)), width)); + lines.push(bottomBorder(width)); + return lines; + } + + const innerWidth = Math.max(1, width - 4); + if (this.#narrowDetailsOpen && selected) { + const details = this.#renderDetailPanel(selected, innerWidth, contentRows, observedById); + lines.push(topBorder(width, `Agent Hub · ${selected.id}`)); + for (const detail of details) lines.push(row(detail, width)); } else { - // Reserve both possible overflow markers plus the fixed table chrome. - // Rendering only this window keeps a large persisted registry cheap. - const chrome = 3 + 3 + (this.#notice ? 1 : 0); - const budget = Math.max(0, termHeight - chrome - 2); - if (budget > 0) { - const rendered: Array = []; - const entryAt = (index: number): string[] => { - const cached = rendered[index]; - if (cached) return cached; - const entry = this.#renderEntry( - this.#rows[index], - index === this.#selectedRow, - width, - index === this.#hoveredRow, - ); - rendered[index] = entry; - return entry; - }; - const appendEntry = (index: number, entry = entryAt(index)): void => { - for (const line of entry) { - this.#hitRows[lines.length] = index; - lines.push(line); - } - }; + const roster = this.#renderRosterPanel(innerWidth, contentRows, observedById); + lines.push(topBorder(width, "Agent Hub")); + for (let i = 0; i < contentRows; i++) { + const hit = roster.hitRows[i]; + if (hit !== undefined) this.#hitRows[lines.length] = hit; + lines.push(row(roster.lines[i] ?? "", width)); + } + } + lines.push(divider(width)); + lines.push(row(this.#footer(this.#narrowDetailsOpen, innerWidth), width)); + lines.push(bottomBorder(width)); + return lines; + } - let start = this.#selectedRow; - let end = this.#selectedRow + 1; - let used = entryAt(this.#selectedRow).length; - if (used > budget) { - appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget)); - } else { - // Grow a window around the selection until the line budget is - // spent, so selection remains visible without rendering every row. - for (let grew = true; grew; ) { - grew = false; - if (end < this.#rows.length) { - const next = entryAt(end); - if (used + next.length <= budget) { - used += next.length; - end++; - 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++) { - appendEntry(i); - } - if (end < this.#rows.length) { - lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); - } + #splitRosterWidth(width: number): number | undefined { + if (width < SPLIT_MIN_WIDTH) return undefined; + const rosterWidth = Math.max(ROSTER_MIN_WIDTH, Math.min(Math.floor(width * 0.58), width - DETAIL_MIN_WIDTH - 7)); + return splitBodyWidth(width, rosterWidth) >= DETAIL_MIN_WIDTH ? rosterWidth : undefined; + } + + #footer(showingNarrowDetails: boolean, availableWidth: number): string { + const nextView = this.#viewMode === "roster" ? "tree" : "roster"; + if (showingNarrowDetails) { + return theme.fg("dim", `Tab:roster Enter:open t:${nextView} Esc:roster`); + } + if (availableWidth < 96) { + return theme.fg("dim", `j/k:select Enter:open t:${nextView} Tab:details r/x:manage Esc:close`); + } + return theme.fg("dim", `j/k/wheel:select Enter/click:open t:${nextView} r:revive x:kill Esc:close`); + } + + #renderRosterPanel(width: number, rows: number, observedById: ReadonlyMap): RosterRender { + const lines = this.#summaryLines(observedById); + const hitRows: Array = Array.from({ length: lines.length }); + if (rows >= 8) { + lines.push(""); + hitRows.push(undefined); + } + + const noticeLines = this.#notice ? [theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width)))] : []; + const budget = Math.max(0, rows - lines.length - noticeLines.length); + if (this.#rows.length === 0) { + if (budget > 0) { + lines.push(theme.fg("dim", "no subagents yet — task spawns appear here")); + hitRows.push(undefined); + } + } else if (budget > 0) { + const window = this.#renderRosterWindow(width, budget, observedById); + lines.push(...window.lines); + hitRows.push(...window.hitRows); + } + for (const notice of noticeLines) { + lines.push(notice); + hitRows.push(undefined); + } + while (lines.length < rows) { + lines.push(""); + hitRows.push(undefined); + } + return { lines: lines.slice(0, rows), hitRows: hitRows.slice(0, rows) }; + } + + #renderRosterWindow( + width: number, + budget: number, + observedById: ReadonlyMap, + ): RosterRender { + const lines: string[] = []; + const hitRows: Array = []; + const rendered: Array = []; + const entryAt = (index: number): string[] => { + const cached = rendered[index]; + if (cached) return cached; + const entry = this.#renderEntry( + this.#rows[index], + index === this.#selectedRow, + width, + observedById.get(this.#rows[index].id), + index === this.#hoveredRow, + ); + rendered[index] = entry; + return entry; + }; + const appendEntry = (index: number, entry = entryAt(index)): void => { + for (const line of entry) { + lines.push(line); + hitRows.push(index); + } + }; + + let start = this.#selectedRow; + let end = this.#selectedRow + 1; + let used = entryAt(this.#selectedRow).length; + if (used > budget) { + appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget)); + return { lines, hitRows }; + } + + // Grow a window around the selection. Only visible entries are rendered, + // so the 5,000-agent Hub retains bounded paint cost. + for (let grew = true; grew; ) { + grew = false; + if (end < this.#rows.length) { + const next = entryAt(end); + if (used + next.length <= budget) { + used += next.length; + end++; + grew = true; + } + } + if (start > 0) { + const previous = entryAt(start - 1); + if (used + previous.length <= budget) { + start--; + used += previous.length; + grew = true; } } } - - if (this.#notice) { - lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`); + // Overflow labels consume real rows. Trim the farthest visible neighbors + // before painting them so the selected entry and both labels fit. + for ( + let markerRows = Number(start > 0) + Number(end < this.#rows.length); + used + markerRows > budget && start < end; + markerRows = Number(start > 0) + Number(end < this.#rows.length) + ) { + if (end - 1 > this.#selectedRow) { + end--; + used -= entryAt(end).length; + } else if (start < this.#selectedRow) { + used -= entryAt(start).length; + start++; + } else { + break; + } } - while (lines.length < termHeight - 3) lines.push(""); - lines.push(""); - lines.push(` ${theme.fg("dim", "j/k/wheel:select Enter/click:open r:revive x:kill Esc/←←:close")}`); - lines.push(...new DynamicBorder().render(width)); - return lines; + const showTopOverflow = start > 0 && used < budget; + const showBottomOverflow = end < this.#rows.length && used + Number(showTopOverflow) < budget; + if (showTopOverflow) { + lines.push(theme.fg("dim", `… ${start} more`)); + hitRows.push(undefined); + } + for (let i = start; i < end; i++) appendEntry(i); + if (showBottomOverflow) { + lines.push(theme.fg("dim", `… ${this.#rows.length - end} more`)); + hitRows.push(undefined); + } + return { lines, hitRows }; + } + + #summaryLines(observedById: ReadonlyMap): string[] { + const view = this.#viewMode === "roster" ? "Roster" : "Spawn tree"; + const counts = this.#statusSummary(); + const metrics = this.#aggregateMetrics(observedById); + const usage: string[] = []; + if (metrics.cost > 0) usage.push(theme.fg("statusLineCost", formatCost(metrics.cost))); + if (metrics.tokens > 0) usage.push(`${formatNumber(metrics.tokens)} tok`); + if (metrics.requests > 0) usage.push(`${formatNumber(metrics.requests)} req`); + if (metrics.tools > 0) usage.push(`${formatNumber(metrics.tools)} tools`); + if (metrics.durationMs > 0) usage.push(`${formatDuration(metrics.durationMs)} agent time`); + return [ + `${theme.bold(view)}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`, + usage.length > 0 + ? theme.fg("dim", usage.join(theme.sep.dot)) + : theme.fg( + "dim", + metrics.reportedAgents > 0 + ? "usage not priced by active providers" + : "usage appears with agent progress", + ), + ]; } #statusSummary(): string { const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; - for (const ref of this.#rows) { - counts[ref.status]++; - } + 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]; @@ -533,20 +768,136 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return parts.join(theme.sep.dot); } + #aggregateMetrics(observedById: ReadonlyMap): AggregateMetrics { + const total: AggregateMetrics = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: 0, + reportedAgents: 0, + }; + for (const ref of this.#rows) { + const metrics = metricsFor(ref, observedById.get(ref.id)); + if (!metrics) continue; + total.reportedAgents++; + total.tokens += metrics.tokens; + total.requests += metrics.requests; + total.tools += metrics.tools; + total.cost += metrics.cost; + total.durationMs += metrics.durationMs; + } + return total; + } + + #renderDetailPanel( + ref: AgentRef | undefined, + width: number, + rows: number, + observedById: ReadonlyMap, + ): string[] { + if (!ref) return [theme.fg("dim", "Select an agent to inspect"), ...Array.from({ length: rows - 1 }, () => "")]; + const observed = observedById.get(ref.id); + const progress = observed?.progress; + const metrics = metricsFor(ref, observed); + const children = this.#rows.filter(candidate => candidate.parentId === ref.id); + const lines: string[] = []; + const add = (line = ""): void => { + if (lines.length < rows) lines.push(truncateToWidth(line, width)); + }; + const addWrapped = (text: string, maxRows = 2): void => { + for (const wrapped of wrapTextWithAnsi(sanitizeLine(text), Math.max(1, width)).slice(0, maxRows)) add(wrapped); + }; + const section = (label: string): void => { + if (lines.length > 0) add(); + add(theme.bold(theme.fg("accent", label))); + }; + + add(`${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.displayName || ref.id))}`); + if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", ref.id)); + const lifecycle = [ + ref.status, + metrics?.durationMs ? formatDuration(metrics.durationMs) : undefined, + `active ${formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))}`, + ].filter(Boolean); + add(theme.fg("dim", lifecycle.join(theme.sep.dot))); + const badge = modelBadge(ref, observed); + if (badge) add(badge); + + const task = observed?.description ?? progress?.task ?? ref.activity; + if (task) { + section("Task"); + addWrapped(task); + } + + const current = progress?.currentTool + ? `${progress.currentTool}${progress.currentToolArgs ? ` · ${progress.currentToolArgs}` : ""}` + : (progress?.lastIntent ?? ref.activity); + if (current) { + section("Current"); + addWrapped(current); + if (progress?.retryState) { + add(theme.fg("warning", `retry ${progress.retryState.attempt}/${progress.retryState.maxAttempts}`)); + } + } + + section("Usage"); + if (metrics) { + const spend = [ + metrics.cost > 0 ? formatCost(metrics.cost) : "cost —", + `${formatNumber(metrics.tokens)} tokens`, + `${formatNumber(metrics.requests)} requests`, + `${formatNumber(metrics.tools)} tools`, + ]; + addWrapped(spend.join(theme.sep.dot)); + if (metrics.contextTokens !== undefined && metrics.contextWindow) { + add(contextGauge(metrics.contextTokens, metrics.contextWindow)); + } + } else { + add(theme.fg("dim", "No usage snapshot")); + } + + section("Lineage"); + add( + `Spawned by ${replaceTabs(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`, + ); + if (children.length > 0) addWrapped(children.map(child => child.id).join(", "), 1); + add(theme.fg("dim", `Registered ${new Date(ref.createdAt).toISOString().slice(0, 16).replace("T", " ")}Z`)); + + section("Changes"); + add( + theme.fg( + "dim", + ref.kind === "advisor" ? "Read-only transcript" : "Shared workspace · per-agent LoC not attributable", + ), + ); + + while (lines.length < rows) lines.push(""); + return lines.slice(0, rows); + } + /** * One agent entry, 1-2 lines: * `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity * left, metadata right-aligned (inlined when the terminal is too narrow) — * plus an indented dim task line when the agent's work is known. */ - #renderEntry(ref: AgentRef, selected: boolean, width: number, hovered = false): string[] { - const max = Math.max(1, width - 2); + #renderEntry( + ref: AgentRef, + selected: boolean, + width: number, + observed: ObservableSession | undefined, + hovered = false, + ): string[] { + const max = Math.max(1, width); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`]; + const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0; + const branch = this.#viewMode === "tree" && depth > 0 ? `${" ".repeat(depth - 1)}↳ ` : ""; + const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${theme.bold(replaceTabs(ref.id))}`]; if (ref.displayName && ref.displayName !== ref.id) { fields.push(theme.fg("dim", replaceTabs(ref.displayName))); } - if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) { + if (this.#viewMode === "roster" && ref.parentId && ref.parentId !== MAIN_AGENT_ID) { fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`)); } if (ref.kind === "advisor") { @@ -556,9 +907,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou if (unread > 0) { fields.push(theme.fg("warning", `⧉ ${unread}`)); } - const left = ` ${fields.join(" ")}`; + const left = fields.join(" "); - const observed = this.#observableFor(ref.id); const meta: string[] = []; const badge = modelBadge(ref, observed); if (badge) meta.push(badge); @@ -575,12 +925,15 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const task = observed?.description ?? observed?.progress?.task ?? ref.activity; if (task) { - entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`); + const indentWidth = Math.min(max - 1, 4 + depth * 2); + entry.push( + `${padding(Math.max(0, indentWidth))}${theme.fg("muted", sanitizeLine(task, Math.max(10, max - indentWidth)))}`, + ); } if (!hovered) return entry; - return entry.map(row => { - const rowWidth = visibleWidth(row); - return theme.bg("selectedBg", rowWidth < max ? row + padding(max - rowWidth) : row); + return entry.map(lineRow => { + const rowWidth = visibleWidth(lineRow); + return theme.bg("selectedBg", rowWidth < max ? lineRow + padding(max - rowWidth) : lineRow); }); } @@ -615,10 +968,31 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #handleTableInput(keyData: string): void { if (matchesKey(keyData, "escape")) { - this.#onDone(); + if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) { + this.#narrowDetailsOpen = false; + this.#requestRender(); + } else { + this.#onDone(); + } + return; + } + if ((matchesKey(keyData, "tab") || keyData === "\t") && !this.#lastRenderWasSplit) { + if (this.#rows.length > 0) this.#narrowDetailsOpen = !this.#narrowDetailsOpen; + this.#requestRender(); + return; + } + if (keyData === "t") { + this.#viewMode = this.#viewMode === "roster" ? "tree" : "roster"; + this.#refreshRows(); + this.#requestRender(); return; } if (matchesKey(keyData, "left")) { + if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) { + this.#narrowDetailsOpen = false; + this.#requestRender(); + return; + } const now = Date.now(); if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) { this.#lastLeftTap = 0; diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 8be901af1..cf8eddffc 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -58,12 +58,13 @@ interface RenderedAgentRow { selected: boolean; } -function renderedAgentRows(hub: AgentHubOverlayComponent): RenderedAgentRow[] { - // Entry first lines are ` …`; task lines are - // indented deeper and chrome lines never carry the cursor slot. +function renderedAgentRows(hub: AgentHubOverlayComponent, width = 120): RenderedAgentRow[] { + // Boxed roster entry first lines are + // `│ [tree-prefix] …`; task lines are + // indented deeper and never match the cursor/status slots. const rows: RenderedAgentRow[] = []; - for (const raw of hub.render(120)) { - const match = /^ (❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); + for (const raw of hub.render(width)) { + const match = /^│ (❯| ) (\S+) (?:(?: {2})*↳ )?(\S+)/u.exec(Bun.stripANSI(raw)); if (match) rows.push({ id: match[3]!, selected: match[1] === "❯" }); } return rows; @@ -174,7 +175,7 @@ describe("Agent hub row ordering", () => { expect(cleanLine.includes("\n")).toBe(false); expect(cleanLine.includes("\r")).toBe(false); const width = visibleWidth(line); - expect(width).toBeLessThanOrEqual(78); + expect(width).toBeLessThanOrEqual(80); } hub.dispose(); @@ -230,14 +231,14 @@ describe("Agent hub row ordering", () => { expect(selectedAgentId(hub)).toBe("Beta"); const frame = hub.render(120); - const alphaRow = frame.findIndex(line => /^ {3}\S+ Alpha/u.test(Bun.stripANSI(line))); + const alphaRow = frame.findIndex(line => /^│ {3}\S+ Alpha/u.test(Bun.stripANSI(line))); expect(alphaRow).toBeGreaterThanOrEqual(0); hub.handleInput(leftClick(alphaRow + 1)); expect(selectedAgentId(hub)).toBe("Alpha"); expect(focused).toEqual([]); const selectedFrame = hub.render(120); - const selectedAlphaRow = selectedFrame.findIndex(line => /^ ❯ \S+ Alpha/u.test(Bun.stripANSI(line))); + const selectedAlphaRow = selectedFrame.findIndex(line => /^│ ❯ \S+ Alpha/u.test(Bun.stripANSI(line))); hub.handleInput(leftClick(selectedAlphaRow + 1)); await Promise.resolve(); expect(focused).toEqual(["Alpha"]); @@ -311,4 +312,145 @@ describe("Agent hub row ordering", () => { hub.dispose(); } }); + + it("renders aggregate usage and a selected-agent inspector without inventing change attribution", () => { + geometry = stubStdoutGeometry(140); + geometry.setRows(28); + const agents = new AgentRegistry(); + agents.register({ + id: "Reviewer", + displayName: "Security Reviewer", + kind: "sub", + parentId: "Main", + session: null, + }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "Reviewer", + kind: "subagent", + label: "Reviewer", + description: "Review the session lifecycle and produce actionable findings", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "Reviewer", + index: 0, + agent: "reviewer", + agentSource: "bundled", + status: "running", + task: "Review the session lifecycle", + currentTool: "read", + currentToolArgs: "src/session/agent-session.ts", + recentTools: [], + recentOutput: [], + toolCount: 27, + requests: 12, + tokens: 18_400, + contextTokens: 31_000, + contextWindow: 128_000, + cost: 0.2134, + durationMs: 134_000, + resolvedModel: "openai/gpt-5.4:high", + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const rendered = Bun.stripANSI(hub.render(140).join("\n")); + expect(rendered).toContain("Roster · 1 running"); + expect(rendered).toContain("$0.213 · 18K tok · 12 req · 27 tools · 2m14s agent time"); + expect(rendered).toContain("Security Reviewer"); + expect(rendered).toContain("Review the session lifecycle and produce"); + expect(rendered).toContain("read · src/session/agent-session.ts"); + expect(rendered).toContain("$0.213 · 18K tokens · 12 requests · 27 tools"); + expect(rendered).toContain("31K/128K 24%"); + expect(rendered).toContain("Registered "); + expect(rendered).toContain("Shared workspace · per-agent LoC not attributable"); + } finally { + hub.dispose(); + } + }); + + it("toggles a parent-before-child spawn tree while preserving selection", () => { + vi.useFakeTimers(); + geometry = stubStdoutGeometry(120); + const agents = new AgentRegistry(); + setSystemTime(1_000); + agents.register({ id: "Parent", displayName: "Parent", kind: "sub", parentId: "Main", session: null }); + setSystemTime(2_000); + agents.register({ id: "Peer", displayName: "Peer", kind: "sub", parentId: "Main", session: null }); + setSystemTime(3_000); + agents.register({ id: "Child", displayName: "Child", kind: "sub", parentId: "Parent", session: null }); + const hub = makeHub(agents); + + try { + expect(renderedAgentIds(hub)).toEqual(["Child", "Peer", "Parent"]); + expect(selectedAgentId(hub)).toBe("Child"); + hub.handleInput("t"); + const treeIds = renderedAgentIds(hub); + expect(treeIds.indexOf("Child")).toBe(treeIds.indexOf("Parent") + 1); + expect(selectedAgentId(hub)).toBe("Child"); + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("Spawn tree"); + expect(rendered).toContain("↳ Child"); + expect(rendered).toContain("Spawned by Parent"); + hub.handleInput("t"); + expect(renderedAgentIds(hub)).toEqual(["Child", "Peer", "Parent"]); + } finally { + hub.dispose(); + vi.useRealTimers(); + setSystemTime(); + } + }); + + it("opens the selected-agent inspector as a narrow-terminal fallback", () => { + geometry = stubStdoutGeometry(80); + geometry.setRows(28); + const agents = new AgentRegistry(); + agents.register({ id: "NarrowAgent", displayName: "Narrow Agent", kind: "sub", session: null }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "NarrowAgent", + kind: "subagent", + label: "Narrow Agent", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "NarrowAgent", + status: "running", + task: "Inspect responsive behavior", + recentTools: [], + recentOutput: [], + toolCount: 3, + requests: 2, + tokens: 900, + cost: 0, + durationMs: 2_000, + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const roster = Bun.stripANSI(hub.render(80).join("\n")); + expect(roster).toContain("Tab:details"); + expect(roster).not.toContain("Registered "); + + hub.handleInput("\t"); + const details = Bun.stripANSI(hub.render(80).join("\n")); + expect(details).toContain("Agent Hub · NarrowAgent"); + expect(details).toContain("Usage"); + expect(details).toContain("cost — · 900 tokens · 2 requests · 3 tools"); + expect(details).toContain("Tab:roster"); + for (const line of hub.render(80)) expect(visibleWidth(line)).toBeLessThanOrEqual(80); + + hub.handleInput("\x1b"); + expect(Bun.stripANSI(hub.render(80).join("\n"))).toContain("Roster"); + } finally { + hub.dispose(); + } + }); }); From c30ca0b15798c97c3c4f71cc1a86076551ba9b6c Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Mon, 3 Aug 2026 14:57:51 -0700 Subject: [PATCH 031/126] refactor(coding-agent): preserve Agent Hub history --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/components/agent-hub.ts | 48 +++++++++-- .../src/registry/agent-registry.ts | 11 ++- .../src/registry/persisted-agents.ts | 83 +++++++++++++++++++ .../test/agent-hub-activate.test.ts | 48 +++++++++++ .../test/agent-hub-ordering.test.ts | 16 +++- 6 files changed, 196 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8850d31fc..47d583e0f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,7 @@ - Replaced arktype with @oh-my-pi/omptype for tool parameter and config schemas, significantly improving startup performance with ~100x faster schema construction. Config schema errors are now reported via OmpErrors using the same path/problem structure. - Replaced arktype with `@oh-my-pi/omptype` across all tool parameter and config schemas: ~100x faster schema construction removes the arktype startup tax (the `scope({}, { jitless: true })` workarounds are gone). Config schema errors now report via `OmpErrors` entries with the same `path`/`problem` shape. -- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, and consistent keyboard and mouse navigation. +- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. ### Fixed diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 21d2887be..4c43425c5 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -115,6 +115,19 @@ function statusGlyph(status: AgentStatus): string { } } +function statusText(status: AgentStatus, text: string): string { + switch (status) { + case "running": + return theme.fg("accent", text); + case "idle": + return theme.fg("success", text); + case "parked": + return theme.fg("muted", text); + case "aborted": + return theme.fg("error", text); + } +} + /** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string { const model = theme.fg("muted", replaceTabs(modelId)); @@ -277,6 +290,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #disposed = false; /** Resolves after persisted historical subagents have been registered and rows refreshed. */ readonly persistedSubagentsReady: Promise; + /** Prevent the async persisted-session scan from flashing a false empty state. */ + #loadingPersistedSubagents = false; // Table state #rows: AgentRef[] = []; @@ -322,6 +337,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#requestRender = deps.requestRender; this.#hubKeys = deps.hubKeys; this.#remote = deps.remote; + this.#loadingPersistedSubagents = !this.#remote && Boolean(deps.sessionFile?.endsWith(".jsonl")); this.#ui = deps.ui ?? ({ @@ -351,6 +367,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou logger.warn("Failed to register persisted subagents", { error }); }) .finally(() => { + this.#loadingPersistedSubagents = false; if (!this.#disposed) this.#requestRender(); }); this.#refreshRows(); @@ -626,9 +643,21 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const noticeLines = this.#notice ? [theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width)))] : []; const budget = Math.max(0, rows - lines.length - noticeLines.length); if (this.#rows.length === 0) { - if (budget > 0) { - lines.push(theme.fg("dim", "no subagents yet — task spawns appear here")); - hitRows.push(undefined); + if (this.#loadingPersistedSubagents) { + if (budget > 0) { + lines.push(`${statusGlyph("running")} ${theme.fg("accent", "Loading saved agents…")}`); + hitRows.push(undefined); + } + } else { + const emptyState = [ + `${theme.fg("muted", theme.status.shadowed)} ${theme.bold("No agents in this session")}`, + theme.fg("dim", "Finished, parked, and killed subagents remain with the session that created them."), + theme.fg("dim", "Resume that session with omp-dev --continue, or spawn a task here."), + ]; + for (const line of emptyState.slice(0, budget)) { + lines.push(line); + hitRows.push(undefined); + } } } else if (budget > 0) { const window = this.#renderRosterWindow(width, budget, observedById); @@ -763,7 +792,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const parts: string[] = []; for (const status of ["running", "idle", "parked", "aborted"] as const) { const count = counts[status]; - if (count > 0) parts.push(`${count} ${status}`); + if (count > 0) parts.push(`${statusGlyph(status)} ${statusText(status, `${count} ${status}`)}`); } return parts.join(theme.sep.dot); } @@ -815,12 +844,13 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou add(`${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.displayName || ref.id))}`); if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", ref.id)); - const lifecycle = [ - ref.status, + const lifecycleDetails = [ metrics?.durationMs ? formatDuration(metrics.durationMs) : undefined, `active ${formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))}`, ].filter(Boolean); - add(theme.fg("dim", lifecycle.join(theme.sep.dot))); + add( + `${statusText(ref.status, ref.status)}${theme.fg("dim", `${theme.sep.dot}${lifecycleDetails.join(theme.sep.dot)}`)}`, + ); const badge = modelBadge(ref, observed); if (badge) add(badge); @@ -893,7 +923,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0; const branch = this.#viewMode === "tree" && depth > 0 ? `${" ".repeat(depth - 1)}↳ ` : ""; - const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${theme.bold(replaceTabs(ref.id))}`]; + const id = replaceTabs(ref.id); + const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id); + const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`]; if (ref.displayName && ref.displayName !== ref.id) { fields.push(theme.fg("dim", replaceTabs(ref.displayName))); } diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index 7baf1dd05..77ad8a72c 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -62,6 +62,12 @@ export interface RegisterInput { session: AgentSession | null; sessionFile?: string | null; status?: AgentStatus; + /** Last persisted task summary, when restoring a historical agent. */ + activity?: string; + /** Original registration timestamp, when known from persisted history. */ + createdAt?: number; + /** Last transcript activity timestamp, when known from persisted history. */ + lastActivity?: number; } export class AgentRegistry { @@ -96,8 +102,9 @@ export class AgentRegistry { status: input.status ?? "running", session: input.session, sessionFile: input.sessionFile ?? null, - createdAt: now, - lastActivity: now, + createdAt: input.createdAt ?? now, + lastActivity: input.lastActivity ?? now, + activity: input.activity, }; this.#refs.set(ref.id, ref); this.#emit({ type: "registered", ref }); diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 3aaadada2..56c386f2d 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -11,6 +11,81 @@ import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; * persisted-subagent scan must not register them as plain `sub` refs. */ const VIBE_LIFECYCLE_MARKER = Buffer.from('"vibe-session-lifecycle"'); +const MAX_METADATA_LINES = 64; + +interface PersistedAgentMetadata { + activity?: string; + createdAt?: number; + lastActivity?: number; +} + +function recordOf(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function timestampOf(value: unknown): number | undefined { + if (typeof value !== "string") return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function summarizePersistedTask(task: string): string | undefined { + const withoutPreamble = task.replace(/^Complete the assignment below,\s*thoroughly:\s*/i, ""); + const lines = withoutPreamble.split(/\r?\n/); + const targetIndex = lines.findIndex(line => line.trim().toLowerCase() === "# target"); + const targetLines: string[] = []; + if (targetIndex >= 0) { + for (const line of lines.slice(targetIndex + 1)) { + if (line.trimStart().startsWith("# ")) break; + targetLines.push(line); + } + } + const summary = (targetLines.length > 0 ? targetLines : lines).join(" ").replace(/\s+/g, " ").trim(); + return summary ? summary.slice(0, 1_000) : undefined; +} + +/** + * Read only the small session prefix needed by the Hub. A subagent's first + * `session_init` is written before its conversation, so this never walks a + * multi-megabyte historical transcript just to populate one roster row. + */ +async function readPersistedAgentMetadata(sessionFile: string): Promise { + const stat = fs.promises.stat(sessionFile).catch(() => undefined); + let createdAt: number | undefined; + let activity: string | undefined; + try { + let linesRead = 0; + for await (const bytes of readLines(Bun.file(sessionFile).stream())) { + if (linesRead++ >= MAX_METADATA_LINES) break; + const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let entry: Record | undefined; + try { + entry = recordOf(JSON.parse(line.toString("utf8"))); + } catch { + continue; + } + if (entry?.type === "session") { + createdAt ??= timestampOf(entry.timestamp); + continue; + } + if (entry?.type !== "session_init") continue; + createdAt ??= timestampOf(entry.timestamp); + if (typeof entry.task === "string") activity = summarizePersistedTask(entry.task); + break; + } + } catch { + // A readable transcript is still useful even when its optional metadata + // prefix is malformed. + } + const file = await stat; + return { + activity, + createdAt: createdAt ?? file?.birthtimeMs, + lastActivity: file?.mtimeMs, + }; +} async function readPersistedVibeChildIds(sessionFile: string): Promise> { const ids = new Set(); @@ -76,6 +151,7 @@ async function registerPersistedSubagentsFromDir( if (existing?.sessionFile !== sessionFile) { // The id is reused across `/new`; refresh it to the current session's file. if (existing) registry.unregister(advisorId); + const metadata = await readPersistedAgentMetadata(sessionFile); registry.register({ id: advisorId, displayName, @@ -83,6 +159,9 @@ async function registerPersistedSubagentsFromDir( parentId: owner, session: null, sessionFile, + activity: metadata.activity, + createdAt: metadata.createdAt, + lastActivity: metadata.lastActivity, status: "parked", }); } @@ -91,6 +170,7 @@ async function registerPersistedSubagentsFromDir( const id = entry.name.slice(0, -6); if (vibeOwnedIds.has(id) && registry.get(id)?.sessionFile !== sessionFile) continue; if (!registry.get(id)) { + const metadata = await readPersistedAgentMetadata(sessionFile); registry.register({ id, displayName: id, @@ -98,6 +178,9 @@ async function registerPersistedSubagentsFromDir( parentId: parentId ?? MAIN_AGENT_ID, session: null, sessionFile, + activity: metadata.activity, + createdAt: metadata.createdAt, + lastActivity: metadata.lastActivity, status: "parked", }); } diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 294b78891..79c7ace51 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -116,6 +116,53 @@ describe("Agent hub Enter activation", () => { expect(agents.get("Worker")?.sessionFile).toBe(workerSessionFile); hub.dispose(); }); + + it("restores saved task metadata and timestamps for completed agents", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-metadata-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); + const createdAt = "2026-07-30T01:13:37.835Z"; + const lastActivity = new Date("2026-07-30T01:15:00.000Z"); + await Bun.write(sessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id: "worker-session", timestamp: createdAt, cwd: TEST_CWD }), + JSON.stringify({ + type: "session_init", + id: "init", + parentId: null, + timestamp: createdAt, + systemPrompt: "system", + task: "Complete the assignment below, thoroughly:\n\n# Target\nInspect dependency boundaries and report unsafe coupling.\n\n# Change\nRead the implementation.", + tools: ["read"], + }), + ].join("\n"), + ); + await fs.utimes(workerSessionFile, lastActivity, lastActivity); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + await hub.persistedSubagentsReady; + + expect(agents.get("Worker")).toMatchObject({ + activity: "Inspect dependency boundaries and report unsafe coupling.", + createdAt: Date.parse(createdAt), + lastActivity: lastActivity.getTime(), + status: "parked", + }); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain( + "Inspect dependency boundaries and report unsafe coupling.", + ); + hub.dispose(); + }); it("avoids multi-second event-loop stalls while discovering agents from a large session", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); @@ -421,6 +468,7 @@ describe("Agent hub double-← gating", () => { expect(hub).toBeDefined(); expect(overlayOptions()).toMatchObject({ width: "100%", maxHeight: "100%", margin: 0, fullscreen: true }); expect(agents.get("Worker")).toBeUndefined(); + expect(Bun.stripANSI(hub!.render(120).join("\n"))).toContain("Loading saved agents"); await hub!.persistedSubagentsReady; expect(agents.get("Worker")?.status).toBe("parked"); hub!.dispose(); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index cf8eddffc..01722e074 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -102,6 +102,20 @@ describe("Agent hub row ordering", () => { AgentRegistry.resetGlobalForTests(); }); + it("renders a useful empty state before any task agents exist", () => { + geometry = stubStdoutGeometry(120); + const hub = makeHub(new AgentRegistry()); + + try { + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("No agents in this session"); + expect(rendered).toContain("Finished, parked, and killed subagents remain with the session"); + expect(rendered).toContain("Resume that session with omp-dev --continue, or spawn a task here."); + } finally { + hub.dispose(); + } + }); + it("freezes the initial lastActivity order while the hub is open", () => { vi.useFakeTimers(); let hub: AgentHubOverlayComponent | undefined; @@ -359,7 +373,7 @@ describe("Agent hub row ordering", () => { try { const rendered = Bun.stripANSI(hub.render(140).join("\n")); - expect(rendered).toContain("Roster · 1 running"); + expect(rendered).toMatch(/Roster · \S+ 1 running/u); expect(rendered).toContain("$0.213 · 18K tok · 12 req · 27 tools · 2m14s agent time"); expect(rendered).toContain("Security Reviewer"); expect(rendered).toContain("Review the session lifecycle and produce"); From 8f1de61e9f371bd64e29b949f85094f0f5343907 Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Mon, 3 Aug 2026 16:11:38 -0700 Subject: [PATCH 032/126] refactor(coding-agent): densify Agent Hub metrics --- .../coding-agent/src/config/model-resolver.ts | 59 ++- .../src/modes/components/agent-hub.ts | 453 +++++++++++++----- .../modes/controllers/selector-controller.ts | 1 + packages/coding-agent/src/task/executor.ts | 23 +- packages/coding-agent/src/task/index.ts | 4 +- .../src/task/structured-subagent.ts | 18 +- packages/coding-agent/src/task/types.ts | 4 + .../test/agent-hub-activate.test.ts | 117 ++++- .../test/agent-hub-ordering.test.ts | 367 +++++++++++++- .../coding-agent/test/model-resolver.test.ts | 62 +++ .../test/task/structured-subagent.test.ts | 108 +++++ 11 files changed, 1056 insertions(+), 160 deletions(-) diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 473fbd13c..8d3e01417 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -932,6 +932,28 @@ function normalizeModelPatternList(value: string | string[] | undefined): string return patterns.map(pattern => pattern.trim()).filter(Boolean); } +/** + * Extract the first explicit model-role alias from a raw model selection. + * + * This intentionally runs before role expansion so callers can retain the + * source identity (`@smol`, `pi/slow`, or `*`) even when it resolves to a + * concrete provider/model or inherited fallback. Bare role names and explicit + * provider/model selectors are not role aliases. + */ +export function resolveExplicitModelRole( + value: string | string[] | undefined, + settings?: ModelRoleLookup, +): string | undefined { + for (const pattern of normalizeModelPatternList(value)) { + const prefixLength = modelRoleAliasPrefixLength(pattern); + if (prefixLength === undefined) continue; + const { base } = splitThinkingSuffix(pattern, prefixLength, MAX_THINKING_SUFFIX_OPTIONS); + const role = getModelRoleAlias(base, settings); + if (role) return role; + } + return undefined; +} + function isSessionInheritedAgentPattern(value: string): boolean { return ( value === DEFAULT_MODEL_ROLE || @@ -1068,6 +1090,8 @@ export function resolveConfiguredModelPatterns( }); } export interface AgentModelPatternResolutionOptions { + /** Highest-priority request selector, when supplied by a caller. */ + requestModel?: string | string[]; settingsOverride?: string | string[]; agentModel?: string | string[]; settings?: Settings; @@ -1075,11 +1099,25 @@ export interface AgentModelPatternResolutionOptions { fallbackModelPattern?: string; } -export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOptions): string[] { - const { settingsOverride, agentModel, settings, activeModelPattern, fallbackModelPattern } = options; +interface EffectiveAgentModelSelection { + source?: string | string[]; + patterns: string[]; +} + +function resolveEffectiveAgentModelSelection( + options: AgentModelPatternResolutionOptions, +): EffectiveAgentModelSelection { + const { requestModel, settingsOverride, agentModel, settings, activeModelPattern, fallbackModelPattern } = options; + + const requestPatterns = resolveConfiguredModelPatterns(requestModel, settings); + if (requestPatterns.length > 0) { + return { source: requestModel, patterns: requestPatterns }; + } const overridePatterns = resolveConfiguredModelPatterns(settingsOverride, settings); - if (overridePatterns.length > 0) return overridePatterns; + if (overridePatterns.length > 0) { + return { source: settingsOverride, patterns: overridePatterns }; + } const normalizedAgentPatterns = normalizeModelPatternList(agentModel); const configuredAgentPatterns = resolveConfiguredModelPatterns(agentModel, settings); @@ -1090,14 +1128,23 @@ export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOp singleAgentPattern === formatModelRoleAlias("task") || singleAgentPattern === `${LEGACY_MODEL_ROLE_ALIAS_PREFIX}task` ) { - return configuredAgentPatterns; + return { source: agentModel, patterns: configuredAgentPatterns }; } - if (!agentInheritsSessionModel) return configuredAgentPatterns; + if (!agentInheritsSessionModel) return { source: agentModel, patterns: configuredAgentPatterns }; } const fallback = activeModelPattern?.trim() || fallbackModelPattern?.trim() || settings?.getModelRole("default")?.trim() || ""; - return resolveConfiguredModelPatterns(fallback, settings); + return { patterns: resolveConfiguredModelPatterns(fallback, settings) }; +} + +/** Return the raw selector source that supplies the effective agent patterns. */ +export function resolveAgentModelSource(options: AgentModelPatternResolutionOptions): string | string[] | undefined { + return resolveEffectiveAgentModelSelection(options).source; +} + +export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOptions): string[] { + return resolveEffectiveAgentModelSelection(options).patterns; } /** Default prewalk hand-off target when no explicit target is configured. */ export const DEFAULT_PREWALK_TARGET = "@smol"; diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 4c43425c5..055ec4944 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -29,6 +29,8 @@ import { } from "@oh-my-pi/pi-tui"; import { formatAge, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; +import { getRoleInfo } from "../../config/model-roles"; +import type { Settings } from "../../config/settings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; @@ -78,6 +80,11 @@ interface RosterRender { hitRows: Array; } +/** Legacy progress snapshots may omit counters; snapshot absence remains distinct. */ +function metricNumber(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + /** Refresh cadence for the relative-time column */ const AGE_TICK_MS = 5_000; const DATA_CHANGE_RENDER_COALESCE_MS = 100; @@ -135,86 +142,98 @@ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): st const display = theme.thinking[level as keyof typeof theme.thinking] ?? level; return `${model} ${theme.getThinkingBorderColor(level)(display)}`; } +/** Textual model-role tag; color reinforces (but never replaces) the label. */ +function formatRoleBadge(role: string, settings: Settings): string { + const info = getRoleInfo(role, settings); + return theme.fg(info.color ?? "muted", replaceTabs(info.tag ?? info.name ?? role)); +} /** Format a resolved selector, preserving provider identity when requested. */ -function formatResolvedModelBadge(resolved: string, preserveProvider = false): string { +function formatResolvedModelBadge(resolved: string, preserveProvider = false, fallbackLevel?: ThinkingLevel): string { // Model ids may themselves contain colons (`qwen3:14b`), so only treat the // suffix as a thinking level when it parses as one. const colon = resolved.lastIndexOf(":"); - const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined; - const selector = level !== undefined ? resolved.slice(0, colon) : resolved; + const explicitLevel = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined; + const selector = explicitLevel !== undefined ? resolved.slice(0, colon) : resolved; const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1); - return formatModelBadge(label, level); + return formatModelBadge(label, explicitLevel ?? fallbackLevel); } /** - * Active model + reasoning level for a hub row: live session state when the - * agent is attached, else the executor-reported `resolvedModel` selector - * (`provider/id`, optionally `:`). Active retry fallbacks retain their - * provider and carry an explicit marker. Undefined when no model is known - * (e.g. a parked historical agent restored from disk). + * Resolved model + reasoning level for a hub row. Exact executor progress is + * authoritative (and survives completion); direct live sessions are the + * fallback for agents without an observer snapshot. Active retry fallbacks + * retain provider identity and carry an explicit marker. */ function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined { const progress = observed?.progress; - // Prefer the live session's own resolved fallback selector; else honor the - // executor-reported fallback flag. The latter covers observer-only rows (no - // live session) AND live rows whose fallback armed no session retry state — - // e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied` - // without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined. + const liveThinkingLevel = ref.session?.thinkingLevel; + // The executor fallback flag also covers retries that do not populate the + // live session's retryFallbackModel (for example Fireworks Fast → base). const fallbackSelector = ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined); if (fallbackSelector) { - return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`; + return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true, liveThinkingLevel)}`; + } + if (progress?.resolvedModel) { + return formatResolvedModelBadge(progress.resolvedModel, false, liveThinkingLevel); } const model = ref.session?.model; - if (model) { - const level = model.thinking ? ref.session?.thinkingLevel : undefined; - return formatModelBadge(model.id, level); - } - const resolved = progress?.resolvedModel; - return resolved ? formatResolvedModelBadge(resolved) : undefined; + if (!model) return undefined; + const level = model.thinking ? liveThinkingLevel : undefined; + return formatModelBadge(model.id, level); } -/** Direct usage for one roster entry. Prefer executor progress; fall back to a live session snapshot. */ -function metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined { +/** Exact observer usage for one roster entry. */ +function progressMetrics(observed: ObservableSession | undefined): AgentMetrics | undefined { const progress = observed?.progress; - if (progress) { - return { - tokens: progress.tokens, - requests: progress.requests, - tools: progress.toolCount, - cost: progress.cost, - durationMs: progress.durationMs, - contextTokens: progress.contextTokens, - contextWindow: progress.contextWindow, - }; - } - const getSessionStats = ref.session?.getSessionStats; - if (typeof getSessionStats !== "function") return undefined; - try { - const stats = getSessionStats.call(ref.session); - return { - // Match AgentProgress lifetime billing volume: cache reads are - // deliberately excluded because every turn rereads cached context. - tokens: stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite, - requests: stats.assistantMessages, - tools: stats.toolCalls, - cost: stats.cost, - durationMs: 0, - contextTokens: stats.contextUsage?.tokens, - contextWindow: stats.contextUsage?.contextWindow, - }; - } catch { - // Render-only test doubles and sessions being torn down may not expose a - // complete statistics host. Missing metrics are preferable to a broken hub. + if (!progress) return undefined; + const { tokens, requests, toolCount: tools, cost, durationMs } = progress; + if ( + typeof tokens !== "number" || + !Number.isFinite(tokens) || + typeof requests !== "number" || + !Number.isFinite(requests) || + typeof tools !== "number" || + !Number.isFinite(tools) || + typeof cost !== "number" || + !Number.isFinite(cost) || + typeof durationMs !== "number" || + !Number.isFinite(durationMs) + ) { return undefined; } + return { + tokens, + requests, + tools, + cost, + durationMs, + contextTokens: + typeof progress.contextTokens === "number" && Number.isFinite(progress.contextTokens) + ? progress.contextTokens + : undefined, + contextWindow: + typeof progress.contextWindow === "number" && Number.isFinite(progress.contextWindow) + ? progress.contextWindow + : undefined, + }; } function formatCost(cost: number): string { - if (cost < 0.01) return `$${cost.toFixed(4)}`; - if (cost < 1) return `$${cost.toFixed(3)}`; - return `$${cost.toFixed(2)}`; + const amount = metricNumber(cost); + if (amount < 0.01) return `$${amount.toFixed(4)}`; + if (amount < 1) return `$${amount.toFixed(3)}`; + return `$${amount.toFixed(2)}`; +} +function formatMetrics(metrics: AgentMetrics): string { + return [ + formatCost(metrics.cost), + formatDuration(metrics.durationMs), + `${formatNumber(metrics.requests)} req`, + `${formatNumber(metrics.tools)} tools`, + `${formatNumber(metrics.tokens)} tok`, + ].join(theme.sep.dot); } function contextGauge(tokens: number, window: number): string { @@ -222,6 +241,26 @@ function contextGauge(tokens: number, window: number): string { const filled = Math.round(ratio * 10); return `${theme.fg("accent", "━".repeat(filled))}${theme.fg("dim", "─".repeat(10 - filled))} ${formatNumber(tokens)}/${formatNumber(window)} ${Math.round(ratio * 100)}%`; } +/** Fit a child-id preview without joining an arbitrarily large child set. */ +function formatChildIds(children: readonly AgentRef[], width: number): string { + const max = Math.max(1, width); + let shown = 0; + let text = ""; + while (shown < children.length) { + const id = sanitizeLine(children[shown].id, max); + const candidate = text ? `${text}, ${id}` : id; + const remaining = children.length - shown - 1; + const suffix = remaining > 0 ? `, … +${remaining}` : ""; + if (visibleWidth(candidate + suffix) > max) { + const includesCurrent = text.length === 0; + const omitted = children.length - shown - Number(includesCurrent); + return truncateToWidth(`${includesCurrent ? id : text}${omitted > 0 ? `, … +${omitted}` : ""}`, max); + } + text = candidate; + shown++; + } + return text; +} /** Result of one host-backed transcript read for the Agent Hub viewer. */ export interface AgentHubRemoteTranscript { @@ -243,6 +282,8 @@ export interface AgentHubRemote { export interface AgentHubDeps { /** Progress/status snapshot source (task lifecycle + progress channels). */ observers: SessionObserverRegistry; + /** Production settings used to resolve textual model-role tags. */ + settings?: Settings; /** Keys that toggle the hub closed from inside (app.agents.hub + app.session.observe). */ hubKeys: KeyId[]; onDone: () => void; @@ -278,6 +319,7 @@ export interface AgentHubDeps { export class AgentHubOverlayComponent extends Container implements SelectListMouseTarget { #registry: AgentRegistry; #observers: SessionObserverRegistry; + #settings: Settings | undefined; #irc: IrcBus; #lifecycle: () => AgentLifecycleManager; #onDone: () => void; @@ -307,6 +349,22 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou /** Operational ordering by default; tree mode groups descendants under their spawner. */ #viewMode: HubViewMode = "roster"; #treeDepthById = new Map(); + /** Current observer index and summary data, rebuilt on source changes rather than every paint. */ + #observedById = new Map(); + #aggregate: AggregateMetrics = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: 0, + reportedAgents: 0, + }; + #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; + #childrenByParent = new Map(); + /** Transcript-derived fallback stats are sampled only on the bounded age cadence. */ + #sessionMetrics = new WeakMap(); + /** Avoid a cadence-time row scan for the common persisted-only roster. */ + #hasFallbackLiveSessions = false; /** On narrow terminals Tab replaces the roster with the selected-agent inspector. */ #narrowDetailsOpen = false; #lastRenderWasSplit = false; @@ -329,6 +387,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou super(); this.#registry = deps.registry ?? AgentRegistry.global(); this.#observers = deps.observers; + this.#settings = deps.settings; this.#irc = deps.irc ?? IrcBus.global(); // Lazy: the lifecycle global self-constructs against the global // registry, so only touch it when revive/kill actually needs it. @@ -354,7 +413,12 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#unsubscribers.push(this.#registry.onChange(() => this.#scheduleDataChange())); this.#unsubscribers.push(this.#observers.onChange(() => this.#scheduleDataChange())); - this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS); + this.#ageTimer = setInterval(() => { + if (this.#hasFallbackLiveSessions) { + this.#aggregate = this.#aggregateMetrics(this.#observedById, true); + } + this.#requestRender(); + }, AGE_TICK_MS); this.#ageTimer.unref?.(); this.persistedSubagentsReady = this.#remote @@ -505,6 +569,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #refreshRows(): void { const selectedId = this.#rows[this.#selectedRow]?.id; const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); + this.#observedById = new Map(); + for (const session of this.#observers.getSessions()) this.#observedById.set(session.id, session); const rowOrder = this.#rowOrder; let rosterRows: AgentRef[]; @@ -513,7 +579,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou rosterRows = refs.sort( (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, ); - this.#rowOrder = new Map(rosterRows.map((ref, i) => [ref.id, i])); + this.#rowOrder = new Map(); + for (let i = 0; i < rosterRows.length; i++) this.#rowOrder.set(rosterRows[i].id, i); } else { // After the hub is open, freeze relative order within each lifecycle // group. New agents append instead of moving the user's selection. @@ -531,11 +598,66 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou if (this.#viewMode === "roster") this.#treeDepthById.clear(); const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1; this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); + + this.#childrenByParent.clear(); + for (const ref of rosterRows) { + const parent = ref.parentId ?? MAIN_AGENT_ID; + const children = this.#childrenByParent.get(parent); + if (children) children.push(ref); + else this.#childrenByParent.set(parent, [ref]); + } + this.#statusCounts = { running: 0, idle: 0, parked: 0, aborted: 0 }; + for (const ref of rosterRows) this.#statusCounts[ref.status]++; + this.#aggregate = this.#aggregateMetrics(this.#observedById); + } + + #metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined { + // An observer snapshot is authoritative. Legacy/incomplete snapshots remain + // unknown rather than being silently replaced by unrelated transcript stats. + if (observed?.progress) return progressMetrics(observed); + const session = this.#fallbackStatsSession(ref, observed); + return session ? this.#sessionMetrics.get(session)?.metrics : undefined; + } + + #fallbackStatsSession( + ref: AgentRef, + observed: ObservableSession | undefined, + ): NonNullable | undefined { + if (observed?.progress) return undefined; + const session = ref.session; + return session && typeof session.getSessionStats === "function" ? session : undefined; + } + + #readSessionMetrics(session: NonNullable): AgentMetrics | undefined { + try { + const stats = session.getSessionStats(); + return { + // Match AgentProgress lifetime billing volume: cache reads are + // deliberately excluded because every turn rereads cached context. + tokens: stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite, + requests: stats.assistantMessages, + tools: stats.toolCalls, + cost: stats.cost, + durationMs: 0, + contextTokens: stats.contextUsage?.tokens, + contextWindow: stats.contextUsage?.contextWindow, + }; + } catch { + // Render-only doubles and sessions being torn down may not expose a + // complete statistics host. Missing metrics are preferable to a broken hub. + return undefined; + } } /** Parent-before-child projection preserving the roster's stable sibling order. */ #orderAsTree(refs: AgentRef[]): AgentRef[] { - const ids = new Set(refs.map(ref => ref.id)); + const ids = new Set(); + const operationalIndex = new Map(); + for (let i = 0; i < refs.length; i++) { + ids.add(refs[i].id); + operationalIndex.set(refs[i].id, i); + } + const children = new Map(); for (const ref of refs) { const parent = @@ -545,15 +667,70 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou else children.set(parent, [ref]); } + // A tree group occupies the position of its earliest operational row. + // Otherwise a recent child could leave its older parent group below an + // unrelated peer (`child, peer, parent` → `peer, parent, child`). + // Compute subtree minima iteratively so even pathological lineage depth + // remains stack-safe. + const subtreeOrder = new Map(); + const visiting = new Set(); + const ranked = new Set(); + for (const start of refs) { + if (ranked.has(start.id)) continue; + const stack: Array<{ ref: AgentRef; expanded: boolean }> = [{ ref: start, expanded: false }]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + if (current.expanded) { + let order = operationalIndex.get(current.ref.id) ?? Number.MAX_SAFE_INTEGER; + for (const child of children.get(current.ref.id) ?? []) { + order = Math.min(order, subtreeOrder.get(child.id) ?? Number.MAX_SAFE_INTEGER); + } + subtreeOrder.set(current.ref.id, order); + visiting.delete(current.ref.id); + ranked.add(current.ref.id); + continue; + } + if (ranked.has(current.ref.id) || visiting.has(current.ref.id)) continue; + visiting.add(current.ref.id); + stack.push({ ref: current.ref, expanded: true }); + const descendants = children.get(current.ref.id); + if (!descendants) continue; + for (let i = descendants.length - 1; i >= 0; i--) { + const child = descendants[i]; + if (!ranked.has(child.id) && !visiting.has(child.id)) { + stack.push({ ref: child, expanded: false }); + } + } + } + } + for (const siblings of children.values()) { + siblings.sort( + (a, b) => + (subtreeOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (subtreeOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER) || + (operationalIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (operationalIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); + } + const ordered: AgentRef[] = []; const visited = new Set(); this.#treeDepthById.clear(); - const visit = (ref: AgentRef, depth: number): void => { - if (visited.has(ref.id)) return; - visited.add(ref.id); - this.#treeDepthById.set(ref.id, depth); - ordered.push(ref); - for (const child of children.get(ref.id) ?? []) visit(child, depth + 1); + const visit = (root: AgentRef, rootDepth: number): void => { + const stack: Array<{ ref: AgentRef; depth: number }> = [{ ref: root, depth: rootDepth }]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current || visited.has(current.ref.id)) continue; + visited.add(current.ref.id); + this.#treeDepthById.set(current.ref.id, current.depth); + ordered.push(current.ref); + const descendants = children.get(current.ref.id); + if (!descendants) continue; + for (let i = descendants.length - 1; i >= 0; i--) { + stack.push({ ref: descendants[i], depth: current.depth + 1 }); + } + } }; for (const root of children.get(MAIN_AGENT_ID) ?? []) visit(root, 0); // Corrupt persisted parent cycles remain visible as roots instead of @@ -562,10 +739,6 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return ordered; } - #observablesById(): Map { - return new Map(this.#observers.getSessions().map(session => [session.id, session])); - } - // ======================================================================== // Table view // ======================================================================== @@ -573,7 +746,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #renderTable(width: number, termHeight: number): string[] { this.#hitRows.length = 0; const contentRows = Math.max(1, termHeight - 4); - const observedById = this.#observablesById(); + const observedById = this.#observedById; const split = this.#splitRosterWidth(width); this.#lastRenderWasSplit = split !== undefined; const selected = this.#rows[this.#selectedRow]; @@ -622,7 +795,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } #footer(showingNarrowDetails: boolean, availableWidth: number): string { - const nextView = this.#viewMode === "roster" ? "tree" : "roster"; + const nextView = this.#viewMode === "roster" ? "by parent" : "flat"; if (showingNarrowDetails) { return theme.fg("dim", `Tab:roster Enter:open t:${nextView} Esc:roster`); } @@ -633,7 +806,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } #renderRosterPanel(width: number, rows: number, observedById: ReadonlyMap): RosterRender { - const lines = this.#summaryLines(observedById); + const lines = this.#summaryLines(width); const hitRows: Array = Array.from({ length: lines.length }); if (rows >= 8) { lines.push(""); @@ -682,9 +855,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou ): RosterRender { const lines: string[] = []; const hitRows: Array = []; - const rendered: Array = []; + const rendered = new Map(); const entryAt = (index: number): string[] => { - const cached = rendered[index]; + const cached = rendered.get(index); if (cached) return cached; const entry = this.#renderEntry( this.#rows[index], @@ -693,7 +866,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou observedById.get(this.#rows[index].id), index === this.#hoveredRow, ); - rendered[index] = entry; + rendered.set(index, entry); return entry; }; const appendEntry = (index: number, entry = entryAt(index)): void => { @@ -763,41 +936,49 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return { lines, hitRows }; } - #summaryLines(observedById: ReadonlyMap): string[] { - const view = this.#viewMode === "roster" ? "Roster" : "Spawn tree"; + #summaryLines(width: number): string[] { + const active = (label: string): string => theme.bg("selectedBg", theme.bold(theme.fg("accent", ` ${label} `))); + const inactive = (label: string): string => theme.fg("muted", ` ${label} `); + const projection = + this.#viewMode === "roster" + ? `${active("Flat")}${theme.fg("dim", "/")}${inactive("By parent")}` + : `${inactive("Flat")}${theme.fg("dim", "/")}${active("By parent")}`; const counts = this.#statusSummary(); - const metrics = this.#aggregateMetrics(observedById); - const usage: string[] = []; - if (metrics.cost > 0) usage.push(theme.fg("statusLineCost", formatCost(metrics.cost))); - if (metrics.tokens > 0) usage.push(`${formatNumber(metrics.tokens)} tok`); - if (metrics.requests > 0) usage.push(`${formatNumber(metrics.requests)} req`); - if (metrics.tools > 0) usage.push(`${formatNumber(metrics.tools)} tools`); - if (metrics.durationMs > 0) usage.push(`${formatDuration(metrics.durationMs)} agent time`); - return [ - `${theme.bold(view)}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`, - usage.length > 0 - ? theme.fg("dim", usage.join(theme.sep.dot)) - : theme.fg( - "dim", - metrics.reportedAgents > 0 - ? "usage not priced by active providers" - : "usage appears with agent progress", - ), - ]; + const header = `${theme.bold("Roster")}${theme.fg("dim", theme.sep.dot)}${projection}${counts ? theme.fg("dim", theme.sep.dot) + counts : ""}`; + const lines = wrapTextWithAnsi(header, Math.max(1, width)); + + const metrics = this.#aggregate; + if (metrics.reportedAgents === 0) { + lines.push( + ...wrapTextWithAnsi( + theme.fg("dim", `Usage —${theme.sep.dot}0/${this.#rows.length} measured`), + Math.max(1, width), + ), + ); + return lines; + } + const usage = [ + theme.fg("statusLineCost", formatCost(metrics.cost)), + theme.fg("dim", `${formatDuration(metrics.durationMs)} agent time`), + theme.fg("dim", `${formatNumber(metrics.requests)} req`), + theme.fg("dim", `${formatNumber(metrics.tools)} tools`), + theme.fg("dim", `${formatNumber(metrics.tokens)} tok`), + theme.fg("dim", `${metrics.reportedAgents}/${this.#rows.length} measured`), + ].join(theme.fg("dim", theme.sep.dot)); + lines.push(...wrapTextWithAnsi(usage, Math.max(1, width))); + return lines; } #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(`${statusGlyph(status)} ${statusText(status, `${count} ${status}`)}`); } return parts.join(theme.sep.dot); } - #aggregateMetrics(observedById: ReadonlyMap): AggregateMetrics { + #aggregateMetrics(observedById: ReadonlyMap, refreshFallback = false): AggregateMetrics { const total: AggregateMetrics = { tokens: 0, requests: 0, @@ -806,8 +987,17 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou durationMs: 0, reportedAgents: 0, }; + let hasFallbackLiveSessions = false; for (const ref of this.#rows) { - const metrics = metricsFor(ref, observedById.get(ref.id)); + const observed = observedById.get(ref.id); + const fallbackSession = this.#fallbackStatsSession(ref, observed); + if (fallbackSession) { + hasFallbackLiveSessions = true; + if (refreshFallback || !this.#sessionMetrics.has(fallbackSession)) { + this.#sessionMetrics.set(fallbackSession, { metrics: this.#readSessionMetrics(fallbackSession) }); + } + } + const metrics = this.#metricsFor(ref, observed); if (!metrics) continue; total.reportedAgents++; total.tokens += metrics.tokens; @@ -816,6 +1006,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou total.cost += metrics.cost; total.durationMs += metrics.durationMs; } + this.#hasFallbackLiveSessions = hasFallbackLiveSessions; return total; } @@ -828,8 +1019,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou if (!ref) return [theme.fg("dim", "Select an agent to inspect"), ...Array.from({ length: rows - 1 }, () => "")]; const observed = observedById.get(ref.id); const progress = observed?.progress; - const metrics = metricsFor(ref, observed); - const children = this.#rows.filter(candidate => candidate.parentId === ref.id); + const metrics = this.#metricsFor(ref, observed); + const children = this.#childrenByParent.get(ref.id) ?? []; const lines: string[] = []; const add = (line = ""): void => { if (lines.length < rows) lines.push(truncateToWidth(line, width)); @@ -851,8 +1042,11 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou add( `${statusText(ref.status, ref.status)}${theme.fg("dim", `${theme.sep.dot}${lifecycleDetails.join(theme.sep.dot)}`)}`, ); + const modelDetails: string[] = []; + if (progress?.modelRole && this.#settings) modelDetails.push(formatRoleBadge(progress.modelRole, this.#settings)); const badge = modelBadge(ref, observed); - if (badge) add(badge); + if (badge) modelDetails.push(badge); + if (modelDetails.length > 0) add(modelDetails.join(theme.sep.dot)); const task = observed?.description ?? progress?.task ?? ref.activity; if (task) { @@ -873,25 +1067,19 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou section("Usage"); if (metrics) { - const spend = [ - metrics.cost > 0 ? formatCost(metrics.cost) : "cost —", - `${formatNumber(metrics.tokens)} tokens`, - `${formatNumber(metrics.requests)} requests`, - `${formatNumber(metrics.tools)} tools`, - ]; - addWrapped(spend.join(theme.sep.dot)); + addWrapped(formatMetrics(metrics), 3); if (metrics.contextTokens !== undefined && metrics.contextWindow) { add(contextGauge(metrics.contextTokens, metrics.contextWindow)); } } else { - add(theme.fg("dim", "No usage snapshot")); + add(theme.fg("dim", "usage —")); } section("Lineage"); add( `Spawned by ${replaceTabs(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`, ); - if (children.length > 0) addWrapped(children.map(child => child.id).join(", "), 1); + if (children.length > 0) add(theme.fg("dim", formatChildIds(children, width))); add(theme.fg("dim", `Registered ${new Date(ref.createdAt).toISOString().slice(0, 16).replace("T", " ")}Z`)); section("Changes"); @@ -907,10 +1095,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } /** - * One agent entry, 1-2 lines: - * `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity - * left, metadata right-aligned (inlined when the terminal is too narrow) — - * plus an indented dim task line when the agent's work is known. + * One agent entry keeps identity/model metadata on one line when it fits, + * then packs task and all five usage metrics together below. Narrow rows wrap + * only those dense secondary fields; restored historical rows say `usage —`. */ #renderEntry( ref: AgentRef, @@ -922,7 +1109,11 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const max = Math.max(1, width); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0; - const branch = this.#viewMode === "tree" && depth > 0 ? `${" ".repeat(depth - 1)}↳ ` : ""; + const visibleDepth = Math.min(depth, Math.max(1, Math.floor(max / 2))); + const branch = + this.#viewMode === "tree" && depth > 0 + ? `${depth > visibleDepth ? "… " : ""}${" ".repeat(Math.max(0, visibleDepth - 1))}↳ ` + : ""; const id = replaceTabs(ref.id); const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id); const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`]; @@ -942,6 +1133,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const left = fields.join(" "); const meta: string[] = []; + if (observed?.progress?.modelRole && this.#settings) { + meta.push(formatRoleBadge(observed.progress.modelRole, this.#settings)); + } const badge = modelBadge(ref, observed); if (badge) meta.push(badge); meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000))))); @@ -949,18 +1143,24 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const leftWidth = visibleWidth(left); const rightWidth = visibleWidth(right); - const line = - leftWidth + 2 + rightWidth <= max - ? left + padding(max - leftWidth - rightWidth) + right - : truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max); - const entry = [line]; + const entry: string[] = []; + const detailIndent = Math.min(max - 1, 4 + depth * 2); + if (leftWidth + 2 + rightWidth <= max) { + entry.push(left + padding(max - leftWidth - rightWidth) + right); + } else { + entry.push(truncateToWidth(left.replace(/[\r\n]+/g, " "), max)); + entry.push(`${padding(Math.max(0, detailIndent))}${truncateToWidth(right, Math.max(1, max - detailIndent))}`); + } + const metrics = this.#metricsFor(ref, observed); + const usage = metrics ? theme.fg("dim", formatMetrics(metrics)) : theme.fg("dim", "usage —"); const task = observed?.description ?? observed?.progress?.task ?? ref.activity; - if (task) { - const indentWidth = Math.min(max - 1, 4 + depth * 2); - entry.push( - `${padding(Math.max(0, indentWidth))}${theme.fg("muted", sanitizeLine(task, Math.max(10, max - indentWidth)))}`, - ); + const detailWidth = Math.max(1, max - detailIndent); + const details = task + ? `${theme.fg("muted", sanitizeLine(task, detailWidth))}${theme.fg("dim", theme.sep.dot)}${usage}` + : usage; + for (const wrapped of wrapTextWithAnsi(details, detailWidth)) { + entry.push(`${padding(Math.max(0, detailIndent))}${wrapped}`); } if (!hovered) return entry; return entry.map(lineRow => { @@ -1014,6 +1214,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return; } if (keyData === "t") { + this.#hoveredRow = null; this.#viewMode = this.#viewMode === "roster" ? "tree" : "roster"; this.#refreshRows(); this.#requestRender(); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 3bc944171..5c6aaaeff 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -2015,6 +2015,7 @@ export class SelectorController { const hub = new AgentHubOverlayComponent({ observers, + settings: this.ctx.settings, hubKeys, expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"), onDone: done, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 5538130a0..9cd6cb61d 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -333,6 +333,8 @@ export interface ExecutorOptions { */ detached?: boolean; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; /** * Active model selector of the parent session, used as an auth-aware fallback * if the resolved subagent model has no working credentials. See #985. @@ -877,6 +879,8 @@ interface RunMonitorArgs { /** Parent settings for tiny-model label generation. */ settings?: Settings; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; signal?: AbortSignal; onProgress?: (progress: AgentProgress) => void; eventBus?: EventBus; @@ -992,6 +996,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { cost: 0, durationMs: 0, modelOverride: args.modelOverride, + modelRole: args.modelRole, }; const outputChunks: string[] = []; @@ -2044,6 +2049,8 @@ interface FinalizeRunArgs { task: string; assignment?: string; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; outputSchemaSource?: StructuredSubagentSchemaSource; @@ -2063,7 +2070,7 @@ interface FinalizeRunArgs { * event. */ async function finalizeRunResult(args: FinalizeRunArgs): Promise { - const { monitor, done, index, id, agent, task, assignment, signal, modelOverride } = args; + const { monitor, done, index, id, agent, task, assignment, signal, modelOverride, modelRole } = args; const progress = monitor.progress; let exitCode = done.exitCode; let stderr = done.error ?? ""; @@ -2187,6 +2194,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise { contextTokens: progress.contextTokens, contextWindow: progress.contextWindow, modelOverride, + modelRole, resolvedModel: progress.resolvedModel, resolvedModelIsFallback: progress.resolvedModelIsFallback, error: exitCode !== 0 && stderr ? stderr : undefined, @@ -2208,6 +2216,8 @@ export interface IrcWakeTurnMonitorOptions { agent: AgentDefinition; description?: string; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; eventBus?: EventBus; parentToolCallId?: string; /** Fallback session file when the registry ref carries none. */ @@ -2252,6 +2262,7 @@ export function attachIrcWakeTurnMonitor(session: AgentSession, options: IrcWake task: ircTask, description: options.description, modelOverride: options.modelOverride, + modelRole: options.modelRole, eventBus: options.eventBus, parentToolCallId: options.parentToolCallId, detached: true, @@ -2309,6 +2320,7 @@ export function attachIrcWakeTurnMonitor(session: AgentSession, options: IrcWake agent, task: ircTask, modelOverride: options.modelOverride, + modelRole: options.modelRole, outputSchema: options.outputSchema, outputSchemaMode: options.outputSchemaMode, outputSchemaSource: options.outputSchemaSource, @@ -2421,6 +2433,8 @@ export interface FollowUpTurnOptions { message: string; index?: number; description?: string; + /** Explicit pre-expansion model role alias retained from the original run. */ + modelRole?: string; /** Structured-output state retained from the original invocation. */ outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; @@ -2460,6 +2474,7 @@ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Pro agent, task: message, description: options.description, + modelRole: options.modelRole, signal, onProgress: options.onProgress, eventBus: options.eventBus, @@ -2509,6 +2524,7 @@ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Pro id, agent, task: message, + modelRole: options.modelRole, outputSchema: options.outputSchema, outputSchemaMode: options.outputSchemaMode, outputSchemaSource: options.outputSchemaSource, @@ -2535,6 +2551,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise/`, optionally suffixed with `:` when the level was set explicitly. Undefined when the model could not be resolved. */ resolvedModel?: string; /** True when {@link resolvedModel} is the target of an active retry fallback (not the originally configured model). Lets observer-only UIs (collab guests, Agent Hub rows with no live session) flag the fallback and keep the provider. */ @@ -495,6 +497,8 @@ export interface SingleResult { /** Model's context window in tokens, when known. */ contextWindow?: number; modelOverride?: string | string[]; + /** Explicit pre-expansion model role alias selected for this run. */ + modelRole?: string; /** Resolved model display string in the form `/`, optionally suffixed with `:` when the level was set explicitly. Omitted from tool-result JSON when undefined to keep wire payloads small. */ resolvedModel?: string; /** True when {@link resolvedModel} is the target of an active retry fallback. Mirrors {@link AgentProgress.resolvedModelIsFallback} onto the settled result. */ diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 79c7ace51..3304cbb39 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -36,6 +36,7 @@ function makeHub(focusAgent: (id: string) => Promise) { const done = Promise.withResolvers(); const renderRequested = Promise.withResolvers(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => { @@ -50,6 +51,30 @@ function makeHub(focusAgent: (id: string) => Promise) { return { hub, doneCalls: () => doneCalls, done: done.promise, renderRequested: renderRequested.promise }; } +const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?: {2})*↳ )?(\S+)/u; + +function renderedRosterEntry(hub: AgentHubOverlayComponent, id: string, width: number): string { + const cells = hub.render(width).map(raw => { + const line = Bun.stripANSI(raw); + if (!line.startsWith("│ ")) return undefined; + const divider = line.indexOf("│", 2); + return divider < 0 ? undefined : line.slice(2, Math.max(2, divider - 1)); + }); + const start = cells.findIndex(cell => { + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + return match?.[3] === id; + }); + expect(start).toBeGreaterThanOrEqual(0); + const entry: string[] = []; + for (let i = start; i < cells.length; i++) { + const cell = cells[i]; + if (cell === undefined || cell.trim().length === 0) break; + if (i > start && ROSTER_ENTRY_PATTERN.test(cell)) break; + entry.push(cell.trimEnd()); + } + return entry.join("\n"); +} + describe("Agent hub Enter activation", () => { beforeAll(() => { initTheme(); @@ -99,6 +124,7 @@ describe("Agent hub Enter activation", () => { await Bun.write(workerSessionFile, ""); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -110,9 +136,8 @@ describe("Agent hub Enter activation", () => { }); await hub.persistedSubagentsReady; - const rendered = Bun.stripANSI(hub.render(120).join("\n")); - expect(rendered).toContain("Worker"); - expect(rendered).toContain("parked"); + const workerEntry = renderedRosterEntry(hub, "Worker", 120); + expect(workerEntry).toContain("○ Worker"); expect(agents.get("Worker")?.sessionFile).toBe(workerSessionFile); hub.dispose(); }); @@ -142,6 +167,7 @@ describe("Agent hub Enter activation", () => { await fs.utimes(workerSessionFile, lastActivity, lastActivity); const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -158,9 +184,10 @@ describe("Agent hub Enter activation", () => { lastActivity: lastActivity.getTime(), status: "parked", }); - expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain( - "Inspect dependency boundaries and report unsafe coupling.", - ); + const workerEntry = renderedRosterEntry(hub, "Worker", 120); + expect(workerEntry).toContain("Inspect dependency boundaries and report unsafe coupling."); + expect(workerEntry.replace(/\s+/g, " ")).toContain("usage —"); + expect(workerEntry).not.toContain("$0.000"); hub.dispose(); }); it("avoids multi-second event-loop stalls while discovering agents from a large session", async () => { @@ -190,6 +217,7 @@ describe("Agent hub Enter activation", () => { const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -263,6 +291,7 @@ describe("Agent hub Enter activation", () => { const agents = new AgentRegistry(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -521,6 +550,7 @@ describe("Agent hub data refresh coalescing", () => { const observers = new SessionObserverRegistry(); const requestRender = vi.fn(); const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers, hubKeys: [], onDone: () => {}, @@ -564,4 +594,79 @@ describe("Agent hub data refresh coalescing", () => { vi.useRealTimers(); } }); + + it("refreshes direct-session fallback stats on the age cadence, not paints or heartbeats", async () => { + vi.useFakeTimers(); + const agents = new AgentRegistry(); + const observers = new SessionObserverRegistry(); + const requestRender = vi.fn(); + let inputTokens = 100; + let assistantMessages = 1; + const getSessionStats = vi.fn(() => ({ + sessionFile: undefined, + sessionId: "sdk-agent", + userMessages: 1, + assistantMessages, + toolCalls: 2, + toolResults: 2, + totalMessages: 6, + tokens: { + input: inputTokens, + output: 50, + reasoning: 0, + cacheRead: 20, + cacheWrite: 0, + total: inputTokens + 70, + }, + premiumRequests: 0, + cost: 0.1, + })); + agents.register({ + id: "SdkAgent", + displayName: "SDK agent", + kind: "sub", + parentId: "Main", + session: { getSessionStats, subscribe: () => () => {} } as unknown as AgentSession, + status: "running", + }); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers, + hubKeys: [], + onDone: () => {}, + requestRender, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + + try { + await hub.persistedSubagentsReady; + expect(getSessionStats).toHaveBeenCalledTimes(1); + for (let i = 0; i < 4; i++) hub.render(120); + expect(getSessionStats).toHaveBeenCalledTimes(1); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("150 tok"); + + inputTokens = 400; + assistantMessages = 2; + agents.setActivity("SdkAgent", "heartbeat"); + vi.advanceTimersByTime(100); + expect(getSessionStats).toHaveBeenCalledTimes(1); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("150 tok"); + + vi.advanceTimersByTime(4_899); + expect(getSessionStats).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(getSessionStats).toHaveBeenCalledTimes(2); + const refreshed = Bun.stripANSI(hub.render(120).join("\n")); + expect(refreshed).toContain("450 tok"); + expect(refreshed).toContain("2 req"); + expect(refreshed).toContain("1/1 measured"); + hub.render(120); + expect(getSessionStats).toHaveBeenCalledTimes(2); + } finally { + hub.dispose(); + vi.useRealTimers(); + } + }); }); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 01722e074..9f2917768 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -6,10 +6,12 @@ * agents that appear while the hub is open are appended at the end. */ import { afterEach, beforeAll, describe, expect, it, setSystemTime, vi } from "bun:test"; +import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; import { type AgentHubDeps, AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; import { SessionObserverRegistry } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; -import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; 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 { visibleWidth } from "@oh-my-pi/pi-tui/utils"; @@ -42,6 +44,7 @@ function stubStdoutGeometry(cols: number): GeometryStub { function makeHub(agents: AgentRegistry, overrides: Partial = {}) { return new AgentHubOverlayComponent({ + settings: Settings.isolated(), observers: new SessionObserverRegistry(), hubKeys: [], onDone: () => {}, @@ -58,13 +61,24 @@ interface RenderedAgentRow { selected: boolean; } +const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?: {2})*↳ )?(\S+)/u; + +function rosterCell(raw: string): string | undefined { + const line = Bun.stripANSI(raw); + if (!line.startsWith("│ ")) return undefined; + const divider = line.indexOf("│", 2); + if (divider < 0) return undefined; + return line.slice(2, Math.max(2, divider - 1)); +} + function renderedAgentRows(hub: AgentHubOverlayComponent, width = 120): RenderedAgentRow[] { - // Boxed roster entry first lines are - // `│ [tree-prefix] …`; task lines are + // Roster entry first cells are + // ` [tree-prefix] …`; task cells are // indented deeper and never match the cursor/status slots. const rows: RenderedAgentRow[] = []; for (const raw of hub.render(width)) { - const match = /^│ (❯| ) (\S+) (?:(?: {2})*↳ )?(\S+)/u.exec(Bun.stripANSI(raw)); + const cell = rosterCell(raw); + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; if (match) rows.push({ id: match[3]!, selected: match[1] === "❯" }); } return rows; @@ -78,6 +92,32 @@ function selectedAgentId(hub: AgentHubOverlayComponent): string | undefined { return renderedAgentRows(hub).find(row => row.selected)?.id; } +function renderedRosterEntry(hub: AgentHubOverlayComponent, id: string, width: number): string { + const cells = hub.render(width).map(rosterCell); + const start = cells.findIndex(cell => { + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + return match?.[3] === id; + }); + expect(start).toBeGreaterThanOrEqual(0); + const entry: string[] = []; + for (let i = start; i < cells.length; i++) { + const cell = cells[i]; + if (cell === undefined || cell.trim().length === 0) break; + if (i > start && ROSTER_ENTRY_PATTERN.test(cell)) break; + entry.push(cell.trimEnd()); + } + return entry.join("\n"); +} +function renderedRosterHeaderLineRaw(hub: AgentHubOverlayComponent, id: string, width: number): string { + const line = hub.render(width).find(raw => { + const cell = rosterCell(raw); + const match = cell ? ROSTER_ENTRY_PATTERN.exec(cell) : null; + return match?.[3] === id; + }); + if (!line) throw new Error(`No rendered roster header for ${id}`); + return line; +} + function leftClick(row1Based: number): string { return `\x1b[<0;4;${row1Based}M`; } @@ -327,6 +367,58 @@ describe("Agent hub row ordering", () => { } }); + it("retains the live thinking level unless progress has an explicit suffix", () => { + geometry = stubStdoutGeometry(140); + const agents = new AgentRegistry(); + const inheritedSession = { thinkingLevel: ThinkingLevel.High } as unknown as AgentSession; + const explicitSession = { thinkingLevel: ThinkingLevel.High } as unknown as AgentSession; + agents.register({ + id: "InheritedLevel", + displayName: "Inherited level", + kind: "sub", + session: inheritedSession, + }); + agents.register({ + id: "ExplicitLevel", + displayName: "Explicit level", + kind: "sub", + session: explicitSession, + }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "InheritedLevel", + kind: "subagent", + label: "Inherited level", + status: "active", + lastUpdate: Date.now(), + progress: { resolvedModel: "openai/gpt-5.4" } as never, + }, + { + id: "ExplicitLevel", + kind: "subagent", + label: "Explicit level", + status: "active", + lastUpdate: Date.now(), + progress: { resolvedModel: "openai/gpt-5.4:low" } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const inherited = renderedRosterEntry(hub, "InheritedLevel", 140); + expect(inherited).toContain("gpt-5.4"); + expect(inherited).toContain(theme.thinking.high); + + const explicit = renderedRosterEntry(hub, "ExplicitLevel", 140); + expect(explicit).toContain("gpt-5.4"); + expect(explicit).toContain(theme.thinking.low); + expect(explicit).not.toContain(theme.thinking.high); + } finally { + hub.dispose(); + } + }); + it("renders aggregate usage and a selected-agent inspector without inventing change attribution", () => { geometry = stubStdoutGeometry(140); geometry.setRows(28); @@ -373,12 +465,12 @@ describe("Agent hub row ordering", () => { try { const rendered = Bun.stripANSI(hub.render(140).join("\n")); - expect(rendered).toMatch(/Roster · \S+ 1 running/u); - expect(rendered).toContain("$0.213 · 18K tok · 12 req · 27 tools · 2m14s agent time"); + expect(rendered).toContain("1 running"); + expect(rendered).toContain("Flat"); + expect(rendered).toContain("By parent"); + expect(rendered).toContain("$0.213 · 2m14s · 12 req · 27 tools · 18K tok"); expect(rendered).toContain("Security Reviewer"); - expect(rendered).toContain("Review the session lifecycle and produce"); expect(rendered).toContain("read · src/session/agent-session.ts"); - expect(rendered).toContain("$0.213 · 18K tokens · 12 requests · 27 tools"); expect(rendered).toContain("31K/128K 24%"); expect(rendered).toContain("Registered "); expect(rendered).toContain("Shared workspace · per-agent LoC not attributable"); @@ -386,8 +478,241 @@ describe("Agent hub row ordering", () => { hub.dispose(); } }); + it("shows dense measured usage for running and completed progress with aggregate coverage", () => { + geometry = stubStdoutGeometry(160); + geometry.setRows(32); + const agents = new AgentRegistry(); + agents.register({ id: "Running", displayName: "Running", kind: "sub", session: null, status: "running" }); + agents.register({ id: "Completed", displayName: "Completed", kind: "sub", session: null, status: "idle" }); + agents.register({ + id: "Historical", + displayName: "Historical", + kind: "sub", + session: null, + status: "parked", + activity: "Restored task", + }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "Running", + kind: "subagent", + label: "Running", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "Running", + index: 0, + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Run checks", + recentTools: [], + recentOutput: [], + toolCount: 4, + requests: 3, + tokens: 1_200, + cost: 0.1234, + durationMs: 6_500, + } as never, + }, + { + id: "Completed", + kind: "subagent", + label: "Completed", + status: "completed", + lastUpdate: Date.now(), + progress: { + id: "Completed", + index: 1, + agent: "worker", + agentSource: "bundled", + status: "completed", + task: "Finish checks", + recentTools: [], + recentOutput: [], + toolCount: 8, + requests: 5, + tokens: 2_500, + cost: 0.4567, + durationMs: 125_000, + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); - it("toggles a parent-before-child spawn tree while preserving selection", () => { + try { + const rendered = Bun.stripANSI(hub.render(160).join("\n")); + expect(rendered).toContain("2/3 measured"); + expect(rendered).toContain("$0.580"); + expect(rendered).toContain("3.7K tok"); + expect(rendered).toContain("8 req"); + expect(rendered).toContain("12 tools"); + expect(rendered).toContain("2m11s agent time"); + + const running = renderedRosterEntry(hub, "Running", 160); + expect(running).toContain("$0.123"); + expect(running).toContain("6.5s"); + expect(running).toContain("3 req"); + expect(running).toContain("4 tools"); + expect(running).toContain("1.2K tok"); + + const completed = renderedRosterEntry(hub, "Completed", 160); + expect(completed).toContain("$0.457"); + expect(completed).toContain("2m5s"); + expect(completed).toContain("5 req"); + expect(completed).toContain("8 tools"); + expect(completed).toContain("2.5K tok"); + + const historical = renderedRosterEntry(hub, "Historical", 160); + expect(historical).toContain("Restored task"); + expect(historical).toContain("usage —"); + expect(historical).not.toContain("$0.000"); + } finally { + hub.dispose(); + } + }); + it("treats incomplete and non-finite progress usage as unknown", () => { + geometry = stubStdoutGeometry(160); + const agents = new AgentRegistry(); + const getSessionStats = vi.fn(() => ({ + sessionFile: undefined, + sessionId: "incomplete", + userMessages: 1, + assistantMessages: 9, + toolCalls: 4, + toolResults: 4, + totalMessages: 18, + tokens: { input: 100, output: 50, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 150 }, + premiumRequests: 0, + cost: 0.25, + })); + agents.register({ + id: "Incomplete", + displayName: "Incomplete", + kind: "sub", + session: { getSessionStats } as unknown as AgentSession, + }); + agents.register({ id: "NonFinite", displayName: "Non-finite", kind: "sub", session: null }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "Incomplete", + kind: "subagent", + label: "Incomplete", + status: "active", + lastUpdate: Date.now(), + progress: { + tokens: 100, + toolCount: 2, + cost: 0.1, + durationMs: 1_000, + } as never, + }, + { + id: "NonFinite", + kind: "subagent", + label: "Non-finite", + status: "active", + lastUpdate: Date.now(), + progress: { + tokens: Number.NaN, + requests: 2, + toolCount: 2, + cost: 0.1, + durationMs: 1_000, + } as never, + }, + ]); + const hub = makeHub(agents, { observers }); + + try { + const rendered = Bun.stripANSI(hub.render(160).join("\n")); + expect(rendered).toContain("0/2 measured"); + expect(renderedRosterEntry(hub, "Incomplete", 160)).toContain("usage —"); + expect(renderedRosterEntry(hub, "NonFinite", 160)).toContain("usage —"); + expect(getSessionStats).not.toHaveBeenCalled(); + } finally { + hub.dispose(); + } + }); + it("shows configured role text beside a resolved model but not for an explicit selector", () => { + geometry = stubStdoutGeometry(160); + const agents = new AgentRegistry(); + agents.register({ id: "RoleAgent", displayName: "Role Agent", kind: "sub", session: null }); + agents.register({ id: "ExplicitAgent", displayName: "Explicit Agent", kind: "sub", session: null }); + const observers = new SessionObserverRegistry(); + vi.spyOn(observers, "getSessions").mockReturnValue([ + { + id: "RoleAgent", + kind: "subagent", + label: "Role Agent", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "RoleAgent", + index: 0, + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Run with the configured role", + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 1, + tokens: 10, + cost: 0, + durationMs: 100, + modelRole: "rapid", + resolvedModel: "openai/gpt-4o", + } as never, + }, + { + id: "ExplicitAgent", + kind: "subagent", + label: "Explicit Agent", + status: "active", + lastUpdate: Date.now(), + progress: { + id: "ExplicitAgent", + index: 1, + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Run with an explicit selector", + recentTools: [], + recentOutput: [], + toolCount: 0, + requests: 1, + tokens: 10, + cost: 0, + durationMs: 100, + resolvedModel: "openai/gpt-4o", + } as never, + }, + ]); + const hub = makeHub(agents, { + observers, + settings: Settings.isolated({ + modelRoles: { rapid: "openai/gpt-4o" }, + modelTags: { rapid: { name: "Quick", color: "warning" } }, + }), + }); + + try { + const roleBlock = renderedRosterEntry(hub, "RoleAgent", 160); + expect(roleBlock).toContain("Quick"); + expect(roleBlock).toContain("gpt-4o"); + expect(roleBlock.indexOf("Quick")).toBeLessThan(roleBlock.indexOf("gpt-4o")); + + const explicitBlock = renderedRosterEntry(hub, "ExplicitAgent", 160); + expect(explicitBlock).toContain("gpt-4o"); + expect(explicitBlock).not.toContain("Quick"); + } finally { + hub.dispose(); + } + }); + it("switches between inline Flat and By parent projections with selection preserved", () => { vi.useFakeTimers(); geometry = stubStdoutGeometry(120); const agents = new AgentRegistry(); @@ -402,16 +727,26 @@ describe("Agent hub row ordering", () => { try { expect(renderedAgentIds(hub)).toEqual(["Child", "Peer", "Parent"]); expect(selectedAgentId(hub)).toBe("Child"); + const flat = Bun.stripANSI(hub.render(120).join("\n")); + expect(flat).toContain("Flat"); + expect(flat).toContain("By parent"); + + hub.setHoverIndex(0); + expect(renderedRosterHeaderLineRaw(hub, "Child", 120)).toContain(theme.getBgAnsi("selectedBg")); hub.handleInput("t"); - const treeIds = renderedAgentIds(hub); - expect(treeIds.indexOf("Child")).toBe(treeIds.indexOf("Parent") + 1); + const byParentIds = renderedAgentIds(hub); + expect(byParentIds).toEqual(["Parent", "Child", "Peer"]); expect(selectedAgentId(hub)).toBe("Child"); - const rendered = Bun.stripANSI(hub.render(120).join("\n")); - expect(rendered).toContain("Spawn tree"); - expect(rendered).toContain("↳ Child"); - expect(rendered).toContain("Spawned by Parent"); + const byParent = Bun.stripANSI(hub.render(120).join("\n")); + expect(byParent).toContain("Flat"); + expect(byParent).toContain("By parent"); + expect(byParentIds.indexOf("Parent")).toBeLessThan(byParentIds.indexOf("Child")); + expect(renderedRosterHeaderLineRaw(hub, "Parent", 120)).not.toContain(theme.getBgAnsi("selectedBg")); + expect(renderedRosterHeaderLineRaw(hub, "Child", 120)).not.toContain(theme.getBgAnsi("selectedBg")); + hub.handleInput("t"); expect(renderedAgentIds(hub)).toEqual(["Child", "Peer", "Parent"]); + expect(selectedAgentId(hub)).toBe("Child"); } finally { hub.dispose(); vi.useRealTimers(); @@ -457,7 +792,7 @@ describe("Agent hub row ordering", () => { const details = Bun.stripANSI(hub.render(80).join("\n")); expect(details).toContain("Agent Hub · NarrowAgent"); expect(details).toContain("Usage"); - expect(details).toContain("cost — · 900 tokens · 2 requests · 3 tools"); + expect(details).toContain("$0.0000 · 2.0s · 2 req · 3 tools · 900 tok"); expect(details).toContain("Tab:roster"); for (const line of hub.render(80)) expect(visibleWidth(line)).toBeLessThanOrEqual(80); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 19f1d8d84..b64a0cc3e 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -10,9 +10,11 @@ import { parseModelString, pickDefaultAvailableModel, resolveAgentModelPatterns, + resolveAgentModelSource, resolveAgentPrewalkPattern, resolveAllowedModels, resolveCliModel, + resolveExplicitModelRole, resolveModelFromString, resolveModelOverride, resolveModelRoleValue, @@ -844,6 +846,43 @@ describe("resolveAgentPrewalkPattern", () => { }); }); describe("resolveAgentModelPatterns", () => { + test("selects the first non-empty source and skips aliases with no patterns", () => { + const settings = Settings.isolated({ + modelRoles: { + empty: "", + override: "openai/gpt-4o", + definition: "anthropic/claude-sonnet-4-5", + }, + }); + + const emptyRequest = { + requestModel: "", + settingsOverride: "@override", + agentModel: ["@definition"], + settings, + }; + expect(resolveAgentModelPatterns(emptyRequest)).toEqual(["openai/gpt-4o"]); + expect(resolveAgentModelSource(emptyRequest)).toBe("@override"); + + const emptyAlias = { + requestModel: "@empty", + settingsOverride: ",,", + agentModel: ["@definition"], + settings, + }; + expect(resolveAgentModelPatterns(emptyAlias)).toEqual(["anthropic/claude-sonnet-4-5"]); + expect(resolveAgentModelSource(emptyAlias)).toEqual(["@definition"]); + + const concreteRequest = { + requestModel: "openai/gpt-4o", + settingsOverride: "@override", + agentModel: ["@definition"], + settings, + }; + expect(resolveAgentModelSource(concreteRequest)).toBe("openai/gpt-4o"); + expect(resolveExplicitModelRole(resolveAgentModelSource(concreteRequest), settings)).toBeUndefined(); + }); + test("falls back to the active session model when @task is unset", () => { const settings = Settings.isolated({ modelRoles: { default: "anthropic/claude-sonnet-4-5" }, @@ -1699,6 +1738,29 @@ describe("resolveModelFromString", () => { }); }); +describe("resolveExplicitModelRole", () => { + test("extracts built-in, custom, legacy, default, and thinking-suffixed aliases before expansion", () => { + const settings = Settings.isolated({ + modelRoles: { + reviewer: "openai/gpt-4o", + }, + }); + + expect(resolveExplicitModelRole("@task", settings)).toBe("task"); + expect(resolveExplicitModelRole("pi/reviewer:high", settings)).toBe("reviewer"); + expect(resolveExplicitModelRole("@reviewer:xhigh", settings)).toBe("reviewer"); + expect(resolveExplicitModelRole("*:low", settings)).toBe("default"); + }); + + test("does not infer a role from an explicit model selector", () => { + const settings = Settings.isolated({ modelRoles: { reviewer: "openai/gpt-4o" } }); + expect(resolveExplicitModelRole("openai/gpt-4o", settings)).toBeUndefined(); + expect(resolveExplicitModelRole("openai/gpt-4o:high", settings)).toBeUndefined(); + expect(resolveExplicitModelRole("openai/gpt-4o:max", settings)).toBeUndefined(); + expect(resolveExplicitModelRole(["openai/gpt-4o", "@reviewer:high"], settings)).toBe("reviewer"); + }); +}); + describe("expandRoleAlias", () => { test("expands @vision to configured vision role", () => { const settings = Settings.isolated(); diff --git a/packages/coding-agent/test/task/structured-subagent.test.ts b/packages/coding-agent/test/task/structured-subagent.test.ts index 371a4d2e6..a24facde7 100644 --- a/packages/coding-agent/test/task/structured-subagent.test.ts +++ b/packages/coding-agent/test/task/structured-subagent.test.ts @@ -37,6 +37,7 @@ function session( maxDepth?: number; isolationMode?: "none" | "worktree"; isolationApply?: boolean; + modelRoles?: Record; } = {}, ): ToolSession { return { @@ -47,6 +48,7 @@ function session( "task.maxRecursionDepth": options.maxDepth ?? 2, "task.isolation.mode": options.isolationMode ?? "none", "task.enableLsp": true, + ...(options.modelRoles ? { modelRoles: options.modelRoles } : {}), ...(options.isolationApply !== undefined ? { "task.isolation.apply": options.isolationApply } : {}), }), getSessionFile: () => null, @@ -165,6 +167,112 @@ describe("structured subagent primitive", () => { ).rejects.toThrow("isolation, apply, and merge controls are unavailable in plan mode"); expect(discover).not.toHaveBeenCalled(); }); + it("propagates a custom thinking-suffixed role alias through policy, dispatch, and settlement", async () => { + const customAgent = { ...AGENT, model: ["@reviewer:high"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { reviewer: "openai/gpt-4o" } }); + const dispatched: executorModule.ExecutorOptions[] = []; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + dispatched.push(options); + return { ...result(), modelRole: options.modelRole }; + }); + + const settled = await runStructuredSubagent( + request({ session: childSession, agent: "worker", retainArtifacts: true }), + ); + + expect(settled.policy.modelRole).toBe("reviewer"); + expect(dispatched[0]?.modelRole).toBe("reviewer"); + expect(settled.result.modelRole).toBe("reviewer"); + await fs.rm(settled.artifactsDir, { recursive: true, force: true }); + }); + it("derives modelRole from the raw selector source in request, override, definition order", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const roleSession = session({ + modelRoles: { + request: "openai/gpt-4o", + override: "openai/gpt-4o", + definition: "openai/gpt-4o", + }, + }); + roleSession.settings.override("task.agentModelOverrides", { worker: "@override" }); + + const requestPolicy = await resolveEffectiveSubagentPolicy(request({ session: roleSession, model: "@request" })); + expect(requestPolicy.modelRole).toBe("request"); + + const overridePolicy = await resolveEffectiveSubagentPolicy(request({ session: roleSession })); + expect(overridePolicy.modelRole).toBe("override"); + + const concreteOverrideSession = session({ + modelRoles: { + override: "openai/gpt-4o", + definition: "openai/gpt-4o", + }, + }); + concreteOverrideSession.settings.override("task.agentModelOverrides", { worker: "openai/gpt-4o" }); + const concreteOverridePolicy = await resolveEffectiveSubagentPolicy( + request({ session: concreteOverrideSession }), + ); + expect(concreteOverridePolicy.modelRole).toBeUndefined(); + + const definitionPolicy = await resolveEffectiveSubagentPolicy( + request({ session: session({ modelRoles: { definition: "openai/gpt-4o" } }) }), + ); + expect(definitionPolicy.modelRole).toBe("definition"); + }); + it("falls through an empty request selector to the agent definition role", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { definition: "openai/gpt-4o" } }); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: childSession, model: "" })); + + expect(policy.modelRole).toBe("definition"); + expect(policy.modelOverride).toEqual(["openai/gpt-4o"]); + }); + + it("falls through an empty configured override to the agent definition role", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { definition: "openai/gpt-4o" } }); + childSession.settings.override("task.agentModelOverrides", { worker: "" }); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: childSession })); + + expect(policy.modelRole).toBe("definition"); + expect(policy.modelOverride).toEqual(["openai/gpt-4o"]); + }); + it("falls through a configured alias that expands to no patterns", async () => { + const customAgent = { ...AGENT, model: ["@definition"] }; + mockDiscovery(customAgent); + const childSession = session({ modelRoles: { empty: "", definition: "openai/gpt-4o" } }); + childSession.settings.override("task.agentModelOverrides", { worker: "@empty" }); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: childSession })); + + expect(policy.modelRole).toBe("definition"); + expect(policy.modelOverride).toEqual(["openai/gpt-4o"]); + }); + + it("does not assign a role when a child uses an explicit model selector", async () => { + mockDiscovery(); + const childSession = session({ modelRoles: { reviewer: "openai/gpt-4o" } }); + const dispatched: executorModule.ExecutorOptions[] = []; + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + dispatched.push(options); + return result(); + }); + + const settled = await runStructuredSubagent( + request({ session: childSession, model: "openai/gpt-4o", retainArtifacts: true }), + ); + + expect(settled.policy.modelRole).toBeUndefined(); + expect(dispatched[0]?.modelRole).toBeUndefined(); + expect(settled.result.modelRole).toBeUndefined(); + await fs.rm(settled.artifactsDir, { recursive: true, force: true }); + }); it("leases temporary artifacts for a retained invocation and registers them for agent URLs", async () => { mockDiscovery(); From 91467c2f27f21c9cceb72ac434f569aa425632fa Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Mon, 3 Aug 2026 17:04:28 -0700 Subject: [PATCH 033/126] fix(coding-agent): restore Agent Hub lineage metrics --- .../src/modes/components/agent-hub.ts | 75 ++++--- .../src/registry/agent-registry.ts | 35 ++++ .../src/registry/persisted-agents.ts | 185 +++++++++++++++++- .../src/session/session-entries.ts | 8 + .../src/session/session-manager.ts | 4 + packages/coding-agent/src/task/executor.ts | 4 + .../test/agent-hub-activate.test.ts | 110 ++++++++++- .../test/agent-hub-ordering.test.ts | 24 ++- 8 files changed, 415 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 055ec4944..147df3041 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -34,7 +34,13 @@ import type { Settings } from "../../config/settings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; -import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; +import { + type AgentMetricsSummary, + type AgentRef, + AgentRegistry, + type AgentStatus, + MAIN_AGENT_ID, +} from "../../registry/agent-registry"; import { registerPersistedSubagents } from "../../registry/persisted-agents"; import { USER_INTERRUPT_LABEL } from "../../session/messages"; import { parseThinkingLevel } from "../../thinking"; @@ -61,15 +67,7 @@ const ROSTER_MIN_WIDTH = 48; type HubViewMode = "roster" | "tree"; -interface AgentMetrics { - tokens: number; - requests: number; - tools: number; - cost: number; - durationMs: number; - contextTokens?: number; - contextWindow?: number; -} +type AgentMetrics = AgentMetricsSummary; interface AggregateMetrics extends AgentMetrics { reportedAgents: number; @@ -175,8 +173,9 @@ function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): str if (fallbackSelector) { return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true, liveThinkingLevel)}`; } - if (progress?.resolvedModel) { - return formatResolvedModelBadge(progress.resolvedModel, false, liveThinkingLevel); + const resolvedModel = progress?.resolvedModel ?? ref.history?.resolvedModel; + if (resolvedModel) { + return formatResolvedModelBadge(resolvedModel, false, liveThinkingLevel); } const model = ref.session?.model; if (!model) return undefined; @@ -349,6 +348,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou /** Operational ordering by default; tree mode groups descendants under their spawner. */ #viewMode: HubViewMode = "roster"; #treeDepthById = new Map(); + #treeParentById = new Map(); + #treeLastSiblingById = new Map(); /** Current observer index and summary data, rebuilt on source changes rather than every paint. */ #observedById = new Map(); #aggregate: AggregateMetrics = { @@ -595,7 +596,11 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } this.#rows = this.#viewMode === "tree" ? this.#orderAsTree(rosterRows) : rosterRows; - if (this.#viewMode === "roster") this.#treeDepthById.clear(); + if (this.#viewMode === "roster") { + this.#treeDepthById.clear(); + this.#treeParentById.clear(); + this.#treeLastSiblingById.clear(); + } const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1; this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); @@ -615,6 +620,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou // An observer snapshot is authoritative. Legacy/incomplete snapshots remain // unknown rather than being silently replaced by unrelated transcript stats. if (observed?.progress) return progressMetrics(observed); + if (ref.history?.metrics) return ref.history.metrics; const session = this.#fallbackStatsSession(ref, observed); return session ? this.#sessionMetrics.get(session)?.metrics : undefined; } @@ -651,6 +657,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou /** Parent-before-child projection preserving the roster's stable sibling order. */ #orderAsTree(refs: AgentRef[]): AgentRef[] { + this.#treeParentById.clear(); + this.#treeLastSiblingById.clear(); const ids = new Set(); const operationalIndex = new Map(); for (let i = 0; i < refs.length; i++) { @@ -662,6 +670,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou for (const ref of refs) { const parent = ref.parentId && ref.parentId !== MAIN_AGENT_ID && ids.has(ref.parentId) ? ref.parentId : MAIN_AGENT_ID; + this.#treeParentById.set(ref.id, parent); const siblings = children.get(parent); if (siblings) siblings.push(ref); else children.set(parent, [ref]); @@ -713,6 +722,11 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou (operationalIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER), ); } + for (const siblings of children.values()) { + for (let i = 0; i < siblings.length; i++) { + this.#treeLastSiblingById.set(siblings[i].id, i === siblings.length - 1); + } + } const ordered: AgentRef[] = []; const visited = new Set(); @@ -739,6 +753,21 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return ordered; } + /** Bash `tree`-style ancestry prefix, clipped from the left on pathological depth. */ + #treeBranch(ref: AgentRef, maxWidth: number): string { + if ((this.#treeDepthById.get(ref.id) ?? 0) === 0) return ""; + const segments: string[] = [this.#treeLastSiblingById.get(ref.id) ? "└── " : "├── "]; + let parent = this.#treeParentById.get(ref.id); + while (parent && this.#treeParentById.get(parent) !== MAIN_AGENT_ID) { + segments.push(this.#treeLastSiblingById.get(parent) ? " " : "│ "); + parent = this.#treeParentById.get(parent); + } + const maxSegments = Math.max(1, Math.floor(Math.max(4, maxWidth - 2) / 4)); + const omitted = Math.max(0, segments.length - maxSegments); + const prefix = segments.slice(0, maxSegments).reverse().join(""); + return theme.fg("dim", `${omitted > 0 ? "… " : ""}${prefix}`); + } + // ======================================================================== // Table view // ======================================================================== @@ -1043,7 +1072,8 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou `${statusText(ref.status, ref.status)}${theme.fg("dim", `${theme.sep.dot}${lifecycleDetails.join(theme.sep.dot)}`)}`, ); const modelDetails: string[] = []; - if (progress?.modelRole && this.#settings) modelDetails.push(formatRoleBadge(progress.modelRole, this.#settings)); + const modelRole = progress?.modelRole ?? ref.history?.modelRole; + if (modelRole && this.#settings) modelDetails.push(formatRoleBadge(modelRole, this.#settings)); const badge = modelBadge(ref, observed); if (badge) modelDetails.push(badge); if (modelDetails.length > 0) add(modelDetails.join(theme.sep.dot)); @@ -1086,7 +1116,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou add( theme.fg( "dim", - ref.kind === "advisor" ? "Read-only transcript" : "Shared workspace · per-agent LoC not attributable", + ref.kind === "advisor" || ref.history?.readOnly + ? "Read-only · 0 LoC" + : "Shared workspace · per-agent LoC not attributable", ), ); @@ -1097,7 +1129,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou /** * One agent entry keeps identity/model metadata on one line when it fits, * then packs task and all five usage metrics together below. Narrow rows wrap - * only those dense secondary fields; restored historical rows say `usage —`. + * only those dense secondary fields. */ #renderEntry( ref: AgentRef, @@ -1109,11 +1141,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const max = Math.max(1, width); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0; - const visibleDepth = Math.min(depth, Math.max(1, Math.floor(max / 2))); - const branch = - this.#viewMode === "tree" && depth > 0 - ? `${depth > visibleDepth ? "… " : ""}${" ".repeat(Math.max(0, visibleDepth - 1))}↳ ` - : ""; + const branch = this.#viewMode === "tree" ? this.#treeBranch(ref, max) : ""; const id = replaceTabs(ref.id); const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id); const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`]; @@ -1133,8 +1161,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const left = fields.join(" "); const meta: string[] = []; - if (observed?.progress?.modelRole && this.#settings) { - meta.push(formatRoleBadge(observed.progress.modelRole, this.#settings)); + const modelRole = observed?.progress?.modelRole ?? ref.history?.modelRole; + if (modelRole && this.#settings) { + meta.push(formatRoleBadge(modelRole, this.#settings)); } const badge = modelBadge(ref, observed); if (badge) meta.push(badge); diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index 77ad8a72c..76d269e61 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -30,6 +30,26 @@ export type AgentStatus = "running" | "idle" | "parked" | "aborted"; */ export type AgentKind = "main" | "sub" | "advisor"; +/** Persisted per-agent totals reconstructed from the child session transcript. */ +export interface AgentMetricsSummary { + tokens: number; + requests: number; + tools: number; + cost: number; + durationMs: number; + contextTokens?: number; + contextWindow?: number; +} + +/** Historical identity and telemetry that remain available after the live session is disposed. */ +export interface AgentHistorySummary { + agent?: string; + modelRole?: string; + resolvedModel?: string; + metrics?: AgentMetricsSummary; + readOnly?: boolean; +} + export interface AgentRef { id: string; displayName: string; @@ -43,6 +63,8 @@ export interface AgentRef { lastActivity: number; /** Short gist of what the agent is currently doing (latest intent or tool), for the work-aware roster. Display-only. */ activity?: string; + /** Persisted identity and telemetry restored after the live observer is gone. */ + history?: AgentHistorySummary; } export type AgentRefExpectation = AgentRef | AgentSession; @@ -50,6 +72,7 @@ export type AgentRefExpectation = AgentRef | AgentSession; export type RegistryEvent = | { type: "registered"; ref: AgentRef } | { type: "status_changed"; ref: AgentRef } + | { type: "metadata_changed"; ref: AgentRef } | { type: "removed"; ref: AgentRef }; type RegistryListener = (event: RegistryEvent) => void; @@ -68,6 +91,8 @@ export interface RegisterInput { createdAt?: number; /** Last transcript activity timestamp, when known from persisted history. */ lastActivity?: number; + /** Persisted identity and telemetry restored after the live observer is gone. */ + history?: AgentHistorySummary; } export class AgentRegistry { @@ -105,6 +130,7 @@ export class AgentRegistry { createdAt: input.createdAt ?? now, lastActivity: input.lastActivity ?? now, activity: input.activity, + history: input.history, }; this.#refs.set(ref.id, ref); this.#emit({ type: "registered", ref }); @@ -123,6 +149,15 @@ export class AgentRegistry { return current === expected && current.status === "parked" && !current.session ? current : undefined; } + /** Attach transcript-derived identity and telemetry without changing lifecycle state. */ + setHistory(id: string, history: AgentHistorySummary, expectedSessionFile?: string): boolean { + const ref = this.#refs.get(id); + if (!ref || (expectedSessionFile !== undefined && ref.sessionFile !== expectedSessionFile)) return false; + ref.history = { ...ref.history, ...history }; + this.#emit({ type: "metadata_changed", ref }); + return true; + } + setStatus(id: string, status: AgentStatus, expected?: AgentRefExpectation): boolean { const ref = this.#refs.get(id); if (!ref || !this.#matchesExpected(ref, expected)) return false; diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 56c386f2d..294946b2d 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -2,8 +2,15 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { readLines } from "@oh-my-pi/pi-utils"; import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../advisor/transcript-recorder"; +import { resolveExplicitModelRole } from "../config/model-resolver"; +import { loadBundledAgents } from "../task/agents"; import { persistedVibeChildIds } from "../vibe/runtime"; -import { type AgentRegistry, MAIN_AGENT_ID } from "./agent-registry"; +import { + type AgentHistorySummary, + type AgentMetricsSummary, + type AgentRegistry, + MAIN_AGENT_ID, +} from "./agent-registry"; /** * Child ids owned by the Vibe roster persisted in this session file. Vibe @@ -17,6 +24,14 @@ interface PersistedAgentMetadata { activity?: string; createdAt?: number; lastActivity?: number; + history?: AgentHistorySummary; +} + +interface PersistedTranscript { + id: string; + sessionFile: string; + createdAt?: number; + lastActivity?: number; } function recordOf(value: unknown): Record | undefined { @@ -46,6 +61,122 @@ function summarizePersistedTask(task: string): string | undefined { return summary ? summary.slice(0, 1_000) : undefined; } +const READ_ONLY_AGENT_TOOLS: Record = { + read: true, + grep: true, + glob: true, + web_search: true, + ast_grep: true, + yield: true, +}; + +function finiteNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function inferBundledAgent(systemPrompt: string): { agent?: string; modelRole?: string; readOnly?: boolean } { + const matches = loadBundledAgents().filter(agent => { + const rolePrompt = agent.systemPrompt.trim(); + return rolePrompt.length > 0 && systemPrompt.includes(rolePrompt); + }); + // `task` and `sonic` intentionally share a prompt body. Ambiguous historical + // prompts stay unlabelled rather than inventing provenance. + if (matches.length !== 1) return {}; + const [agent] = matches; + return { + agent: agent.name, + modelRole: resolveExplicitModelRole(agent.model), + readOnly: !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_AGENT_TOOLS[tool] === true), + }; +} + +function usageTokens(usage: Record): number { + const computed = finiteNumber(usage.input) + finiteNumber(usage.output) + finiteNumber(usage.cacheWrite); + return computed > 0 ? computed : finiteNumber(usage.totalTokens); +} + +interface AssistantMetrics { + tokens: number; + tools: number; + cost: number; + contextTokens?: number; + resolvedModel?: string; +} + +function assistantMetrics(message: Record): AssistantMetrics { + const usage = recordOf(message.usage) ?? {}; + const cost = recordOf(usage.cost); + const content = Array.isArray(message.content) ? message.content : []; + const provider = typeof message.provider === "string" ? message.provider : undefined; + const model = typeof message.model === "string" ? message.model : undefined; + return { + tokens: usageTokens(usage), + tools: content.filter(part => recordOf(part)?.type === "toolCall").length, + cost: finiteNumber(cost?.total), + contextTokens: finiteNumber(usage.totalTokens) || undefined, + resolvedModel: provider && model ? `${provider}/${model}` : undefined, + }; +} + +async function readPersistedAgentHistory(transcript: PersistedTranscript): Promise { + const parents = new Map(); + const assistantById = new Map(); + let leafId: string | undefined; + let leafTimestamp: number | undefined; + try { + for await (const bytes of readLines(Bun.file(transcript.sessionFile).stream())) { + const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const prefix = line.subarray(0, Math.min(line.byteLength, 512)).toString("utf8"); + const id = /"id":"([^"]+)"/.exec(prefix)?.[1]; + if (!id) continue; + const parentMatch = /"parentId":(?:"([^"]+)"|null)/.exec(prefix); + parents.set(id, parentMatch?.[1]); + leafId = id; + const entryTimestamp = /"timestamp":"([^"]+)"/.exec(prefix)?.[1]; + const parsedTimestamp = timestampOf(entryTimestamp); + if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; + if (!prefix.includes('"type":"message"') || !line.includes(Buffer.from('"role":"assistant"'))) continue; + try { + const entry = recordOf(JSON.parse(line.toString("utf8"))); + const message = recordOf(entry?.message); + if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); + } catch { + // One malformed historical entry must not erase valid totals. + } + } + } catch { + return {}; + } + + const metrics: AgentMetricsSummary = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: Math.max( + 0, + (leafTimestamp ?? transcript.lastActivity ?? transcript.createdAt ?? 0) - + (transcript.createdAt ?? leafTimestamp ?? 0), + ), + }; + let resolvedModel: string | undefined; + let contextTokens: number | undefined; + const visited = new Set(); + for (let id = leafId; id && !visited.has(id); id = parents.get(id)) { + visited.add(id); + const assistant = assistantById.get(id); + if (!assistant) continue; + metrics.requests++; + metrics.tokens += assistant.tokens; + metrics.tools += assistant.tools; + metrics.cost += assistant.cost; + contextTokens ??= assistant.contextTokens; + resolvedModel ??= assistant.resolvedModel; + } + metrics.contextTokens = contextTokens; + return { metrics: metrics.requests > 0 ? metrics : undefined, resolvedModel }; +} + /** * Read only the small session prefix needed by the Hub. A subagent's first * `session_init` is written before its conversation, so this never walks a @@ -55,6 +186,7 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise undefined); let createdAt: number | undefined; let activity: string | undefined; + let history: AgentHistorySummary = {}; try { let linesRead = 0; for await (const bytes of readLines(Bun.file(sessionFile).stream())) { @@ -70,9 +202,27 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise { + for (;;) { + const index = nextTranscript++; + const transcript = transcripts[index]; + if (!transcript) return; + const history = await readPersistedAgentHistory(transcript); + registry.setHistory(transcript.id, history, transcript.sessionFile); + } + }); + await Promise.all(workers); } async function registerPersistedSubagentsFromDir( @@ -123,6 +286,7 @@ async function registerPersistedSubagentsFromDir( dir: string, parentId: string | undefined, vibeOwnedIds: ReadonlySet, + transcripts: PersistedTranscript[], ): Promise { let entries: fs.Dirent[]; try { @@ -162,8 +326,15 @@ async function registerPersistedSubagentsFromDir( activity: metadata.activity, createdAt: metadata.createdAt, lastActivity: metadata.lastActivity, + history: { ...metadata.history, readOnly: true }, status: "parked", }); + transcripts.push({ + id: advisorId, + sessionFile, + createdAt: metadata.createdAt, + lastActivity: metadata.lastActivity, + }); } continue; } @@ -181,9 +352,17 @@ async function registerPersistedSubagentsFromDir( activity: metadata.activity, createdAt: metadata.createdAt, lastActivity: metadata.lastActivity, + history: metadata.history, status: "parked", }); + const ref = registry.get(id); + transcripts.push({ + id, + sessionFile, + createdAt: ref?.createdAt, + lastActivity: ref?.lastActivity, + }); } - await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id, vibeOwnedIds); + await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id, vibeOwnedIds, transcripts); } } diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index bd28e4332..d0114559b 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -207,6 +207,14 @@ export interface SessionInitEntry extends SessionEntryBase { task: string; /** Tools available to the agent */ tools: string[]; + /** Agent definition name (for example `scout` or `reviewer`). */ + agent?: string; + /** Semantic model role declared by the agent, retained even after concrete model resolution. */ + modelRole?: string; + /** Initially resolved provider/model selector for historical display. */ + resolvedModel?: string; + /** Whether the agent definition is read-only, allowing an exact zero-LoC attribution. */ + readOnly?: boolean; /** Output schema if structured output was requested. */ outputSchema?: unknown; /** Enforcement policy recorded with the output schema for faithful revival. */ diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 6b2d7def5..7d0152ba5 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -2058,6 +2058,10 @@ export class SessionManager { systemPrompt: string; task: string; tools: string[]; + agent?: string; + modelRole?: string; + resolvedModel?: string; + readOnly?: boolean; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; restrictToolNames?: boolean; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 9cd6cb61d..9c5c058af 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -17,6 +17,7 @@ import { formatModelStringWithRouting, resolveAgentPrewalkPattern, resolveConfiguredModelPatterns, + resolveExplicitModelRole, resolveModelOverride, resolveModelOverrideWithAuthFallback, } from "../config/model-resolver"; @@ -3092,6 +3093,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise Promise) { return { hub, doneCalls: () => doneCalls, done: done.promise, renderRequested: renderRequested.promise }; } -const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?: {2})*↳ )?(\S+)/u; +const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?:(?:│ {3}| {4})*)(?:├── |└── ))?(\S+)/u; function renderedRosterEntry(hub: AgentHubOverlayComponent, id: string, width: number): string { const cells = hub.render(width).map(raw => { const line = Bun.stripANSI(raw); if (!line.startsWith("│ ")) return undefined; - const divider = line.indexOf("│", 2); + const divider = line.indexOf("│", Math.max(2, Math.floor(line.length / 3))); return divider < 0 ? undefined : line.slice(2, Math.max(2, divider - 1)); }); const start = cells.findIndex(cell => { @@ -142,6 +143,34 @@ describe("Agent hub Enter activation", () => { hub.dispose(); }); + it("restores nested parent lineage after restart", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-tree-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const parentSessionFile = path.join(tempDir.path(), "main", "Parent.jsonl"); + const childSessionFile = path.join(tempDir.path(), "main", "Parent", "Child.jsonl"); + await Bun.write(sessionFile, ""); + await Bun.write(parentSessionFile, ""); + await Bun.write(childSessionFile, ""); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + await hub.persistedSubagentsReady; + + expect(agents.get("Parent")?.parentId).toBe("Main"); + expect(agents.get("Child")?.parentId).toBe("Parent"); + hub.handleInput("t"); + expect(Bun.stripANSI(renderedRosterEntry(hub, "Child", 120))).toContain("└── Child"); + hub.dispose(); + }); + it("restores saved task metadata and timestamps for completed agents", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-persisted-metadata-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); @@ -190,6 +219,83 @@ describe("Agent hub Enter activation", () => { expect(workerEntry).not.toContain("$0.000"); hub.dispose(); }); + + it("restores persisted model role, usage, spend, and tool totals", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-usage-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + const workerSessionFile = path.join(tempDir.path(), "main", "Worker.jsonl"); + const createdAt = "2026-07-30T01:13:30.000Z"; + const lastActivity = new Date("2026-07-30T01:15:00.000Z"); + await Bun.write(sessionFile, ""); + await Bun.write( + workerSessionFile, + [ + JSON.stringify({ type: "session", version: 3, id: "worker-session", timestamp: createdAt, cwd: TEST_CWD }), + JSON.stringify({ + type: "model_change", + id: "model", + parentId: null, + timestamp: createdAt, + model: "openai-codex/gpt-5.6-luna", + // Historical concrete overrides did not persist a model-role field. + }), + JSON.stringify({ + type: "session_init", + id: "init", + parentId: "model", + timestamp: createdAt, + systemPrompt: `base prompt\n\nROLE\n====\n${getBundledAgent("scout")?.systemPrompt}`, + task: "Inspect persisted telemetry.", + tools: ["read", "grep"], + }), + JSON.stringify({ + type: "message", + id: "assistant", + parentId: "init", + timestamp: lastActivity.toISOString(), + message: { + role: "assistant", + timestamp: lastActivity.getTime(), + content: [ + { type: "toolCall", id: "read-call", name: "read", arguments: { path: "src/a.ts" } }, + { type: "toolCall", id: "grep-call", name: "grep", arguments: { pattern: "needle" } }, + ], + usage: { + input: 100, + output: 25, + cacheRead: 200, + cacheWrite: 10, + totalTokens: 335, + cost: { input: 0.01, output: 0.1, cacheRead: 0.01, cacheWrite: 0.003, total: 0.123 }, + }, + }, + }), + ].join("\n"), + ); + await fs.utimes(workerSessionFile, lastActivity, lastActivity); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + await hub.persistedSubagentsReady; + + const workerEntry = renderedRosterEntry(hub, "Worker", 120).replace(/\s+/g, " "); + expect(workerEntry).toContain("SMOL"); + expect(workerEntry).toContain("$0.123"); + expect(workerEntry).toContain("1m30s"); + expect(workerEntry).toContain("1 req"); + expect(workerEntry).toContain("2 tools"); + expect(workerEntry).toContain("135 tok"); + expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("Read-only · 0 LoC"); + hub.dispose(); + }); it("avoids multi-second event-loop stalls while discovering agents from a large session", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 9f2917768..1e358e81c 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -61,12 +61,12 @@ interface RenderedAgentRow { selected: boolean; } -const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?: {2})*↳ )?(\S+)/u; +const ROSTER_ENTRY_PATTERN = /^(❯| ) (\S+) (?:(?:(?:│ {3}| {4})*)(?:├── |└── ))?(\S+)/u; function rosterCell(raw: string): string | undefined { const line = Bun.stripANSI(raw); if (!line.startsWith("│ ")) return undefined; - const divider = line.indexOf("│", 2); + const divider = line.indexOf("│", Math.max(2, Math.floor(line.length / 3))); if (divider < 0) return undefined; return line.slice(2, Math.max(2, divider - 1)); } @@ -754,6 +754,26 @@ describe("Agent hub row ordering", () => { } }); + it("renders parent lineage with bash-style tree connectors", () => { + geometry = stubStdoutGeometry(120); + geometry.setRows(32); + const agents = new AgentRegistry(); + agents.register({ id: "Parent", displayName: "Parent", kind: "sub", parentId: "Main", session: null }); + agents.register({ id: "First", displayName: "First", kind: "sub", parentId: "Parent", session: null }); + agents.register({ id: "Grandchild", displayName: "Grandchild", kind: "sub", parentId: "First", session: null }); + agents.register({ id: "Last", displayName: "Last", kind: "sub", parentId: "Parent", session: null }); + const hub = makeHub(agents); + + try { + hub.handleInput("t"); + expect(Bun.stripANSI(renderedRosterHeaderLineRaw(hub, "First", 120))).toContain("├── First"); + expect(Bun.stripANSI(renderedRosterHeaderLineRaw(hub, "Grandchild", 120))).toContain("│ └── Grandchild"); + expect(Bun.stripANSI(renderedRosterHeaderLineRaw(hub, "Last", 120))).toContain("└── Last"); + } finally { + hub.dispose(); + } + }); + it("opens the selected-agent inspector as a narrow-terminal fallback", () => { geometry = stubStdoutGeometry(80); geometry.setRows(28); From 6eb9574436c314f5fab14b968e13160eaa1a4820 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 02:55:38 +0000 Subject: [PATCH 034/126] 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 035/126] 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 036/126] 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 037/126] 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 038/126] 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 039/126] 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 040/126] 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 041/126] 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 042/126] 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 043/126] 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 044/126] 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 045/126] 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 046/126] 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 047/126] 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 048/126] 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 049/126] 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 050/126] 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 051/126] 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 052/126] 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 053/126] 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 054/126] 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 055/126] 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 056/126] 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 057/126] 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 058/126] 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 059/126] 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 060/126] 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 fce059930ef46779a6b1a4cfd410da219f25cce7 Mon Sep 17 00:00:00 2001 From: Pete Samwel Date: Tue, 4 Aug 2026 13:13:18 -0500 Subject: [PATCH 061/126] fix(catalog): emit AWS GovCloud us-gov Bedrock Claude inference profiles Bare anthropic.claude-* Bedrock rows already derived eu.* selectors; also emit us-gov.* so GovCloud accounts can resolve system inference profiles without requiring a full partition ARN. --- packages/catalog/CHANGELOG.md | 4 + packages/catalog/src/models.json | 326 +++++++++++++++++- .../src/provider-models/openai-compat.ts | 15 +- .../test/amazon-bedrock-opus-5.test.ts | 19 +- .../test/amazon-bedrock-us-gov.test.ts | 62 ++++ 5 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 packages/catalog/test/amazon-bedrock-us-gov.test.ts diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index cab9afb53..1bc5a62ed 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Amazon Bedrock catalog generation omitting AWS GovCloud `us-gov.*` Claude inference-profile IDs, so selectors like `amazon-bedrock/us-gov.anthropic.claude-sonnet-4-5-…` resolve instead of failing model lookup (or misrouting commercial `us.*` geos onto `us-east-1` with GovCloud credentials). + ## [17.2.7] - 2026-08-03 ### Fixed diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 07a28ec00..99910f606 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -10488,6 +10488,330 @@ "contextWindow": 262000, "maxTokens": 262000 }, + "us-gov.anthropic.claude-fable-5": { + "id": "us-gov.anthropic.claude-fable-5", + "name": "Claude Fable 5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-haiku-4-5-20251001-v1:0": { + "id": "us-gov.anthropic.claude-haiku-4-5-20251001-v1:0", + "name": "Claude Haiku 4.5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 1, + "output": 5, + "cacheRead": 0.1, + "cacheWrite": 1.25 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-opus-4-1-20250805-v1:0": { + "id": "us-gov.anthropic.claude-opus-4-1-20250805-v1:0", + "name": "Claude Opus 4.1 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 15, + "output": 75, + "cacheRead": 1.5, + "cacheWrite": 18.75 + }, + "contextWindow": 200000, + "maxTokens": 32000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-opus-4-5-20251101-v1:0": { + "id": "us-gov.anthropic.claude-opus-4-5-20251101-v1:0", + "name": "Claude Opus 4.5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "thinking": { + "mode": "anthropic-budget-effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-opus-4-6-v1": { + "id": "us-gov.anthropic.claude-opus-4-6-v1", + "name": "Claude Opus 4.6 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ] + } + }, + "us-gov.anthropic.claude-opus-4-7": { + "id": "us-gov.anthropic.claude-opus-4-7", + "name": "Claude Opus 4.7 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-opus-4-8": { + "id": "us-gov.anthropic.claude-opus-4-8", + "name": "Claude Opus 4.8 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-opus-5": { + "id": "us-gov.anthropic.claude-opus-5", + "name": "Claude Opus 5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, + "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "id": "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + "name": "Claude Sonnet 4.5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-sonnet-4-6": { + "id": "us-gov.anthropic.claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 1000000, + "maxTokens": 64000, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + "us-gov.anthropic.claude-sonnet-5": { + "id": "us-gov.anthropic.claude-sonnet-5", + "name": "Claude Sonnet 5 (GovCloud)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "efforts": [ + "low", + "medium", + "high", + "max" + ], + "supportsDisplay": true + } + }, "us.amazon.nova-lite-v1:0": { "id": "us.amazon.nova-lite-v1:0", "name": "Nova Lite", @@ -105772,4 +106096,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 9153c208b..d6a948bda 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -5608,14 +5608,25 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK: readonly ModelsDevProviderDescrip id: crossRegionId, name: toModelName(m.name, crossRegionId), }; - // Also emit EU variants for Claude models + // Also emit EU and AWS GovCloud (`us-gov.`) geo inference-profile + // variants for Claude models. GovCloud accounts list system profiles + // under the `us-gov.` prefix (e.g. us-gov.anthropic.claude-sonnet-4-5-…); + // without these rows the catalog only has commercial geos (`us.`/`eu.`/…) + // and model resolution rejects the GovCloud id (or misroutes commercial + // geos onto us-east-1 with GovCloud credentials → 403). if (modelId.startsWith("anthropic.claude-")) { + const displayName = toModelName(m.name, modelId); return [ bedrockModel, { ...bedrockModel, id: `eu.${modelId}`, - name: `${toModelName(m.name, modelId)} (EU)`, + name: `${displayName} (EU)`, + }, + { + ...bedrockModel, + id: `us-gov.${modelId}`, + name: `${displayName} (GovCloud)`, }, ]; } diff --git a/packages/catalog/test/amazon-bedrock-opus-5.test.ts b/packages/catalog/test/amazon-bedrock-opus-5.test.ts index d1e54e6ca..4b4d9591e 100644 --- a/packages/catalog/test/amazon-bedrock-opus-5.test.ts +++ b/packages/catalog/test/amazon-bedrock-opus-5.test.ts @@ -4,18 +4,23 @@ import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; import { dropUnsupportedBedrockGeoIds } from "../scripts/generated-policies"; -// AWS's Bedrock model card for Claude Opus 5 lists exactly these Programmatic -// Access IDs — the bare model ID plus the us./eu./au. Geo and global. -// inference profiles. Japan is explicitly marked unsupported for Geo +// AWS's Bedrock model card for Claude Opus 5 lists these commercial/geo +// Programmatic Access IDs — the bare model ID plus the us./eu./au. Geo and +// global inference profiles. Japan is explicitly marked unsupported for Geo // inference in the same card's regional-availability table, so no `jp.` // profile exists for this model (unlike several Opus 4.x generations). // https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html +// +// The catalog also synthesizes `us-gov.*` Claude geo profiles for AWS GovCloud +// (same path as the derived `eu.*` row) so GovCloud selectors resolve without +// requiring a full inference-profile ARN. const AWS_DOCUMENTED_OPUS_5_IDS = [ "anthropic.claude-opus-5", "us.anthropic.claude-opus-5", "eu.anthropic.claude-opus-5", "au.anthropic.claude-opus-5", "global.anthropic.claude-opus-5", + "us-gov.anthropic.claude-opus-5", ]; // A representative `stencil.so` "amazon-bedrock" payload for Claude Opus 5. @@ -78,10 +83,10 @@ describe("Amazon Bedrock Claude Opus 5", () => { ); const opus5Ids = dropUnsupportedBedrockGeoIds(mapped).map(model => model.id); - // Set semantics: the descriptor also derives an `eu.` variant from the - // bare `anthropic.` row, so `eu.` legitimately arrives from both that - // derivation and the standalone stencil.so row (deduped downstream by - // the generator). We assert the documented ID coverage, not row count. + // Set semantics: the descriptor also derives `eu.` and `us-gov.` variants + // from the bare `anthropic.` row, so `eu.` legitimately arrives from both + // that derivation and the standalone stencil.so row (deduped downstream + // by the generator). We assert the documented ID coverage, not row count. expect(new Set(opus5Ids)).toEqual(new Set(AWS_DOCUMENTED_OPUS_5_IDS)); // `stencil.so` lists `jp.anthropic.claude-opus-5`, but Bedrock has no such // inference profile for this model and would reject it, so the generation diff --git a/packages/catalog/test/amazon-bedrock-us-gov.test.ts b/packages/catalog/test/amazon-bedrock-us-gov.test.ts new file mode 100644 index 000000000..7904fe56d --- /dev/null +++ b/packages/catalog/test/amazon-bedrock-us-gov.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi/pi-catalog/provider-models"; + +/** + * Contract: bare Anthropic Claude foundation rows from models.dev/stencil.so + * must produce a `us-gov.` Bedrock inference-profile selector. + * GovCloud accounts expose system profiles under that geo prefix; without it, + * `omp --model amazon-bedrock/us-gov.…` fails model resolution even though + * AWS CLI and ARN-based selectors work. + */ +const CLAUDE_FOUNDATION_ID = "anthropic.claude-sonnet-4-5-20250929-v1:0"; + +const BEDROCK_CLAUDE_FIXTURE = { + "amazon-bedrock": { + models: { + [CLAUDE_FOUNDATION_ID]: { + name: "Claude Sonnet 4.5", + tool_call: true, + reasoning: true, + limit: { context: 200_000, output: 64_000 }, + cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, + modalities: { input: ["text", "image"] }, + }, + // Non-Claude Bedrock model must not get a us-gov sibling from the Claude transform. + "amazon.nova-pro-v1:0": { + name: "Nova Pro", + tool_call: true, + reasoning: false, + limit: { context: 300_000, output: 10_000 }, + cost: { input: 0.8, output: 3.2, cache_read: 0, cache_write: 0 }, + modalities: { input: ["text", "image"] }, + }, + }, + }, +}; + +describe("Amazon Bedrock GovCloud (us-gov) catalog mapping", () => { + test("bare Claude foundation ids emit a us-gov geo inference-profile selector", () => { + const mapped = mapModelsDevToModels(BEDROCK_CLAUDE_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "amazon-bedrock", + ); + const ids = mapped.map(model => model.id); + + expect(ids).toContain(`us-gov.${CLAUDE_FOUNDATION_ID}`); + expect(ids).toContain(`eu.${CLAUDE_FOUNDATION_ID}`); + + const gov = mapped.find(model => model.id === `us-gov.${CLAUDE_FOUNDATION_ID}`); + expect(gov).toBeDefined(); + expect(gov?.api).toBe("bedrock-converse-stream"); + expect(gov?.name).toContain("GovCloud"); + }); + + test("non-Claude Bedrock models do not receive synthesized us-gov variants", () => { + const mapped = mapModelsDevToModels(BEDROCK_CLAUDE_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS).filter( + model => model.provider === "amazon-bedrock", + ); + const ids = mapped.map(model => model.id); + + expect(ids.some(id => id.startsWith("us-gov.amazon."))).toBe(false); + expect(ids).not.toContain("us-gov.amazon.nova-pro-v1:0"); + }); +}); 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 062/126] 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 063/126] 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 064/126] 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 d33f2a16581b6d438e4df0b99fa6410e3ad32714 Mon Sep 17 00:00:00 2001 From: Magicien <162632566+lederniermagicien@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:39:24 +0100 Subject: [PATCH 065/126] fix(ai): retry GitHub Copilot fleet-skew model 400s Any Copilot model in the middle of a rollout (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...) returned a raw HTTP 400 on roughly half of all turns. GET /models on api.githubcopilot.com returns two different catalogs across repeated calls: part of the fleet serves those ids, part rejects them with 400 {"error":{"message":"The requested model is not available for integrator \"copilot-language-server\". ...", "code":"model_not_available_for_integrator", ...}} The absorb machinery already existed and was correct (isCopilotTransientModelError -> isProviderRetryableError -> the Anthropic transport's PROVIDER_MAX_RETRIES). Only the classifier missed: it matched the older model_not_supported code and probed err.code / err.error.code, while the real code is model_not_available_for_integrator sitting at err.error.error.code (the SDK stores the parsed body on .error, and Copilot's body is itself {error:{code}}). So isProviderRetryableError fell through to "4xx => terminal". Fix the classifier: providerErrorCode() walks the error envelope up to depth 3 instead of hardcoding a shape, both Copilot model-availability codes are accepted, and a wire-body text match backs it up because SDK envelope shapes drift between provider families while the stringified message does not. This alone restores the retry path, because isProviderRetryableError consults the provider hook before its 4xx short-circuit. Retry shape, since a rejection is a per-request replica reroll rather than upstream backpressure: - both transports wait a flat delay between model-flap attempts instead of the growing backoff; generic retryable failures (429/5xx/transport) keep their linear ramp and Retry-After handling - the OpenAI-transport budget goes 3 -> 8 attempts, because a measured ~70% flap window produced a turn that needed 6 wire attempts and exhausting the budget escalates to the agent-level retry, which restarts the whole turn Absorbed attempts cost no tokens: rejections are gateway-side, carry no usage block, and return in ~208ms median versus ~1884ms for a served request. Also refresh the exhausted-retry guidance text, which cited a nonexistent model id and described the cause as a per-client rollout gap rather than fleet skew. --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/error/flags.ts | 54 ++++++-- packages/ai/src/error/retryable.ts | 2 +- packages/ai/src/providers/anthropic.ts | 18 ++- packages/ai/src/utils/http-inspector.ts | 11 +- packages/ai/src/utils/retry.ts | 19 ++- packages/ai/test/anthropic-retry.test.ts | 18 +++ packages/ai/test/copilot-retry.test.ts | 82 +++++++++++- ...ithub-copilot-anthropic-fleet-skew.test.ts | 125 ++++++++++++++++++ packages/ai/test/github-copilot-error.test.ts | 18 +-- 10 files changed, 312 insertions(+), 39 deletions(-) create mode 100644 packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3f4da9b40..892fb1f30 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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 retried on a flat delay, with the OpenAI-transport retry budget raised from 3 to 8 attempts. + ## [17.2.7] - 2026-08-03 ### Changed diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 09e54f17a..6df73686b 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -107,10 +107,17 @@ const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale| export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN = /failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i; -// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the -// error, also surfaced in text). Treated as transient — a retry usually lands -// on a backend that has the model. -const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i; +// Copilot fleet skew: HTTP 400 rejecting a model that `/models` advertised on +// the very same host. Two codes appear in the wild — `model_not_supported` +// (per-OAuth-client rollout gap) and `model_not_available_for_integrator` +// (replicas whose integrator allowlist predates the model). Both flap +// request-to-request, so a retry usually lands on a backend that has the model. +const COPILOT_TRANSIENT_MODEL_CODES: Record = { + model_not_supported: true, + model_not_available_for_integrator: true, +}; +const COPILOT_MODEL_UNAVAILABLE_PATTERN = + /model_not_supported|model_not_available_for_integrator|not available for integrator/i; // Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error). // Feature-gated deployments (Azure Foundry, Baseten, …) reject `strict: true` // tools outright when the hosted model lacks structured outputs, e.g. @@ -345,8 +352,8 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un kinds |= Flag.StaleResponsesItem; } - // Copilot per-client routing flap is transient. - if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient; + // Copilot fleet-skew model rejection is transient. + if (statusClean === 400 && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(cleanMessage)) kinds |= Flag.Transient; if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar; if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported; } @@ -460,16 +467,35 @@ export function isFastModeUnsupported(error: unknown): boolean { } /** - * GitHub Copilot 400 `model_not_supported` routing flap — transient. Reads the - * structural `code` (and falls back to {@link Flag.Transient} text classification). + * Depth-bounded search for a provider error `code`. SDK error objects keep the + * parsed response body on `.error`, and Copilot's body is itself + * `{ error: { code } }`, so the code sits up to two envelopes below the thrown + * error depending on which SDK produced it. + */ +function providerErrorCode(error: object): string | undefined { + let node: object = error; + for (let depth = 0; depth < 3; depth++) { + if ("code" in node && typeof node.code === "string") return node.code; + if (!("error" in node)) return undefined; + const nested: unknown = node.error; + if (!nested || typeof nested !== "object") return undefined; + node = nested; + } + return undefined; +} + +/** + * GitHub Copilot 400 rejecting a model its own `/models` catalog advertises — + * transient fleet skew, not a malformed request. Reads the structural `code` + * through the SDK/body envelopes, then falls back to the stringified body both + * SDK families put in `message` (shapes drift; the wire text does not). */ export function isCopilotTransientModelError(error: unknown): boolean { - if (status(error) === 400 && error && typeof error === "object") { - const info = error as { code?: unknown; error?: { code?: unknown } | null }; - const code = typeof info.code === "string" ? info.code : info.error?.code; - if (code === "model_not_supported") return true; - } - return false; + if (!error || typeof error !== "object" || status(error) !== 400) return false; + const code = providerErrorCode(error); + if (code !== undefined && COPILOT_TRANSIENT_MODEL_CODES[code]) return true; + const message: unknown = "message" in error ? error.message : undefined; + return typeof message === "string" && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(message); } export function classifyMessage(message: { diff --git a/packages/ai/src/error/retryable.ts b/packages/ai/src/error/retryable.ts index 90a065ee3..af2f3ae87 100644 --- a/packages/ai/src/error/retryable.ts +++ b/packages/ai/src/error/retryable.ts @@ -33,7 +33,7 @@ function isTransientTransportMessage(message: string): boolean { export interface ProviderRetryableHooks { /** Provider id of the failing request, used to gate provider-specific checks. */ provider?: string; - /** Provider-specific transient predicate (e.g. Copilot `model_not_supported`). */ + /** Provider-specific transient predicate (e.g. Copilot model-availability 400s). */ isProviderTransient?: (error: Error) => boolean; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 880a6b42e..9232364b4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1531,6 +1531,13 @@ async function* observeDecodedAnthropicSdkEvents( const PROVIDER_MAX_RETRIES = 10; +/** + * Flat delay between attempts when Copilot 400s a model its own `/models` + * catalog advertises. Part of the fleet carries the model and part doesn't, so + * the retry is a reroll rather than a wait for capacity to free up. + */ +const COPILOT_MODEL_FLAP_RETRY_DELAY_MS = 400; + /** * How long `ping` keepalives may keep extending the idle deadline without any * semantic stream progress, as a multiple of the idle timeout. Anthropic pings @@ -1561,8 +1568,8 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean { /** * Whether an Anthropic (or Copilot-over-Anthropic) stream error should be * retried. The classification lives in {@link AIError.isProviderRetryableError}; - * this wrapper injects the Copilot-specific `model_not_supported` transient - * check, which the error module must not import directly. + * this wrapper injects the Copilot-specific model-availability transient check, + * which the error module must not import directly. */ export function isProviderRetryableError(error: unknown, provider?: string): boolean { return AIError.isProviderRetryableError(error, { @@ -2709,7 +2716,12 @@ const streamAnthropicOnce = ( throw streamFailure; } providerRetryAttempt++; - const backoffDelayMs = calculateAnthropicRetryDelayMs(providerRetryAttempt - 1); + // Copilot's model-availability 400 is a per-request replica reroll, not + // upstream backpressure — the exponential curve would just add dead + // time to a coin flip that the next attempt is as likely to win. + const backoffDelayMs = AIError.isCopilotTransientModelError(streamFailure) + ? COPILOT_MODEL_FLAP_RETRY_DELAY_MS + : calculateAnthropicRetryDelayMs(providerRetryAttempt - 1); // Honor the server's retry hint (`retry-after-ms`/`retry-after`) on // 429/529-style failures: retrying sooner than the server asked is a // guaranteed failure that just burns the retry budget. diff --git a/packages/ai/src/utils/http-inspector.ts b/packages/ai/src/utils/http-inspector.ts index bf4f4e047..b62e0f5fd 100644 --- a/packages/ai/src/utils/http-inspector.ts +++ b/packages/ai/src/utils/http-inspector.ts @@ -95,10 +95,11 @@ export async function finalizeErrorMessage( * Rewrite error message for GitHub Copilot request failures. * Must run AFTER finalizeErrorMessage since it replaces the message entirely. * - * 400 `model_not_supported` = Copilot routing rollout gap for our OAuth client. - * A preview model (gpt-5.3-codex, gpt-5.4*, ...) flaps between 200 and - * 400 because only some of Copilot's backends have the model. After the - * in-request retry exhausts, surface guidance rather than the raw error. + * 400 model-unavailable = Copilot fleet skew. A model that `/models` advertises + * (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...) + * flaps between 200 and 400 because only part of Copilot's fleet has it + * in the integrator allowlist. After the in-request retry exhausts, + * surface guidance rather than the raw error. * 401 = token invalid/expired → credential removal is safe, prompt re-login. * 403 = token valid but access denied (plan, model policy, org restriction) → * do NOT reuse the auth-failed string (which triggers credential removal). @@ -113,7 +114,7 @@ export function rewriteCopilotError(errorMessage: string, error: unknown, provid return `GitHub Copilot access denied (HTTP 403). Your account may not have access to this model or feature. Check your Copilot plan or model policy settings.`; } if (isCopilotTransientModelError(error)) { - return `GitHub Copilot rejected this model (HTTP 400 model_not_supported) after retries. This is a known intermittent rollout gap for preview models on OAuth clients other than VS Code. Try again in a few seconds, switch to a GA model (gpt-5-mini, gpt-5.2), or run this model from VS Code.`; + return `GitHub Copilot rejected this model (HTTP 400) after retries: only part of its fleet currently serves this model id, even though /models advertises it. Try again in a few seconds or switch to a model Copilot serves fleet-wide (claude-opus-4.7, claude-sonnet-4.5, gpt-4.1).`; } return errorMessage; } diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index d89ee81b1..8d786b94a 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -7,14 +7,22 @@ import { getHeadersFromError, getRetryAfterMsFromHeaders } from "./retry-after"; // home). Re-exported here so existing `../utils/retry` importers keep working. export { isCopilotTransientModelError }; -const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3; +// Copilot's model-availability flap is a per-request coin flip across fleet +// replicas, not backpressure. Measured per-attempt rejection rates for models +// mid-rollout reach ~70% (gpt-5.4, 2026-08-04), and a live 10-turn run needed 6 +// attempts on one turn — so a small budget just pushes the failure up to the +// agent-level retry, which restarts the whole turn. Eight attempts on a flat +// delay keep the residual near 5% at p=0.7 and under 1% at p=0.5, bounded at +// ~2.8s of dead time in the pathological case. +const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 8; const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400; /** Longest server-requested backoff we are willing to sit out before giving up. */ const COPILOT_RETRY_AFTER_MAX_WAIT_MS = 30_000; /** - * Wrap an initial Copilot request so transient `model_not_supported` 400s are - * retried a small number of times. No-op for non-Copilot providers. + * Wrap an initial Copilot request so transient model-availability 400s + * (`model_not_supported`, `model_not_available_for_integrator`) are retried a + * small number of times. No-op for non-Copilot providers. * * The callback **MUST** create a fresh in-flight request each invocation — a * once-consumed AsyncIterable cannot be re-iterated. @@ -39,7 +47,10 @@ export async function callWithCopilotModelRetry( const transientModelError = isCopilotTransientModelError(error); if (!transientModelError && !isRetryableError(error)) throw error; if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break; - let delayMs = retryBaseDelayMs * (attempt + 1); + // Reroll the model flap on a flat delay: a ramp only adds dead time to a + // coin flip the next attempt is equally likely to win. Generic retryable + // failures (429/5xx/transport) keep the linear backoff below. + let delayMs = transientModelError ? retryBaseDelayMs : retryBaseDelayMs * (attempt + 1); if (!transientModelError) { const errorStatus = status(error); if (errorStatus !== undefined) { diff --git a/packages/ai/test/anthropic-retry.test.ts b/packages/ai/test/anthropic-retry.test.ts index 9f20af608..8ab9a26e0 100644 --- a/packages/ai/test/anthropic-retry.test.ts +++ b/packages/ai/test/anthropic-retry.test.ts @@ -105,4 +105,22 @@ describe("isProviderRetryableError", () => { expect(isProviderRetryableError(err, "anthropic")).toBe(false); expect(isProviderRetryableError(err)).toBe(false); }); + + it("retries Copilot's model_not_available_for_integrator 400 from the Anthropic messages proxy", () => { + // Shape thrown by @anthropic-ai/sdk against api.githubcopilot.com/v1/messages: + // the parsed body lands on `.error` and is itself `{ error: { code } }`. + const body = { + error: { + message: + 'The requested model is not available for integrator "copilot-language-server". Available models: [gpt-4.1 claude-opus-4.7]. Verify the correct Copilot-Integration-Id header is being sent.', + code: "model_not_available_for_integrator", + param: "model", + type: "invalid_request_error", + }, + }; + const err = new Error(`400 ${JSON.stringify(body)}`); + Object.assign(err, { status: 400, error: body }); + expect(isProviderRetryableError(err, "github-copilot")).toBe(true); + expect(isProviderRetryableError(err, "anthropic")).toBe(false); + }); }); diff --git a/packages/ai/test/copilot-retry.test.ts b/packages/ai/test/copilot-retry.test.ts index 79b79d85f..e0f892438 100644 --- a/packages/ai/test/copilot-retry.test.ts +++ b/packages/ai/test/copilot-retry.test.ts @@ -1,8 +1,18 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { scheduler } from "node:timers/promises"; import { callWithCopilotModelRetry, isCopilotTransientModelError } from "@oh-my-pi/pi-ai/utils/retry"; import { isRetryableError } from "@oh-my-pi/pi-utils"; -type ErrorShape = { status: number; code?: string; error?: { code?: string; message?: string }; message: string }; +afterEach(() => { + vi.restoreAllMocks(); +}); + +type ErrorShape = { + status: number; + code?: string; + error?: { code?: string; message?: string } | { error: { code?: string; message?: string } }; + message: string; +}; function copilotError({ status, code, error, message }: ErrorShape): Error { const err = new Error(message); @@ -31,6 +41,31 @@ describe("isCopilotTransientModelError", () => { expect(isCopilotTransientModelError(err)).toBe(true); }); + it("matches 400 model_not_available_for_integrator nested two envelopes deep (Anthropic SDK shape)", () => { + // api.githubcopilot.com/v1/messages: the SDK stores the parsed body on + // `.error`, and that body is itself `{ error: { code } }`. + const err = copilotError({ + status: 400, + error: { + error: { + code: "model_not_available_for_integrator", + message: 'The requested model is not available for integrator "copilot-language-server".', + }, + }, + message: + '400 {"error":{"message":"The requested model is not available for integrator \\"copilot-language-server\\". Available models: [gpt-4.1 claude-opus-4.7]","code":"model_not_available_for_integrator","param":"model","type":"invalid_request_error"}}', + }); + expect(isCopilotTransientModelError(err)).toBe(true); + }); + + it("falls back to the stringified body when no envelope exposes a code", () => { + const err = copilotError({ + status: 400, + message: '400 {"error":{"message":"The requested model is not available for integrator \\"x\\"."}}', + }); + expect(isCopilotTransientModelError(err)).toBe(true); + }); + it("does not match other 400 codes", () => { const err = copilotError({ status: 400, @@ -74,7 +109,7 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(1); }); - it("retries up to 3 attempts for Copilot transient errors and eventually throws the last error", async () => { + it("retries up to 8 attempts for Copilot transient errors and eventually throws the last error", async () => { let calls = 0; const err = copilotError({ status: 400, code: "model_not_supported", message: "transient" }); await expect( @@ -86,7 +121,7 @@ describe("callWithCopilotModelRetry", () => { { provider: "github-copilot", retryBaseDelayMs: 0 }, ), ).rejects.toBe(err); - expect(calls).toBe(3); + expect(calls).toBe(8); }); it("succeeds on the second attempt when the first is transient", async () => { @@ -171,6 +206,45 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(2); }); + it("keeps the model-flap delay flat while generic retryable failures ramp", async () => { + const flatWaits: number[] = []; + const rampWaits: number[] = []; + const record = (into: number[]) => { + const spy = vi.spyOn(scheduler, "wait"); + spy.mockImplementation(async (delay?: number) => { + into.push(delay ?? 0); + }); + return spy; + }; + + record(flatWaits); + await expect( + callWithCopilotModelRetry( + async () => { + throw copilotError({ status: 400, code: "model_not_available_for_integrator", message: "flap" }); + }, + { provider: "github-copilot", retryBaseDelayMs: 100 }, + ), + ).rejects.toBeInstanceOf(Error); + vi.restoreAllMocks(); + + record(rampWaits); + await expect( + callWithCopilotModelRetry( + async () => { + throw new Error( + 'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()', + ); + }, + { provider: "github-copilot", retryBaseDelayMs: 100 }, + ), + ).rejects.toBeInstanceOf(Error); + vi.restoreAllMocks(); + + expect(flatWaits).toEqual([100, 100, 100, 100, 100, 100, 100]); + expect(rampWaits).toEqual([100, 200, 300, 400, 500, 600, 700]); + }); + it("stops retrying when the caller aborts during backoff", async () => { const controller = new AbortController(); controller.abort(); diff --git a/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts b/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts new file mode 100644 index 000000000..3146cfac5 --- /dev/null +++ b/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { OPENCODE_HEADERS } from "@oh-my-pi/pi-catalog/wire/github-copilot"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeCopilotClaudeModel(): Model<"anthropic-messages"> { + return buildModel({ + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { ...OPENCODE_HEADERS }, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_000, + }); +} + +const testContext: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +/** + * Verbatim body served by `api.githubcopilot.com/v1/messages` when the request + * lands on a fleet replica whose integrator allowlist predates the model, even + * though `/models` on the same host advertises it. + */ +const FLEET_SKEW_BODY = { + error: { + message: + 'The requested model is not available for integrator "copilot-language-server". Available models: [gpt-4.1 claude-opus-4.7 claude-sonnet-4.5]. Verify the correct Copilot-Integration-Id header is being sent.', + code: "model_not_available_for_integrator", + param: "model", + type: "invalid_request_error", + }, +}; + +const SSE_EVENTS = [ + { + type: "message_start", + message: { + id: "msg_fleet", + type: "message", + role: "assistant", + model: "claude-sonnet-4.6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 7, output_tokens: 0 }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "second try" } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 3 }, + }, + { type: "message_stop" }, +]; + +function sseResponse(): Response { + const body = `${SSE_EVENTS.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n`).join("\n")}\n`; + return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } }); +} + +describe("GitHub Copilot Anthropic fleet skew", () => { + it("retries the model-availability 400 and completes on the next replica", async () => { + let attempts = 0; + const fetchMock = vi.fn(async () => { + attempts += 1; + if (attempts === 1) { + return new Response(JSON.stringify(FLEET_SKEW_BODY), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + return sseResponse(); + }); + + const result = await streamAnthropic(makeCopilotClaudeModel(), testContext, { + apiKey: "ghu_test_copilot_token", + fetch: fetchMock as unknown as typeof fetch, + providerRetryWait: async () => {}, + }).result(); + + expect(attempts).toBe(2); + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + expect(result.content).toMatchObject([{ type: "text", text: "second try" }]); + // Billing is per user prompt, not per wire attempt: a turn that burned an + // extra gateway-rejected attempt must still report one premium request. + expect(result.usage.premiumRequests).toBe(1); + }); + + it("surfaces fleet-skew guidance once every retry lands on a stale replica", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(FLEET_SKEW_BODY), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await streamAnthropic(makeCopilotClaudeModel(), testContext, { + apiKey: "ghu_test_copilot_token", + fetch: fetchMock as unknown as typeof fetch, + providerRetryWait: async () => {}, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("only part of its fleet"); + // Every attempt is a fresh wire request, not a replayed promise. + expect(fetchMock.mock.calls.length).toBeGreaterThan(1); + }); +}); diff --git a/packages/ai/test/github-copilot-error.test.ts b/packages/ai/test/github-copilot-error.test.ts index e7fce6edb..badf9be60 100644 --- a/packages/ai/test/github-copilot-error.test.ts +++ b/packages/ai/test/github-copilot-error.test.ts @@ -33,14 +33,16 @@ describe("rewriteCopilotError", () => { expect(result).not.toContain("/login github-copilot"); }); - it("rewrites 400 model_not_supported with rollout-gap guidance", () => { - const err = new Error("400 The requested model is not supported."); - (err as unknown as { status: number; code: string }).status = 400; - (err as unknown as { status: number; code: string }).code = "model_not_supported"; - const result = rewriteCopilotError("original", err, "github-copilot"); - expect(result).toContain("HTTP 400 model_not_supported"); - expect(result).toContain("rollout gap"); - expect(result).not.toContain("authentication failed"); + it("rewrites 400 model-unavailable codes with fleet-skew guidance", () => { + for (const code of ["model_not_supported", "model_not_available_for_integrator"]) { + const err = new Error("400 The requested model is not available."); + (err as unknown as { status: number; code: string }).status = 400; + (err as unknown as { status: number; code: string }).code = code; + const result = rewriteCopilotError("original", err, "github-copilot"); + expect(result).toContain("HTTP 400"); + expect(result).toContain("only part of its fleet"); + expect(result).not.toContain("authentication failed"); + } }); it("leaves non-copilot 400 model_not_supported untouched", () => { From f3ad5ede6b08d70c4fe7d0ff7e3544bf71b4841d Mon Sep 17 00:00:00 2001 From: Magicien <162632566+lederniermagicien@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:51:35 +0100 Subject: [PATCH 066/126] fix(ai): scope the Copilot 8-attempt retry budget to model flaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 -> 8 bump in callWithCopilotModelRetry was shared by the generic retryable branch, so a persistent status-less transport blip on Copilot would ramp across 8 attempts (~11.2s of dead time) instead of the 3 it took before, and a repeated Retry-After 429 could stretch the same way on top of the transport's own fetchWithRetry budget. Derive the budget from the failure kind: model-availability 400s keep the 8-attempt reroll, everything else caps at the previous 3. Also read COPILOT_TRANSIENT_MODEL_CODES with Object.hasOwn — `code` is provider-controlled, so a 400 body whose code was `__proto__` or `toString` classified as transient through the prototype chain. --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/error/flags.ts | 4 +- packages/ai/src/utils/retry.ts | 10 ++++- packages/ai/test/copilot-retry.test.ts | 60 ++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 892fb1f30..15c9f504e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- 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 retried on a flat delay, with the OpenAI-transport retry budget raised from 3 to 8 attempts. +- 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. ## [17.2.7] - 2026-08-03 diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 6df73686b..4e348493b 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -493,7 +493,9 @@ function providerErrorCode(error: object): string | undefined { export function isCopilotTransientModelError(error: unknown): boolean { if (!error || typeof error !== "object" || status(error) !== 400) return false; const code = providerErrorCode(error); - if (code !== undefined && COPILOT_TRANSIENT_MODEL_CODES[code]) return true; + // `Object.hasOwn`, not a bare index: `code` is provider-controlled, and a + // prototype key (`__proto__`, `toString`, …) would otherwise read truthy. + if (code !== undefined && Object.hasOwn(COPILOT_TRANSIENT_MODEL_CODES, code)) return true; const message: unknown = "message" in error ? error.message : undefined; return typeof message === "string" && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(message); } diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 8d786b94a..fce896bce 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -15,6 +15,9 @@ export { isCopilotTransientModelError }; // delay keep the residual near 5% at p=0.7 and under 1% at p=0.5, bounded at // ~2.8s of dead time in the pathological case. const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 8; +// Transport blips and status-bearing failures keep the pre-flap budget: they are +// not coin flips, so a longer ramp only delays surfacing a persistent fault. +const COPILOT_GENERIC_RETRY_MAX_ATTEMPTS = 3; const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400; /** Longest server-requested backoff we are willing to sit out before giving up. */ const COPILOT_RETRY_AFTER_MAX_WAIT_MS = 30_000; @@ -46,7 +49,12 @@ export async function callWithCopilotModelRetry( if (options.signal?.aborted) throw error; const transientModelError = isCopilotTransientModelError(error); if (!transientModelError && !isRetryableError(error)) throw error; - if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break; + // Budget is per failure kind, counted over attempts already spent: the + // eight-attempt allowance only covers the cheap model-availability reroll. + const maxAttempts = transientModelError + ? COPILOT_MODEL_RETRY_MAX_ATTEMPTS + : COPILOT_GENERIC_RETRY_MAX_ATTEMPTS; + if (attempt >= maxAttempts - 1) break; // Reroll the model flap on a flat delay: a ramp only adds dead time to a // coin flip the next attempt is equally likely to win. Generic retryable // failures (429/5xx/transport) keep the linear backoff below. diff --git a/packages/ai/test/copilot-retry.test.ts b/packages/ai/test/copilot-retry.test.ts index e0f892438..850d26506 100644 --- a/packages/ai/test/copilot-retry.test.ts +++ b/packages/ai/test/copilot-retry.test.ts @@ -12,13 +12,18 @@ type ErrorShape = { code?: string; error?: { code?: string; message?: string } | { error: { code?: string; message?: string } }; message: string; + headers?: Record; }; -function copilotError({ status, code, error, message }: ErrorShape): Error { +function copilotError({ status, code, error, message, headers }: ErrorShape): Error { const err = new Error(message); - (err as unknown as ErrorShape).status = status; - if (code !== undefined) (err as unknown as ErrorShape).code = code; - if (error !== undefined) (err as unknown as ErrorShape).error = error; + // Single sanctioned assertion point: `Error` carries no provider fields, and + // every test reads them back through the real classifier. + const shaped = err as unknown as ErrorShape; + shaped.status = status; + if (code !== undefined) shaped.code = code; + if (error !== undefined) shaped.error = error; + if (headers !== undefined) shaped.headers = headers; return err; } @@ -75,6 +80,13 @@ describe("isCopilotTransientModelError", () => { expect(isCopilotTransientModelError(err)).toBe(false); }); + it("does not match 400 codes that collide with Object.prototype keys", () => { + for (const code of ["__proto__", "constructor", "toString", "hasOwnProperty"]) { + const err = copilotError({ status: 400, code, message: "bad request" }); + expect(isCopilotTransientModelError(err)).toBe(false); + } + }); + it("does not match 401/403/500 regardless of code", () => { for (const status of [401, 403, 500]) { const err = copilotError({ @@ -176,9 +188,7 @@ describe("callWithCopilotModelRetry", () => { async () => { calls += 1; if (calls === 1) { - const err = copilotError({ status: 429, message: "rate limited" }); - (err as unknown as { headers: Record }).headers = { "retry-after": "0.01" }; - throw err; + throw copilotError({ status: 429, message: "rate limited", headers: { "retry-after": "0.01" } }); } return "ok" as const; }, @@ -188,6 +198,21 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(2); }); + it("does not stretch a persistent Retry-After 429 across the flap budget", async () => { + let calls = 0; + const err = copilotError({ status: 429, message: "rate limited", headers: { "retry-after": "0.01" } }); + await expect( + callWithCopilotModelRetry( + async () => { + calls += 1; + throw err; + }, + { provider: "github-copilot", retryBaseDelayMs: 0 }, + ), + ).rejects.toBe(err); + expect(calls).toBe(3); + }); + it("still retries status-less transport blips with the linear backoff", async () => { let calls = 0; const result = await callWithCopilotModelRetry( @@ -206,7 +231,24 @@ describe("callWithCopilotModelRetry", () => { expect(calls).toBe(2); }); - it("keeps the model-flap delay flat while generic retryable failures ramp", async () => { + it("caps persistent generic retryable failures at the pre-flap budget", async () => { + let calls = 0; + const err = new Error( + 'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()', + ); + await expect( + callWithCopilotModelRetry( + async () => { + calls += 1; + throw err; + }, + { provider: "github-copilot", retryBaseDelayMs: 0 }, + ), + ).rejects.toBe(err); + expect(calls).toBe(3); + }); + + it("keeps the flat delay and the larger budget scoped to model flaps", async () => { const flatWaits: number[] = []; const rampWaits: number[] = []; const record = (into: number[]) => { @@ -242,7 +284,7 @@ describe("callWithCopilotModelRetry", () => { vi.restoreAllMocks(); expect(flatWaits).toEqual([100, 100, 100, 100, 100, 100, 100]); - expect(rampWaits).toEqual([100, 200, 300, 400, 500, 600, 700]); + expect(rampWaits).toEqual([100, 200]); }); it("stops retrying when the caller aborts during backoff", async () => { From b872e51e6fa4db1c4c0599ce5d60c90e500b4583 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 20:57:47 +0000 Subject: [PATCH 067/126] 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 068/126] 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 069/126] 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 172ce9bf6c6bbc95beaaf4898dc1a7f554519a8b Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 21:09:19 +0000 Subject: [PATCH 070/126] revert(session): restored legacy project directory names Reverted PR #7397 session directory hashing and automatic migration. Restored existing script compatibility with the legacy cwd-derived naming scheme. Fixes #7646 --- packages/coding-agent/CHANGELOG.md | 14 ++ .../coding-agent/src/session/session-paths.ts | 179 ++++++------------ .../session-manager/file-operations.test.ts | 52 +---- 3 files changed, 76 insertions(+), 169 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 22a72debb..00b5106e4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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)). + ## [17.2.8] - 2026-08-04 ### Changed @@ -92,6 +96,16 @@ - Fixed heavily branched conversation trees shifting linear continuations into disconnected columns. - 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/coding-agent/src/session/session-paths.ts b/packages/coding-agent/src/session/session-paths.ts index 9f47d02e9..237aa013e 100644 --- a/packages/coding-agent/src/session/session-paths.ts +++ b/packages/coding-agent/src/session/session-paths.ts @@ -2,17 +2,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getTerminalId } from "@oh-my-pi/pi-tui"; -import { - getSessionsDir, - getTerminalSessionsDir, - isEnoent, - isRecord, - logger, - resolveEquivalentPath, -} from "@oh-my-pi/pi-utils"; +import { getSessionsDir, getTerminalSessionsDir, isEnoent, logger, resolveEquivalentPath } from "@oh-my-pi/pi-utils"; import type { SessionStorage } from "./session-storage"; -const SESSION_HEADER_PREFIX_BYTES = 4096; +const migratedSessionRoots = new Set(); /** * Merge or rename a legacy session directory into its canonical target. @@ -42,16 +35,12 @@ function encodeLegacyAbsoluteSessionDirName(cwd: string): string { return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } -function encodeLegacyRelativeSessionDirName(prefix: string, relative: string): string { +function encodeRelativeSessionDirName(prefix: string, relative: string): string { const encoded = relative.replace(/[/\\:]/g, "-"); return encoded ? (prefix.endsWith("-") ? `${prefix}${encoded}` : `${prefix}-${encoded}`) : prefix; } -function getDefaultSessionDirName(cwd: string): { - encodedDirName: string; - legacyRelativeDirName: string | undefined; - resolvedCwd: string; -} { +function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } { const resolvedCwd = path.resolve(cwd); const canonicalCwd = resolveEquivalentPath(resolvedCwd); const home = os.homedir(); @@ -60,111 +49,72 @@ function getDefaultSessionDirName(cwd: string): { const canonicalTempRoot = resolveEquivalentPath(tempRoot); const homeRelative = path.relative(canonicalHome, canonicalCwd); const tempRelative = path.relative(canonicalTempRoot, canonicalCwd); - - let scope: "home" | "tmp" | "abs"; - let legacyRelativeDirName: string | undefined; - if (homeRelative === "" || (!homeRelative.startsWith("..") && !path.isAbsolute(homeRelative))) { - scope = "home"; - legacyRelativeDirName = encodeLegacyRelativeSessionDirName("-", homeRelative); - } else if (tempRelative === "" || (!tempRelative.startsWith("..") && !path.isAbsolute(tempRelative))) { - scope = "tmp"; - legacyRelativeDirName = encodeLegacyRelativeSessionDirName("-tmp", tempRelative); - } else { - scope = "abs"; - } - - const normalized = canonicalCwd.replaceAll("\\", "/"); - const readable = path - .basename(canonicalCwd) - .replace(/[^a-zA-Z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(-80); - const digest = Bun.SHA256.hash(normalized, "hex"); - const encodedDirName = `${scope}-${readable || "project"}-${digest}`; - return { encodedDirName, legacyRelativeDirName, resolvedCwd }; + const encodedDirName = + homeRelative === "" || (!homeRelative.startsWith("..") && !path.isAbsolute(homeRelative)) + ? encodeRelativeSessionDirName("-", homeRelative) + : tempRelative === "" || (!tempRelative.startsWith("..") && !path.isAbsolute(tempRelative)) + ? encodeRelativeSessionDirName("-tmp", tempRelative) + : encodeLegacyAbsoluteSessionDirName(canonicalCwd); + return { encodedDirName, resolvedCwd }; } -function readSessionCwd(sessionFile: string, buffer: Buffer): string | undefined { - let descriptor: number | undefined; +/** + * Migrate old `---*--` session dirs to the new `-*` format. + * Runs once per sessions root on first access, best-effort. + */ +function migrateHomeSessionDirs(sessionsRoot: string): void { + if (migratedSessionRoots.has(sessionsRoot)) return; + migratedSessionRoots.add(sessionsRoot); + + const home = os.homedir(); + const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); + const oldPrefix = `--${homeEncoded}-`; + const oldExact = `--${homeEncoded}--`; + + let entries: string[]; try { - descriptor = fs.openSync(sessionFile, "r"); - const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0); - const prefix = buffer.toString("utf8", 0, bytesRead); - for (const line of prefix.split(/\r?\n/, 3)) { - try { - const record: unknown = JSON.parse(line); - if (isRecord(record) && record.type === "session" && typeof record.cwd === "string") { - return record.cwd; - } - } catch { - // Ignore title slots or truncated/corrupt headers. - } - } + entries = fs.readdirSync(sessionsRoot); } catch { - // Best-effort migration leaves unreadable sessions in the current bucket. - } finally { - if (descriptor !== undefined) fs.closeSync(descriptor); + return; } - return undefined; -} -function moveSessionBundle(sourceDir: string, targetDir: string, sessionName: string, entries: string[]): void { - fs.mkdirSync(targetDir, { recursive: true }); - const artifactsName = path.basename(sessionName, ".jsonl"); for (const entry of entries) { - if (entry !== sessionName && entry !== artifactsName && !entry.startsWith(`${sessionName}.`)) continue; - const source = path.join(sourceDir, entry); - if (!fs.existsSync(source)) continue; - const target = path.join(targetDir, entry); - const existing = fs.statSync(target, { throwIfNoEntry: false }); - if (!existing) { - fs.renameSync(source, target); - } else if (existing.isDirectory() && fs.statSync(source).isDirectory()) { - migrateSessionDirPath(source, target); + let remainder: string; + if (entry === oldExact) { + remainder = ""; + } else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) { + remainder = entry.slice(oldPrefix.length, -2); } else { - fs.rmSync(source, { recursive: true, force: true }); - } - } -} - -function rerouteCollidingSessions( - cwd: string, - legacyDir: string, - sessionsRoot: string, - kind: "relative" | "absolute", -): void { - const entries = fs.readdirSync(legacyDir); - const currentCwd = resolveEquivalentPath(path.resolve(cwd)); - const buffer = Buffer.allocUnsafe(SESSION_HEADER_PREFIX_BYTES); - for (const entry of entries) { - if (!entry.endsWith(".jsonl")) continue; - const recordedCwd = readSessionCwd(path.join(legacyDir, entry), buffer); - if (!recordedCwd) continue; - const recordedCanonical = resolveEquivalentPath(path.resolve(recordedCwd)); - if ( - recordedCanonical === currentCwd || - !fs.statSync(recordedCanonical, { throwIfNoEntry: false })?.isDirectory() - ) { continue; } - const recordedNames = getDefaultSessionDirName(recordedCanonical); - const recordedLegacyName = - kind === "relative" - ? recordedNames.legacyRelativeDirName - : encodeLegacyAbsoluteSessionDirName(recordedCanonical); - if (recordedLegacyName !== path.basename(legacyDir)) continue; - moveSessionBundle(legacyDir, path.join(sessionsRoot, recordedNames.encodedDirName), entry, entries); + + const newName = remainder ? `-${remainder}` : "-"; + const oldPath = path.join(sessionsRoot, entry); + const newPath = path.join(sessionsRoot, newName); + + try { + migrateSessionDirPath(oldPath, newPath); + } catch { + // Best effort + } + } +} + +function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void { + const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd)); + if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return; + + try { + migrateSessionDirPath(legacyDir, sessionDir); + } catch { + // Best effort } } export function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined { const currentDirName = path.basename(sessionDir); - const { encodedDirName, legacyRelativeDirName } = getDefaultSessionDirName(cwd); - if ( - currentDirName !== encodedDirName && - currentDirName !== legacyRelativeDirName && - currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd) - ) { + const { encodedDirName } = getDefaultSessionDirName(cwd); + if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) { return undefined; } return path.dirname(sessionDir); @@ -180,23 +130,10 @@ export function computeDefaultSessionDir( storage: SessionStorage, sessionsRoot: string = getSessionsDir(), ): string { - const { encodedDirName, legacyRelativeDirName, resolvedCwd } = getDefaultSessionDirName(cwd); + const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd); + migrateHomeSessionDirs(sessionsRoot); const sessionDir = path.join(sessionsRoot, encodedDirName); - const legacyDirs: Array<{ kind: "relative" | "absolute"; name: string | undefined }> = [ - { kind: "relative", name: legacyRelativeDirName }, - { kind: "absolute", name: encodeLegacyAbsoluteSessionDirName(resolvedCwd) }, - ]; - for (const legacy of legacyDirs) { - if (!legacy.name) continue; - const legacyDir = path.join(sessionsRoot, legacy.name); - if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) continue; - try { - rerouteCollidingSessions(resolvedCwd, legacyDir, sessionsRoot, legacy.kind); - migrateSessionDirPath(legacyDir, sessionDir); - } catch { - // Best effort - } - } + migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot); storage.ensureDirSync(sessionDir); return sessionDir; } diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index e5fc9ebc2..7c3abe61f 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -6,14 +6,7 @@ import type { FileEntry, SessionHeader } from "@oh-my-pi/pi-coding-agent/session import { findMostRecentSession, resolveResumableSession } from "@oh-my-pi/pi-coding-agent/session/session-listing"; import { loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-loader"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { - getConfigRootDir, - getSessionsDir, - removeSyncWithRetries, - resolveEquivalentPath, - Snowflake, - setAgentDir, -} from "@oh-my-pi/pi-utils"; +import { getConfigRootDir, getSessionsDir, removeSyncWithRetries, Snowflake, setAgentDir } from "@oh-my-pi/pi-utils"; describe("loadEntriesFromFile", () => { let tempDir: string; @@ -170,9 +163,7 @@ describe("SessionManager temp cwd session dirs", () => { const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); function expectedTempSessionDirName(tempCwd: string): string { - const normalized = resolveEquivalentPath(tempCwd).replaceAll("\\", "/"); - const digest = Bun.SHA256.hash(normalized, "hex"); - return `tmp-${path.basename(tempCwd)}-${digest}`; + return `-tmp-${path.relative(os.tmpdir(), path.resolve(tempCwd)).replace(/[/\\:]/g, "-")}`; } function toLegacyAbsoluteSessionDirName(cwd: string): string { @@ -197,7 +188,7 @@ describe("SessionManager temp cwd session dirs", () => { removeSyncWithRetries(testAgentDir); }); - it("stores temp-root cwd sessions under safe hashed directories", () => { + it("stores temp-root cwd sessions under -tmp-prefixed directories", () => { const tempCwd = path.join(testAgentDir, `temp-cwd-${Snowflake.next()}`); fs.mkdirSync(tempCwd, { recursive: true }); @@ -208,7 +199,7 @@ describe("SessionManager temp cwd session dirs", () => { expect(path.dirname(sessionFile)).toBe(path.join(getSessionsDir(), expectedTempSessionDirName(tempCwd))); }); - it("migrates legacy temp-root absolute session dirs to safe names", () => { + it("migrates legacy temp-root absolute session dirs to -tmp prefixes", () => { const tempCwd = path.join(testAgentDir, `legacy-cwd-${Snowflake.next()}`); fs.mkdirSync(tempCwd, { recursive: true }); @@ -226,41 +217,6 @@ describe("SessionManager temp cwd session dirs", () => { expect(path.dirname(sessionFile)).toBe(expectedDir); expect(fs.existsSync(path.join(expectedDir, "carried.jsonl"))).toBe(true); }); - - it("separates colliding legacy cwd buckets into safe directories", () => { - const firstCwd = path.join(testAgentDir, "project", "hail-mary"); - const secondCwd = path.join(testAgentDir, "project-hail-mary"); - fs.mkdirSync(firstCwd, { recursive: true }); - fs.mkdirSync(secondCwd, { recursive: true }); - - const legacyName = `-tmp-${path.relative(os.tmpdir(), firstCwd).replace(/[/\\:]/g, "-")}`; - expect(legacyName).toBe(`-tmp-${path.relative(os.tmpdir(), secondCwd).replace(/[/\\:]/g, "-")}`); - const legacyDir = path.join(getSessionsDir(), legacyName); - fs.mkdirSync(path.join(legacyDir, "first"), { recursive: true }); - fs.mkdirSync(path.join(legacyDir, "second"), { recursive: true }); - fs.writeFileSync( - path.join(legacyDir, "first.jsonl"), - `${JSON.stringify({ type: "session", id: "first", cwd: firstCwd })}\n`, - ); - fs.writeFileSync( - path.join(legacyDir, "second.jsonl"), - `${JSON.stringify({ type: "session", id: "second", cwd: secondCwd })}\n`, - ); - fs.writeFileSync(path.join(legacyDir, "first", "artifact.txt"), "first"); - fs.writeFileSync(path.join(legacyDir, "second", "artifact.txt"), "second"); - - const firstDir = path.dirname(SessionManager.create(firstCwd).getSessionFile()!); - const secondDir = path.dirname(SessionManager.create(secondCwd).getSessionFile()!); - - expect(firstDir).not.toBe(secondDir); - expect(path.basename(firstDir).startsWith("-")).toBe(false); - expect(path.basename(secondDir).startsWith("-")).toBe(false); - expect(fs.existsSync(path.join(firstDir, "first.jsonl"))).toBe(true); - expect(fs.existsSync(path.join(firstDir, "first", "artifact.txt"))).toBe(true); - expect(fs.existsSync(path.join(secondDir, "second.jsonl"))).toBe(true); - expect(fs.existsSync(path.join(secondDir, "second", "artifact.txt"))).toBe(true); - expect(fs.existsSync(legacyDir)).toBe(false); - }); }); describe("SessionManager legacy session migration persistence", () => { From 9f92d36425769808bc502837cee1f9dd98c03e7e Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 21:14:11 +0000 Subject: [PATCH 071/126] 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 072/126] 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 073/126] 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 074/126] 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 075/126] 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 076/126] 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 077/126] 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 078/126] 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 079/126] 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 080/126] 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 081/126] 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 082/126] 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 083/126] 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 084/126] 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 085/126] 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 086/126] 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 087/126] 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 088/126] 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 089/126] 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 090/126] 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 8e5f61950271765feaeae062146d5b727c5f7b9c Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Tue, 4 Aug 2026 16:29:15 -0700 Subject: [PATCH 091/126] fix(coding-agent): harden Agent Hub lifecycle and persistence --- .../modes/components/agent-hub-projection.ts | 248 ++++++++ .../modes/components/agent-hub-renderer.ts | 194 ++++++ .../src/modes/components/agent-hub.ts | 587 +++++------------- .../modes/controllers/selector-controller.ts | 4 +- .../src/registry/agent-lifecycle.ts | 21 +- .../src/registry/agent-registry.ts | 17 +- .../src/registry/persisted-agents.ts | 186 +++--- .../src/session/session-entries.ts | 2 + .../src/session/session-loader.ts | 82 ++- .../src/session/session-manager.ts | 20 +- .../coding-agent/src/session/turn-recovery.ts | 4 +- packages/coding-agent/src/task/executor.ts | 24 +- packages/coding-agent/src/task/index.ts | 33 +- .../coding-agent/src/task/persisted-revive.ts | 17 +- .../coding-agent/src/task/read-only-policy.ts | 27 + .../test/agent-hub-activate.test.ts | 119 ++-- .../test/agent-hub-ordering.test.ts | 60 +- .../test/registry/agent-lifecycle.test.ts | 11 +- .../test/session-loader-stream.test.ts | 30 +- .../test/task/persisted-revive.test.ts | 40 +- .../test/vibe/vibe-runtime.test.ts | 35 ++ 21 files changed, 1115 insertions(+), 646 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/agent-hub-projection.ts create mode 100644 packages/coding-agent/src/modes/components/agent-hub-renderer.ts create mode 100644 packages/coding-agent/src/task/read-only-policy.ts diff --git a/packages/coding-agent/src/modes/components/agent-hub-projection.ts b/packages/coding-agent/src/modes/components/agent-hub-projection.ts new file mode 100644 index 000000000..6d908c527 --- /dev/null +++ b/packages/coding-agent/src/modes/components/agent-hub-projection.ts @@ -0,0 +1,248 @@ +import type { AgentMetricsSummary, AgentRef, AgentStatus } from "../../registry/agent-registry"; +import { MAIN_AGENT_ID } from "../../registry/agent-registry"; +import type { ObservableSession } from "../session-observer-registry"; + +export type AgentMetrics = AgentMetricsSummary; + +export interface AggregateMetrics extends AgentMetrics { + reportedAgents: number; + /** Rows whose duration is an observer-measured active runtime. */ + activeDurationAgents: number; +} + +export interface AgentTreeProjection { + rows: AgentRef[]; + depthById: Map; + parentById: Map; + lastSiblingById: Map; +} + +export const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; + +function finiteMetric(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** Exact observer usage for one roster entry. */ +export function progressMetrics(observed: ObservableSession | undefined): AgentMetrics | undefined { + const progress = observed?.progress; + if (!progress) return undefined; + const { tokens, requests, toolCount: tools, cost, durationMs } = progress; + if ( + typeof tokens !== "number" || + !Number.isFinite(tokens) || + typeof requests !== "number" || + !Number.isFinite(requests) || + typeof tools !== "number" || + !Number.isFinite(tools) || + typeof cost !== "number" || + !Number.isFinite(cost) || + typeof durationMs !== "number" || + !Number.isFinite(durationMs) + ) { + return undefined; + } + return { + tokens, + requests, + tools, + cost, + durationMs, + durationKind: "active", + contextTokens: + typeof progress.contextTokens === "number" && Number.isFinite(progress.contextTokens) + ? progress.contextTokens + : undefined, + contextWindow: + typeof progress.contextWindow === "number" && Number.isFinite(progress.contextWindow) + ? progress.contextWindow + : undefined, + }; +} + +/** + * Read direct assistant usage from a live session. SessionStats also includes + * usage embedded in completed `task` tool results, so using it for a parent + * row would double-count child rows in the aggregate. + */ +export function readSessionMetrics(session: NonNullable): AgentMetrics | undefined { + try { + const stats = session.getSessionStats(); + const messages = session.agent?.state?.messages; + if (!Array.isArray(messages)) { + return { + tokens: stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite, + requests: stats.assistantMessages, + tools: stats.toolCalls, + cost: stats.cost, + durationMs: 0, + durationKind: "unknown", + contextTokens: stats.contextUsage?.tokens, + contextWindow: stats.contextUsage?.contextWindow, + }; + } + + let tokens = 0; + let requests = 0; + let tools = 0; + let cost = 0; + for (const message of messages) { + if (message.role !== "assistant") continue; + requests++; + tokens += message.usage.input + message.usage.output + message.usage.cacheWrite; + tools += message.content.filter(content => content.type === "toolCall").length; + cost += message.usage.cost.total; + } + return { + tokens, + requests, + tools, + cost, + durationMs: 0, + durationKind: "unknown", + contextTokens: stats.contextUsage?.tokens, + contextWindow: stats.contextUsage?.contextWindow, + }; + } catch { + // Render-only doubles and sessions being torn down may not expose a + // complete statistics host. Missing metrics are preferable to a broken hub. + return undefined; + } +} + +export function aggregateMetrics(args: { + rows: readonly AgentRef[]; + observedById: ReadonlyMap; + metricsFor: (ref: AgentRef, observed: ObservableSession | undefined) => AgentMetrics | undefined; + fallbackStatsSession: ( + ref: AgentRef, + observed: ObservableSession | undefined, + ) => NonNullable | undefined; + sessionMetrics: WeakMap; + refreshFallback: boolean; +}): { metrics: AggregateMetrics; hasFallbackLiveSessions: boolean } { + const total: AggregateMetrics = { + tokens: 0, + requests: 0, + tools: 0, + cost: 0, + durationMs: 0, + durationKind: "active", + reportedAgents: 0, + activeDurationAgents: 0, + }; + let hasFallbackLiveSessions = false; + const countedFallbackSessions = new Set>(); + for (const ref of args.rows) { + const observed = args.observedById.get(ref.id); + const fallbackSession = args.fallbackStatsSession(ref, observed); + if (fallbackSession) { + hasFallbackLiveSessions = true; + if (args.refreshFallback || !args.sessionMetrics.has(fallbackSession)) { + args.sessionMetrics.set(fallbackSession, { metrics: readSessionMetrics(fallbackSession) }); + } + } + const metrics = args.metricsFor(ref, observed); + if (!metrics || (fallbackSession && countedFallbackSessions.has(fallbackSession))) continue; + if (fallbackSession) countedFallbackSessions.add(fallbackSession); + total.reportedAgents++; + total.tokens += finiteMetric(metrics.tokens); + total.requests += finiteMetric(metrics.requests); + total.tools += finiteMetric(metrics.tools); + total.cost += finiteMetric(metrics.cost); + if (metrics.durationKind === "active") { + total.durationMs += finiteMetric(metrics.durationMs); + total.activeDurationAgents++; + } + } + return { metrics: total, hasFallbackLiveSessions }; +} + +/** Parent-before-child projection preserving the roster's stable sibling order. */ +export function projectAgentTree(refs: readonly AgentRef[]): AgentTreeProjection { + const ids = new Set(); + const operationalIndex = new Map(); + for (let i = 0; i < refs.length; i++) { + ids.add(refs[i].id); + operationalIndex.set(refs[i].id, i); + } + + const parentById = new Map(); + const children = new Map(); + for (const ref of refs) { + const parent = + ref.parentId && ref.parentId !== MAIN_AGENT_ID && ids.has(ref.parentId) ? ref.parentId : MAIN_AGENT_ID; + parentById.set(ref.id, parent); + const siblings = children.get(parent); + if (siblings) siblings.push(ref); + else children.set(parent, [ref]); + } + + // A tree group occupies the position of its earliest operational row. + // Compute subtree minima iteratively so pathological lineage depth remains stack-safe. + const subtreeOrder = new Map(); + const visiting = new Set(); + const ranked = new Set(); + for (const start of refs) { + if (ranked.has(start.id)) continue; + const stack: Array<{ ref: AgentRef; expanded: boolean }> = [{ ref: start, expanded: false }]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + if (current.expanded) { + let order = operationalIndex.get(current.ref.id) ?? Number.MAX_SAFE_INTEGER; + for (const child of children.get(current.ref.id) ?? []) { + order = Math.min(order, subtreeOrder.get(child.id) ?? Number.MAX_SAFE_INTEGER); + } + subtreeOrder.set(current.ref.id, order); + visiting.delete(current.ref.id); + ranked.add(current.ref.id); + continue; + } + if (ranked.has(current.ref.id) || visiting.has(current.ref.id)) continue; + visiting.add(current.ref.id); + stack.push({ ref: current.ref, expanded: true }); + const descendants = children.get(current.ref.id); + if (!descendants) continue; + for (let i = descendants.length - 1; i >= 0; i--) { + const child = descendants[i]; + if (!ranked.has(child.id) && !visiting.has(child.id)) stack.push({ ref: child, expanded: false }); + } + } + } + for (const siblings of children.values()) { + siblings.sort( + (a, b) => + (subtreeOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (subtreeOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER) || + (operationalIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (operationalIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); + } + + const lastSiblingById = new Map(); + for (const siblings of children.values()) { + for (let i = 0; i < siblings.length; i++) lastSiblingById.set(siblings[i].id, i === siblings.length - 1); + } + + const rows: AgentRef[] = []; + const visited = new Set(); + const depthById = new Map(); + const visit = (root: AgentRef, rootDepth: number): void => { + const stack: Array<{ ref: AgentRef; depth: number }> = [{ ref: root, depth: rootDepth }]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current || visited.has(current.ref.id)) continue; + visited.add(current.ref.id); + depthById.set(current.ref.id, current.depth); + rows.push(current.ref); + const descendants = children.get(current.ref.id); + if (!descendants) continue; + for (let i = descendants.length - 1; i >= 0; i--) + stack.push({ ref: descendants[i], depth: current.depth + 1 }); + } + }; + for (const root of children.get(MAIN_AGENT_ID) ?? []) visit(root, 0); + // Corrupt persisted parent cycles remain visible as roots instead of disappearing. + for (const ref of refs) visit(ref, 0); + return { rows, depthById, parentById, lastSiblingById }; +} diff --git a/packages/coding-agent/src/modes/components/agent-hub-renderer.ts b/packages/coding-agent/src/modes/components/agent-hub-renderer.ts new file mode 100644 index 000000000..3200b76bd --- /dev/null +++ b/packages/coding-agent/src/modes/components/agent-hub-renderer.ts @@ -0,0 +1,194 @@ +import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Ellipsis, visibleWidth } from "@oh-my-pi/pi-tui"; +import { formatDuration, formatNumber, sanitizeText } from "@oh-my-pi/pi-utils"; +import { getRoleInfo } from "../../config/model-roles"; +import type { Settings } from "../../config/settings"; +import { type AgentRef, MAIN_AGENT_ID } from "../../registry/agent-registry"; +import { parseThinkingLevel } from "../../thinking"; +import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; +import type { ObservableSession } from "../session-observer-registry"; +import { theme } from "../theme/theme"; +import type { AgentMetrics } from "./agent-hub-projection"; + +export interface RosterRender { + lines: string[]; + hitRows: Array; +} + +/** Legacy progress snapshots may omit counters; snapshot absence remains distinct. */ +export function metricNumber(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** Compute the max content width for the current terminal, accounting for chrome. */ +export function contentWidth(): number { + return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6); +} + +/** Remove terminal controls and normalize a value before it reaches the TUI. */ +export function sanitizeDisplayText(text: string): string { + return replaceTabs(sanitizeText(text)).replace(/[\r\n]+/g, " "); +} + +/** Sanitize a line for TUI display and truncate it to the viewport width. */ +export function sanitizeLine(text: string, maxWidth?: number): string { + return truncateToWidth(sanitizeDisplayText(text), maxWidth ?? contentWidth()); +} + +export function clampHubLine(line: string, width: number): string { + return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width), Ellipsis.Omit); +} + +/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */ +export function statusGlyph(status: AgentRef["status"]): string { + switch (status) { + case "running": + return theme.fg("accent", theme.status.running); + case "idle": + return theme.fg("success", theme.status.enabled); + case "parked": + return theme.fg("muted", theme.status.shadowed); + case "aborted": + return theme.fg("error", theme.status.aborted); + } +} + +export function statusText(status: AgentRef["status"], text: string): string { + switch (status) { + case "running": + return theme.fg("accent", text); + case "idle": + return theme.fg("success", text); + case "parked": + return theme.fg("muted", text); + case "aborted": + return theme.fg("error", text); + } +} + +/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */ +export function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string { + const model = theme.fg("muted", sanitizeDisplayText(modelId)); + if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model; + const display = theme.thinking[level as keyof typeof theme.thinking] ?? level; + return `${model} ${theme.getThinkingBorderColor(level)(display)}`; +} + +/** Textual model-role tag; color reinforces (but never replaces) the label. */ +export function formatRoleBadge(role: string, settings: Settings): string { + const info = getRoleInfo(role, settings); + return theme.fg(info.color ?? "muted", sanitizeDisplayText(info.tag ?? info.name ?? role)); +} + +/** Format a resolved selector, preserving provider identity when requested. */ +export function formatResolvedModelBadge( + resolved: string, + preserveProvider = false, + fallbackLevel?: ThinkingLevel, +): string { + const cleanResolved = sanitizeDisplayText(resolved); + // Model ids may themselves contain colons (`qwen3:14b`), so only treat the + // suffix as a thinking level when it parses as one. + const colon = cleanResolved.lastIndexOf(":"); + const explicitLevel = colon >= 0 ? parseThinkingLevel(cleanResolved.slice(colon + 1)) : undefined; + const selector = explicitLevel !== undefined ? cleanResolved.slice(0, colon) : cleanResolved; + const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1); + return formatModelBadge(label, explicitLevel ?? fallbackLevel); +} + +/** + * Resolved model + reasoning level for a hub row. Exact executor progress is + * authoritative (and survives completion); direct live sessions are the + * fallback for agents without an observer snapshot. + */ +export function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined { + const progress = observed?.progress; + const liveThinkingLevel = ref.session?.thinkingLevel; + const fallbackSelector = + ref.session?.retryFallbackModel ?? + (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined) ?? + (ref.history?.resolvedModelIsFallback ? ref.history.resolvedModel : undefined); + if (fallbackSelector) { + return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true, liveThinkingLevel)}`; + } + const resolvedModel = progress?.resolvedModel ?? ref.history?.resolvedModel; + if (resolvedModel) return formatResolvedModelBadge(resolvedModel, false, liveThinkingLevel); + const model = ref.session?.model; + if (!model) return undefined; + const level = model.thinking ? liveThinkingLevel : undefined; + return formatModelBadge(model.id, level); +} + +export function formatMetricDuration(metrics: AgentMetrics): string | undefined { + const durationMs = metricNumber(metrics.durationMs); + if (durationMs <= 0) return undefined; + const label = metrics.durationKind === "active" ? "active" : metrics.durationKind === "span" ? "span" : "duration"; + return `${formatDuration(durationMs)} ${label}`; +} + +export function formatCost(cost: number): string { + const amount = metricNumber(cost); + if (amount < 0.01) return `$${amount.toFixed(4)}`; + if (amount < 1) return `$${amount.toFixed(3)}`; + return `$${amount.toFixed(2)}`; +} + +export function formatMetrics(metrics: AgentMetrics): string { + return [ + formatCost(metrics.cost), + formatMetricDuration(metrics) ?? "time —", + `${formatNumber(metrics.requests)} req`, + `${formatNumber(metrics.tools)} tools`, + `${formatNumber(metrics.tokens)} tok`, + ].join(theme.sep.dot); +} + +export function contextGauge(tokens: number, window: number): string { + const ratio = Math.max(0, Math.min(1, tokens / window)); + const filled = Math.round(ratio * 10); + return `${theme.fg("accent", "━".repeat(filled))}${theme.fg("dim", "─".repeat(10 - filled))} ${formatNumber(tokens)}/${formatNumber(window)} ${Math.round(ratio * 100)}%`; +} + +/** Fit a child-id preview without joining an arbitrarily large child set. */ +export function formatChildIds(children: readonly AgentRef[], width: number): string { + const max = Math.max(1, width); + let shown = 0; + let text = ""; + while (shown < children.length) { + const id = sanitizeLine(children[shown].id, max); + const candidate = text ? `${text}, ${id}` : id; + const remaining = children.length - shown - 1; + const suffix = remaining > 0 ? `, … +${remaining}` : ""; + if (visibleWidth(candidate + suffix) > max) { + const includesCurrent = text.length === 0; + const omitted = children.length - shown - Number(includesCurrent); + return truncateToWidth(`${includesCurrent ? id : text}${omitted > 0 ? `, … +${omitted}` : ""}`, max); + } + text = candidate; + shown++; + } + return text; +} + +/** Bash `tree`-style ancestry prefix, clipped from the left on pathological depth. */ +export function treeBranch( + ref: AgentRef, + maxWidth: number, + depthById: ReadonlyMap, + parentById: ReadonlyMap, + lastSiblingById: ReadonlyMap, +): string { + if ((depthById.get(ref.id) ?? 0) === 0) return ""; + const segments: string[] = [lastSiblingById.get(ref.id) ? "└── " : "├── "]; + const ancestry = new Set(); + let parent = parentById.get(ref.id); + while (parent && parentById.get(parent) !== MAIN_AGENT_ID && !ancestry.has(parent)) { + ancestry.add(parent); + segments.push(lastSiblingById.get(parent) ? " " : "│ "); + parent = parentById.get(parent); + } + const maxSegments = Math.max(1, Math.floor(Math.max(4, maxWidth - 2) / 4)); + const omitted = Math.max(0, segments.length - maxSegments); + const prefix = segments.slice(0, maxSegments).reverse().join(""); + return theme.fg("dim", `${omitted > 0 ? "… " : ""}${prefix}`); +} diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 147df3041..ae9c31440 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -13,10 +13,9 @@ * * Replaces the old SessionObserverOverlayComponent (ctrl+s observer). */ -import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { Container, - Ellipsis, matchesKey, type OverlayHandle, padding, @@ -27,27 +26,43 @@ import { visibleWidth, wrapTextWithAnsi, } from "@oh-my-pi/pi-tui"; -import { formatAge, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { formatAge, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; -import { getRoleInfo } from "../../config/model-roles"; import type { Settings } from "../../config/settings"; import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; -import { - type AgentMetricsSummary, - type AgentRef, - AgentRegistry, - type AgentStatus, - MAIN_AGENT_ID, -} from "../../registry/agent-registry"; +import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import { registerPersistedSubagents } from "../../registry/persisted-agents"; import { USER_INTERRUPT_LABEL } from "../../session/messages"; -import { parseThinkingLevel } from "../../thinking"; -import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; +import { truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import { + type AgentMetrics, + type AggregateMetrics, + aggregateMetrics, + progressMetrics, + projectAgentTree, + STATUS_ORDER, +} from "./agent-hub-projection"; +import { + clampHubLine, + contextGauge, + formatChildIds, + formatCost, + formatMetricDuration, + formatMetrics, + formatRoleBadge, + modelBadge, + type RosterRender, + sanitizeDisplayText, + sanitizeLine, + statusGlyph, + statusText, + treeBranch, +} from "./agent-hub-renderer"; import { AgentTranscriptViewer } from "./agent-transcript-viewer"; import { bottomBorder, @@ -60,207 +75,18 @@ import { topBorderSplit, } from "./overlay-box"; -/** Two-pane mode needs a useful roster and a readable inspector. */ -const SPLIT_MIN_WIDTH = 96; -const DETAIL_MIN_WIDTH = 34; -const ROSTER_MIN_WIDTH = 48; - type HubViewMode = "roster" | "tree"; -type AgentMetrics = AgentMetricsSummary; - -interface AggregateMetrics extends AgentMetrics { - reportedAgents: number; -} - -interface RosterRender { - lines: string[]; - hitRows: Array; -} - -/** Legacy progress snapshots may omit counters; snapshot absence remains distinct. */ -function metricNumber(value: number | undefined): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -/** Refresh cadence for the relative-time column */ +/** Refresh cadence for the relative-time column. */ const AGE_TICK_MS = 5_000; const DATA_CHANGE_RENDER_COALESCE_MS = 100; /** Double-tap window for the table's left-left "close hub" gesture. */ const LEFT_TAP_WINDOW_MS = 500; -/** Compute the max content width for the current terminal, accounting for chrome. */ -function contentWidth(): number { - return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6); -} - -/** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */ -function sanitizeLine(text: string, maxWidth?: number): string { - const singleLine = replaceTabs(text).replace(/[\r\n]+/g, " "); - return truncateToWidth(singleLine, maxWidth ?? contentWidth()); -} - -function clampHubLine(line: string, width: number): string { - return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width), Ellipsis.Omit); -} - -const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; - -/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */ -function statusGlyph(status: AgentStatus): string { - switch (status) { - case "running": - return theme.fg("accent", theme.status.running); - case "idle": - return theme.fg("success", theme.status.enabled); - case "parked": - return theme.fg("muted", theme.status.shadowed); - case "aborted": - return theme.fg("error", theme.status.aborted); - } -} - -function statusText(status: AgentStatus, text: string): string { - switch (status) { - case "running": - return theme.fg("accent", text); - case "idle": - return theme.fg("success", text); - case "parked": - return theme.fg("muted", text); - case "aborted": - return theme.fg("error", text); - } -} - -/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */ -function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string { - const model = theme.fg("muted", replaceTabs(modelId)); - if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model; - const display = theme.thinking[level as keyof typeof theme.thinking] ?? level; - return `${model} ${theme.getThinkingBorderColor(level)(display)}`; -} -/** Textual model-role tag; color reinforces (but never replaces) the label. */ -function formatRoleBadge(role: string, settings: Settings): string { - const info = getRoleInfo(role, settings); - return theme.fg(info.color ?? "muted", replaceTabs(info.tag ?? info.name ?? role)); -} - -/** Format a resolved selector, preserving provider identity when requested. */ -function formatResolvedModelBadge(resolved: string, preserveProvider = false, fallbackLevel?: ThinkingLevel): string { - // Model ids may themselves contain colons (`qwen3:14b`), so only treat the - // suffix as a thinking level when it parses as one. - const colon = resolved.lastIndexOf(":"); - const explicitLevel = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined; - const selector = explicitLevel !== undefined ? resolved.slice(0, colon) : resolved; - const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1); - return formatModelBadge(label, explicitLevel ?? fallbackLevel); -} - -/** - * Resolved model + reasoning level for a hub row. Exact executor progress is - * authoritative (and survives completion); direct live sessions are the - * fallback for agents without an observer snapshot. Active retry fallbacks - * retain provider identity and carry an explicit marker. - */ -function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined { - const progress = observed?.progress; - const liveThinkingLevel = ref.session?.thinkingLevel; - // The executor fallback flag also covers retries that do not populate the - // live session's retryFallbackModel (for example Fireworks Fast → base). - const fallbackSelector = - ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined); - if (fallbackSelector) { - return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true, liveThinkingLevel)}`; - } - const resolvedModel = progress?.resolvedModel ?? ref.history?.resolvedModel; - if (resolvedModel) { - return formatResolvedModelBadge(resolvedModel, false, liveThinkingLevel); - } - const model = ref.session?.model; - if (!model) return undefined; - const level = model.thinking ? liveThinkingLevel : undefined; - return formatModelBadge(model.id, level); -} - -/** Exact observer usage for one roster entry. */ -function progressMetrics(observed: ObservableSession | undefined): AgentMetrics | undefined { - const progress = observed?.progress; - if (!progress) return undefined; - const { tokens, requests, toolCount: tools, cost, durationMs } = progress; - if ( - typeof tokens !== "number" || - !Number.isFinite(tokens) || - typeof requests !== "number" || - !Number.isFinite(requests) || - typeof tools !== "number" || - !Number.isFinite(tools) || - typeof cost !== "number" || - !Number.isFinite(cost) || - typeof durationMs !== "number" || - !Number.isFinite(durationMs) - ) { - return undefined; - } - return { - tokens, - requests, - tools, - cost, - durationMs, - contextTokens: - typeof progress.contextTokens === "number" && Number.isFinite(progress.contextTokens) - ? progress.contextTokens - : undefined, - contextWindow: - typeof progress.contextWindow === "number" && Number.isFinite(progress.contextWindow) - ? progress.contextWindow - : undefined, - }; -} - -function formatCost(cost: number): string { - const amount = metricNumber(cost); - if (amount < 0.01) return `$${amount.toFixed(4)}`; - if (amount < 1) return `$${amount.toFixed(3)}`; - return `$${amount.toFixed(2)}`; -} -function formatMetrics(metrics: AgentMetrics): string { - return [ - formatCost(metrics.cost), - formatDuration(metrics.durationMs), - `${formatNumber(metrics.requests)} req`, - `${formatNumber(metrics.tools)} tools`, - `${formatNumber(metrics.tokens)} tok`, - ].join(theme.sep.dot); -} - -function contextGauge(tokens: number, window: number): string { - const ratio = Math.max(0, Math.min(1, tokens / window)); - const filled = Math.round(ratio * 10); - return `${theme.fg("accent", "━".repeat(filled))}${theme.fg("dim", "─".repeat(10 - filled))} ${formatNumber(tokens)}/${formatNumber(window)} ${Math.round(ratio * 100)}%`; -} -/** Fit a child-id preview without joining an arbitrarily large child set. */ -function formatChildIds(children: readonly AgentRef[], width: number): string { - const max = Math.max(1, width); - let shown = 0; - let text = ""; - while (shown < children.length) { - const id = sanitizeLine(children[shown].id, max); - const candidate = text ? `${text}, ${id}` : id; - const remaining = children.length - shown - 1; - const suffix = remaining > 0 ? `, … +${remaining}` : ""; - if (visibleWidth(candidate + suffix) > max) { - const includesCurrent = text.length === 0; - const omitted = children.length - shown - Number(includesCurrent); - return truncateToWidth(`${includesCurrent ? id : text}${omitted > 0 ? `, … +${omitted}` : ""}`, max); - } - text = candidate; - shown++; - } - return text; -} - +/** Two-pane mode needs a useful roster and a readable inspector. */ +const SPLIT_MIN_WIDTH = 96; +const DETAIL_MIN_WIDTH = 34; +const ROSTER_MIN_WIDTH = 48; /** Result of one host-backed transcript read for the Agent Hub viewer. */ export interface AgentHubRemoteTranscript { text: string; @@ -343,6 +169,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #notice: string | undefined; /** Captured row order from the first refresh; keeps the hub stable while open. */ #rowOrder: Map | undefined; + #nextRowOrder = 0; /** Double-tap window state for the table's left-left "close hub" gesture. */ #lastLeftTap = 0; /** Operational ordering by default; tree mode groups descendants under their spawner. */ @@ -358,7 +185,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou tools: 0, cost: 0, durationMs: 0, + durationKind: "active", reportedAgents: 0, + activeDurationAgents: 0, }; #statusCounts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 }; #childrenByParent = new Map(); @@ -369,6 +198,10 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou /** On narrow terminals Tab replaces the roster with the selected-agent inspector. */ #narrowDetailsOpen = false; #lastRenderWasSplit = false; + #lastSplitRosterWidth: number | undefined; + /** Scroll offset for the selected-agent inspector when its content overflows. */ + #detailScrollOffset = 0; + #detailAgentId: string | undefined; // Transcript-viewer launch deps (passed through to AgentTranscriptViewer). #ui: TUI; @@ -416,7 +249,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#unsubscribers.push(this.#observers.onChange(() => this.#scheduleDataChange())); this.#ageTimer = setInterval(() => { if (this.#hasFallbackLiveSessions) { - this.#aggregate = this.#aggregateMetrics(this.#observedById, true); + this.#refreshAggregate(true); } this.#requestRender(); }, AGE_TICK_MS); @@ -476,7 +309,15 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } handleInput(keyData: string): void { - if (routeSgrMouseInput(keyData, event => routeSelectListMouse(this, event, event.row))) return; + if ( + routeSgrMouseInput(keyData, event => { + const split = this.#lastSplitRosterWidth; + if (split !== undefined && event.wheel === null && event.col > split + 2) return false; + return routeSelectListMouse(this, event, event.row); + }) + ) { + return; + } // The hub/observe keys always close the overlay (toggle semantics) for (const key of this.#hubKeys) { @@ -508,11 +349,12 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou * restored when the viewer closes. No-op without a real TUI (render-only test stub). */ openChat(id: string): void { - if (!this.#registry.get(id)) return; + if (this.#disposed || !this.#registry.get(id)) return; if (typeof this.#ui.showOverlay !== "function") return; this.#closeTranscriptOverlay(); this.#notice = undefined; - const viewer = new AgentTranscriptViewer({ + let viewer: AgentTranscriptViewer; + viewer = new AgentTranscriptViewer({ agentId: id, registry: this.#registry, remote: this.#remote, @@ -527,10 +369,11 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou expandKeys: this.#expandKeys, hubKeys: this.#hubKeys, requestRender: this.#requestRender, - onClose: () => this.#closeTranscriptOverlay(), + onClose: () => this.#closeTranscriptOverlay(viewer), onHubClose: () => { - this.#closeTranscriptOverlay(); - this.#onDone(); + if (this.#disposed) return; + this.#closeTranscriptOverlay(viewer); + if (!this.#disposed) this.#onDone(); }, }); this.#transcriptViewer = viewer; @@ -540,13 +383,19 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } /** Close and dispose the transcript overlay, restoring focus to the hub table. */ - #closeTranscriptOverlay(): void { - this.#transcriptOverlay?.hide(); + #closeTranscriptOverlay(expectedViewer?: AgentTranscriptViewer): void { + if (expectedViewer && this.#transcriptViewer !== expectedViewer) return; + const overlay = this.#transcriptOverlay; + const viewer = this.#transcriptViewer; + if (!overlay && !viewer) return; + overlay?.hide(); this.#transcriptOverlay = undefined; - this.#transcriptViewer?.dispose(); + viewer?.dispose(); this.#transcriptViewer = undefined; - if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this); - this.#requestRender(); + if (!this.#disposed) { + if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this); + this.#requestRender(); + } } // ======================================================================== @@ -572,37 +421,45 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID); this.#observedById = new Map(); for (const session of this.#observers.getSessions()) this.#observedById.set(session.id, session); - const rowOrder = this.#rowOrder; let rosterRows: AgentRef[]; if (!rowOrder) { - // First refresh (usually the constructor): order by status, then recency. rosterRows = refs.sort( - (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity, + (a, b) => + STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || + b.lastActivity - a.lastActivity || + a.id.localeCompare(b.id), ); this.#rowOrder = new Map(); - for (let i = 0; i < rosterRows.length; i++) this.#rowOrder.set(rosterRows[i].id, i); + for (const ref of rosterRows) this.#rowOrder.set(ref.id, this.#nextRowOrder++); } else { - // After the hub is open, freeze relative order within each lifecycle - // group. New agents append instead of moving the user's selection. - rosterRows = refs.sort((a, b) => { - const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; - if (statusDiff !== 0) return statusDiff; - return (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER); - }); + rosterRows = refs.sort( + (a, b) => (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); for (const ref of rosterRows) { - if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, rowOrder.size); + if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, this.#nextRowOrder++); } } - this.#rows = this.#viewMode === "tree" ? this.#orderAsTree(rosterRows) : rosterRows; - if (this.#viewMode === "roster") { + if (this.#viewMode === "tree") { + const tree = projectAgentTree(rosterRows); + this.#rows = tree.rows; + this.#treeDepthById = tree.depthById; + this.#treeParentById = tree.parentById; + this.#treeLastSiblingById = tree.lastSiblingById; + } else { + this.#rows = rosterRows; this.#treeDepthById.clear(); this.#treeParentById.clear(); this.#treeLastSiblingById.clear(); } const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1; this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1)); + const detailAgentId = this.#rows[this.#selectedRow]?.id; + if (detailAgentId !== this.#detailAgentId) { + this.#detailAgentId = detailAgentId; + this.#detailScrollOffset = 0; + } this.#childrenByParent.clear(); for (const ref of rosterRows) { @@ -613,12 +470,10 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } this.#statusCounts = { running: 0, idle: 0, parked: 0, aborted: 0 }; for (const ref of rosterRows) this.#statusCounts[ref.status]++; - this.#aggregate = this.#aggregateMetrics(this.#observedById); + this.#refreshAggregate(); } #metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined { - // An observer snapshot is authoritative. Legacy/incomplete snapshots remain - // unknown rather than being silently replaced by unrelated transcript stats. if (observed?.progress) return progressMetrics(observed); if (ref.history?.metrics) return ref.history.metrics; const session = this.#fallbackStatsSession(ref, observed); @@ -634,140 +489,6 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return session && typeof session.getSessionStats === "function" ? session : undefined; } - #readSessionMetrics(session: NonNullable): AgentMetrics | undefined { - try { - const stats = session.getSessionStats(); - return { - // Match AgentProgress lifetime billing volume: cache reads are - // deliberately excluded because every turn rereads cached context. - tokens: stats.tokens.input + stats.tokens.output + stats.tokens.cacheWrite, - requests: stats.assistantMessages, - tools: stats.toolCalls, - cost: stats.cost, - durationMs: 0, - contextTokens: stats.contextUsage?.tokens, - contextWindow: stats.contextUsage?.contextWindow, - }; - } catch { - // Render-only doubles and sessions being torn down may not expose a - // complete statistics host. Missing metrics are preferable to a broken hub. - return undefined; - } - } - - /** Parent-before-child projection preserving the roster's stable sibling order. */ - #orderAsTree(refs: AgentRef[]): AgentRef[] { - this.#treeParentById.clear(); - this.#treeLastSiblingById.clear(); - const ids = new Set(); - const operationalIndex = new Map(); - for (let i = 0; i < refs.length; i++) { - ids.add(refs[i].id); - operationalIndex.set(refs[i].id, i); - } - - const children = new Map(); - for (const ref of refs) { - const parent = - ref.parentId && ref.parentId !== MAIN_AGENT_ID && ids.has(ref.parentId) ? ref.parentId : MAIN_AGENT_ID; - this.#treeParentById.set(ref.id, parent); - const siblings = children.get(parent); - if (siblings) siblings.push(ref); - else children.set(parent, [ref]); - } - - // A tree group occupies the position of its earliest operational row. - // Otherwise a recent child could leave its older parent group below an - // unrelated peer (`child, peer, parent` → `peer, parent, child`). - // Compute subtree minima iteratively so even pathological lineage depth - // remains stack-safe. - const subtreeOrder = new Map(); - const visiting = new Set(); - const ranked = new Set(); - for (const start of refs) { - if (ranked.has(start.id)) continue; - const stack: Array<{ ref: AgentRef; expanded: boolean }> = [{ ref: start, expanded: false }]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) continue; - if (current.expanded) { - let order = operationalIndex.get(current.ref.id) ?? Number.MAX_SAFE_INTEGER; - for (const child of children.get(current.ref.id) ?? []) { - order = Math.min(order, subtreeOrder.get(child.id) ?? Number.MAX_SAFE_INTEGER); - } - subtreeOrder.set(current.ref.id, order); - visiting.delete(current.ref.id); - ranked.add(current.ref.id); - continue; - } - if (ranked.has(current.ref.id) || visiting.has(current.ref.id)) continue; - visiting.add(current.ref.id); - stack.push({ ref: current.ref, expanded: true }); - const descendants = children.get(current.ref.id); - if (!descendants) continue; - for (let i = descendants.length - 1; i >= 0; i--) { - const child = descendants[i]; - if (!ranked.has(child.id) && !visiting.has(child.id)) { - stack.push({ ref: child, expanded: false }); - } - } - } - } - for (const siblings of children.values()) { - siblings.sort( - (a, b) => - (subtreeOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - - (subtreeOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER) || - (operationalIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - - (operationalIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER), - ); - } - for (const siblings of children.values()) { - for (let i = 0; i < siblings.length; i++) { - this.#treeLastSiblingById.set(siblings[i].id, i === siblings.length - 1); - } - } - - const ordered: AgentRef[] = []; - const visited = new Set(); - this.#treeDepthById.clear(); - const visit = (root: AgentRef, rootDepth: number): void => { - const stack: Array<{ ref: AgentRef; depth: number }> = [{ ref: root, depth: rootDepth }]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current || visited.has(current.ref.id)) continue; - visited.add(current.ref.id); - this.#treeDepthById.set(current.ref.id, current.depth); - ordered.push(current.ref); - const descendants = children.get(current.ref.id); - if (!descendants) continue; - for (let i = descendants.length - 1; i >= 0; i--) { - stack.push({ ref: descendants[i], depth: current.depth + 1 }); - } - } - }; - for (const root of children.get(MAIN_AGENT_ID) ?? []) visit(root, 0); - // Corrupt persisted parent cycles remain visible as roots instead of - // disappearing from the operational surface. - for (const ref of refs) visit(ref, 0); - return ordered; - } - - /** Bash `tree`-style ancestry prefix, clipped from the left on pathological depth. */ - #treeBranch(ref: AgentRef, maxWidth: number): string { - if ((this.#treeDepthById.get(ref.id) ?? 0) === 0) return ""; - const segments: string[] = [this.#treeLastSiblingById.get(ref.id) ? "└── " : "├── "]; - let parent = this.#treeParentById.get(ref.id); - while (parent && this.#treeParentById.get(parent) !== MAIN_AGENT_ID) { - segments.push(this.#treeLastSiblingById.get(parent) ? " " : "│ "); - parent = this.#treeParentById.get(parent); - } - const maxSegments = Math.max(1, Math.floor(Math.max(4, maxWidth - 2) / 4)); - const omitted = Math.max(0, segments.length - maxSegments); - const prefix = segments.slice(0, maxSegments).reverse().join(""); - return theme.fg("dim", `${omitted > 0 ? "… " : ""}${prefix}`); - } - // ======================================================================== // Table view // ======================================================================== @@ -778,6 +499,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const observedById = this.#observedById; const split = this.#splitRosterWidth(width); this.#lastRenderWasSplit = split !== undefined; + this.#lastSplitRosterWidth = split; const selected = this.#rows[this.#selectedRow]; const lines: string[] = []; @@ -826,12 +548,15 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou #footer(showingNarrowDetails: boolean, availableWidth: number): string { const nextView = this.#viewMode === "roster" ? "by parent" : "flat"; if (showingNarrowDetails) { - return theme.fg("dim", `Tab:roster Enter:open t:${nextView} Esc:roster`); + return theme.fg("dim", `Tab:roster PgUp/PgDn:scroll Enter:open t:${nextView} Esc:roster`); } if (availableWidth < 96) { return theme.fg("dim", `j/k:select Enter:open t:${nextView} Tab:details r/x:manage Esc:close`); } - return theme.fg("dim", `j/k/wheel:select Enter/click:open t:${nextView} r:revive x:kill Esc:close`); + return theme.fg( + "dim", + `j/k/wheel:select PgUp/PgDn:details Enter/click:open t:${nextView} r:revive x:kill Esc:close`, + ); } #renderRosterPanel(width: number, rows: number, observedById: ReadonlyMap): RosterRender { @@ -986,12 +711,14 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou ); return lines; } + const activeTime = formatMetricDuration(metrics); const usage = [ theme.fg("statusLineCost", formatCost(metrics.cost)), - theme.fg("dim", `${formatDuration(metrics.durationMs)} agent time`), + theme.fg("dim", activeTime ? `${activeTime} agent time` : "agent time —"), theme.fg("dim", `${formatNumber(metrics.requests)} req`), theme.fg("dim", `${formatNumber(metrics.tools)} tools`), theme.fg("dim", `${formatNumber(metrics.tokens)} tok`), + theme.fg("dim", `${metrics.activeDurationAgents}/${metrics.reportedAgents} timed`), theme.fg("dim", `${metrics.reportedAgents}/${this.#rows.length} measured`), ].join(theme.fg("dim", theme.sep.dot)); lines.push(...wrapTextWithAnsi(usage, Math.max(1, width))); @@ -1007,36 +734,17 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou return parts.join(theme.sep.dot); } - #aggregateMetrics(observedById: ReadonlyMap, refreshFallback = false): AggregateMetrics { - const total: AggregateMetrics = { - tokens: 0, - requests: 0, - tools: 0, - cost: 0, - durationMs: 0, - reportedAgents: 0, - }; - let hasFallbackLiveSessions = false; - for (const ref of this.#rows) { - const observed = observedById.get(ref.id); - const fallbackSession = this.#fallbackStatsSession(ref, observed); - if (fallbackSession) { - hasFallbackLiveSessions = true; - if (refreshFallback || !this.#sessionMetrics.has(fallbackSession)) { - this.#sessionMetrics.set(fallbackSession, { metrics: this.#readSessionMetrics(fallbackSession) }); - } - } - const metrics = this.#metricsFor(ref, observed); - if (!metrics) continue; - total.reportedAgents++; - total.tokens += metrics.tokens; - total.requests += metrics.requests; - total.tools += metrics.tools; - total.cost += metrics.cost; - total.durationMs += metrics.durationMs; - } - this.#hasFallbackLiveSessions = hasFallbackLiveSessions; - return total; + #refreshAggregate(refreshFallback = false): void { + const result = aggregateMetrics({ + rows: this.#rows, + observedById: this.#observedById, + metricsFor: (ref, observed) => this.#metricsFor(ref, observed), + fallbackStatsSession: (ref, observed) => this.#fallbackStatsSession(ref, observed), + sessionMetrics: this.#sessionMetrics, + refreshFallback, + }); + this.#aggregate = result.metrics; + this.#hasFallbackLiveSessions = result.hasFallbackLiveSessions; } #renderDetailPanel( @@ -1052,20 +760,20 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const children = this.#childrenByParent.get(ref.id) ?? []; const lines: string[] = []; const add = (line = ""): void => { - if (lines.length < rows) lines.push(truncateToWidth(line, width)); + lines.push(truncateToWidth(line, width)); }; const addWrapped = (text: string, maxRows = 2): void => { for (const wrapped of wrapTextWithAnsi(sanitizeLine(text), Math.max(1, width)).slice(0, maxRows)) add(wrapped); }; - const section = (label: string): void => { - if (lines.length > 0) add(); + const section = (label: string, contentRows = 0): void => { + if (lines.length > 0 && lines.length + 1 + contentRows < rows) add(); add(theme.bold(theme.fg("accent", label))); }; - add(`${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.displayName || ref.id))}`); - if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", ref.id)); + add(`${statusGlyph(ref.status)} ${theme.bold(sanitizeDisplayText(ref.displayName || ref.id))}`); + if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", sanitizeDisplayText(ref.id))); const lifecycleDetails = [ - metrics?.durationMs ? formatDuration(metrics.durationMs) : undefined, + metrics ? formatMetricDuration(metrics) : undefined, `active ${formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))}`, ].filter(Boolean); add( @@ -1095,7 +803,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } } - section("Usage"); + section("Usage", 1); if (metrics) { addWrapped(formatMetrics(metrics), 3); if (metrics.contextTokens !== undefined && metrics.contextWindow) { @@ -1107,7 +815,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou section("Lineage"); add( - `Spawned by ${replaceTabs(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`, + `Spawned by ${sanitizeDisplayText(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`, ); if (children.length > 0) add(theme.fg("dim", formatChildIds(children, width))); add(theme.fg("dim", `Registered ${new Date(ref.createdAt).toISOString().slice(0, 16).replace("T", " ")}Z`)); @@ -1122,8 +830,11 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou ), ); - while (lines.length < rows) lines.push(""); - return lines.slice(0, rows); + const maxScroll = Math.max(0, lines.length - rows); + this.#detailScrollOffset = Math.min(this.#detailScrollOffset, maxScroll); + const visible = lines.slice(this.#detailScrollOffset, this.#detailScrollOffset + rows); + while (visible.length < rows) visible.push(""); + return visible; } /** @@ -1141,15 +852,18 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou const max = Math.max(1, width); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0; - const branch = this.#viewMode === "tree" ? this.#treeBranch(ref, max) : ""; - const id = replaceTabs(ref.id); + const branch = + this.#viewMode === "tree" + ? treeBranch(ref, max, this.#treeDepthById, this.#treeParentById, this.#treeLastSiblingById) + : ""; + const id = sanitizeDisplayText(ref.id); const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id); const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`]; if (ref.displayName && ref.displayName !== ref.id) { - fields.push(theme.fg("dim", replaceTabs(ref.displayName))); + fields.push(theme.fg("dim", sanitizeDisplayText(ref.displayName))); } if (this.#viewMode === "roster" && ref.parentId && ref.parentId !== MAIN_AGENT_ID) { - fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`)); + fields.push(theme.fg("dim", `↳ ${sanitizeDisplayText(ref.parentId)}`)); } if (ref.kind === "advisor") { fields.push(theme.fg("warning", "read-only")); @@ -1198,10 +912,23 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou }); } + #scrollDetails(direction: -1 | 1): void { + this.#detailScrollOffset = Math.max(0, this.#detailScrollOffset + direction * 5); + this.#requestRender(); + } + + #selectRow(index: number): void { + if (index !== this.#selectedRow) { + this.#detailScrollOffset = 0; + this.#detailAgentId = this.#rows[index]?.id; + } + this.#selectedRow = index; + } + handleWheel(delta: -1 | 1): void { this.#hoveredRow = null; if (this.#rows.length > 0) { - this.#selectedRow = Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1)); + this.#selectRow(Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1))); } this.#requestRender(); } @@ -1217,14 +944,12 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou } clickItem(index: number): void { + const selected = this.#rows[index]; + if (!selected) return; this.#hoveredRow = index; - if (index === this.#selectedRow) { - const selected = this.#rows[index]; - if (selected) this.#activateAgent(selected); - return; - } - this.#selectedRow = index; + this.#selectRow(index); this.#requestRender(); + this.#activateAgent(selected); } #handleTableInput(keyData: string): void { @@ -1242,6 +967,16 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#requestRender(); return; } + if (this.#lastRenderWasSplit || this.#narrowDetailsOpen) { + if (matchesKey(keyData, "pageUp")) { + this.#scrollDetails(-1); + return; + } + if (matchesKey(keyData, "pageDown")) { + this.#scrollDetails(1); + return; + } + } if (keyData === "t") { this.#hoveredRow = null; this.#viewMode = this.#viewMode === "roster" ? "tree" : "roster"; @@ -1267,14 +1002,14 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.#hoveredRow = null; if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) { if (this.#rows.length > 0) { - this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); + this.#selectRow(Math.min(this.#selectedRow + 1, this.#rows.length - 1)); } this.#requestRender(); return; } if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) { if (this.#rows.length > 0) { - this.#selectedRow = Math.max(this.#selectedRow - 1, 0); + this.#selectRow(Math.max(this.#selectedRow - 1, 0)); } this.#requestRender(); return; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 5c6aaaeff..b3fd2384b 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -2009,7 +2009,9 @@ export class SelectorController { closed = true; hub.dispose(); overlayHandle?.hide(); - this.focusActiveEditorArea(); + // A gated empty Hub may never have been mounted. Restoring editor + // focus in that case would steal focus from a menu opened meanwhile. + if (overlayHandle) this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts index 3864c60f1..e5d0f03ff 100644 --- a/packages/coding-agent/src/registry/agent-lifecycle.ts +++ b/packages/coding-agent/src/registry/agent-lifecycle.ts @@ -20,18 +20,28 @@ * a superseded revive) can never clobber a newer same-id ref. */ +import * as fs from "node:fs/promises"; import { logger } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; import { type AgentRef, type AgentRefExpectation, AgentRegistry, + getAgentTombstonePath, MAIN_AGENT_ID, type RegistryEvent, } from "./agent-registry"; export type AgentReviver = (expected: AgentRef) => Promise; +async function persistAgentTombstone(sessionFile: string): Promise { + try { + await fs.writeFile(getAgentTombstonePath(sessionFile), "", { encoding: "utf8", flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } +} + /** * 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 @@ -379,13 +389,10 @@ export class AgentLifecycleManager { } if (options?.tombstone) { - // Explicit kill: mark the ref terminal `aborted` and detach the session - // BEFORE disposing. aborted refs must satisfy the AgentRef invariant - // (session === null) so ensureLive / hub focus can never route into a - // disposed session; setting the terminal status first also makes - // createAgentSession's dispose wrapper (unregisterUnlessParked) preserve - // the ref instead of removing it, so a later persisted-subagent rescan - // skips it. The transcript file is left intact (history://). + // Persist the terminal decision before detaching the session. The + // sidecar prevents a later discovery pass from reviving this transcript + // as a fresh parked ref. + if (ref.sessionFile) await persistAgentTombstone(ref.sessionFile); this.#registry.setStatus(id, "aborted", ref); } const live = this.#registry.get(id) === ref ? ref.session : null; diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index 76d269e61..af2574150 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -14,6 +14,13 @@ import { oneLineLabel } from "../task/types"; export const MAIN_AGENT_ID = "Main"; +/** Sidecar marker retained beside a child transcript after an explicit kill. */ +export const AGENT_TOMBSTONE_SUFFIX = ".tombstone"; + +export function getAgentTombstonePath(sessionFile: string): string { + return `${sessionFile}${AGENT_TOMBSTONE_SUFFIX}`; +} + /** * - `running`: a turn is in flight. * - `idle`: live AgentSession in memory, awaiting work. Finished agents are @@ -22,6 +29,8 @@ export const MAIN_AGENT_ID = "Main"; * - `aborted`: hard-killed, terminal. */ export type AgentStatus = "running" | "idle" | "parked" | "aborted"; +/** Provenance of a displayed duration: active runtime, transcript span, or unavailable. */ +export type AgentDurationKind = "active" | "span" | "unknown"; /** * - `main`/`sub`: the user-facing agent tree (driving agent + task subagents). * - `advisor`: a passive review transcript persisted like a subagent for usage @@ -37,6 +46,7 @@ export interface AgentMetricsSummary { tools: number; cost: number; durationMs: number; + durationKind?: AgentDurationKind; contextTokens?: number; contextWindow?: number; } @@ -46,6 +56,8 @@ export interface AgentHistorySummary { agent?: string; modelRole?: string; resolvedModel?: string; + /** Whether the last resolved model was selected by retry fallback routing. */ + resolvedModelIsFallback?: boolean; metrics?: AgentMetricsSummary; readOnly?: boolean; } @@ -153,7 +165,10 @@ export class AgentRegistry { setHistory(id: string, history: AgentHistorySummary, expectedSessionFile?: string): boolean { const ref = this.#refs.get(id); if (!ref || (expectedSessionFile !== undefined && ref.sessionFile !== expectedSessionFile)) return false; - ref.history = { ...ref.history, ...history }; + const definedHistory = Object.fromEntries( + Object.entries(history).filter(([, value]) => value !== undefined), + ) as AgentHistorySummary; + ref.history = { ...ref.history, ...definedHistory }; this.#emit({ type: "metadata_changed", ref }); return true; } diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index 294946b2d..e94446551 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -1,23 +1,21 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { readLines } from "@oh-my-pi/pi-utils"; import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../advisor/transcript-recorder"; import { resolveExplicitModelRole } from "../config/model-resolver"; +import { EPHEMERAL_MODEL_CHANGE_ROLE } from "../session/session-entries"; +import { visitEntriesFromFileStream } from "../session/session-loader"; import { loadBundledAgents } from "../task/agents"; +import { isReadOnlyAgent } from "../task/read-only-policy"; import { persistedVibeChildIds } from "../vibe/runtime"; import { type AgentHistorySummary, type AgentMetricsSummary, type AgentRegistry, + getAgentTombstonePath, MAIN_AGENT_ID, } from "./agent-registry"; -/** - * Child ids owned by the Vibe roster persisted in this session file. Vibe - * workers are revived through the Vibe registry's own journal, so the generic - * persisted-subagent scan must not register them as plain `sub` refs. - */ -const VIBE_LIFECYCLE_MARKER = Buffer.from('"vibe-session-lifecycle"'); +/** Maximum prefix entries inspected for task metadata. */ const MAX_METADATA_LINES = 64; interface PersistedAgentMetadata { @@ -61,15 +59,6 @@ function summarizePersistedTask(task: string): string | undefined { return summary ? summary.slice(0, 1_000) : undefined; } -const READ_ONLY_AGENT_TOOLS: Record = { - read: true, - grep: true, - glob: true, - web_search: true, - ast_grep: true, - yield: true, -}; - function finiteNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } @@ -86,7 +75,7 @@ function inferBundledAgent(systemPrompt: string): { agent?: string; modelRole?: return { agent: agent.name, modelRole: resolveExplicitModelRole(agent.model), - readOnly: !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_AGENT_TOOLS[tool] === true), + readOnly: isReadOnlyAgent(agent), }; } @@ -121,29 +110,32 @@ function assistantMetrics(message: Record): AssistantMetrics { async function readPersistedAgentHistory(transcript: PersistedTranscript): Promise { const parents = new Map(); const assistantById = new Map(); + const modelChangeById = new Map(); let leafId: string | undefined; let leafTimestamp: number | undefined; try { - for await (const bytes of readLines(Bun.file(transcript.sessionFile).stream())) { - const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const prefix = line.subarray(0, Math.min(line.byteLength, 512)).toString("utf8"); - const id = /"id":"([^"]+)"/.exec(prefix)?.[1]; - if (!id) continue; - const parentMatch = /"parentId":(?:"([^"]+)"|null)/.exec(prefix); - parents.set(id, parentMatch?.[1]); + await visitEntriesFromFileStream(transcript.sessionFile, entry => { + const record = recordOf(entry); + if (!record) return; + const id = typeof record.id === "string" ? record.id : undefined; + if (!id) return; + const parentId = typeof record.parentId === "string" ? record.parentId : undefined; + parents.set(id, parentId); leafId = id; - const entryTimestamp = /"timestamp":"([^"]+)"/.exec(prefix)?.[1]; - const parsedTimestamp = timestampOf(entryTimestamp); + const parsedTimestamp = timestampOf(record.timestamp); if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; - if (!prefix.includes('"type":"message"') || !line.includes(Buffer.from('"role":"assistant"'))) continue; - try { - const entry = recordOf(JSON.parse(line.toString("utf8"))); - const message = recordOf(entry?.message); - if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); - } catch { - // One malformed historical entry must not erase valid totals. + if (record.type === "model_change" && typeof record.model === "string") { + modelChangeById.set(id, { + model: record.model, + role: typeof record.role === "string" ? record.role : undefined, + resolvedModelIsFallback: record.resolvedModelIsFallback === true, + }); + return; } - } + if (record.type !== "message") return; + const message = recordOf(record.message); + if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); + }); } catch { return {}; } @@ -158,23 +150,42 @@ async function readPersistedAgentHistory(transcript: PersistedTranscript): Promi (leafTimestamp ?? transcript.lastActivity ?? transcript.createdAt ?? 0) - (transcript.createdAt ?? leafTimestamp ?? 0), ), + durationKind: "span", }; let resolvedModel: string | undefined; + let resolvedModelIsFallback: boolean | undefined; + let modelRole: string | undefined; let contextTokens: number | undefined; + let modelChangeFound = false; const visited = new Set(); for (let id = leafId; id && !visited.has(id); id = parents.get(id)) { visited.add(id); + const modelChange = modelChangeById.get(id); + if (modelChange && !modelChangeFound) { + modelChangeFound = true; + resolvedModel = modelChange.model; + resolvedModelIsFallback = modelChange.resolvedModelIsFallback; + if (modelChange.role && modelChange.role !== EPHEMERAL_MODEL_CHANGE_ROLE) { + modelRole = modelChange.role; + } + } const assistant = assistantById.get(id); if (!assistant) continue; + if (!modelChangeFound && resolvedModel === undefined && assistant.resolvedModel) { + resolvedModel = assistant.resolvedModel; + } metrics.requests++; metrics.tokens += assistant.tokens; metrics.tools += assistant.tools; metrics.cost += assistant.cost; contextTokens ??= assistant.contextTokens; - resolvedModel ??= assistant.resolvedModel; } - metrics.contextTokens = contextTokens; - return { metrics: metrics.requests > 0 ? metrics : undefined, resolvedModel }; + if (contextTokens !== undefined) metrics.contextTokens = contextTokens; + return { + ...(metrics.requests > 0 ? { metrics } : {}), + ...(resolvedModel ? { resolvedModel, resolvedModelIsFallback } : {}), + ...(modelRole ? { modelRole } : {}), + }; } /** @@ -188,43 +199,42 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise= MAX_METADATA_LINES) break; - const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); - let entry: Record | undefined; - try { - entry = recordOf(JSON.parse(line.toString("utf8"))); - } catch { - continue; - } - if (entry?.type === "session") { - createdAt ??= timestampOf(entry.timestamp); - continue; - } - if (entry?.type === "model_change") { - if (typeof entry.model === "string") history.resolvedModel = entry.model; - if (typeof entry.role === "string") history.modelRole = entry.role; - continue; - } - if (entry?.type !== "session_init") continue; - createdAt ??= timestampOf(entry.timestamp); - if (typeof entry.task === "string") activity = summarizePersistedTask(entry.task); - const inferred = - typeof entry.systemPrompt === "string" - ? inferBundledAgent(entry.systemPrompt) - : ({} satisfies AgentHistorySummary); - history = { - ...history, - ...inferred, - agent: typeof entry.agent === "string" ? entry.agent : inferred.agent, - modelRole: - typeof entry.modelRole === "string" ? entry.modelRole : (history.modelRole ?? inferred.modelRole), - resolvedModel: typeof entry.resolvedModel === "string" ? entry.resolvedModel : history.resolvedModel, - readOnly: typeof entry.readOnly === "boolean" ? entry.readOnly : inferred.readOnly, - }; - break; - } + await visitEntriesFromFileStream( + sessionFile, + entry => { + const record = recordOf(entry); + if (!record) return; + if (record.type === "session") { + createdAt ??= timestampOf(record.timestamp); + return; + } + if (record.type === "model_change") { + if (typeof record.model === "string") history.resolvedModel = record.model; + if (typeof record.role === "string" && record.role !== EPHEMERAL_MODEL_CHANGE_ROLE) { + history.modelRole = record.role; + } + if (typeof record.resolvedModelIsFallback === "boolean") { + history.resolvedModelIsFallback = record.resolvedModelIsFallback; + } + return; + } + if (record.type !== "session_init") return; + createdAt ??= timestampOf(record.timestamp); + if (typeof record.task === "string") activity = summarizePersistedTask(record.task); + const inferred = typeof record.systemPrompt === "string" ? inferBundledAgent(record.systemPrompt) : {}; + history = { + ...history, + ...inferred, + agent: typeof record.agent === "string" ? record.agent : inferred.agent, + modelRole: + typeof record.modelRole === "string" ? record.modelRole : (history.modelRole ?? inferred.modelRole), + resolvedModel: typeof record.resolvedModel === "string" ? record.resolvedModel : history.resolvedModel, + readOnly: typeof record.readOnly === "boolean" ? record.readOnly : inferred.readOnly, + }; + return false; + }, + { maxRecords: MAX_METADATA_LINES }, + ); } catch { // A readable transcript is still useful even when its optional metadata // prefix is malformed. @@ -241,17 +251,9 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise> { const ids = new Set(); try { - for await (const bytes of readLines(Bun.file(sessionFile).stream())) { - const line = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (line.indexOf(VIBE_LIFECYCLE_MARKER) === -1) continue; - try { - const entry: unknown = JSON.parse(line.toString("utf8")); - for (const id of persistedVibeChildIds([entry])) ids.add(id); - } catch { - // Match lenient session loading: one malformed line must not hide - // valid lifecycle entries later in the transcript. - } - } + await visitEntriesFromFileStream(sessionFile, entry => { + for (const id of persistedVibeChildIds([entry])) ids.add(id); + }); return ids; } catch { return new Set(); @@ -294,7 +296,12 @@ async function registerPersistedSubagentsFromDir( } catch { return; } + let entriesSinceYield = 0; for (const entry of entries) { + if (++entriesSinceYield >= 16) { + entriesSinceYield = 0; + await Bun.sleep(0); + } if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; const sessionFile = path.join(dir, entry.name); // The advisor transcript is observability-only: register it as a non-peer @@ -340,6 +347,13 @@ async function registerPersistedSubagentsFromDir( } const id = entry.name.slice(0, -6); if (vibeOwnedIds.has(id) && registry.get(id)?.sessionFile !== sessionFile) continue; + let tombstoned = false; + try { + await fs.promises.access(getAgentTombstonePath(sessionFile)); + tombstoned = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue; + } if (!registry.get(id)) { const metadata = await readPersistedAgentMetadata(sessionFile); registry.register({ @@ -353,7 +367,7 @@ async function registerPersistedSubagentsFromDir( createdAt: metadata.createdAt, lastActivity: metadata.lastActivity, history: metadata.history, - status: "parked", + status: tombstoned ? "aborted" : "parked", }); const ref = registry.get(id); transcripts.push({ diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index d0114559b..36c627759 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -84,6 +84,8 @@ export interface ModelChangeEntry extends SessionEntryBase { model: string; /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */ role?: string; + /** True when this transition selected a retry-fallback model rather than the configured model. */ + resolvedModelIsFallback?: boolean; } export interface ServiceTierChangeEntry extends SessionEntryBase { diff --git a/packages/coding-agent/src/session/session-loader.ts b/packages/coding-agent/src/session/session-loader.ts index f493e974e..2c2f0fab2 100644 --- a/packages/coding-agent/src/session/session-loader.ts +++ b/packages/coding-agent/src/session/session-loader.ts @@ -21,6 +21,19 @@ import { } from "./session-title-slot"; const STREAM_LOAD_THRESHOLD_BYTES = 8 * 1024 * 1024; +const STREAM_YIELD_BYTES = 1 * 1024 * 1024; +const STREAM_YIELD_ENTRIES = 8_192; + +export interface VisitEntriesFromFileStreamOptions { + /** Stop after the visitor returns `false`. */ + shouldContinue?: () => boolean; + /** Stop after this many valid or malformed JSONL records have been consumed. */ + maxRecords?: number; + /** Yield to the macrotask queue after this many bytes have been consumed. */ + yieldEveryBytes?: number; + /** Yield to the macrotask queue after this many entries have been visited. */ + yieldEveryEntries?: number; +} function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } { const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content)); @@ -59,11 +72,19 @@ export function parseSessionContent(content: string): { /** Parse session JSONL and visit each entry without retaining prior entries. */ export async function visitEntriesFromFileStream( filePath: string, - visit: (entry: FileEntry) => void, + visit: (entry: FileEntry) => void | boolean, + options: VisitEntriesFromFileStreamOptions = {}, ): Promise { let titleSlot: SessionTitleUpdate | undefined; let sawFirstLine = false; + let bytesSinceYield = 0; + let entriesSinceYield = 0; + let recordsSeen = 0; + const maxRecords = Math.max(0, options.maxRecords ?? Number.POSITIVE_INFINITY); + let stopped = false; let visitorThrew = false; + const yieldEveryBytes = Math.max(0, options.yieldEveryBytes ?? STREAM_YIELD_BYTES); + const yieldEveryEntries = Math.max(0, options.yieldEveryEntries ?? STREAM_YIELD_ENTRIES); // Byte buffer (NOT a decoded string): multibyte UTF-8 sequences that straddle // a stream-chunk boundary stay intact, and Bun.JSONL.parseChunk accepts typed // arrays directly. Only the unconsumed remainder is held (≤ one record + a @@ -72,22 +93,62 @@ export async function visitEntriesFromFileStream( let buffer: Uint8Array = new Uint8Array(); const decoder = new TextDecoder(); - const drain = () => { - while (buffer.length > 0) { + const yieldToMacrotask = async (): Promise => { + if (yieldEveryBytes === 0 && yieldEveryEntries === 0) return; + const bytesReady = yieldEveryBytes === 0 || bytesSinceYield < yieldEveryBytes; + const entriesReady = yieldEveryEntries === 0 || entriesSinceYield < yieldEveryEntries; + if (bytesReady && entriesReady) { + return; + } + bytesSinceYield = 0; + entriesSinceYield = 0; + await Bun.sleep(0); + }; + + const drain = async (): Promise => { + while (buffer.length > 0 && !stopped) { + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } const { values, error, read, done } = Bun.JSONL.parseChunk(buffer); for (const value of values) { + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } + if (options.shouldContinue && !options.shouldContinue()) { + stopped = true; + break; + } try { - visit(value as FileEntry); + if (visit(value as FileEntry) === false) { + stopped = true; + break; + } + recordsSeen++; + entriesSinceYield++; + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } } catch (err) { visitorThrew = true; throw err; } + await yieldToMacrotask(); } + if (stopped) break; if (error) { // Malformed record: skip past the next newline and continue. const nextNewline = buffer.indexOf(0x0a, read); if (nextNewline === -1) break; // rest of the bad line not yet received + recordsSeen++; buffer = buffer.subarray(nextNewline + 1); + if (recordsSeen >= maxRecords) { + stopped = true; + break; + } continue; } if (read === 0) break; // incomplete record awaiting more data @@ -101,6 +162,8 @@ export async function visitEntriesFromFileStream( try { for await (const chunk of Bun.file(filePath).stream()) { + if (stopped) break; + bytesSinceYield += chunk.byteLength; buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]); // The optional fixed-width title slot is a physical first line that is // NOT JSON; peel it before the parser would (correctly) reject it. The @@ -121,14 +184,15 @@ export async function visitEntriesFromFileStream( } } } - drain(); + await drain(); + await yieldToMacrotask(); } // A trailing record without a final newline: terminate it so the parser // can complete it (readline yielded it; parseChunk needs the delimiter). - if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) { + if (!stopped && buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) { buffer = Buffer.concat([buffer, new Uint8Array([0x0a])]); + await drain(); } - drain(); } catch (err) { if (visitorThrew) throw err; if (isEnoent(err)) return undefined; @@ -144,7 +208,9 @@ export async function loadEntriesFromFileStream(filePath: string): Promise<{ titleSlot: SessionTitleUpdate | undefined; }> { const entries: FileEntry[] = []; - const titleSlot = await visitEntriesFromFileStream(filePath, entry => entries.push(entry)); + const titleSlot = await visitEntriesFromFileStream(filePath, entry => { + entries.push(entry); + }); return { entries: foldTitleSlot(entries, titleSlot), titleSlot }; } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 7d0152ba5..5a311c508 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -2047,9 +2047,16 @@ export class SessionManager { * Append a model change as a child of the current leaf, then advance the leaf. * @param model Model in "provider/modelId" format * @param role Optional role (default: "default") + * @param resolvedModelIsFallback Whether this transition selected a retry-fallback model */ - appendModelChange(model: string, role?: string): string { - const entry: ModelChangeEntry = { type: "model_change", ...this.#freshEntryFields(), model, role }; + appendModelChange(model: string, role?: string, resolvedModelIsFallback = false): string { + const entry: ModelChangeEntry = { + type: "model_change", + ...this.#freshEntryFields(), + model, + role, + resolvedModelIsFallback, + }; this.#recordEntry(entry); return entry.id; } @@ -2551,6 +2558,9 @@ export class SessionManager { systemPrompt: string; task: string; tools: string[]; + agent?: string; + modelRole?: string; + resolvedModel?: string; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; restrictToolNames?: boolean; @@ -2571,6 +2581,9 @@ export class SessionManager { systemPrompt: string; task: string; tools: string[]; + agent?: string; + modelRole?: string; + resolvedModel?: string; outputSchema?: unknown; outputSchemaMode?: StructuredSubagentSchemaMode; restrictToolNames?: boolean; @@ -2584,6 +2597,9 @@ export class SessionManager { systemPrompt: entry.systemPrompt, task: entry.task, tools: entry.tools, + agent: entry.agent, + modelRole: entry.modelRole, + resolvedModel: entry.resolvedModel, outputSchema: entry.outputSchema, outputSchemaMode: entry.outputSchemaMode, restrictToolNames: entry.restrictToolNames, diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 629138402..6f5d15156 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -1102,7 +1102,7 @@ export class TurnRecovery { : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling()); const candidateSelector = formatModelStringWithRouting(candidate); await this.#host.setModelWithProviderSessionReset(candidate); - this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE); + this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true); this.#host.settings.getStorage()?.recordModelUsage(candidateSelector); this.#host.setThinkingLevel(nextThinkingLevel); if (!this.#activeRetryFallback) { @@ -1220,7 +1220,7 @@ export class TurnRecovery { if (!apiKey) return false; const baseSelector = formatModelStringWithRouting(baseModel); await this.#host.setModelWithProviderSessionReset(baseModel); - this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE); + this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true); this.#host.settings.getStorage()?.recordModelUsage(baseSelector); await this.#host.emitSessionEvent({ type: "retry_fallback_applied", diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 9c5c058af..94b34854a 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -59,6 +59,7 @@ import { buildNamedToolChoice } from "../utils/tool-choice"; import type { WorkspaceTree } from "../workspace-tree"; import { generateTaskLabel } from "./label"; import { resolveAgentPrewalkDefault } from "./prewalk"; +import { isReadOnlyAgent } from "./read-only-policy"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; import { type AgentDefinition, @@ -2378,14 +2379,20 @@ export async function finalizeSubagentLifecycle(args: { args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null; if (args.aborted && !resumableAbort) { if (ref && ownsRef) { - // Terminal hard kill: mark `aborted` and detach the session before - // disposing so the ref satisfies the AgentRef invariant (session null - // when aborted) — ensureLive/hub focus must treat it as terminal, never - // route into the disposed session. - registry.setStatus(args.id, "aborted", ref); - registry.detachSession(args.id, ref); + // Route hard kills through the lifecycle owner so the terminal + // decision is durable and a restart cannot rediscover the transcript + // as a revivable parked agent. + try { + await AgentLifecycleManager.global().release(args.id, ref, { tombstone: true }); + } catch (error) { + logger.warn("runSubagent: failed to persist kill tombstone", { id: args.id, error: String(error) }); + registry.setStatus(args.id, "aborted", ref); + registry.detachSession(args.id, ref); + await disposeSession(); + } + } else { + await disposeSession(); } - await disposeSession(); return; } @@ -3094,8 +3101,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise = new Set([ - "read", - "grep", - "glob", - "web_search", - "ast_grep", - "yield", - "hub", - "ask", - "todo", - "recall", - "reflect", - "retain", - "memory_edit", - "inspect_image", - "checkpoint", - "rewind", -]); - -export function isReadOnlyAgent(agent: AgentDefinition): boolean { - return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool)); -} - /** * Preview text for a child result. Falls back to "(no output)" — annotated * with the request count when the child actually did work, so the parent can @@ -1146,6 +1122,8 @@ export class TaskTool implements AgentTool { // Re-open fresh on every revive: park closes the writer, so this takes // the single-writer lock cleanly and restores the full message history. @@ -96,10 +104,9 @@ export function createPersistedSubagentReviverFactory( cwd: ctx.session.sessionManager.getCwd(), authStorage: ctx.authStorage, modelRegistry: ctx.modelRegistry, - settings: createSubagentSettings( - ctx.settings, - init.readSummarize === false ? { "read.summarize.enabled": false } : undefined, - ), + ...(persistedModelPattern ? { modelPattern: persistedModelPattern } : {}), + modelPatternAuthFallback: init.resolvedModel, + settings: subagentSettings, sessionManager: reopened, agentId: ref.id, agentDisplayName: ref.displayName, diff --git a/packages/coding-agent/src/task/read-only-policy.ts b/packages/coding-agent/src/task/read-only-policy.ts new file mode 100644 index 000000000..2e5a3a018 --- /dev/null +++ b/packages/coding-agent/src/task/read-only-policy.ts @@ -0,0 +1,27 @@ +import type { AgentDefinition } from "./types"; + +// Built-in tools whose approval tier is "read" (see tool classes' `approval`). +// An agent is read-only iff its declared tools are a non-empty subset of this set. +// Fail-safe: any unknown tool makes the agent not read-only. +export const READ_ONLY_TOOL_NAMES: ReadonlySet = new Set([ + "read", + "grep", + "glob", + "web_search", + "ast_grep", + "yield", + "hub", + "ask", + "todo", + "recall", + "reflect", + "retain", + "memory_edit", + "inspect_image", + "checkpoint", + "rewind", +]); + +export function isReadOnlyAgent(agent: AgentDefinition): boolean { + return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool)); +} diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 7dc63cce4..a98bc03eb 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -15,6 +15,7 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; 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 { visitEntriesFromFileStream } from "@oh-my-pi/pi-coding-agent/session/session-loader"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getBundledAgent } from "@oh-my-pi/pi-coding-agent/task/agents"; import { TempDir } from "@oh-my-pi/pi-utils"; @@ -296,62 +297,43 @@ describe("Agent hub Enter activation", () => { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("Read-only · 0 LoC"); hub.dispose(); }); - it("avoids multi-second event-loop stalls while discovering agents from a large session", async () => { + it("yields to a macrotask while streaming a large session", async () => { + vi.useFakeTimers(); using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); - const sessionFile = path.join(tempDir.path(), "main.jsonl"); - const header = JSON.stringify({ - type: "session", - version: 3, - id: "responsive-test", - timestamp: new Date().toISOString(), - cwd: tempDir.path(), - }); - const payload = "x".repeat(250_000); - const message = JSON.stringify({ + const sessionFile = path.join(tempDir.path(), "session.jsonl"); + const entry = JSON.stringify({ type: "message", - id: "large-message", + id: "entry", parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [{ type: "text", text: payload }], - timestamp: Date.now(), - }, + timestamp: "2026-07-30T01:13:30.000Z", + message: { role: "user", content: [{ type: "text", text: "small" }] }, }); - await Bun.write(sessionFile, `${header}\n${Array.from({ length: 600 }, () => message).join("\n")}\n`); - Bun.gc(true); - - const agents = new AgentRegistry(); - const hub = new AgentHubOverlayComponent({ - settings: Settings.isolated(), - observers: new SessionObserverRegistry(), - hubKeys: [], - onDone: () => {}, - requestRender: () => {}, - registry: agents, - irc: new IrcBus(agents), - focusAgent: async () => {}, + await Bun.write(sessionFile, `${entry}\n`.repeat(8_193)); + let complete = false; + let yieldedBeforeComplete = false; + let visited = 0; + const visit = visitEntriesFromFileStream( sessionFile, + () => { + visited++; + if (visited !== 8_192) return; + setTimeout(() => { + if (!complete) yieldedBeforeComplete = true; + }, 0); + }, + { yieldEveryBytes: 0, yieldEveryEntries: 8_192 }, + ).finally(() => { + complete = true; }); - - let maxLagMs = 0; - let previous = performance.now(); - const timer = setInterval(() => { - const now = performance.now(); - maxLagMs = Math.max(maxLagMs, now - previous - 2); - previous = now; - }, 2); try { - await hub.persistedSubagentsReady; - await Bun.sleep(10); + for (let i = 0; i < 20_000 && visited < 8_192 && !complete; i++) await Promise.resolve(); + expect(visited).toBeGreaterThanOrEqual(8_192); + vi.runOnlyPendingTimers(); + await visit; + expect(yieldedBeforeComplete).toBe(true); } finally { - clearInterval(timer); - hub.dispose(); + vi.useRealTimers(); } - - // Guard the original seconds-long freeze without treating shared-runner - // scheduling jitter as an Agent Hub regression. - expect(maxLagMs).toBeLessThan(500); }); it("does not generically revive active or tombstoned Vibe children copied by a post-exit fork", async () => { @@ -767,7 +749,8 @@ describe("Agent hub data refresh coalescing", () => { const refreshed = Bun.stripANSI(hub.render(120).join("\n")); expect(refreshed).toContain("450 tok"); expect(refreshed).toContain("2 req"); - expect(refreshed).toContain("1/1 measured"); + expect(refreshed).toContain("1/1"); + expect(refreshed).toContain("measured"); hub.render(120); expect(getSessionStats).toHaveBeenCalledTimes(2); } finally { @@ -775,4 +758,44 @@ describe("Agent hub data refresh coalescing", () => { vi.useRealTimers(); } }); + + it("counts shared fallback session usage once across parent and descendant rows", () => { + const agents = new AgentRegistry(); + const getSessionStats = vi.fn(() => ({ + tokens: { input: 100, output: 40, cacheRead: 10, cacheWrite: 10, total: 160 }, + assistantMessages: 1, + toolCalls: 2, + cost: 0.1, + contextUsage: undefined, + })); + const session = { getSessionStats } as unknown as AgentSession; + agents.register({ id: "Parent", displayName: "Parent", kind: "sub", session, status: "idle" }); + agents.register({ + id: "Child", + displayName: "Child", + kind: "sub", + parentId: "Parent", + session, + status: "idle", + }); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + }); + try { + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("150 tok"); + expect(rendered).toContain("1/2"); + expect(rendered).toContain("measured"); + expect(getSessionStats).toHaveBeenCalledTimes(1); + } finally { + hub.dispose(); + } + }); }); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 1e358e81c..de4ab9d95 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -176,17 +176,18 @@ describe("Agent hub row ordering", () => { hub = makeHub(agents); expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]); - - // Bump A's lastActivity far ahead of the others. The hub is already open, - // so the captured order must not change. + // Bump A's lastActivity far ahead of the others; captured order wins. setSystemTime(4000); agents.setActivity("A", "still running"); - // Registering a new agent schedules a coalesced row refresh; the - // existing rows must stay put once the scheduled refresh runs. + // Status changes must not reorder the captured roster either. + agents.setStatus("B", "idle"); + + // Registering a new agent schedules a coalesced row refresh; even a + // different status is appended after all rows captured on open. setSystemTime(5000); const sessionD = {} as AgentSession; - agents.register({ id: "D", displayName: "Delta", kind: "sub", session: sessionD }); + agents.register({ id: "D", displayName: "Delta", kind: "sub", session: sessionD, status: "parked" }); expect(renderedAgentIds(hub)).toEqual(["C", "B", "A"]); vi.advanceTimersByTime(100); @@ -204,7 +205,7 @@ describe("Agent hub row ordering", () => { const sessionA = {} as AgentSession; agents.register({ id: "RevAgentStream", - displayName: "Agent runtime + compaction reviewer", + displayName: "Agent runtime + compaction reviewer\u0007", kind: "sub", session: sessionA, }); @@ -218,12 +219,17 @@ describe("Agent hub row ordering", () => { status: "active", description: "Complete the assignment below, thoroughly:\n- check performance\n- check leaks", lastUpdate: Date.now(), + progress: { + currentTool: "bash", + currentToolArgs: "\x1b[2Jdangerous args", + } as never, }, ]); const hub = makeHub(agents, { observers }); const lines = hub.render(80); + expect(lines.join("\n")).not.toContain("\u0007"); for (const line of lines) { const cleanLine = Bun.stripANSI(line); expect(cleanLine.includes("\n")).toBe(false); @@ -231,6 +237,10 @@ describe("Agent hub row ordering", () => { const width = visibleWidth(line); expect(width).toBeLessThanOrEqual(80); } + hub.handleInput("\t"); + const details = hub.render(80).join("\n"); + expect(details).not.toContain("\x1b[2J"); + expect(details).toContain("dangerous args"); hub.dispose(); }); @@ -287,14 +297,12 @@ describe("Agent hub row ordering", () => { const frame = hub.render(120); const alphaRow = frame.findIndex(line => /^│ {3}\S+ Alpha/u.test(Bun.stripANSI(line))); expect(alphaRow).toBeGreaterThanOrEqual(0); - hub.handleInput(leftClick(alphaRow + 1)); - expect(selectedAgentId(hub)).toBe("Alpha"); + hub.handleInput(`\x1b[<0;110;${alphaRow + 1}M`); + expect(selectedAgentId(hub)).toBe("Beta"); expect(focused).toEqual([]); - - const selectedFrame = hub.render(120); - const selectedAlphaRow = selectedFrame.findIndex(line => /^│ ❯ \S+ Alpha/u.test(Bun.stripANSI(line))); - hub.handleInput(leftClick(selectedAlphaRow + 1)); + hub.handleInput(leftClick(alphaRow + 1)); await Promise.resolve(); + expect(selectedAgentId(hub)).toBe("Alpha"); expect(focused).toEqual(["Alpha"]); expect(done).toHaveBeenCalledTimes(1); } finally { @@ -468,7 +476,7 @@ describe("Agent hub row ordering", () => { expect(rendered).toContain("1 running"); expect(rendered).toContain("Flat"); expect(rendered).toContain("By parent"); - expect(rendered).toContain("$0.213 · 2m14s · 12 req · 27 tools · 18K tok"); + expect(rendered).toContain("$0.213 · 2m14s active · 12 req · 27 tools · 18K tok"); expect(rendered).toContain("Security Reviewer"); expect(rendered).toContain("read · src/session/agent-session.ts"); expect(rendered).toContain("31K/128K 24%"); @@ -548,7 +556,7 @@ describe("Agent hub row ordering", () => { expect(rendered).toContain("3.7K tok"); expect(rendered).toContain("8 req"); expect(rendered).toContain("12 tools"); - expect(rendered).toContain("2m11s agent time"); + expect(rendered).toContain("2m11s active agent time"); const running = renderedRosterEntry(hub, "Running", 160); expect(running).toContain("$0.123"); @@ -774,9 +782,25 @@ describe("Agent hub row ordering", () => { } }); + it("keeps cyclic parent links renderable in tree mode", () => { + const agents = new AgentRegistry(); + agents.register({ id: "CycleA", displayName: "Cycle A", kind: "sub", parentId: "CycleB", session: null }); + agents.register({ id: "CycleB", displayName: "Cycle B", kind: "sub", parentId: "CycleA", session: null }); + const hub = makeHub(agents); + + try { + hub.handleInput("t"); + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("CycleA"); + expect(rendered).toContain("CycleB"); + } finally { + hub.dispose(); + } + }); + it("opens the selected-agent inspector as a narrow-terminal fallback", () => { geometry = stubStdoutGeometry(80); - geometry.setRows(28); + geometry.setRows(12); const agents = new AgentRegistry(); agents.register({ id: "NarrowAgent", displayName: "Narrow Agent", kind: "sub", session: null }); const observers = new SessionObserverRegistry(); @@ -812,8 +836,10 @@ describe("Agent hub row ordering", () => { const details = Bun.stripANSI(hub.render(80).join("\n")); expect(details).toContain("Agent Hub · NarrowAgent"); expect(details).toContain("Usage"); - expect(details).toContain("$0.0000 · 2.0s · 2 req · 3 tools · 900 tok"); + expect(details).toContain("$0.0000 · 2.0s active · 2 req · 3 tools · 900 tok"); expect(details).toContain("Tab:roster"); + hub.handleInput("\x1b[6~"); + expect(Bun.stripANSI(hub.render(80).join("\n"))).toContain("Changes"); for (const line of hub.render(80)) expect(visibleWidth(line)).toBeLessThanOrEqual(80); hub.handleInput("\x1b"); diff --git a/packages/coding-agent/test/registry/agent-lifecycle.test.ts b/packages/coding-agent/test/registry/agent-lifecycle.test.ts index 6c3356f66..36e70837d 100644 --- a/packages/coding-agent/test/registry/agent-lifecycle.test.ts +++ b/packages/coding-agent/test/registry/agent-lifecycle.test.ts @@ -597,10 +597,11 @@ describe("AgentLifecycleManager", () => { // session (the ref carries session === null), it treats it as unrevivable. await expect(lifecycle.ensureLive(workerId)).rejects.toThrow(/aborted/); - // Reopening the Agent Hub rescans on-disk transcripts. The surviving - // `.jsonl` must not be re-adopted as a fresh `parked` row, because the - // id is still present in the registry. - await registerPersistedSubagents(registry, rootSessionFile); - expect(registry.get(workerId)?.status).toBe("aborted"); + // Reopening after the original registry is gone must preserve the terminal + // decision from the sidecar, not infer a fresh parked agent from the JSONL. + expect(await Bun.file(`${workerSessionFile}.tombstone`).exists()).toBe(true); + const restoredRegistry = new AgentRegistry(); + await registerPersistedSubagents(restoredRegistry, rootSessionFile); + expect(restoredRegistry.get(workerId)?.status).toBe("aborted"); }); }); diff --git a/packages/coding-agent/test/session-loader-stream.test.ts b/packages/coding-agent/test/session-loader-stream.test.ts index 61484c5d8..a0900f904 100644 --- a/packages/coding-agent/test/session-loader-stream.test.ts +++ b/packages/coding-agent/test/session-loader-stream.test.ts @@ -86,7 +86,9 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { ].join("\n"); const file = await writeTemp(content); const visited: FileEntry[] = []; - const titleSlot = await sessionLoader.visitEntriesFromFileStream(file, entry => visited.push(entry)); + const titleSlot = await sessionLoader.visitEntriesFromFileStream(file, entry => { + visited.push(entry); + }); expect(titleSlot?.title).toBe("Visitor"); expect(entryIds(visited)).toEqual(["s1", "m1", "m2"]); @@ -101,11 +103,35 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => { const file = await writeTemp(content); const visited: FileEntry[] = []; - await sessionLoader.visitEntriesFromFileStream(file, entry => visited.push(entry)); + await sessionLoader.visitEntriesFromFileStream(file, entry => { + visited.push(entry); + }); expect(entryIds(visited)).toEqual(["s1", "m1", "m2"]); }); + it("bounds visitor scans by physical records, including malformed lines", async () => { + const content = [ + JSON.stringify(HEADER), + "{ malformed one", + "{ malformed two", + "{ malformed three", + JSON.stringify(msg("after-bad", "s1", "must not be visited")), + ].join("\n"); + const file = await writeTemp(content); + const visited: FileEntry[] = []; + + await sessionLoader.visitEntriesFromFileStream( + file, + entry => { + visited.push(entry); + }, + { maxRecords: 2 }, + ); + + expect(entryIds(visited)).toEqual(["s1"]); + }); + it("propagates ENOENT errors thrown by the visitor", async () => { const file = await writeTemp(`${JSON.stringify(HEADER)}\n`); const failure = Object.assign(new Error("visitor failed"), { code: "ENOENT" }); diff --git a/packages/coding-agent/test/task/persisted-revive.test.ts b/packages/coding-agent/test/task/persisted-revive.test.ts index eede151d1..cac45363d 100644 --- a/packages/coding-agent/test/task/persisted-revive.test.ts +++ b/packages/coding-agent/test/task/persisted-revive.test.ts @@ -62,7 +62,7 @@ function createRevivedSession(activeToolNames: string[][]): RevivedSessionHandle return { session, observer: () => observer }; } -async function createPersistedSession(cwd: string, restrictToolNames?: boolean): Promise { +async function createPersistedSession(cwd: string, restrictToolNames?: boolean, modelRole?: string): Promise { const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); const sessionFile = manager.getSessionFile(); if (!sessionFile) throw new Error("Expected a persisted session file"); @@ -71,6 +71,8 @@ async function createPersistedSession(cwd: string, restrictToolNames?: boolean): task: "persisted task", tools: ["read", "yield"], restrictToolNames, + modelRole, + resolvedModel: modelRole ? "anthropic/claude-sonnet-4-5" : undefined, }); manager.appendMessage({ role: "assistant", @@ -179,6 +181,42 @@ describe("persisted subagent revival", () => { expect(capturedOptions?.customTools?.map(tool => tool.name)).toEqual(["mcp__server_read"]); }); + it("restores the persisted custom model role before reopening the session", async () => { + const cwd = makeTempDir("@pi-custom-role-revive-"); + const sessionFile = await createPersistedSession(cwd, false, "review-fast"); + let capturedOptions: CreateAgentSessionOptions | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + capturedOptions = options; + return { session: createRevivedSession([]).session } as CreateAgentSessionResult; + }); + + const ref = createRef(sessionFile); + const reviver = await createFactory(cwd)(ref); + if (!reviver) throw new Error("Expected a persisted reviver"); + await reviver(ref); + + expect(capturedOptions?.modelPattern).toEqual(["@review-fast", "anthropic/claude-sonnet-4-5"]); + expect(capturedOptions?.modelPatternAuthFallback).toBe("anthropic/claude-sonnet-4-5"); + }); + + it("pins the persisted concrete model when the default role is revived", async () => { + const cwd = makeTempDir("@pi-default-role-revive-"); + const sessionFile = await createPersistedSession(cwd, false, "default"); + let capturedOptions: CreateAgentSessionOptions | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + capturedOptions = options; + return { session: createRevivedSession([]).session } as CreateAgentSessionResult; + }); + + const ref = createRef(sessionFile); + const reviver = await createFactory(cwd)(ref); + if (!reviver) throw new Error("Expected a persisted reviver"); + await reviver(ref); + + expect(capturedOptions?.modelPattern).toBe("anthropic/claude-sonnet-4-5"); + expect(capturedOptions?.modelPatternAuthFallback).toBe("anthropic/claude-sonnet-4-5"); + }); + it("installs an IRC wake monitor that emits cold-revive lifecycle frames on the shared bus", async () => { AgentRegistry.resetGlobalForTests(); AgentLifecycleManager.resetGlobalForTests(); diff --git a/packages/coding-agent/test/vibe/vibe-runtime.test.ts b/packages/coding-agent/test/vibe/vibe-runtime.test.ts index 288d5595a..8e442a73f 100644 --- a/packages/coding-agent/test/vibe/vibe-runtime.test.ts +++ b/packages/coding-agent/test/vibe/vibe-runtime.test.ts @@ -1952,6 +1952,41 @@ describe("vibe session registry", () => { } }); + it("finalizer persists a hard-abort tombstone before disposing the live ref", async () => { + const parentManager = await createPersistedParent(); + await parentManager.ensureOnDisk(); + const parentSessionFile = parentManager.getSessionFile(); + if (!parentSessionFile) throw new Error("Expected a persisted parent session file"); + const workerId = "finalizer-hard-abort"; + const workerSessionFile = path.join(parentSessionFile.slice(0, -6), `${workerId}.jsonl`); + await Bun.write(workerSessionFile, ""); + const worker = createFakeWorkerSession(); + const ref = AgentRegistry.global().register({ + id: workerId, + displayName: workerId, + kind: "sub", + parentId: "Main", + session: worker.session, + sessionFile: workerSessionFile, + status: "running", + }); + + await executorModule.finalizeSubagentLifecycle({ + id: workerId, + session: worker.session, + aborted: true, + keepAlive: true, + isolated: false, + agentIdleTtlMs: 0, + reviveSession: null, + }); + + expect(worker.isDisposed()).toBe(true); + expect(AgentRegistry.global().get(workerId)).toMatchObject({ status: "aborted", session: null }); + expect(AgentRegistry.global().get(workerId)).toBe(ref); + expect(await fileExists(`${workerSessionFile}.tombstone`)).toBe(true); + }); + 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 => { From 8b120cecc567ae27adce464a694f3fd56cfc4604 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 23:51:32 +0000 Subject: [PATCH 092/126] 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 3180fd3d7fc272c8eeaedb96d96f92f34713d77d Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Tue, 4 Aug 2026 17:20:00 -0700 Subject: [PATCH 093/126] fix(coding-agent): complete Agent Hub inspector metadata --- packages/coding-agent/CHANGELOG.md | 5 +- .../src/modes/components/agent-hub.ts | 10 +- .../src/registry/agent-registry.ts | 6 + .../src/registry/persisted-agents.ts | 109 ++++++++++++------ packages/coding-agent/src/task/executor.ts | 4 +- .../coding-agent/src/task/isolation-runner.ts | 35 ++++-- .../test/agent-hub-activate.test.ts | 22 ++++ .../test/agent-hub-ordering.test.ts | 9 ++ .../test/task/isolation-runner.test.ts | 10 ++ 9 files changed, 161 insertions(+), 49 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 47d583e0f..da5b2de80 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,16 @@ ## [Unreleased] +### Changed + +- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. + ## [17.2.7] - 2026-08-03 ### Changed - Replaced arktype with @oh-my-pi/omptype for tool parameter and config schemas, significantly improving startup performance with ~100x faster schema construction. Config schema errors are now reported via OmpErrors using the same path/problem structure. - Replaced arktype with `@oh-my-pi/omptype` across all tool parameter and config schemas: ~100x faster schema construction removes the arktype startup tax (the `scope({}, { jitless: true })` workarounds are gone). Config schema errors now report via `OmpErrors` entries with the same `path`/`problem` shape. -- Reworked the Ctrl+S Agent Hub into a responsive fullscreen roster and selected-agent inspector with aggregate status/usage, per-agent task/model/activity/usage/lineage details, roster and spawn-tree views, stable ordering, bounded large-roster rendering, asynchronous persisted-session discovery, restored task/timestamp metadata for historical agents, and consistent keyboard and mouse navigation. ### Fixed diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index ae9c31440..06dbdfd76 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -35,7 +35,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import { registerPersistedSubagents } from "../../registry/persisted-agents"; import { USER_INTERRUPT_LABEL } from "../../session/messages"; -import { truncateToWidth } from "../../tools/render-utils"; +import { shortenPath, truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; import { theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; @@ -257,7 +257,9 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou this.persistedSubagentsReady = this.#remote ? Promise.resolve() - : registerPersistedSubagents(this.#registry, deps.sessionFile) + : registerPersistedSubagents(this.#registry, deps.sessionFile, { + shouldContinue: () => !this.#disposed, + }) .then(() => { if (!this.#disposed) this.#refreshRows(); }) @@ -829,6 +831,10 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou : "Shared workspace · per-agent LoC not attributable", ), ); + const artifacts = ref.history; + if (artifacts?.outputPath) addWrapped(`Output ${shortenPath(artifacts.outputPath)}`); + if (artifacts?.patchPath) addWrapped(`Patch ${shortenPath(artifacts.patchPath)}`); + if (artifacts?.branchName) addWrapped(`Worktree branch ${artifacts.branchName}`); const maxScroll = Math.max(0, lines.length - rows); this.#detailScrollOffset = Math.min(this.#detailScrollOffset, maxScroll); diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index af2574150..2b5929184 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -60,6 +60,12 @@ export interface AgentHistorySummary { resolvedModelIsFallback?: boolean; metrics?: AgentMetricsSummary; readOnly?: boolean; + /** Durable task output artifact, when the executor wrote one. */ + outputPath?: string; + /** Captured isolated-worktree patch, when patch capture succeeded. */ + patchPath?: string; + /** Isolated branch identity, when branch-mode capture succeeded. */ + branchName?: string; } export interface AgentRef { diff --git a/packages/coding-agent/src/registry/persisted-agents.ts b/packages/coding-agent/src/registry/persisted-agents.ts index e94446551..79b19a417 100644 --- a/packages/coding-agent/src/registry/persisted-agents.ts +++ b/packages/coding-agent/src/registry/persisted-agents.ts @@ -107,35 +107,42 @@ function assistantMetrics(message: Record): AssistantMetrics { }; } -async function readPersistedAgentHistory(transcript: PersistedTranscript): Promise { +async function readPersistedAgentHistory( + transcript: PersistedTranscript, + shouldContinue: () => boolean, +): Promise { const parents = new Map(); const assistantById = new Map(); const modelChangeById = new Map(); let leafId: string | undefined; let leafTimestamp: number | undefined; try { - await visitEntriesFromFileStream(transcript.sessionFile, entry => { - const record = recordOf(entry); - if (!record) return; - const id = typeof record.id === "string" ? record.id : undefined; - if (!id) return; - const parentId = typeof record.parentId === "string" ? record.parentId : undefined; - parents.set(id, parentId); - leafId = id; - const parsedTimestamp = timestampOf(record.timestamp); - if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; - if (record.type === "model_change" && typeof record.model === "string") { - modelChangeById.set(id, { - model: record.model, - role: typeof record.role === "string" ? record.role : undefined, - resolvedModelIsFallback: record.resolvedModelIsFallback === true, - }); - return; - } - if (record.type !== "message") return; - const message = recordOf(record.message); - if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); - }); + await visitEntriesFromFileStream( + transcript.sessionFile, + entry => { + const record = recordOf(entry); + if (!record) return; + const id = typeof record.id === "string" ? record.id : undefined; + if (!id) return; + const parentId = typeof record.parentId === "string" ? record.parentId : undefined; + parents.set(id, parentId); + leafId = id; + const parsedTimestamp = timestampOf(record.timestamp); + if (parsedTimestamp !== undefined) leafTimestamp = parsedTimestamp; + if (record.type === "model_change" && typeof record.model === "string") { + modelChangeById.set(id, { + model: record.model, + role: typeof record.role === "string" ? record.role : undefined, + resolvedModelIsFallback: record.resolvedModelIsFallback === true, + }); + return; + } + if (record.type !== "message") return; + const message = recordOf(record.message); + if (message?.role === "assistant") assistantById.set(id, assistantMetrics(message)); + }, + { shouldContinue }, + ); } catch { return {}; } @@ -195,6 +202,10 @@ async function readPersistedAgentHistory(transcript: PersistedTranscript): Promi */ async function readPersistedAgentMetadata(sessionFile: string): Promise { const stat = fs.promises.stat(sessionFile).catch(() => undefined); + const artifactBase = sessionFile.slice(0, -".jsonl".length); + const outputPath = `${artifactBase}.md`; + const patchPath = `${artifactBase}.patch`; + const artifactFiles = Promise.all([Bun.file(outputPath).exists(), Bun.file(patchPath).exists()]); let createdAt: number | undefined; let activity: string | undefined; let history: AgentHistorySummary = {}; @@ -239,21 +250,29 @@ async function readPersistedAgentMetadata(sessionFile: string): Promise> { +async function readPersistedVibeChildIds(sessionFile: string, shouldContinue: () => boolean): Promise> { const ids = new Set(); try { - await visitEntriesFromFileStream(sessionFile, entry => { - for (const id of persistedVibeChildIds([entry])) ids.add(id); - }); + await visitEntriesFromFileStream( + sessionFile, + entry => { + for (const id of persistedVibeChildIds([entry])) ids.add(id); + }, + { shouldContinue }, + ); return ids; } catch { return new Set(); @@ -264,19 +283,26 @@ async function readPersistedVibeChildIds(sessionFile: string): Promise boolean } = {}, ): Promise { if (!sessionFile?.endsWith(".jsonl")) return; - const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile); + const shouldContinue = options.shouldContinue ?? (() => true); + if (!shouldContinue()) return; + const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile, shouldContinue); + if (!shouldContinue()) return; const root = sessionFile.slice(0, -6); const transcripts: PersistedTranscript[] = []; - await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds, transcripts); + await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds, transcripts, shouldContinue); + if (!shouldContinue()) return; let nextTranscript = 0; const workers = Array.from({ length: Math.min(4, transcripts.length) }, async () => { for (;;) { + if (!shouldContinue()) return; const index = nextTranscript++; const transcript = transcripts[index]; if (!transcript) return; - const history = await readPersistedAgentHistory(transcript); + const history = await readPersistedAgentHistory(transcript, shouldContinue); + if (!shouldContinue()) return; registry.setHistory(transcript.id, history, transcript.sessionFile); } }); @@ -289,19 +315,24 @@ async function registerPersistedSubagentsFromDir( parentId: string | undefined, vibeOwnedIds: ReadonlySet, transcripts: PersistedTranscript[], + shouldContinue: () => boolean, ): Promise { + if (!shouldContinue()) return; let entries: fs.Dirent[]; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } + if (!shouldContinue()) return; let entriesSinceYield = 0; for (const entry of entries) { + if (!shouldContinue()) return; if (++entriesSinceYield >= 16) { entriesSinceYield = 0; await Bun.sleep(0); } + if (!shouldContinue()) return; if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; const sessionFile = path.join(dir, entry.name); // The advisor transcript is observability-only: register it as a non-peer @@ -320,9 +351,10 @@ async function registerPersistedSubagentsFromDir( // user task literally named `/advisor`): leave it, skip the advisor. if (existing && existing.kind !== "advisor") continue; if (existing?.sessionFile !== sessionFile) { + const metadata = await readPersistedAgentMetadata(sessionFile); + if (!shouldContinue()) return; // The id is reused across `/new`; refresh it to the current session's file. if (existing) registry.unregister(advisorId); - const metadata = await readPersistedAgentMetadata(sessionFile); registry.register({ id: advisorId, displayName, @@ -354,8 +386,10 @@ async function registerPersistedSubagentsFromDir( } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue; } + if (!shouldContinue()) return; if (!registry.get(id)) { const metadata = await readPersistedAgentMetadata(sessionFile); + if (!shouldContinue()) return; registry.register({ id, displayName: id, @@ -377,6 +411,13 @@ async function registerPersistedSubagentsFromDir( lastActivity: ref?.lastActivity, }); } - await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id, vibeOwnedIds, transcripts); + await registerPersistedSubagentsFromDir( + registry, + path.join(dir, id), + id, + vibeOwnedIds, + transcripts, + shouldContinue, + ); } } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 94b34854a..cae1147a0 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -3314,7 +3314,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { hub.dispose(); }); + it("stops persisted discovery when the Hub is disposed", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-disposed-scan-"); + const sessionFile = path.join(tempDir.path(), "main.jsonl"); + await Bun.write(sessionFile, ""); + await Bun.write(path.join(tempDir.path(), "main", "Worker.jsonl"), ""); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + settings: Settings.isolated(), + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + sessionFile, + }); + + hub.dispose(); + await hub.persistedSubagentsReady; + + expect(agents.get("Worker")).toBeUndefined(); + }); it("restores nested parent lineage after restart", async () => { using tempDir = TempDir.createSync("@omp-agent-hub-persisted-tree-"); const sessionFile = path.join(tempDir.path(), "main.jsonl"); diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index de4ab9d95..a04c4f96c 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -437,6 +437,11 @@ describe("Agent hub row ordering", () => { kind: "sub", parentId: "Main", session: null, + history: { + outputPath: "/tmp/Reviewer.md", + patchPath: "/tmp/Reviewer.patch", + branchName: "omp/task/Reviewer", + }, }); const observers = new SessionObserverRegistry(); vi.spyOn(observers, "getSessions").mockReturnValue([ @@ -482,6 +487,10 @@ describe("Agent hub row ordering", () => { expect(rendered).toContain("31K/128K 24%"); expect(rendered).toContain("Registered "); expect(rendered).toContain("Shared workspace · per-agent LoC not attributable"); + expect(rendered).toContain("Output /tmp/Reviewer.md"); + expect(rendered).toContain("Patch /tmp/Reviewer.patch"); + hub.handleInput("\x1b[6~"); + expect(Bun.stripANSI(hub.render(140).join("\n"))).toContain("Worktree branch omp/task/Reviewer"); } finally { hub.dispose(); } diff --git a/packages/coding-agent/test/task/isolation-runner.test.ts b/packages/coding-agent/test/task/isolation-runner.test.ts index 6a9a3c242..f29dc52de 100644 --- a/packages/coding-agent/test/task/isolation-runner.test.ts +++ b/packages/coding-agent/test/task/isolation-runner.test.ts @@ -2,6 +2,7 @@ import { afterEach, 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 { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; import { applyEligibleNestedPatches, @@ -73,6 +74,7 @@ async function seedFooRepo(finalContent: string): Promise<{ repoRoot: string; pa describe("runIsolatedSubprocess", () => { afterEach(async () => { vi.restoreAllMocks(); + AgentRegistry.resetGlobalForTests(); await Promise.all(tempRoots.splice(0).map(tempRoot => fs.rm(tempRoot, { force: true, recursive: true }))); }); @@ -107,6 +109,13 @@ describe("runIsolatedSubprocess", () => { nestedPatches: [], }); const cleanupSpy = vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue(); + AgentRegistry.global().register({ + id: "PreserveBranchFailure", + displayName: "PreserveBranchFailure", + kind: "sub", + session: null, + status: "parked", + }); const deleteSpy = vi.spyOn(gitModule.branch, "tryDelete").mockResolvedValue(true); const outcome = await runIsolatedSubprocess({ @@ -138,6 +147,7 @@ describe("runIsolatedSubprocess", () => { expect(captureSpy).toHaveBeenCalledWith(isolationDir, baseline); expect(deleteSpy).toHaveBeenCalledWith(repoRoot, "omp/task/PreserveBranchFailure"); expect(cleanupSpy).toHaveBeenCalledTimes(1); + expect(AgentRegistry.global().get("PreserveBranchFailure")?.history?.patchPath).toBe(patchPath); }); }); From f687a074d8fbd4622eaeca4d4b758ad1f8dc525d Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 02:27:08 +0200 Subject: [PATCH 094/126] 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 095/126] 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 096/126] 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 097/126] 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 098/126] 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 099/126] 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 100/126] 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 101/126] 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 102/126] 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 103/126] 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 104/126] 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 105/126] 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 106/126] 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 107/126] 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 108/126] 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(); - } - }); -}); From b0a94a8fc003dfa1164cca6c508772577f68ec7b Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 03:07:16 +0200 Subject: [PATCH 109/126] chore: cleanup --- Cargo.toml | 1 + README.md | 1 - bun.lock | 18 - crates/pi-natives/src/desktop/ax.rs | 2 +- crates/pi-natives/src/desktop/keys.rs | 12 +- .../pi-natives/src/desktop/win32/delivery.rs | 16 +- crates/pi-natives/src/desktop/win32/mod.rs | 2 +- crates/pi-natives/src/fd.rs | 9 +- crates/pi-shell/src/process.rs | 6 +- crates/pi-shell/src/shell.rs | 12 +- crates/vendor/brush-builtins/Cargo.toml | 88 +--- crates/vendor/brush-core/Cargo.toml | 88 +--- .../vendor/uu-sort/src/ext_sort/threaded.rs | 5 +- docs/extensions.md | 2 +- docs/user-facing-packages.md | 12 - packages/ai/src/error/index.ts | 1 + packages/ai/src/providers/pi-native-client.ts | 2 +- packages/ai/src/providers/pi-native-server.ts | 2 +- packages/ai/test/openai-codex-stream.test.ts | 3 +- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/tools/browser/launch.ts | 2 +- packages/coding-agent/src/vibe/runtime.ts | 2 + .../test/pi-scope-aliases.test.ts | 1 - packages/swarm-extension/.gitignore | 1 - packages/swarm-extension/CHANGELOG.md | 15 - packages/swarm-extension/README.md | 472 ------------------ packages/swarm-extension/package.json | 55 -- packages/swarm-extension/src/cli.ts | 106 ---- packages/swarm-extension/src/extension.ts | 256 ---------- packages/swarm-extension/src/swarm/dag.ts | 146 ------ .../swarm-extension/src/swarm/executor.ts | 111 ---- .../swarm-extension/src/swarm/pipeline.ts | 213 -------- packages/swarm-extension/src/swarm/render.ts | 63 --- packages/swarm-extension/src/swarm/schema.ts | 157 ------ packages/swarm-extension/src/swarm/state.ts | 127 ----- .../test/swarm/executor.test.ts | 68 --- packages/swarm-extension/tsconfig.json | 6 - 37 files changed, 52 insertions(+), 2033 deletions(-) delete mode 100644 packages/swarm-extension/.gitignore delete mode 100644 packages/swarm-extension/CHANGELOG.md delete mode 100644 packages/swarm-extension/README.md delete mode 100644 packages/swarm-extension/package.json delete mode 100755 packages/swarm-extension/src/cli.ts delete mode 100644 packages/swarm-extension/src/extension.ts delete mode 100644 packages/swarm-extension/src/swarm/dag.ts delete mode 100644 packages/swarm-extension/src/swarm/executor.ts delete mode 100644 packages/swarm-extension/src/swarm/pipeline.ts delete mode 100644 packages/swarm-extension/src/swarm/render.ts delete mode 100644 packages/swarm-extension/src/swarm/schema.ts delete mode 100644 packages/swarm-extension/src/swarm/state.ts delete mode 100644 packages/swarm-extension/test/swarm/executor.test.ts delete mode 100644 packages/swarm-extension/tsconfig.json diff --git a/Cargo.toml b/Cargo.toml index 4a084f620..e20dd5806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ option_if_let_else = "allow" # match/if-let-else often clearer enum_glob_use = "allow" items_after_statements = "allow" # Sometimes more readable wildcard_imports = "allow" # Cleaner for preludes and test modules +redundant_pub_crate = "allow" # ────────────────────────────────────────────────────────────────────────────── # Variables & Type Inference diff --git a/README.md b/README.md index 931eb9144..015d534e3 100644 --- a/README.md +++ b/README.md @@ -614,7 +614,6 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP | **[@oh-my-pi/hashline](packages/hashline)** | Line-anchored patch language and applier behind the `edit` tool | | **[@oh-my-pi/pi-mnemopi](packages/mnemopi)** | Local SQLite memory engine for Oh My Pi agents | | **[@oh-my-pi/snapcompact](packages/snapcompact)** | Bitmap-frame context compression package and SQuAD eval suite | -| **[@oh-my-pi/swarm-extension](packages/swarm-extension)** | Swarm orchestration extension package | | **[@oh-my-pi/browser-relay](packages/browser-relay)** | Chrome extension that lets the browser tool drive your existing tabs | | **[@oh-my-pi/pi-metaharness](packages/metaharness)** | Unified benchmark runners, Harbor run storage, REST/SSE API, live dashboard | | **[@oh-my-pi/typescript-edit-benchmark](packages/typescript-edit-benchmark)** | Edit benchmark suite built on TypeScript source mutations | diff --git a/bun.lock b/bun.lock index 298c970da..40d8f3e13 100644 --- a/bun.lock +++ b/bun.lock @@ -283,22 +283,6 @@ "postcss": "catalog:", }, }, - "packages/swarm-extension": { - "name": "@oh-my-pi/swarm-extension", - "version": "17.2.8", - "bin": { - "omp-swarm": "src/cli.ts", - }, - "dependencies": { - "@oh-my-pi/pi-utils": "workspace:*", - }, - "devDependencies": { - "@types/bun": "^1.3.14", - }, - "peerDependencies": { - "@oh-my-pi/pi-coding-agent": "^16", - }, - }, "packages/tui": { "name": "@oh-my-pi/pi-tui", "version": "17.2.8", @@ -831,8 +815,6 @@ "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@workspace:packages/snapcompact"], - "@oh-my-pi/swarm-extension": ["@oh-my-pi/swarm-extension@workspace:packages/swarm-extension"], - "@oh-my-pi/typescript-edit-benchmark": ["@oh-my-pi/typescript-edit-benchmark@workspace:packages/typescript-edit-benchmark"], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], diff --git a/crates/pi-natives/src/desktop/ax.rs b/crates/pi-natives/src/desktop/ax.rs index 171cea1ae..1bb2bdfe8 100644 --- a/crates/pi-natives/src/desktop/ax.rs +++ b/crates/pi-natives/src/desktop/ax.rs @@ -467,7 +467,7 @@ pub fn normalize_role_macos(native: &str) -> String { .to_ascii_lowercase() } #[cfg(any(target_os = "windows", test))] -pub(crate) fn normalize_role_uia(native: &str) -> String { +pub fn normalize_role_uia(native: &str) -> String { match native { "Edit" => "textfield", "Document" => "textarea", diff --git a/crates/pi-natives/src/desktop/keys.rs b/crates/pi-natives/src/desktop/keys.rs index 69b56ea47..6ca47d828 100644 --- a/crates/pi-natives/src/desktop/keys.rs +++ b/crates/pi-natives/src/desktop/keys.rs @@ -237,14 +237,14 @@ pub fn parse_modifiers(mods: &[String]) -> CoreResult { #[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum KeyDirection { +pub enum KeyDirection { Press, Release, Click, } #[cfg(test)] -pub(crate) fn execute_chord_with( +pub fn execute_chord_with( keys: &[KeyName], mut emit: impl FnMut(KeyName, KeyDirection) -> Result<(), E>, ) -> Result<(), E> { @@ -263,10 +263,10 @@ pub(crate) fn execute_chord_with( } let mut first_error = None; for &key in pressed.iter().rev() { - if let Err(error) = emit(key, KeyDirection::Release) { - if first_error.is_none() { - first_error = Some(error); - } + if let Err(error) = emit(key, KeyDirection::Release) + && first_error.is_none() + { + first_error = Some(error); } } first_error.map_or(Ok(()), Err) diff --git a/crates/pi-natives/src/desktop/win32/delivery.rs b/crates/pi-natives/src/desktop/win32/delivery.rs index e206da5f6..75322c959 100644 --- a/crates/pi-natives/src/desktop/win32/delivery.rs +++ b/crates/pi-natives/src/desktop/win32/delivery.rs @@ -4,7 +4,7 @@ //! exercised by the host test suite on every platform. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum EventKind { +pub enum EventKind { MouseClick, MouseMove, MouseScroll, @@ -26,34 +26,34 @@ impl EventKind { } } -pub(crate) fn is_chromium_class(class: &str) -> bool { +pub fn is_chromium_class(class: &str) -> bool { class .strip_prefix("Chrome_WidgetWin_") .is_some_and(|suffix| !suffix.is_empty()) } -pub(crate) fn is_winui3_class(class: &str) -> bool { +pub fn is_winui3_class(class: &str) -> bool { class == "WinUIDesktopWin32WindowClass" } -pub(crate) fn is_wpf_class(class: &str) -> bool { +pub fn is_wpf_class(class: &str) -> bool { class .strip_prefix("HwndWrapper[") .is_some_and(|body| !body.is_empty() && body.ends_with(']')) } -pub(crate) fn is_tk_class(class: &str) -> bool { +pub fn is_tk_class(class: &str) -> bool { class == "TkTopLevel" || class .strip_prefix("TkTopLevel.") .is_some_and(|suffix| !suffix.is_empty()) } -pub(crate) fn is_gtk_class(class: &str) -> bool { +pub fn is_gtk_class(class: &str) -> bool { class == "gdkWindowToplevel" || class == "gdkSurfaceToplevel" } -pub(crate) fn is_vcl_class(class: &str) -> bool { +pub fn is_vcl_class(class: &str) -> bool { class .strip_prefix("SAL") .is_some_and(|suffix| !suffix.is_empty()) @@ -61,7 +61,7 @@ pub(crate) fn is_vcl_class(class: &str) -> bool { /// Returns the empirical reason that a posted event would be accepted by /// Win32 but silently ignored by the target toolkit. -pub(crate) fn would_be_silently_dropped(class: &str, kind: EventKind) -> Option<&'static str> { +pub fn would_be_silently_dropped(class: &str, kind: EventKind) -> Option<&'static str> { use EventKind::{KeyCombo, Keystroke, MouseClick, MouseMove, MouseScroll, TextInput}; if is_chromium_class(class) { diff --git a/crates/pi-natives/src/desktop/win32/mod.rs b/crates/pi-natives/src/desktop/win32/mod.rs index ff8cf5fc0..d88f91dee 100644 --- a/crates/pi-natives/src/desktop/win32/mod.rs +++ b/crates/pi-natives/src/desktop/win32/mod.rs @@ -2,7 +2,7 @@ mod ax; #[cfg(target_os = "windows")] mod capture; -pub(crate) mod delivery; +pub mod delivery; #[cfg(target_os = "windows")] mod input; diff --git a/crates/pi-natives/src/fd.rs b/crates/pi-natives/src/fd.rs index 4272e94e9..059b38162 100644 --- a/crates/pi-natives/src/fd.rs +++ b/crates/pi-natives/src/fd.rs @@ -163,8 +163,8 @@ fn path_depth(path: &str) -> usize { /// /// 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. +/// 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, @@ -234,14 +234,15 @@ impl TopMatches { } /// Exact number of scoring hits, clamped to the `u32` wire type. - fn total_matches(&self) -> u32 { + const 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 + self + .heap .into_sorted_vec() .into_iter() .map(|ranked| ranked.entry) diff --git a/crates/pi-shell/src/process.rs b/crates/pi-shell/src/process.rs index bb299be9d..7dd97fe11 100644 --- a/crates/pi-shell/src/process.rs +++ b/crates/pi-shell/src/process.rs @@ -1937,7 +1937,7 @@ mod tests { // Treat the child's pid as protected (standing in for the harness/an // ancestor). The sweep must refuse to signal it. - let protected: HashSet = [child_pid].into_iter().collect(); + let protected: HashSet = HashSet::from([child_pid]); let signaled = root.signal_tree_excluding(KILL_SIGNAL, &protected); assert_eq!(signaled, 0, "a protected root must never be signalled"); @@ -1965,8 +1965,8 @@ mod tests { #[test] fn protected_subtree_is_pruned_not_just_the_pid() { // root(1) -> host(2, protected) -> worker(3); root(1) -> real_child(4). - let parents: HashMap = [(2, 1), (3, 2), (4, 1)].into_iter().collect(); - let protected: HashSet = [2].into_iter().collect(); + let parents: HashMap = HashMap::from([(2, 1), (3, 2), (4, 1)]); + let protected: HashSet = HashSet::from([2]); assert!( pid_in_protected_subtree(2, &protected, &parents), diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 5d0046b50..3f9a19e41 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -8583,12 +8583,12 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }] /// A segment that carries a file redirect is still segmented, and the brush /// `Display` reconstruction the runner executes must round-trip through - /// brush's own parser **without losing the redirect**. `echo hidden - /// >/dev/null` suppresses its own stdout: if the reconstruction dropped the - /// redirect, `hidden` would leak into the captured output. Proves the - /// reconstruction path is semantically sound for the redirect-bearing - /// shapes the per-stage whitelist accepts (not just syntactically - /// parseable). + /// brush's own parser **without losing the redirect**. + /// `echo hidden >/dev/null` suppresses its own stdout: if the reconstruction + /// dropped the redirect, `hidden` would leak into the captured output. + /// Proves the reconstruction path is semantically sound for the + /// redirect-bearing shapes the per-stage whitelist accepts (not just + /// syntactically parseable). #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn segmented_chain_with_redirect_executes_correctly() { diff --git a/crates/vendor/brush-builtins/Cargo.toml b/crates/vendor/brush-builtins/Cargo.toml index 82e3056eb..c24f538c5 100644 --- a/crates/vendor/brush-builtins/Cargo.toml +++ b/crates/vendor/brush-builtins/Cargo.toml @@ -242,87 +242,9 @@ features = [ version = "0.11.0" [lints.clippy] -bool_to_int_with_if = "allow" -cognitive_complexity = "allow" -collapsible_else_if = "allow" -collapsible_if = "allow" -expect_used = "deny" -format_push_string = "deny" -if_not_else = "allow" -if_same_then_else = "allow" -match_same_arms = "allow" -missing_errors_doc = "allow" -multiple_crate_versions = "allow" -multiple_unsafe_ops_per_block = "deny" -must_use_candidate = "allow" -option_if_let_else = "allow" -panic = "deny" -panic_in_result_fn = "deny" -redundant_closure_for_method_calls = "allow" -redundant_else = "allow" -redundant_pub_crate = "allow" -result_large_err = "allow" -similar_names = "allow" -string_lit_chars_any = "deny" -string_slice = "deny" -struct_excessive_bools = "allow" -tests_outside_test_module = "deny" -todo = "deny" -undocumented_unsafe_blocks = "deny" -unwrap_in_result = "deny" -unwrap_used = "deny" - -[lints.clippy.all] -level = "deny" -priority = -1 - -[lints.clippy.cargo] -level = "deny" -priority = -1 - -[lints.clippy.nursery] -level = "deny" -priority = -1 - -[lints.clippy.pedantic] -level = "deny" -priority = -1 - -[lints.clippy.perf] -level = "deny" -priority = -1 - +all = { level = "allow", priority = -1 } +nursery = { level = "allow", priority = -1 } +pedantic = { level = "allow", priority = -1 } +cargo = { level = "allow", priority = -1 } [lints.rust] -unnameable_types = "deny" -unsafe_op_in_unsafe_fn = "deny" -unused_attributes = "deny" -unused_lifetimes = "deny" -unused_macro_rules = "deny" - -[lints.rust.future_incompatible] -level = "deny" -priority = 0 - -[lints.rust.missing_docs] -level = "deny" -priority = 0 - -[lints.rust.nonstandard_style] -level = "deny" -priority = 0 - -[lints.rust.rust_2018_idioms] -level = "deny" -priority = -1 - -[lints.rust.unknown_lints] -level = "allow" -priority = -100 - -[lints.rust.warnings] -level = "deny" -priority = 0 - -[lints.rustdoc.all] -level = "deny" -priority = -1 +unfulfilled_lint_expectations = { level = "allow", priority = -1 } diff --git a/crates/vendor/brush-core/Cargo.toml b/crates/vendor/brush-core/Cargo.toml index 14a497b40..2267a9ac8 100644 --- a/crates/vendor/brush-core/Cargo.toml +++ b/crates/vendor/brush-core/Cargo.toml @@ -212,87 +212,7 @@ version = "1.23.1" features = ["js"] [lints.clippy] -bool_to_int_with_if = "allow" -cognitive_complexity = "allow" -collapsible_else_if = "allow" -collapsible_if = "allow" -expect_used = "deny" -format_push_string = "deny" -if_not_else = "allow" -if_same_then_else = "allow" -match_same_arms = "allow" -missing_errors_doc = "allow" -multiple_crate_versions = "allow" -multiple_unsafe_ops_per_block = "deny" -must_use_candidate = "allow" -option_if_let_else = "allow" -panic = "deny" -panic_in_result_fn = "deny" -redundant_closure_for_method_calls = "allow" -redundant_else = "allow" -redundant_pub_crate = "allow" -result_large_err = "allow" -similar_names = "allow" -string_lit_chars_any = "deny" -string_slice = "deny" -struct_excessive_bools = "allow" -tests_outside_test_module = "deny" -todo = "deny" -undocumented_unsafe_blocks = "deny" -unwrap_in_result = "deny" -unwrap_used = "deny" - -[lints.clippy.all] -level = "deny" -priority = -1 - -[lints.clippy.cargo] -level = "deny" -priority = -1 - -[lints.clippy.nursery] -level = "deny" -priority = -1 - -[lints.clippy.pedantic] -level = "deny" -priority = -1 - -[lints.clippy.perf] -level = "deny" -priority = -1 - -[lints.rust] -unnameable_types = "deny" -unsafe_op_in_unsafe_fn = "deny" -unused_attributes = "deny" -unused_lifetimes = "deny" -unused_macro_rules = "deny" - -[lints.rust.future_incompatible] -level = "deny" -priority = 0 - -[lints.rust.missing_docs] -level = "deny" -priority = 0 - -[lints.rust.nonstandard_style] -level = "deny" -priority = 0 - -[lints.rust.rust_2018_idioms] -level = "deny" -priority = -1 - -[lints.rust.unknown_lints] -level = "allow" -priority = -100 - -[lints.rust.warnings] -level = "deny" -priority = 0 - -[lints.rustdoc.all] -level = "deny" -priority = -1 +all = { level = "allow", priority = -1 } +nursery = { level = "allow", priority = -1 } +pedantic = { level = "allow", priority = -1 } +cargo = { level = "allow", priority = -1 } diff --git a/crates/vendor/uu-sort/src/ext_sort/threaded.rs b/crates/vendor/uu-sort/src/ext_sort/threaded.rs index 9efce0ebf..49db811fc 100644 --- a/crates/vendor/uu-sort/src/ext_sort/threaded.rs +++ b/crates/vendor/uu-sort/src/ext_sort/threaded.rs @@ -327,9 +327,8 @@ mod tests { fn ext_sort_spills_to_files_and_sorts() { let input: String = (0..200u32).rev().map(|i| format!("{i:04}\n")).collect(); - let mut settings = GlobalSettings::default(); - settings.buffer_size = 64; - settings.buffer_size_is_explicit = true; + let settings = + GlobalSettings { buffer_size: 64, buffer_size_is_explicit: true, ..Default::default() }; let out_dir = tempfile::tempdir().expect("temp dir"); let out_path = out_dir.path().join("sorted.txt"); diff --git a/docs/extensions.md b/docs/extensions.md index 0e6534844..46a087bbe 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -12,7 +12,7 @@ This document covers the current extension runtime in: For discovery paths and filesystem loading rules, see [`extension-loading.md`](./extension-loading.md). -For packaged user-facing extension CLIs/features such as `packages/swarm-extension`, see [`user-facing-packages.md`](./user-facing-packages.md). +For packaged user-facing extension CLIs/features, see [`user-facing-packages.md`](./user-facing-packages.md). ## What an extension is diff --git a/docs/user-facing-packages.md b/docs/user-facing-packages.md index 7150b09bd..8dc7e40bc 100644 --- a/docs/user-facing-packages.md +++ b/docs/user-facing-packages.md @@ -22,18 +22,6 @@ Sources: [`python/robomp/README.md`](../python/robomp/README.md), [`python/robom - Root commands: `bun run robomp:install` installs the Python package for host development; `bun run robomp:serve` runs it on the host; `bun run robomp:build`/`bun run robomp:rebuild`, `bun run robomp:up`, `bun run robomp:down`, `bun run robomp:restart`, `bun run robomp:logs`, `bun run robomp:dev`, and `bun run robomp:reset` manage the container deployment. - Prerequisites: Docker Compose v2, a host-reachable LiteLLM-style model proxy, container model configuration, a GitHub webhook endpoint, and a bot PAT with write access to every allowlisted repository. The default two-container deployment keeps the PAT in an HMAC-authenticated `gh-proxy` sidecar rather than the orchestrator. -### `packages/swarm-extension` — swarm orchestration - -Sources: [`packages/swarm-extension/README.md`](../packages/swarm-extension/README.md), [`packages/swarm-extension/package.json`](../packages/swarm-extension/package.json), [`packages/swarm-extension/src/cli.ts`](../packages/swarm-extension/src/cli.ts), [`packages/swarm-extension/src/extension.ts`](../packages/swarm-extension/src/extension.ts). - -- Package: `@oh-my-pi/swarm-extension`; bin: `omp-swarm`. -- Feature: multi-agent DAG orchestration from YAML swarms, supporting `pipeline`, `parallel`, and `sequential` modes. -- Standalone CLI: `omp-swarm path/to/swarm.yaml` runs until completion or process termination. -- TUI extension mode: add the package path to `extensions`, then use `/swarm run `, `/swarm status `, or `/swarm help`. -- Inputs: YAML under top-level `swarm` with `name`, `workspace`, `mode`, optional `target_count`/`model`, and `agents` with `role`, `task`, optional `model`, `waits_for`, and `reports_to`. -- Side effects/output: creates the workspace if needed and persists state/logs under `/.swarm_/`. -- Limits/errors: validates the YAML definition, dependency graph, and cycles before execution; standalone runs have no built-in timeout. - ### `packages/stats` — local usage dashboard Sources: [`packages/stats/README.md`](../packages/stats/README.md), [`packages/stats/package.json`](../packages/stats/package.json), [`packages/coding-agent/src/cli/stats-cli.ts`](../packages/coding-agent/src/cli/stats-cli.ts). diff --git a/packages/ai/src/error/index.ts b/packages/ai/src/error/index.ts index 4cdb1c3f5..fff0e2036 100644 --- a/packages/ai/src/error/index.ts +++ b/packages/ai/src/error/index.ts @@ -9,5 +9,6 @@ export * from "./format"; export * from "./gateway"; export * from "./oauth"; export * from "./provider"; +export * from "./rate-limit"; export * from "./retryable"; export * from "./validation"; diff --git a/packages/ai/src/providers/pi-native-client.ts b/packages/ai/src/providers/pi-native-client.ts index 71276b739..4826318a4 100644 --- a/packages/ai/src/providers/pi-native-client.ts +++ b/packages/ai/src/providers/pi-native-client.ts @@ -11,7 +11,7 @@ * * Activated when a {@link Model} has `transport: "pi-native"` set; the * dispatch hook lives in `streamSimple()` (see `../stream.ts`). Used by - * containerized omp deployments (robomp slots, the swarm extension) that + * containerized omp deployments (such as robomp slots) that * route every LLM call through a credential-holding sidecar so the slot * itself stays credential-free. */ diff --git a/packages/ai/src/providers/pi-native-server.ts b/packages/ai/src/providers/pi-native-server.ts index bcea48c35..b8eddd2c7 100644 --- a/packages/ai/src/providers/pi-native-server.ts +++ b/packages/ai/src/providers/pi-native-server.ts @@ -4,7 +4,7 @@ * Where the OpenAI / Anthropic / Responses route modules translate foreign * wire shapes through pi-ai's canonical {@link Context}, this module accepts * the canonical shape *directly* — for clients that already speak pi-ai - * (containerized omp, the swarm extension, robomp's sidecar auth-gateway). + * (containerized omp, robomp's sidecar auth-gateway). * Skipping the wire-format → Context → wire-format round-trip cuts * per-request CPU but, more importantly, avoids the quantization that those * translations impose on first-class pi-ai fields (service tier, cache diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index a650a3e43..9b79ce4bd 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -19,6 +19,7 @@ import type { } from "@oh-my-pi/pi-ai/types"; import { __resetProxyCache } from "@oh-my-pi/pi-ai/utils/proxy"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; import * as piUtils from "@oh-my-pi/pi-utils"; import { withEnv } from "./helpers"; @@ -519,7 +520,7 @@ describe("openai-codex streaming", () => { const result = await streamSimple(model, context, { apiKey: token, fetch: fetchMock, - reasoning: "medium", + reasoning: Effort.Medium, }).result(); expect(result.stopReason).toBe("stop"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7faf470d6..1267dbc21 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5403,7 +5403,7 @@ - Fixed command-fixup notices to list all stripped segments instead of reporting only one - Fixed summarized `read` output stalling agents on elided regions by appending an explicit footer like `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`. The footer fires whenever the structural summarizer elided at least one span, so the model gets a concrete recovery selector instead of having to guess from a bare `...` / `{ .. }` marker. Surfaces `elidedLines` on `ReadToolDetails.summary` alongside the existing `elidedSpans`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046)) - Updated the `read` tool prompt to describe the new elision footer and instruct the model to follow `:raw` (or an explicit line range) when the elided body is actually needed, rather than guessing. -- Fixed plugin extensions failing to load when their `peerDependencies` reference internal `pi-*` packages under any scope other than `@mariozechner` (e.g. `Cannot find module '@earendil-works/pi-tui'` from `@juicesharp/rpiv-ask-user-question`, or `Cannot find module '@oh-my-pi/pi-utils'` from `@oh-my-pi/swarm-extension`). The legacy-pi specifier shim now treats `@mariozechner`, `@earendil-works`, **and** the canonical `@oh-my-pi` itself as aliases for the same set of bundled in-process packages (`pi-agent-core`, `pi-ai`, `pi-coding-agent`, `pi-natives`, `pi-tui`, `pi-utils`), and additionally rewrites the upstream-only `pi-ai/oauth` subpath onto our `pi-ai/utils/oauth` layout. Restored the `Key` runtime helper export on `@oh-my-pi/pi-tui` to match upstream — plugins using `Key.enter` / `Key.ctrl("c")` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) no longer fail with `Export named 'Key' not found`. End-to-end verified against `@juicesharp/rpiv-ask-user-question`, `@oh-my-pi/swarm-extension`, and `@plannotator/pi-extension` — each now loads cleanly with all of its tools/commands/handlers registered. Plugins importing any of those scopes are remapped to the omp binary's own copy at load time, so peer deps are no longer dragged in from npm and there is exactly one module instance per package regardless of which scope name the plugin's manifest happened to declare. +- Fixed plugin extensions failing to load when their `peerDependencies` reference internal `pi-*` packages under any scope other than `@mariozechner` (e.g. `Cannot find module '@earendil-works/pi-tui'` from `@juicesharp/rpiv-ask-user-question`). The legacy-pi specifier shim now treats `@mariozechner`, `@earendil-works`, **and** the canonical `@oh-my-pi` itself as aliases for the same set of bundled in-process packages (`pi-agent-core`, `pi-ai`, `pi-coding-agent`, `pi-natives`, `pi-tui`, `pi-utils`), and additionally rewrites the upstream-only `pi-ai/oauth` subpath onto our `pi-ai/utils/oauth` layout. Restored the `Key` runtime helper export on `@oh-my-pi/pi-tui` to match upstream … - Fixed `omp commit` hanging after a successful commit instead of returning to the shell. The command now mirrors the `runPrintMode` exit pattern and calls `postmortem.quit(0)` once the pipeline resolves so lingering HTTP/2 keep-alive sockets, the Settings autosave timer, and other AgentSession background handles don't keep the event loop pinned. ([#1041](https://github.com/can1357/oh-my-pi/issues/1041)) - Fixed hashline payload parsing to silently treat truly-blank lines as empty `~`-prefixed payload lines when more payload follows in the same run. The previous behavior broke at the blank ("payload line has no preceding +, <, or = operation.") even though the intent is obvious — the only ambiguity is between in-payload blanks and end-of-section blanks, and a one-line lookahead resolves it: blanks that precede a non-payload op still end the run cleanly as section separators. Recovers the common case of forgetting the leading separator on a blank inserted line without changing how trailing blanks between ops behave. - Rewrote the hashline edit prompt examples to use an ASCII-only `TITLE = "Mr"` → `"Mrs"` / `"Dr"` motif instead of the previous `" • "` and `"·"` separators. Some agents had been copying the middle-dot literal characters into real edits as if they were format scaffolding (e.g. emitting payload lines like `~ ·`), since the demo inserts were near-twins of the existing string. The new example keeps every original op shape (single-line replace, multiline replace, insert AFTER/BEFORE, append, delete, blank, plus both anti-patterns) but uses content that is obviously domain-specific and clearly distinct from any payload separator. Pure prompt change; no parser, schema, or runtime behavior is affected. diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index 76cccf66b..219d1b734 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -205,7 +205,7 @@ const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_ch function systemChromiumCandidates( platform: NodeJS.Platform = process.platform, home = os.homedir(), - which: (name: string) => string | undefined = $which, + which: (name: string) => string | null | undefined = $which, ): string[] { const candidates: string[] = []; switch (platform) { diff --git a/packages/coding-agent/src/vibe/runtime.ts b/packages/coding-agent/src/vibe/runtime.ts index 03b119f47..b9c0526d1 100644 --- a/packages/coding-agent/src/vibe/runtime.ts +++ b/packages/coding-agent/src/vibe/runtime.ts @@ -68,6 +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 Vibe cancellation/release cleanup before teardown detaches (ms). */ const VIBE_TEARDOWN_GRACE_MS = 5_000; diff --git a/packages/coding-agent/test/pi-scope-aliases.test.ts b/packages/coding-agent/test/pi-scope-aliases.test.ts index 2449fb8b5..b7e3b7aba 100644 --- a/packages/coding-agent/test/pi-scope-aliases.test.ts +++ b/packages/coding-agent/test/pi-scope-aliases.test.ts @@ -8,7 +8,6 @@ * * Reported failures the test covers: * - `@juicesharp/rpiv-ask-user-question` ⇒ `@earendil-works/pi-tui` - * - `@oh-my-pi/swarm-extension` ⇒ `@oh-my-pi/pi-utils` * - `@plannotator/pi-extension` ⇒ `@oh-my-pi/pi-agent-core` * - `@runfusion/fusion` ⇒ `@oh-my-pi/pi-coding-agent/...` * diff --git a/packages/swarm-extension/.gitignore b/packages/swarm-extension/.gitignore deleted file mode 100644 index c2658d7d1..000000000 --- a/packages/swarm-extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/packages/swarm-extension/CHANGELOG.md b/packages/swarm-extension/CHANGELOG.md deleted file mode 100644 index d5bc04f2b..000000000 --- a/packages/swarm-extension/CHANGELOG.md +++ /dev/null @@ -1,15 +0,0 @@ -# Changelog - -## [Unreleased] - -## [16.3.7] - 2026-07-05 - -### Fixed - -- Fixed the peer dependency range for @oh-my-pi/pi-coding-agent to match the current ^16 major version. - -## [15.9.0] - 2026-06-04 - -### Fixed - -- Fixed swarm `/swarm run` failing with authStorage/modelRegistry identity error ([#1472](https://github.com/can1357/oh-my-pi/issues/1472)) diff --git a/packages/swarm-extension/README.md b/packages/swarm-extension/README.md deleted file mode 100644 index f2ed4b51a..000000000 --- a/packages/swarm-extension/README.md +++ /dev/null @@ -1,472 +0,0 @@ -# Swarm Extension - -Multi-agent orchestration for oh-my-pi. Define agent workflows in YAML — pipelines, parallel fan-outs, sequential chains, or any DAG — and run them unattended until completion. - -Each agent is a full oh-my-pi subagent with access to every tool: bash, python, read, write, edit, grep, find, fetch, web_search, browser. The orchestrator manages lifecycle and ordering; agents communicate through the shared workspace filesystem. - -Use it for anything: research pipelines, code generation, data processing, content creation, analysis workflows, CI-like automation — any multi-step task that benefits from specialized agents working in coordination. - -## Setup - -```bash -cd packages/swarm-extension -bun install -``` - -## Running - -### Standalone (recommended for long-running work) - -```bash -# Foreground — runs until complete, no timeout: -omp-swarm path/to/swarm.yaml - -# Background — survives terminal close: -nohup omp-swarm path/to/swarm.yaml \ - > pipeline.log 2>&1 & disown -``` - -The standalone runner has no timeout. It runs iteration after iteration until the pipeline finishes or you kill it. - -### Inside oh-my-pi (TUI) - -Register the extension in your config (`~/.omp/config.json` or `.omp/config.json`): - -```json -{ - "extensions": ["packages/swarm-extension"] -} -``` - -Then: - -``` -/swarm run path/to/swarm.yaml -/swarm status -/swarm help -``` - -## Monitoring - -State persists to `/.swarm_/` while the pipeline runs: - -``` -.swarm_/ - state/pipeline.json # Live pipeline + per-agent status - logs/orchestrator.log # Wave transitions, iteration progress - logs/.log # Per-agent timestamps and errors - context/ # Agent session artifacts -``` - -Check on a running pipeline: - -```bash -# Quick status -cat workspace/.swarm_mypipeline/state/pipeline.json | python -m json.tool - -# Watch the orchestrator log -tail -f workspace/.swarm_mypipeline/logs/orchestrator.log -``` - ---- - -## YAML Reference - -Every swarm is a single YAML file with a top-level `swarm` key: - -```yaml -swarm: - name: my-pipeline # Identifier (state stored in .swarm_/) - workspace: ./workspace # Working directory (relative to YAML file location) - mode: pipeline # pipeline | parallel | sequential - target_count: 10 # Iterations (pipeline mode only, default: 1) - model: claude-opus-4-6 # Default model for agents without an override (optional) - - agents: - first_agent: - role: short-role-name - task: | - Full instructions for this agent. - extra_context: | - Optional additional system prompt text. - reports_to: - - downstream_agent - waits_for: - - upstream_agent - model: claude-sonnet-4-5 # Optional per-agent override -``` - -### Top-Level Fields - -| Field | Required | Default | Description | -| -------------- | -------- | --------------- | ------------------------------------------------------------------------------ | -| `name` | yes | — | Pipeline identifier. State directory is `.swarm_/` | -| `workspace` | yes | — | Shared working directory. Relative paths resolve from YAML file location | -| `mode` | no | `sequential` | Execution mode (see below) | -| `target_count` | no | `1` | How many times to repeat the full pipeline. Only meaningful in `pipeline` mode | -| `model` | no | session default | Default model for agents that do not set `agents..model` | - -### Agent Fields - -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------------------------------------- | -| `role` | yes | Short role identifier — becomes the agent's system prompt | -| `task` | yes | Complete instructions sent as user prompt. Use YAML `\|` for multi-line | -| `extra_context` | no | Additional text appended to system prompt | -| `model` | no | Model override for this agent only | -| `reports_to` | no | List of agent names that depend on this agent | -| `waits_for` | no | List of agent names this agent depends on | - -### Execution Modes - -**`pipeline`** — Repeat the full agent graph `target_count` times. Each iteration runs all waves in order. Use for accumulative work: "find 50 things, one per iteration." - -**`sequential`** — Run agents once, chained by declaration order (unless explicit dependencies override). The default mode. - -**`parallel`** — Run all agents simultaneously (unless explicit dependencies impose ordering). - -### Dependency Resolution - -The orchestrator builds a DAG from `waits_for` and `reports_to`, then groups agents into **waves** using topological sort. Agents in the same wave run in parallel; waves execute in sequence. - -- `waits_for: [a, b]` — this agent won't start until both `a` and `b` finish -- `reports_to: [x]` — equivalent to `x` having `waits_for: [this_agent]` -- No explicit deps + `pipeline`/`sequential` mode — agents chain by YAML declaration order -- No explicit deps + `parallel` mode — all agents run in one wave -- Cycles are detected and rejected before execution - ---- - -## Patterns - -### Pipeline: Iterative Accumulation - -Run the same agent chain N times. Each iteration builds on the previous one's output. Good for: research collection, data gathering, batch processing, iterative refinement. - -```yaml -swarm: - name: research-collector - workspace: ./workspace - mode: pipeline - target_count: 25 - model: claude-opus-4-6 - - agents: - finder: - role: researcher - task: | - Find ONE new source on the topic defined in workspace/topic.md. - - 1. Read processed.txt to see what's already been found - 2. Use web_search to find a new, high-quality source - 3. Append the URL to processed.txt - 4. Write the URL to signals/finder_out.txt: FOUND: - - analyzer: - role: analyst - task: | - Read signals/finder_out.txt for the URL. - Fetch the page and extract key findings. - Read tracking/count.txt, increment it, write back. - Write analysis to analyzed/item_.md - Write to signals/analyzer_out.txt: DONE: - - compiler: - role: technical-writer - task: | - Read signals/analyzer_out.txt for the item number. - Read analyzed/item_.md. - Append a summary to output/report.md under a new section. -``` - -After 25 iterations: 25 sources found, analyzed, and compiled into a single report. - -### Fan-In: Parallel Specialists - -Multiple agents work independently, one synthesizer combines results. Good for: multi-perspective analysis, parallel code review, comprehensive audits. - -```yaml -swarm: - name: codebase-audit - workspace: ./workspace - - agents: - security: - role: security-auditor - task: | - Audit all code in src/ for security vulnerabilities. - Write findings to reports/security.md with severity ratings. - reports_to: - - lead - - performance: - role: performance-analyst - task: | - Profile and analyze src/ for performance bottlenecks. - Write findings to reports/performance.md with benchmarks. - reports_to: - - lead - - architecture: - role: architecture-reviewer - task: | - Review src/ for architectural issues, coupling, and tech debt. - Write findings to reports/architecture.md with refactoring suggestions. - reports_to: - - lead - - lead: - role: engineering-lead - task: | - Read all reports in reports/. - Create a prioritized action plan in output/action_plan.md. - Rank issues by impact and effort. - waits_for: - - security - - performance - - architecture -``` - -Execution: security + performance + architecture run in parallel (wave 1), lead starts after all three complete (wave 2). - -### Sequential Chain: Staged Handoff - -Linear progression through distinct phases. Good for: content pipelines, multi-stage processing, review chains. - -```yaml -swarm: - name: blog-post - workspace: ./workspace - mode: sequential - - agents: - researcher: - role: researcher - task: | - Research the topic in topic.md using web_search. - Write raw findings and source links to research/notes.md - - writer: - role: technical-writer - task: | - Read research/notes.md. - Write a complete blog post draft to drafts/post.md. - Include code examples where relevant. - - editor: - role: editor - task: | - Read drafts/post.md. - Fix grammar, improve flow, tighten prose. - Rewrite to drafts/post.md. - - reviewer: - role: senior-reviewer - task: | - Read drafts/post.md. - Check technical accuracy against research/notes.md. - Add an editorial note at top if issues found, otherwise - copy to output/final.md. -``` - -Execution: researcher -> writer -> editor -> reviewer, one after another. - -### Diamond: Fan-Out Then Fan-In - -One planner, parallel workers, one integrator. Good for: divide-and-conquer, modular code generation, multi-file refactors. - -```yaml -swarm: - name: feature-implementation - workspace: ./workspace - - agents: - planner: - role: architect - task: | - Read the feature spec in spec.md. - Break it into independent implementation tasks. - Write the plan to plan.md with file assignments. - reports_to: - - api - - ui - - tests - - api: - role: backend-developer - task: | - Read plan.md for your assigned files. - Implement the API layer. Write to src/api/. - reports_to: - - integrator - - ui: - role: frontend-developer - task: | - Read plan.md for your assigned files. - Implement the UI components. Write to src/ui/. - reports_to: - - integrator - - tests: - role: test-engineer - task: | - Read plan.md for the full feature scope. - Write integration tests to tests/. - reports_to: - - integrator - - integrator: - role: tech-lead - task: | - Read plan.md and review all code in src/ and tests/. - Wire everything together. Fix any integration issues. - Run the tests and fix failures. - Write status to output/done.md. -``` - -Execution: planner (wave 1) -> api + ui + tests in parallel (wave 2) -> integrator (wave 3). - -### Hybrid: Mixed Dependencies - -Any DAG is valid. Combine patterns freely. - -```yaml -swarm: - name: data-pipeline - workspace: ./workspace - mode: pipeline - target_count: 10 - - agents: - scraper_a: - role: web-scraper - task: | - Scrape data source A. Write to raw/source_a.json - reports_to: - - transformer - - scraper_b: - role: web-scraper - task: | - Scrape data source B. Write to raw/source_b.json - reports_to: - - transformer - - transformer: - role: data-engineer - task: | - Read raw/source_a.json and raw/source_b.json. - Clean, normalize, merge. Write to processed/merged.json - reports_to: - - loader - - validator - - validator: - role: qa-analyst - task: | - Read processed/merged.json. - Validate schema, check for anomalies. - Write report to qa/validation.md - - loader: - role: data-engineer - task: | - Read processed/merged.json. - Append to output/dataset.jsonl -``` - -Execution per iteration: scraper_a + scraper_b (wave 1) -> transformer (wave 2) -> loader + validator (wave 3). - ---- - -## Writing Agent Tasks - -### What Agents Can Do - -Each agent is a full oh-my-pi session. It can: - -- **bash/python**: Run commands, scripts, install packages, process data -- **read/write/edit**: Create and modify files in the workspace -- **grep/find**: Search the workspace (or anywhere on disk) -- **web_search**: Search the internet (via configured provider) -- **fetch**: Download web pages, APIs, documents -- **browser**: Navigate websites, scrape dynamic content, take screenshots - -### Inter-Agent Communication - -The orchestrator starts and stops agents in the right order. It does **not** pass data between them. Agents communicate through files in the shared workspace. - -Design your own protocol. Common patterns: - -**Signal files** — lightweight status flags an agent writes when done: - -``` -signals/finder_out.txt -> "FOUND:https://example.com" -signals/analyzer_out.txt -> "DONE:42" -signals/reviewer_out.txt -> "APPROVED" or "REJECTED:reason" -``` - -**Structured output** — detailed results other agents read: - -``` -analyzed/item_1.md -> Full analysis document -results/report.json -> Machine-readable data -output/final.docx -> Accumulated deliverable -``` - -**Tracking files** — prevent duplicate work across pipeline iterations: - -``` -processed.txt -> Items already handled (one per line) -tracking/count.txt -> Current item counter -tracking/status.json -> Cumulative state -``` - -### Tips for Reliable Agents - -- **Be explicit about paths.** Agents start fresh each iteration — they don't remember previous runs. Tell them exactly where to read input and write output. -- **Check existing state.** In pipeline mode, tell agents to read tracking files before doing work: "Read processed.txt to avoid duplicates." -- **Use numbered outputs.** `item_1.md`, `item_2.md` etc. so iterations don't clobber each other. -- **Handle failure.** Tell agents what to do when things go wrong: "If the source lacks depth, write SKIP to signals/out.txt and explain why." -- **Keep signal files simple.** One line, parseable format. Complex data goes in structured output files. -- **Scope the task tightly.** An agent that tries to do five things will do zero well. One clear objective per agent. - ---- - -## Models - -Any model configured in omp works. Set a swarm default and optionally override per agent: - -```yaml -swarm: - model: claude-opus-4-6 - agents: - writer: - role: technical-writer - task: | - Write the draft. - reviewer: - role: reviewer - model: claude-sonnet-4-5 - task: | - Review the draft. -``` - -Precedence: `agents..model` → `swarm.model` → session default. Check `packages/ai/src/models.json` for available model IDs. - ---- - -## Architecture - -``` -src/extension.ts TUI entry point (registers /swarm command) -src/cli.ts Standalone runner (no TUI, no timeout) -src/swarm/ - schema.ts YAML parsing + validation - dag.ts Dependency graph, cycle detection, topological sort - executor.ts Spawns agents via oh-my-pi's runSubprocess - pipeline.ts Iteration loop + wave controller - state.ts Filesystem state persistence - render.ts Progress display formatting -``` diff --git a/packages/swarm-extension/package.json b/packages/swarm-extension/package.json deleted file mode 100644 index 1041b8abe..000000000 --- a/packages/swarm-extension/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "type": "module", - "name": "@oh-my-pi/swarm-extension", - "version": "17.2.8", - "description": "Swarm orchestration extension for omp", - "homepage": "https://omp.sh", - "author": "Derek Rynd", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/can1357/oh-my-pi.git", - "directory": "packages/swarm-extension" - }, - "bugs": { - "url": "https://github.com/can1357/oh-my-pi/issues" - }, - "keywords": [ - "swarm", - "orchestration", - "agent", - "extension" - ], - "bin": { - "omp-swarm": "src/cli.ts" - }, - "scripts": { - "check": "biome check . && bun run check:types", - "check:types": "tsgo -p tsconfig.json --noEmit", - "lint": "biome lint .", - "fix": "biome check --write --unsafe .", - "fmt": "biome format --write ." - }, - "dependencies": { - "@oh-my-pi/pi-utils": "workspace:*" - }, - "devDependencies": { - "@types/bun": "^1.3.14" - }, - "peerDependencies": { - "@oh-my-pi/pi-coding-agent": "^16" - }, - "engines": { - "bun": ">=1.3.14" - }, - "omp": { - "extensions": [ - "./src/extension.ts" - ] - }, - "files": [ - "src", - "README.md", - "CHANGELOG.md" - ] -} diff --git a/packages/swarm-extension/src/cli.ts b/packages/swarm-extension/src/cli.ts deleted file mode 100755 index 6537fc059..000000000 --- a/packages/swarm-extension/src/cli.ts +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bun -/** - * Direct pipeline runner — executes a swarm pipeline outside of the TUI. - * - * Usage: bun cli.ts - */ - -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { discoverAuthStorage } from "@oh-my-pi/pi-coding-agent"; -import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { buildDependencyGraph, buildExecutionWaves, detectCycles } from "./swarm/dag"; -import { PipelineController } from "./swarm/pipeline"; -import { renderSwarmProgress } from "./swarm/render"; -import { parseSwarmYaml, validateSwarmDefinition } from "./swarm/schema"; -import { StateTracker } from "./swarm/state"; - -const yamlPath = process.argv[2]; -if (!yamlPath) { - console.error("Usage: omp-swarm "); - process.exit(1); -} - -const resolvedPath = path.resolve(yamlPath); -console.log(`Reading: ${resolvedPath}`); - -const content = await Bun.file(resolvedPath).text(); -const def = parseSwarmYaml(content); - -console.log(`Swarm: ${def.name}`); -console.log(`Mode: ${def.mode}`); -console.log(`Target count: ${def.targetCount}`); -console.log(`Agents: ${[...def.agents.keys()].join(", ")}`); - -// Validate -const errors = validateSwarmDefinition(def); -if (errors.length > 0) { - console.error("Validation errors:", errors); - process.exit(1); -} - -// Build DAG -const deps = buildDependencyGraph(def); -const cycles = detectCycles(deps); -if (cycles) { - console.error("Cycle detected:", cycles); - process.exit(1); -} -const waves = buildExecutionWaves(deps); -console.log(`Waves: ${waves.map((w, i) => `W${i + 1}:[${w.join(",")}]`).join(" -> ")}`); - -// Resolve workspace -const workspace = path.isAbsolute(def.workspace) - ? def.workspace - : path.resolve(path.dirname(resolvedPath), def.workspace); - -await fs.mkdir(workspace, { recursive: true }); -console.log(`Workspace: ${workspace}`); - -// Initialize -const stateTracker = new StateTracker(workspace, def.name); -await stateTracker.init([...def.agents.keys()], def.targetCount, def.mode); - -// Auth + settings -const authStorage = await discoverAuthStorage(); -const modelRegistry = new ModelRegistry(authStorage); -const settings = Settings.isolated(); - -// Progress display -let lastProgressDump = 0; -const PROGRESS_INTERVAL_MS = 5000; - -// Run -console.log("\n--- Pipeline starting ---\n"); - -const controller = new PipelineController(def, waves, stateTracker); -const result = await controller.run({ - workspace, - onProgress: () => { - const now = Date.now(); - if (now - lastProgressDump > PROGRESS_INTERVAL_MS) { - lastProgressDump = now; - const lines = renderSwarmProgress(stateTracker.state); - console.log(lines.join("\n")); - console.log(); - } - }, - modelRegistry, - settings, -}); - -console.log("\n--- Pipeline finished ---\n"); -console.log(`Status: ${result.status}`); -console.log(`Iterations completed: ${result.iterations}/${def.targetCount}`); -if (result.errors.length > 0) { - console.log(`Errors (${result.errors.length}):`); - for (const err of result.errors) { - console.log(` - ${err}`); - } -} -console.log(`\nState saved to: ${stateTracker.swarmDir}`); - -// Final state dump -const lines = renderSwarmProgress(stateTracker.state); -console.log(lines.join("\n")); diff --git a/packages/swarm-extension/src/extension.ts b/packages/swarm-extension/src/extension.ts deleted file mode 100644 index c0223cd7c..000000000 --- a/packages/swarm-extension/src/extension.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Swarm Extension — Multi-agent pipeline orchestration from YAML definitions. - * - * Registers: - * - /swarm run — Execute a swarm pipeline - * - /swarm status — Show current pipeline status - * - * Usage: Add this extension's directory to your extensions config, - * then use /swarm in any oh-my-pi session. - */ - -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import type { ExtensionAPI, ExtensionCommandContext } from "@oh-my-pi/pi-coding-agent"; -import { formatDuration } from "@oh-my-pi/pi-utils"; -import { buildDependencyGraph, buildExecutionWaves, detectCycles } from "./swarm/dag"; -import { PipelineController } from "./swarm/pipeline"; -import { renderSwarmProgress } from "./swarm/render"; -import { parseSwarmYaml, type SwarmDefinition, validateSwarmDefinition } from "./swarm/schema"; -import { StateTracker } from "./swarm/state"; - -export default function swarmExtension(pi: ExtensionAPI): void { - pi.setLabel("Swarm Orchestrator"); - - pi.registerCommand("swarm", { - description: "Run a multi-agent swarm pipeline from YAML", - getArgumentCompletions: prefix => { - const subcommands = ["run", "status", "help"]; - if (!prefix) return subcommands.map(s => ({ label: s, value: s })); - return subcommands.filter(s => s.startsWith(prefix)).map(s => ({ label: s, value: s })); - }, - handler: async (args: string, ctx: ExtensionCommandContext) => { - const parts = args.trim().split(/\s+/); - const subcommand = parts[0] ?? "help"; - - switch (subcommand) { - case "run": { - const yamlPath = parts[1]; - if (!yamlPath) { - ctx.ui.notify("Usage: /swarm run ", "error"); - return; - } - await handleRun(yamlPath, ctx, pi); - return; - } - case "status": { - await handleStatus(parts[1], ctx); - return; - } - default: - ctx.ui.notify( - [ - "Swarm — multi-agent pipeline orchestrator", - "", - " /swarm run Run a pipeline", - " /swarm status [name] Show pipeline status", - " /swarm help Show this help", - ].join("\n"), - "info", - ); - return; - } - }, - }); -} - -// ============================================================================ -// /swarm run -// ============================================================================ - -async function handleRun(yamlPath: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { - // 1. Resolve and read YAML - const resolvedPath = path.isAbsolute(yamlPath) ? yamlPath : path.resolve(ctx.cwd, yamlPath); - - let content: string; - try { - content = await Bun.file(resolvedPath).text(); - } catch { - ctx.ui.notify(`Cannot read file: ${resolvedPath}`, "error"); - return; - } - - // 2. Parse YAML - let def: SwarmDefinition; - try { - def = parseSwarmYaml(content); - } catch (err) { - ctx.ui.notify(`YAML error: ${err instanceof Error ? err.message : String(err)}`, "error"); - return; - } - - // 3. Validate - const validationErrors = validateSwarmDefinition(def); - if (validationErrors.length > 0) { - ctx.ui.notify(`Validation errors:\n${validationErrors.map(e => ` - ${e}`).join("\n")}`, "error"); - return; - } - - // 4. Build DAG - const deps = buildDependencyGraph(def); - const cycleNodes = detectCycles(deps); - if (cycleNodes) { - ctx.ui.notify(`Cycle detected in agent dependencies: [${cycleNodes.join(", ")}]`, "error"); - return; - } - const waves = buildExecutionWaves(deps); - - // 5. Resolve workspace (relative to YAML file location) - const workspace = path.isAbsolute(def.workspace) - ? def.workspace - : path.resolve(path.dirname(resolvedPath), def.workspace); - - // Ensure workspace exists - await fs.mkdir(workspace, { recursive: true }); - - // 6. Initialize state tracker - const stateTracker = new StateTracker(workspace, def.name); - await stateTracker.init([...def.agents.keys()], def.targetCount, def.mode); - - // 7. Log start - const agentList = [...def.agents.keys()].join(", "); - const waveDesc = waves.map((w, i) => `wave ${i + 1}: [${w.join(", ")}]`).join("; "); - pi.logger.debug("Swarm starting", { - name: def.name, - mode: def.mode, - agents: agentList, - waves: waveDesc, - workspace, - }); - - ctx.ui.notify( - `Starting swarm '${def.name}': ${def.agents.size} agents, ${waves.length} waves, ${def.targetCount} iteration(s)`, - "info", - ); - - // 8. Set up progress widget - const widgetKey = `swarm-${def.name}`; - const updateWidget = () => { - const lines = renderSwarmProgress(stateTracker.state); - ctx.ui.setWidget(widgetKey, lines); - }; - updateWidget(); - - // 9. Run pipeline - const controller = new PipelineController(def, waves, stateTracker); - - const result = await controller.run({ - workspace, - onProgress: () => updateWidget(), - modelRegistry: ctx.modelRegistry, - settings: pi.pi.settings, - }); - - // 10. Clear widget and show summary - ctx.ui.setWidget(widgetKey, undefined); - - const elapsed = stateTracker.state.completedAt - ? formatDuration(stateTracker.state.completedAt - stateTracker.state.startedAt) - : "unknown"; - - const summaryParts = [ - `Swarm '${def.name}' ${result.status}`, - `${result.iterations}/${def.targetCount} iterations`, - `elapsed: ${elapsed}`, - ]; - - if (result.errors.length > 0) { - summaryParts.push(`${result.errors.length} error(s)`); - } - - const summaryType = result.status === "completed" ? "info" : "error"; - ctx.ui.notify(summaryParts.join(" | "), summaryType); - - // Log errors - if (result.errors.length > 0) { - pi.logger.warn("Swarm completed with errors", { errors: result.errors }); - } - - // 11. Send summary to the conversation so the LLM knows what happened - const summaryMessage = buildSummaryMessage(def, result, stateTracker, workspace); - pi.sendMessage( - { - customType: "swarm-result", - content: [{ type: "text", text: summaryMessage }], - display: true, - details: { - swarmName: def.name, - status: result.status, - iterations: result.iterations, - errorCount: result.errors.length, - }, - }, - { triggerTurn: false }, - ); -} - -// ============================================================================ -// /swarm status -// ============================================================================ - -async function handleStatus(name: string | undefined, ctx: ExtensionCommandContext): Promise { - if (!name) { - ctx.ui.notify("Usage: /swarm status (reads .swarm_/state/pipeline.json from cwd)", "info"); - return; - } - - const stateTracker = new StateTracker(ctx.cwd, name); - const state = await stateTracker.load(); - if (!state) { - ctx.ui.notify(`No state found for swarm '${name}' in ${ctx.cwd}`, "error"); - return; - } - - const lines = renderSwarmProgress(state); - ctx.ui.notify(lines.join("\n"), "info"); -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function buildSummaryMessage( - def: SwarmDefinition, - result: { status: string; iterations: number; errors: string[] }, - stateTracker: StateTracker, - workspace: string, -): string { - const lines: string[] = []; - lines.push(`## Swarm Pipeline: ${def.name}`); - lines.push(""); - lines.push(`- **Status**: ${result.status}`); - lines.push(`- **Mode**: ${def.mode}`); - lines.push(`- **Iterations**: ${result.iterations}/${def.targetCount}`); - lines.push(`- **Workspace**: ${workspace}`); - lines.push(`- **State dir**: ${stateTracker.swarmDir}`); - lines.push(""); - - lines.push("### Agent Results"); - lines.push(""); - for (const [name, agent] of Object.entries(stateTracker.state.agents)) { - const duration = - agent.startedAt && agent.completedAt ? formatDuration(agent.completedAt - agent.startedAt) : "n/a"; - lines.push(`- **${name}**: ${agent.status} (${duration})${agent.error ? ` — ${agent.error}` : ""}`); - } - - if (result.errors.length > 0) { - lines.push(""); - lines.push("### Errors"); - lines.push(""); - for (const error of result.errors) { - lines.push(`- ${error}`); - } - } - - return lines.join("\n"); -} diff --git a/packages/swarm-extension/src/swarm/dag.ts b/packages/swarm-extension/src/swarm/dag.ts deleted file mode 100644 index 7efc1cbba..000000000 --- a/packages/swarm-extension/src/swarm/dag.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Directed Acyclic Graph operations for swarm agent dependencies. - * - * Builds a dependency graph from waits_for / reports_to relationships, - * detects cycles, and produces execution waves via topological sort. - */ -import type { SwarmDefinition } from "./schema"; - -/** - * Build a dependency map: agent name → set of agents it depends on. - * - * Dependencies come from: - * 1. Explicit `waits_for` declarations - * 2. Implicit from `reports_to` (if A reports_to B, then B depends on A) - * 3. For pipeline/sequential mode with no explicit deps: chain by YAML declaration order - */ -export function buildDependencyGraph(def: SwarmDefinition): Map> { - const deps = new Map>(); - - for (const name of def.agents.keys()) { - deps.set(name, new Set()); - } - - // Explicit waits_for - for (const [name, agent] of def.agents) { - for (const dep of agent.waitsFor) { - if (deps.has(dep)) { - deps.get(name)!.add(dep); - } - } - } - - // reports_to implies the target waits for the reporter - for (const [name, agent] of def.agents) { - for (const target of agent.reportsTo) { - if (deps.has(target)) { - deps.get(target)!.add(name); - } - } - } - - // For pipeline/sequential with no explicit deps, chain by declaration order - if ((def.mode === "pipeline" || def.mode === "sequential") && !hasExplicitDeps(deps)) { - for (let i = 1; i < def.agentOrder.length; i++) { - deps.get(def.agentOrder[i])!.add(def.agentOrder[i - 1]); - } - } - - return deps; -} - -function hasExplicitDeps(deps: Map>): boolean { - for (const s of deps.values()) { - if (s.size > 0) return true; - } - return false; -} - -/** - * Detect cycles in the dependency graph. - * Returns the names of agents involved in cycles, or null if acyclic. - */ -export function detectCycles(deps: Map>): string[] | null { - // Kahn's algorithm: if topological sort doesn't include all nodes, cycles exist - const inDegree = new Map(); - const forward = new Map(); // dependency → its dependents - - for (const [node, nodeDeps] of deps) { - inDegree.set(node, nodeDeps.size); - for (const dep of nodeDeps) { - const list = forward.get(dep) ?? []; - list.push(node); - forward.set(dep, list); - } - } - - const queue: string[] = []; - for (const [node, degree] of inDegree) { - if (degree === 0) queue.push(node); - } - - const sorted: string[] = []; - while (queue.length > 0) { - const node = queue.shift()!; - sorted.push(node); - for (const dependent of forward.get(node) ?? []) { - const newDegree = inDegree.get(dependent)! - 1; - inDegree.set(dependent, newDegree); - if (newDegree === 0) queue.push(dependent); - } - } - - if (sorted.length < deps.size) { - return [...deps.keys()].filter(k => !sorted.includes(k)); - } - - return null; -} - -/** - * Build execution waves from dependency graph via topological sort. - * - * Each wave contains agents whose dependencies are all in earlier waves. - * Agents within a wave can execute in parallel. - */ -export function buildExecutionWaves(deps: Map>): string[][] { - const waves: string[][] = []; - const completed = new Set(); - const remaining = new Set(deps.keys()); - - while (remaining.size > 0) { - const wave: string[] = []; - - for (const node of remaining) { - const nodeDeps = deps.get(node)!; - let ready = true; - for (const dep of nodeDeps) { - if (!completed.has(dep)) { - ready = false; - break; - } - } - if (ready) { - wave.push(node); - } - } - - if (wave.length === 0) { - throw new Error( - `Deadlock: agents [${[...remaining].join(", ")}] cannot make progress. This indicates a bug in cycle detection.`, - ); - } - - // Sort for deterministic execution order - wave.sort(); - - for (const node of wave) { - remaining.delete(node); - completed.add(node); - } - - waves.push(wave); - } - - return waves; -} diff --git a/packages/swarm-extension/src/swarm/executor.ts b/packages/swarm-extension/src/swarm/executor.ts deleted file mode 100644 index 083698034..000000000 --- a/packages/swarm-extension/src/swarm/executor.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Swarm agent execution via oh-my-pi's subagent infrastructure. - * - * Wraps `runSubprocess` to spawn individual swarm agents with full tool access. - * Each agent runs in the swarm workspace with its task instructions as the user prompt. - */ -import * as path from "node:path"; -import type { - AgentDefinition, - AgentProgress, - AgentSource, - ModelRegistry, - Settings, - SingleResult, -} from "@oh-my-pi/pi-coding-agent"; -import { runSubprocess } from "@oh-my-pi/pi-coding-agent"; -import type { SwarmAgent } from "./schema"; -import type { StateTracker } from "./state"; - -export interface SwarmExecutorOptions { - workspace: string; - swarmName: string; - iteration: number; - modelOverride?: string; - signal?: AbortSignal; - onProgress?: (agentName: string, progress: AgentProgress) => void; - modelRegistry?: ModelRegistry; - settings?: Settings; - stateTracker: StateTracker; -} - -/** - * Execute a single swarm agent as an oh-my-pi subagent. - * - * The agent receives: - * - System prompt: built from role + extra_context - * - User prompt (task): the full task instructions from the YAML - * - Working directory: the swarm workspace - * - Full tool access (bash, python, read, write, edit, grep, find, fetch, web_search, browser) - */ -export async function executeSwarmAgent( - agent: SwarmAgent, - index: number, - options: SwarmExecutorOptions, -): Promise { - const { workspace, swarmName, iteration, modelOverride, signal, onProgress, modelRegistry, settings, stateTracker } = - options; - - const agentId = `swarm-${swarmName}-${agent.name}-${iteration}`; - - const agentDef: AgentDefinition = { - name: agent.name, - description: `Swarm agent: ${agent.role}`, - systemPrompt: buildSystemPrompt(agent), - source: "project" as AgentSource, - }; - - await stateTracker.updateAgent(agent.name, { - status: "running", - iteration, - startedAt: Date.now(), - }); - await stateTracker.appendLog(agent.name, `Starting iteration ${iteration}`); - - try { - const result = await runSubprocess({ - cwd: workspace, - agent: agentDef, - task: agent.task, - index, - id: agentId, - modelOverride, - signal, - onProgress: progress => onProgress?.(agent.name, progress), - modelRegistry, - settings, - enableLsp: false, - artifactsDir: path.join(stateTracker.swarmDir, "context"), - }); - - const status = result.exitCode === 0 ? ("completed" as const) : ("failed" as const); - await stateTracker.updateAgent(agent.name, { - status, - completedAt: Date.now(), - error: result.error, - }); - await stateTracker.appendLog( - agent.name, - `Iteration ${iteration} ${status}${result.error ? `: ${result.error}` : ""}`, - ); - - return result; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - await stateTracker.updateAgent(agent.name, { - status: "failed", - completedAt: Date.now(), - error, - }); - await stateTracker.appendLog(agent.name, `Iteration ${iteration} error: ${error}`); - throw err; - } -} - -function buildSystemPrompt(agent: SwarmAgent): string { - const parts = [`You are a ${agent.role}.`]; - if (agent.extraContext) { - parts.push(agent.extraContext); - } - return parts.join("\n\n"); -} diff --git a/packages/swarm-extension/src/swarm/pipeline.ts b/packages/swarm-extension/src/swarm/pipeline.ts deleted file mode 100644 index d2e05464d..000000000 --- a/packages/swarm-extension/src/swarm/pipeline.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Pipeline controller for swarm execution. - * - * Orchestrates execution waves within each iteration: - * - Agents in the same wave execute in parallel - * - Waves execute sequentially (wave N+1 starts after wave N completes) - * - For pipeline mode, iterations repeat the full DAG execution - */ -import type { AgentSource, ModelRegistry, Settings, SingleResult } from "@oh-my-pi/pi-coding-agent"; -import { executeSwarmAgent } from "./executor"; -import type { SwarmDefinition } from "./schema"; -import type { StateTracker } from "./state"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface PipelineOptions { - workspace: string; - signal?: AbortSignal; - onProgress?: (state: PipelineProgress) => void; - modelRegistry?: ModelRegistry; - settings?: Settings; -} - -export interface PipelineProgress { - iteration: number; - targetCount: number; - currentWave: number; - totalWaves: number; - agents: Record; -} - -export interface PipelineResult { - status: "completed" | "failed" | "aborted"; - iterations: number; - agentResults: Map; - errors: string[]; -} - -// ============================================================================ -// Controller -// ============================================================================ - -export class PipelineController { - #def: SwarmDefinition; - #waves: string[][]; - #stateTracker: StateTracker; - - constructor(def: SwarmDefinition, waves: string[][], stateTracker: StateTracker) { - this.#def = def; - this.#waves = waves; - this.#stateTracker = stateTracker; - } - - async run(options: PipelineOptions): Promise { - const { workspace, signal, onProgress, modelRegistry, settings } = options; - const allResults = new Map(); - const errors: string[] = []; - - for (const name of this.#def.agents.keys()) { - allResults.set(name, []); - } - - const targetCount = this.#def.targetCount; - - await this.#stateTracker.appendOrchestratorLog( - `Pipeline '${this.#def.name}' starting: mode=${this.#def.mode} iterations=${targetCount} waves=${this.#waves.length} agents=${this.#def.agents.size}`, - ); - - try { - for (let iteration = 0; iteration < targetCount; iteration++) { - if (signal?.aborted) { - await this.#stateTracker.updatePipeline({ status: "aborted" }); - return { status: "aborted", iterations: iteration, agentResults: allResults, errors }; - } - - await this.#stateTracker.updatePipeline({ iteration }); - await this.#stateTracker.appendOrchestratorLog(`--- Iteration ${iteration + 1}/${targetCount} ---`); - - const emitProgress = (currentWave: number) => { - onProgress?.({ - iteration, - targetCount, - currentWave, - totalWaves: this.#waves.length, - agents: this.#buildProgressSnapshot(), - }); - }; - - const iterationResults = await this.#runIteration(iteration, { - workspace, - signal, - emitProgress, - modelRegistry, - settings, - }); - - for (const [agentName, result] of iterationResults) { - allResults.get(agentName)!.push(result); - if (result.exitCode !== 0) { - errors.push( - `${agentName} (iteration ${iteration + 1}): ${result.error || `exit code ${result.exitCode}`}`, - ); - } - } - } - - const status = errors.length > 0 ? ("failed" as const) : ("completed" as const); - await this.#stateTracker.updatePipeline({ status, completedAt: Date.now() }); - await this.#stateTracker.appendOrchestratorLog(`Pipeline ${status} (${errors.length} errors)`); - return { status, iterations: targetCount, agentResults: allResults, errors }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - await this.#stateTracker.updatePipeline({ status: "failed", completedAt: Date.now() }); - await this.#stateTracker.appendOrchestratorLog(`Pipeline fatal error: ${error}`); - errors.push(error); - return { status: "failed", iterations: 0, agentResults: allResults, errors }; - } - } - - async #runIteration( - iteration: number, - options: { - workspace: string; - signal?: AbortSignal; - emitProgress: (currentWave: number) => void; - modelRegistry?: ModelRegistry; - settings?: Settings; - }, - ): Promise> { - const results = new Map(); - let agentIndex = 0; - - for (let waveIdx = 0; waveIdx < this.#waves.length; waveIdx++) { - const wave = this.#waves[waveIdx]; - - if (options.signal?.aborted) break; - - await this.#stateTracker.appendOrchestratorLog( - `Wave ${waveIdx + 1}/${this.#waves.length}: [${wave.join(", ")}]`, - ); - - // Mark agents in this wave as waiting - for (const agentName of wave) { - await this.#stateTracker.updateAgent(agentName, { - status: "waiting", - iteration, - wave: waveIdx, - }); - } - options.emitProgress(waveIdx); - - // Execute all agents in wave in parallel, catching per-agent errors - const waveResults = await Promise.all( - wave.map(async agentName => { - const agent = this.#def.agents.get(agentName)!; - const currentIndex = agentIndex++; - try { - const result = await executeSwarmAgent(agent, currentIndex, { - workspace: options.workspace, - swarmName: this.#def.name, - iteration, - modelOverride: agent.model ?? this.#def.model, - signal: options.signal, - onProgress: (_name, _progress) => { - options.emitProgress(waveIdx); - }, - modelRegistry: options.modelRegistry, - settings: options.settings, - stateTracker: this.#stateTracker, - }); - return { agentName, result }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - const failResult: SingleResult = { - index: currentIndex, - id: `swarm-${this.#def.name}-${agentName}-${iteration}`, - agent: agentName, - agentSource: "project" as AgentSource, - task: agent.task, - exitCode: 1, - output: "", - stderr: error, - truncated: false, - durationMs: 0, - tokens: 0, - requests: 0, - error, - }; - return { agentName, result: failResult }; - } - }), - ); - - for (const { agentName, result } of waveResults) { - results.set(agentName, result); - } - - options.emitProgress(waveIdx); - } - - return results; - } - - #buildProgressSnapshot(): Record { - const snapshot: Record = {}; - for (const [name, agent] of Object.entries(this.#stateTracker.state.agents)) { - snapshot[name] = { status: agent.status, iteration: agent.iteration }; - } - return snapshot; - } -} diff --git a/packages/swarm-extension/src/swarm/render.ts b/packages/swarm-extension/src/swarm/render.ts deleted file mode 100644 index e17da65a6..000000000 --- a/packages/swarm-extension/src/swarm/render.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * TUI progress rendering for swarm pipeline status. - */ -import { formatDuration, truncate } from "@oh-my-pi/pi-utils"; -import type { AgentState, SwarmState } from "./state"; - -const STATUS_LABELS: Record = { - completed: "[done]", - running: "[....]", - failed: "[FAIL]", - pending: "[ ]", - waiting: "[wait]", - idle: "[idle]", - aborted: "[stop]", -}; - -export function renderSwarmProgress(state: SwarmState): string[] { - const lines: string[] = []; - - const statusLabel = state.status.toUpperCase(); - lines.push(`Swarm: ${state.name} [${statusLabel}]`); - lines.push(`Mode: ${state.mode} | Iteration: ${state.iteration + 1}/${state.targetCount}`); - lines.push(""); - - const agents: AgentState[] = Object.values(state.agents); - if (agents.length === 0) { - lines.push(" (no agents)"); - return lines; - } - - for (const agent of agents) { - const icon = STATUS_LABELS[agent.status] ?? "[????]"; - const duration = formatAgentDuration(agent); - const errorSuffix = agent.error ? ` - ${truncate(agent.error, 60)}` : ""; - lines.push(` ${icon} ${agent.name}: ${agent.status}${duration}${errorSuffix}`); - } - - // Summary line - const completed = agents.filter(a => a.status === "completed").length; - const failed = agents.filter(a => a.status === "failed").length; - const running = agents.filter(a => a.status === "running").length; - - lines.push(""); - const parts = [`${completed}/${agents.length} done`]; - if (running > 0) parts.push(`${running} running`); - if (failed > 0) parts.push(`${failed} failed`); - if (state.startedAt) { - parts.push(`elapsed: ${formatDuration(Date.now() - state.startedAt)}`); - } - lines.push(` ${parts.join(" | ")}`); - - return lines; -} - -function formatAgentDuration(agent: { startedAt?: number; completedAt?: number; status: string }): string { - if (agent.startedAt && agent.completedAt) { - return ` (${formatDuration(agent.completedAt - agent.startedAt)})`; - } - if (agent.startedAt && (agent.status === "running" || agent.status === "waiting")) { - return ` (${formatDuration(Date.now() - agent.startedAt)}...)`; - } - return ""; -} diff --git a/packages/swarm-extension/src/swarm/schema.ts b/packages/swarm-extension/src/swarm/schema.ts deleted file mode 100644 index 567b94349..000000000 --- a/packages/swarm-extension/src/swarm/schema.ts +++ /dev/null @@ -1,157 +0,0 @@ -// ============================================================================ -// Raw YAML shape (snake_case, optional fields) -// ============================================================================ - -interface RawSwarmAgentConfig { - role: string; - task: string; - extra_context?: string; - reports_to?: string[]; - waits_for?: string[]; - model?: string; -} - -interface RawSwarmConfig { - name: string; - workspace: string; - mode?: string; - target_count?: number; - model?: string; - agents: Record; -} - -// ============================================================================ -// Normalized types (camelCase, defaults applied) -// ============================================================================ - -export type SwarmMode = "pipeline" | "parallel" | "sequential"; - -export interface SwarmAgent { - name: string; - role: string; - task: string; - extraContext?: string; - reportsTo: string[]; - waitsFor: string[]; - model?: string; -} - -export interface SwarmDefinition { - name: string; - workspace: string; - mode: SwarmMode; - targetCount: number; - model?: string; - agents: Map; - /** Preserves YAML declaration order for implicit pipeline sequencing. */ - agentOrder: string[]; -} - -// ============================================================================ -// Parsing -// ============================================================================ - -const VALID_MODES = new Set(["pipeline", "parallel", "sequential"]); -const VALID_SWARM_NAME = /^[a-zA-Z0-9._-]+$/; - -export function parseSwarmYaml(content: string): SwarmDefinition { - const raw = Bun.YAML.parse(content) as { swarm?: RawSwarmConfig } | null; - if (!raw?.swarm) { - throw new Error("YAML must have a top-level 'swarm' key"); - } - const swarm = raw.swarm; - - if (!swarm.name || typeof swarm.name !== "string") { - throw new Error("swarm.name is required and must be a string"); - } - if (!VALID_SWARM_NAME.test(swarm.name)) { - throw new Error("swarm.name may only contain letters, numbers, dot, underscore, and dash"); - } - if (!swarm.workspace || typeof swarm.workspace !== "string") { - throw new Error("swarm.workspace is required and must be a string"); - } - if (!swarm.agents || typeof swarm.agents !== "object" || Object.keys(swarm.agents).length === 0) { - throw new Error("swarm.agents must contain at least one agent"); - } - - const mode = swarm.mode ?? "sequential"; - if (!VALID_MODES.has(mode)) { - throw new Error(`Invalid mode '${mode}'. Must be one of: ${[...VALID_MODES].join(", ")}`); - } - - const agentOrder: string[] = []; - const agents = new Map(); - - for (const [name, config] of Object.entries(swarm.agents)) { - if (!config.role || typeof config.role !== "string") { - throw new Error(`Agent '${name}': 'role' is required`); - } - if (!config.task || typeof config.task !== "string") { - throw new Error(`Agent '${name}': 'task' is required`); - } - - agentOrder.push(name); - agents.set(name, { - name, - role: config.role, - task: config.task.trim(), - extraContext: config.extra_context?.trim(), - reportsTo: Array.isArray(config.reports_to) ? config.reports_to : [], - model: typeof config.model === "string" ? config.model.trim() : undefined, - waitsFor: Array.isArray(config.waits_for) ? config.waits_for : [], - }); - } - - return { - name: swarm.name, - workspace: swarm.workspace, - mode: mode as SwarmMode, - targetCount: swarm.target_count ?? 1, - model: typeof swarm.model === "string" ? swarm.model.trim() : undefined, - agents, - agentOrder, - }; -} - -// ============================================================================ -// Validation (semantic — references, constraints) -// ============================================================================ - -export function validateSwarmDefinition(def: SwarmDefinition): string[] { - const errors: string[] = []; - const agentNames = new Set(def.agents.keys()); - - if (def.model !== undefined && def.model.length === 0) { - errors.push("swarm.model must not be empty when provided"); - } - for (const [name, agent] of def.agents) { - for (const dep of agent.waitsFor) { - if (!agentNames.has(dep)) { - errors.push(`Agent '${name}' waits_for unknown agent '${dep}'`); - } - if (dep === name) { - errors.push(`Agent '${name}' cannot wait for itself`); - } - } - for (const target of agent.reportsTo) { - if (!agentNames.has(target)) { - errors.push(`Agent '${name}' reports_to unknown agent '${target}'`); - } - if (target === name) { - errors.push(`Agent '${name}' cannot report to itself`); - } - } - if (agent.model !== undefined && agent.model.length === 0) { - errors.push(`Agent '${name}' model must not be empty when provided`); - } - } - - if (def.targetCount < 1) { - errors.push("target_count must be at least 1"); - } - if (def.mode !== "pipeline" && def.targetCount !== 1) { - errors.push("target_count is only supported in pipeline mode"); - } - - return errors; -} diff --git a/packages/swarm-extension/src/swarm/state.ts b/packages/swarm-extension/src/swarm/state.ts deleted file mode 100644 index 4d818991d..000000000 --- a/packages/swarm-extension/src/swarm/state.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Filesystem state tracker for swarm pipeline execution. - * - * Persists pipeline and per-agent state to `.swarm_/` in the workspace. - * Supports resumability by loading state from disk. - */ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - -// ============================================================================ -// State types -// ============================================================================ - -export type PipelineStatus = "idle" | "running" | "completed" | "failed" | "aborted"; -export type AgentStatus = "pending" | "waiting" | "running" | "completed" | "failed"; - -export interface AgentState { - name: string; - status: AgentStatus; - iteration: number; - wave: number; - startedAt?: number; - completedAt?: number; - error?: string; -} - -export interface SwarmState { - name: string; - status: PipelineStatus; - mode: string; - iteration: number; - targetCount: number; - agents: Record; - startedAt: number; - completedAt?: number; -} - -// ============================================================================ -// State tracker -// ============================================================================ - -export class StateTracker { - #swarmDir: string; - #state: SwarmState; - - constructor(workspaceDir: string, name: string) { - this.#swarmDir = path.join(workspaceDir, `.swarm_${name}`); - this.#state = { - name, - status: "idle", - mode: "sequential", - iteration: 0, - targetCount: 1, - agents: {}, - startedAt: Date.now(), - }; - } - - get swarmDir(): string { - return this.#swarmDir; - } - - get state(): Readonly { - return this.#state; - } - - async init(agentNames: string[], targetCount: number, mode: string): Promise { - await fs.mkdir(path.join(this.#swarmDir, "state"), { recursive: true }); - await fs.mkdir(path.join(this.#swarmDir, "logs"), { recursive: true }); - await fs.mkdir(path.join(this.#swarmDir, "context"), { recursive: true }); - - this.#state.targetCount = targetCount; - this.#state.mode = mode; - this.#state.status = "running"; - this.#state.startedAt = Date.now(); - - for (const name of agentNames) { - this.#state.agents[name] = { - name, - status: "pending", - iteration: 0, - wave: 0, - }; - } - - await this.#persist(); - } - - async updateAgent(name: string, update: Partial): Promise { - const agent = this.#state.agents[name]; - if (!agent) return; - Object.assign(agent, update); - await this.#persist(); - } - - async updatePipeline(update: Partial): Promise { - Object.assign(this.#state, update); - await this.#persist(); - } - - async appendLog(agentName: string, message: string): Promise { - const logPath = path.join(this.#swarmDir, "logs", `${agentName}.log`); - const timestamp = new Date().toISOString(); - await fs.appendFile(logPath, `[${timestamp}] ${message}\n`); - } - - async appendOrchestratorLog(message: string): Promise { - const logPath = path.join(this.#swarmDir, "logs", "orchestrator.log"); - const timestamp = new Date().toISOString(); - await fs.appendFile(logPath, `[${timestamp}] ${message}\n`); - } - - async load(): Promise { - const statePath = path.join(this.#swarmDir, "state", "pipeline.json"); - try { - const content = await Bun.file(statePath).text(); - this.#state = JSON.parse(content) as SwarmState; - return this.#state; - } catch { - return null; - } - } - - async #persist(): Promise { - await Bun.write(path.join(this.#swarmDir, "state", "pipeline.json"), JSON.stringify(this.#state, null, 2)); - } -} diff --git a/packages/swarm-extension/test/swarm/executor.test.ts b/packages/swarm-extension/test/swarm/executor.test.ts deleted file mode 100644 index dc7782d7c..000000000 --- a/packages/swarm-extension/test/swarm/executor.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 type { ModelRegistry, SingleResult } from "@oh-my-pi/pi-coding-agent"; -import * as taskExecutor from "@oh-my-pi/pi-coding-agent"; -import { executeSwarmAgent } from "../../src/swarm/executor"; -import { StateTracker } from "../../src/swarm/state"; - -const mockResult = { - index: 0, - id: "test-agent-0", - agent: "test", - agentSource: "project", - task: "test task", - exitCode: 0, - output: "ok", - stderr: "", - truncated: false, - durationMs: 100, - tokens: 0, -} as SingleResult; - -let workspace: string; - -beforeEach(async () => { - workspace = await fs.mkdtemp(path.join(os.tmpdir(), "swarm-test-")); -}); - -afterEach(async () => { - vi.restoreAllMocks(); - await fs.rm(workspace, { recursive: true, force: true }); -}); - -describe("executeSwarmAgent", () => { - it("does not pass authStorage to runSubprocess when modelRegistry is provided", async () => { - const runSubprocessSpy = vi.spyOn(taskExecutor, "runSubprocess").mockResolvedValue(mockResult); - - const mockModelRegistry = { - authStorage: { discover: vi.fn() }, - } as unknown as ModelRegistry; - - const stateTracker = new StateTracker(workspace, "test-swarm"); - await stateTracker.init(["test-agent"], 1, "parallel"); - - const agent = { - name: "test-agent", - role: "tester", - task: "do something", - reportsTo: [], - waitsFor: [], - }; - - await executeSwarmAgent(agent, 0, { - workspace, - swarmName: "test-swarm", - iteration: 0, - modelRegistry: mockModelRegistry, - stateTracker, - }); - - expect(runSubprocessSpy).toHaveBeenCalledTimes(1); - const passedOptions = runSubprocessSpy.mock.calls[0][0]; - const { authStorage, modelRegistry } = passedOptions; - expect(authStorage).toBeUndefined(); - expect(modelRegistry).toBe(mockModelRegistry); - }); -}); diff --git a/packages/swarm-extension/tsconfig.json b/packages/swarm-extension/tsconfig.json deleted file mode 100644 index d9a6e62ad..000000000 --- a/packages/swarm-extension/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig.workspace.json", - "include": [ - "src" - ] -} From f7f8e040ee04710414fbd775431091fa301b9786 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 5 Aug 2026 03:07:47 +0200 Subject: [PATCH 110/126] chore: bump version to 17.2.9 --- Cargo.lock | 50 +++++++++---------- Cargo.toml | 2 +- bun.lock | 80 +++++++++++++++++------------- crates/pi-natives/src/lib.rs | 2 +- package.json | 26 +++++----- packages/agent/CHANGELOG.md | 2 + packages/agent/package.json | 2 +- packages/ai/CHANGELOG.md | 2 + packages/ai/package.json | 2 +- packages/catalog/CHANGELOG.md | 2 + packages/catalog/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 8 +-- packages/coding-agent/package.json | 2 +- packages/hashline/package.json | 2 +- packages/mnemopi/package.json | 2 +- packages/natives/CHANGELOG.md | 2 + packages/natives/native/index.d.ts | 2 +- packages/natives/native/index.js | 2 +- packages/natives/package.json | 2 +- packages/omptype/CHANGELOG.md | 2 + packages/omptype/package.json | 2 +- packages/snapcompact/package.json | 2 +- packages/stats/CHANGELOG.md | 2 + packages/stats/package.json | 2 +- packages/tui/CHANGELOG.md | 2 + packages/tui/package.json | 2 +- packages/utils/CHANGELOG.md | 2 + packages/utils/package.json | 2 +- packages/wire/package.json | 2 +- 29 files changed, 122 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11b6a6aef..e09cef59f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,9 +514,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64-simd" @@ -1963,9 +1963,9 @@ dependencies = [ [[package]] name = "encoding_rs_io" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" dependencies = [ "encoding_rs", ] @@ -2573,9 +2573,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -2783,13 +2783,13 @@ checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" [[package]] name = "html-to-markdown-rs" -version = "3.10.2" +version = "3.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf23a50d1e4f5ca342b75308a309466882f672a324654a363e26ff723a40e791" +checksum = "1b0fdf3ba00130a03686f79af007ba066f13e2b0ea3d736ede049cfd8b55015a" dependencies = [ "ahash", "astral-tl", - "base64 0.23.0", + "base64 0.23.1", "bitflags 2.13.1", "html-escape", "html5ever", @@ -3148,9 +3148,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -3537,9 +3537,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "17.2.8" +version = "17.2.9" dependencies = [ "anyhow", "ast-grep-core", @@ -4968,19 +4968,19 @@ dependencies = [ [[package]] name = "pi-iso" -version = "17.2.8" +version = "17.2.9" dependencies = [ "async-trait", "libc", "parking_lot", - "similar 3.1.1", + "similar 3.1.2", "tokio", "windows-sys 0.61.2", ] [[package]] name = "pi-natives" -version = "17.2.8" +version = "17.2.9" dependencies = [ "anyhow", "arboard", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "17.2.8" +version = "17.2.9" dependencies = [ "anyhow", "brush-builtins", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "pi-voice" -version = "17.2.8" +version = "17.2.9" dependencies = [ "audiopus_sys", "bytes", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "pi-walker" -version = "17.2.8" +version = "17.2.9" dependencies = [ "dashmap", "globset", @@ -5169,7 +5169,7 @@ dependencies = [ "clap", "parking_lot", "pi-uutils-ctx", - "similar 3.1.1", + "similar 3.1.2", "tempfile", ] @@ -5714,9 +5714,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6238,9 +6238,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "similar" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +checksum = "85ee016af5d736b69fc89e19254540fa4b5f5492853fb5503920f084011c78b6" dependencies = [ "bstr", ] diff --git a/Cargo.toml b/Cargo.toml index e20dd5806..a762a6067 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/pi-*", "crates/vendor/*"] resolver = "3" [workspace.package] -version = "17.2.8" +version = "17.2.9" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/bun.lock b/bun.lock index 40d8f3e13..0caad27a6 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -40,7 +40,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -64,7 +64,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/omptype": "catalog:", @@ -78,7 +78,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "17.2.8", + "version": "17.2.9", "bin": { "omp": "src/cli.ts", }, @@ -152,7 +152,7 @@ }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "lru-cache": "catalog:", @@ -196,7 +196,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "17.2.8", + "version": "17.2.9", "bin": { "mnemopi": "src/cli.ts", }, @@ -223,7 +223,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "17.2.8", + "version": "17.2.9", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -231,7 +231,7 @@ }, "packages/omptype": { "name": "@oh-my-pi/omptype", - "version": "17.2.8", + "version": "17.2.9", "devDependencies": { "@ark/attest": "0.56.3", "@ark/schema": "0.56.2", @@ -245,7 +245,7 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -258,7 +258,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "17.2.8", + "version": "17.2.9", "bin": { "omp-stats": "./src/index.ts", }, @@ -285,7 +285,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -324,7 +324,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "17.2.8", + "version": "17.2.9", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "handlebars": "catalog:", @@ -337,7 +337,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "17.2.8", + "version": "17.2.9", "devDependencies": { "@types/bun": "catalog:", }, @@ -378,19 +378,19 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.2.8", - "@oh-my-pi/omp-stats": "17.2.8", - "@oh-my-pi/omptype": "17.2.8", - "@oh-my-pi/pi-agent-core": "17.2.8", - "@oh-my-pi/pi-ai": "17.2.8", - "@oh-my-pi/pi-catalog": "17.2.8", - "@oh-my-pi/pi-coding-agent": "17.2.8", - "@oh-my-pi/pi-mnemopi": "17.2.8", - "@oh-my-pi/pi-natives": "17.2.8", - "@oh-my-pi/pi-tui": "17.2.8", - "@oh-my-pi/pi-utils": "17.2.8", - "@oh-my-pi/pi-wire": "17.2.8", - "@oh-my-pi/snapcompact": "17.2.8", + "@oh-my-pi/hashline": "17.2.9", + "@oh-my-pi/omp-stats": "17.2.9", + "@oh-my-pi/omptype": "17.2.9", + "@oh-my-pi/pi-agent-core": "17.2.9", + "@oh-my-pi/pi-ai": "17.2.9", + "@oh-my-pi/pi-catalog": "17.2.9", + "@oh-my-pi/pi-coding-agent": "17.2.9", + "@oh-my-pi/pi-mnemopi": "17.2.9", + "@oh-my-pi/pi-natives": "17.2.9", + "@oh-my-pi/pi-tui": "17.2.9", + "@oh-my-pi/pi-utils": "17.2.9", + "@oh-my-pi/pi-wire": "17.2.9", + "@oh-my-pi/snapcompact": "17.2.9", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", @@ -761,13 +761,13 @@ "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], - "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + "@octokit/core": ["@octokit/core@7.0.7", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", "@octokit/request": "^10.0.13", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA=="], "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + "@octokit/graphql": ["@octokit/graphql@9.0.4", "", { "dependencies": { "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg=="], - "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], @@ -775,13 +775,13 @@ "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], - "@octokit/request": ["@octokit/request@10.0.11", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q=="], + "@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="], - "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="], "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], - "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], "@oh-my-pi/browser-relay": ["@oh-my-pi/browser-relay@workspace:packages/browser-relay"], @@ -1063,7 +1063,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], @@ -1545,7 +1545,7 @@ "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], - "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1649,6 +1649,12 @@ "@napi-rs/wasm-tools-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@octokit/endpoint/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], @@ -1761,6 +1767,12 @@ "@napi-rs/tar-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], "@typescript/analyze-trace/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 0606eaa48..ac8c049f6 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -255,7 +255,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in /// `packages/natives/native/index.js` (which derives the name from /// `package.json#version`). -#[napi(js_name = "__piNativesV17_2_8")] +#[napi(js_name = "__piNativesV17_2_9")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/package.json b/package.json index c3f0dc7a2..70c358a22 100644 --- a/package.json +++ b/package.json @@ -26,19 +26,19 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.2", - "@oh-my-pi/hashline": "17.2.8", - "@oh-my-pi/omp-stats": "17.2.8", - "@oh-my-pi/omptype": "17.2.8", - "@oh-my-pi/pi-agent-core": "17.2.8", - "@oh-my-pi/pi-ai": "17.2.8", - "@oh-my-pi/pi-catalog": "17.2.8", - "@oh-my-pi/pi-coding-agent": "17.2.8", - "@oh-my-pi/pi-mnemopi": "17.2.8", - "@oh-my-pi/pi-natives": "17.2.8", - "@oh-my-pi/pi-tui": "17.2.8", - "@oh-my-pi/pi-utils": "17.2.8", - "@oh-my-pi/pi-wire": "17.2.8", - "@oh-my-pi/snapcompact": "17.2.8", + "@oh-my-pi/hashline": "17.2.9", + "@oh-my-pi/omp-stats": "17.2.9", + "@oh-my-pi/omptype": "17.2.9", + "@oh-my-pi/pi-agent-core": "17.2.9", + "@oh-my-pi/pi-ai": "17.2.9", + "@oh-my-pi/pi-catalog": "17.2.9", + "@oh-my-pi/pi-coding-agent": "17.2.9", + "@oh-my-pi/pi-mnemopi": "17.2.9", + "@oh-my-pi/pi-natives": "17.2.9", + "@oh-my-pi/pi-tui": "17.2.9", + "@oh-my-pi/pi-utils": "17.2.9", + "@oh-my-pi/pi-wire": "17.2.9", + "@oh-my-pi/snapcompact": "17.2.9", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index de1366ec0..e42677ce5 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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. diff --git a/packages/agent/package.json b/packages/agent/package.json index effde2654..3d88812ad 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "17.2.8", + "version": "17.2.9", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 04a42d13b..df85314e0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### Fixed - 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. diff --git a/packages/ai/package.json b/packages/ai/package.json index 6d9876dad..b0199d06a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "17.2.8", + "version": "17.2.9", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 1bc5a62ed..cc674c288 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### Fixed - Fixed Amazon Bedrock catalog generation omitting AWS GovCloud `us-gov.*` Claude inference-profile IDs, so selectors like `amazon-bedrock/us-gov.anthropic.claude-sonnet-4-5-…` resolve instead of failing model lookup (or misrouting commercial `us.*` geos onto `us-east-1` with GovCloud credentials). diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 037a0131f..7af1dbb78 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "17.2.8", + "version": "17.2.9", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1267dbc21..5bcee1054 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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. @@ -46,6 +48,9 @@ - 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 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)) +- Fixed `omp setup python` to validate the same configured or discovered interpreter used by the Python eval runtime. +- 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 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.8] - 2026-08-04 @@ -137,9 +142,6 @@ - Fixed heavily branched conversation trees shifting linear continuations into disconnected columns. - 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 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 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/coding-agent/package.json b/packages/coding-agent/package.json index 03ac0fabc..e7ef42261 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "17.2.8", + "version": "17.2.9", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 81aa7620c..449a92376 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/hashline", - "version": "17.2.8", + "version": "17.2.9", "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/mnemopi/package.json b/packages/mnemopi/package.json index 24a938afc..5e4141c27 100644 --- a/packages/mnemopi/package.json +++ b/packages/mnemopi/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "17.2.8", + "version": "17.2.9", "description": "Local SQLite memory engine for Oh My Pi agents", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index c9966b01f..eef2c690a 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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)). diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 0a0b89c25..9ff263be2 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -279,7 +279,7 @@ export declare function __ompInstallTokioRuntime(): void * `packages/natives/native/index.js` (which derives the name from * `package.json#version`). */ -export declare function __piNativesV17_2_8(): void +export declare function __piNativesV17_2_9(): void /** * Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index cbf4f7405..d6da608ad 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -29,7 +29,7 @@ export const Shell = nativeBindings.Shell; // functions export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime; -export const __piNativesV17_2_8 = nativeBindings.__piNativesV17_2_8; +export const __piNativesV17_2_9 = nativeBindings.__piNativesV17_2_9; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; export const astMatch = nativeBindings.astMatch; diff --git a/packages/natives/package.json b/packages/natives/package.json index 9ea41c710..9d5ded0f5 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -1,6 +1,6 @@ { "name": "@oh-my-pi/pi-natives", - "version": "17.2.8", + "version": "17.2.9", "description": "Native Rust bindings for audio, WebRTC, grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API", "type": "module", "homepage": "https://omp.sh", diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md index 555d1af30..c20279cb2 100644 --- a/packages/omptype/CHANGELOG.md +++ b/packages/omptype/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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)). diff --git a/packages/omptype/package.json b/packages/omptype/package.json index 7d177dcfd..1972a698a 100644 --- a/packages/omptype/package.json +++ b/packages/omptype/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omptype", - "version": "17.2.8", + "version": "17.2.9", "description": "ArkType-compatible runtime schema validation with lazy JIT compilation", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/snapcompact/package.json b/packages/snapcompact/package.json index cefb9c8a6..38c2e6eb7 100644 --- a/packages/snapcompact/package.json +++ b/packages/snapcompact/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/snapcompact", - "version": "17.2.8", + "version": "17.2.9", "description": "Bitmap-frame context compression for vision-capable LLMs", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 85d43e7ae..23c5c0396 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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)). diff --git a/packages/stats/package.json b/packages/stats/package.json index 3a0c73edd..97454dc55 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/omp-stats", - "version": "17.2.8", + "version": "17.2.9", "description": "Local observability dashboard for pi AI usage statistics", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 603cdd528..98fdcca42 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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)). diff --git a/packages/tui/package.json b/packages/tui/package.json index 1a4c7e81d..54f41a8be 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-tui", - "version": "17.2.8", + "version": "17.2.9", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index f970f939f..a05d90562 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [17.2.9] - 2026-08-05 + ### 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. diff --git a/packages/utils/package.json b/packages/utils/package.json index 80822c8f0..ba1617713 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-utils", - "version": "17.2.8", + "version": "17.2.9", "description": "Shared utilities for pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/wire/package.json b/packages/wire/package.json index 0a7bf77ff..3d389164b 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-wire", - "version": "17.2.8", + "version": "17.2.9", "description": "Shared wire protocol types for Oh My Pi packages", "homepage": "https://omp.sh", "author": "Can Boluk", From 0a0eca3834a8dd9f835ed044e5452c20b5b3bf91 Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Tue, 4 Aug 2026 18:32:06 -0700 Subject: [PATCH 111/126] fix(coding-agent): preserve override role provenance --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/task/executor.ts | 2 +- .../test/task/executor-pass-through.test.ts | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 09170ec15..e81079090 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,6 +40,7 @@ - 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 persisted Agent Hub rows dropping an explicit caller model role when a subagent used a model override, preserving role provenance after restart. - 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)). diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 38133be8f..c4db555f3 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -3127,7 +3127,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { const forwarded = spy.mock.calls[0]?.[0]; expect(forwarded?.thinkingLevel).toBe(ThinkingLevel.Low); }); + it("persists an explicit role from a caller model override", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + const settings = Settings.isolated({ + modelRoles: { reviewer: `${model.provider}/${model.id}` }, + }); + const session = yieldEmittingSession(); + const initSpy = vi.spyOn(session.sessionManager, "appendSessionInit"); + vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session)); + + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-model-override-role", + modelOverride: "@reviewer", + settings, + modelRegistry: createModelRegistry(model), + }); + + expect(result.exitCode).toBe(0); + expect(initSpy).toHaveBeenCalledWith(expect.objectContaining({ modelRole: "reviewer" })); + }); }); From afdc1caa6c67d6fcaf0cae6bd1bdbc6ef5b2c41f Mon Sep 17 00:00:00 2001 From: Kyle McCleary Date: Tue, 4 Aug 2026 20:10:05 -0700 Subject: [PATCH 112/126] fix(ci): align native install smoke metadata --- scripts/install-tests/native-version.test.ts | 13 ++++++ scripts/install-tests/native-version.ts | 22 ++++++++++ scripts/install-tests/run-ci.sh | 42 +++++++++++++++++++- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 scripts/install-tests/native-version.test.ts create mode 100644 scripts/install-tests/native-version.ts diff --git a/scripts/install-tests/native-version.test.ts b/scripts/install-tests/native-version.test.ts new file mode 100644 index 000000000..97718f1cb --- /dev/null +++ b/scripts/install-tests/native-version.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "bun:test"; +import { nativeVersionFromExports } from "./native-version"; + +describe("native addon release sentinel", () => { + it("normalizes the unique version sentinel", () => { + expect(nativeVersionFromExports(["load", "__piNativesV17_2_6", "other"])).toBe("17.2.6"); + }); + + it("rejects missing or ambiguous sentinels", () => { + expect(nativeVersionFromExports(["load"])).toBeUndefined(); + expect(nativeVersionFromExports(["__piNativesV17_2_6", "__piNativesV17_2_7"])).toBeUndefined(); + }); +}); diff --git a/scripts/install-tests/native-version.ts b/scripts/install-tests/native-version.ts new file mode 100644 index 000000000..d00f8535c --- /dev/null +++ b/scripts/install-tests/native-version.ts @@ -0,0 +1,22 @@ +import { createRequire } from "node:module"; + +const VERSION_SENTINEL_RE = /^__piNativesV(\d+)_(\d+)_(\d+)$/; + +/** Return the sole release version advertised by a native addon's exports. */ +export function nativeVersionFromExports(exports: readonly string[]): string | undefined { + const versions = exports + .map(name => VERSION_SENTINEL_RE.exec(name)) + .filter((match): match is RegExpExecArray => match !== null) + .map(match => `${match[1]}.${match[2]}.${match[3]}`); + return versions.length === 1 ? versions[0] : undefined; +} + +if (import.meta.main) { + const addonPath = process.argv[2]; + if (!addonPath) throw new Error("Usage: bun scripts/install-tests/native-version.ts "); + const require = createRequire(import.meta.url); + const bindings = require(addonPath) as Record; + const version = nativeVersionFromExports(Object.keys(bindings)); + if (!version) throw new Error(`Native addon has no unique release version sentinel: ${addonPath}`); + process.stdout.write(version); +} diff --git a/scripts/install-tests/run-ci.sh b/scripts/install-tests/run-ci.sh index 31ee8eee2..8621d25e5 100755 --- a/scripts/install-tests/run-ci.sh +++ b/scripts/install-tests/run-ci.sh @@ -7,7 +7,15 @@ WORK_DIR="$(mktemp -d)" TMP_WORK_DIR="$WORK_DIR/tmp" mkdir -p "$TMP_WORK_DIR" export TMPDIR="$TMP_WORK_DIR" -trap 'rm -rf "$WORK_DIR"' EXIT + +NATIVES_PACKAGE="$ROOT_DIR/packages/natives/package.json" +NATIVES_PACKAGE_INITIAL="$WORK_DIR/natives-package.initial.json" +cp "$NATIVES_PACKAGE" "$NATIVES_PACKAGE_INITIAL" +restore_workspace() { + cp "$NATIVES_PACKAGE_INITIAL" "$NATIVES_PACKAGE" + rm -rf "$WORK_DIR" +} +trap restore_workspace EXIT section() { echo "" @@ -42,10 +50,42 @@ find_tarball() { echo "${matches[0]}" } +align_native_manifest() { + local addon_version="" + local addon + local candidate_version + local candidates=() + shopt -s nullglob + candidates=("$ROOT_DIR"/packages/natives/native/pi_natives.*.node) + shopt -u nullglob + + if [ "${#candidates[@]}" -eq 0 ]; then + echo "No native addon found for install smoke" >&2 + exit 1 + fi + for addon in "${candidates[@]}"; do + candidate_version="$(bun "$ROOT_DIR/scripts/install-tests/native-version.ts" "$addon")" || exit 1 + if [ -z "$addon_version" ]; then + addon_version="$candidate_version" + elif [ "$addon_version" != "$candidate_version" ]; then + echo "Native addon version mismatch: $addon_version vs $candidate_version ($addon)" >&2 + exit 1 + fi + done + + local declared_version + declared_version="$(jq -r '.version' "$NATIVES_PACKAGE")" + if [ "$declared_version" = "$addon_version" ]; then return; fi + + echo "Aligning install smoke native manifest $declared_version → $addon_version" + jq --arg version "$addon_version" '.version = $version' "$NATIVES_PACKAGE" > "$WORK_DIR/natives-package.aligned.json" + mv "$WORK_DIR/natives-package.aligned.json" "$NATIVES_PACKAGE" +} section "Binary install smoke" if [ "${OMP_INSTALL_TEST_SKIP_NATIVE_BUILD:-0}" != "1" ]; then bun --cwd=packages/natives run build fi +align_native_manifest bun --cwd=packages/coding-agent run build BINARY_DIR="$WORK_DIR/binary-bin" From 364e13c49e669257d760335cabb8fe80a080a4d2 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:16:38 +0800 Subject: [PATCH 113/126] fix(coding-agent): contained browser timeout rejections Observed every browser facade continuation so fire-and-forget helper timeouts cannot wedge or kill a tab worker. Preserved native Promise identity for callers and test matchers. --- packages/coding-agent/CHANGELOG.md | 3 + .../src/tools/browser/tab-worker.ts | 6 +- packages/coding-agent/src/tools/run-scope.ts | 90 +++++++++++++++++-- .../tools/browser-run-cancellation.test.ts | 37 ++++++++ .../test/tools/browser-tab-evaluate.test.ts | 38 ++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5bcee1054..ba25b3dd4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -142,6 +142,9 @@ - Fixed heavily branched conversation trees shifting linear continuations into disconnected columns. - Fixed plugin installation validation failures for legacy compatibility shims. - Removed hard-coded references to disabled or absent agents in system and tool prompts. +### Fixed + +- Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation. ## [17.2.4] - 2026-08-01 diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index 8f257feac..7eed8a823 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -24,6 +24,7 @@ import { bindRunFacade, CELL_BUDGET_SLACK_MS, markHandled, + markBrowserRunRejection, resolvePredicateTimeout, type WaitPredicateOptions, waitForRun, @@ -44,6 +45,7 @@ import { loadPuppeteerInWorker, } from "./launch"; import { extractReadableFromHtml, type ReadableFormat } from "./readable"; + import { cloneSafe, RunOutput } from "./run-output"; import type { Observation, @@ -1182,9 +1184,9 @@ export class WorkerCore { (opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError")) ) { const hint = selector ? await this.#selectorTimeoutHint(selector) : ""; - throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`); + throw markBrowserRunRejection(new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`)); } - throw err; + throw markBrowserRunRejection(err); } finally { earlyAc.abort(); active.inflight.delete(opId); diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index 742c8daf8..5d44bfc06 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -1,6 +1,79 @@ +import { logger, postmortem } from "@oh-my-pi/pi-utils"; import { untilAborted } from "@oh-my-pi/pi-utils/abortable"; import { ToolError, throwIfAborted } from "./tool-errors"; +const BROWSER_RUN_REJECTION = Symbol.for("omp.browserRunRejection"); + +export function markBrowserRunRejection(reason: T): T { + if (reason !== null && (typeof reason === "object" || typeof reason === "function")) { + Reflect.set(reason, BROWSER_RUN_REJECTION, true); + } + return reason; +} + +function isBrowserRunRejection(reason: unknown): boolean { + let current = reason; + for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) { + if (Reflect.get(current, BROWSER_RUN_REJECTION) === true) return true; + current = "cause" in current ? current.cause : undefined; + } + return false; +} + +function consumeBrowserRunRejection(reason: unknown): boolean { + if (!isBrowserRunRejection(reason)) return false; + logger.warn("Contained unhandled browser-run rejection (missing await?)", { + error: reason instanceof Error ? reason.message : String(reason), + }); + return true; +} + +postmortem.interceptUnhandledRejections(consumeBrowserRunRejection); + +/** + * Observe every continuation created from a browser facade promise. A catch on + * only the original promise does not cover `tab.waitForResponse(...).then(...)`: + * `then` creates a fresh rejection that can otherwise kill or wedge the worker. + */ +const observedPromiseTrees = new WeakSet>(); + +function observePromiseTree(promise: Promise): Promise { + if (observedPromiseTrees.has(promise)) return promise; + observedPromiseTrees.add(promise); + markHandled(promise); + const originalThen = promise.then.bind(promise); + const originalCatch = promise.catch.bind(promise); + const originalFinally = promise.finally.bind(promise); + Object.defineProperties(promise, { + // biome-ignore lint/suspicious/noThenProperty: native Promise continuations must remain thenable. + then: { + configurable: true, + value: ( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise => observePromiseTree(originalThen(onFulfilled, onRejected)), + }, + catch: { + configurable: true, + value: ( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise => observePromiseTree(originalCatch(onRejected)), + }, + finally: { + configurable: true, + value: (onFinally?: (() => void) | null): Promise => observePromiseTree(originalFinally(onFinally)), + }, + }); + return promise; +} + +function trackBrowserRunPromise(promise: Promise): Promise { + const tracked = promise.catch(error => { + throw markBrowserRunRejection(error); + }); + return observePromiseTree(tracked); +} + /** * Marks a run-scoped promise as observed without changing its behavior for awaited callers. * @@ -83,7 +156,7 @@ export function waitForRun( await untilAborted(signal, async () => await Bun.sleep(interval)); } })(); - return markHandled(promise); + return trackBrowserRunPromise(promise); } /** Binds a long-lived scope facade (page/tab/desktop objects) to one evaluated run's abort signal. */ @@ -102,11 +175,16 @@ export function bindRunFacade(target: T, signal: AbortSignal): if (result && typeof result === "object") { const then = Reflect.get(result, "then"); if (typeof then === "function") { - return markHandled( - Promise.resolve(result).then(resolved => { - throwIfAborted(signal); - return resolved; - }), + return trackBrowserRunPromise( + Promise.resolve(result).then( + resolved => { + throwIfAborted(signal); + return resolved; + }, + error => { + throw markBrowserRunRejection(error); + }, + ), ); } } diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index 52f2c24ca..0fa306232 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -4,6 +4,9 @@ import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime"; import { bindRunFacade, markHandled, waitForRun } from "../../src/tools/run-scope"; import { ToolAbortError } from "../../src/tools/tool-errors"; +const runCancellationModuleUrl = new URL("../../src/tools/browser/run-cancellation.ts", import.meta.url).href; +const toolErrorsModuleUrl = new URL("../../src/tools/tool-errors.ts", import.meta.url).href; + async function collectUnhandledRejections(action: () => void | Promise): Promise { const reasons: unknown[] = []; const onUnhandled = (reason: unknown) => reasons.push(reason); @@ -127,6 +130,40 @@ describe("browser run cancellation", () => { expect(reasons).toEqual([]); }); + it("contains an unhandled descendant of a timed-out browser helper promise", async () => { + const script = ` + import { bindBrowserRunFacade } from ${JSON.stringify(runCancellationModuleUrl)}; + import { ToolError } from ${JSON.stringify(toolErrorsModuleUrl)}; + + const facade = bindBrowserRunFacade( + { + waitForResponse() { + return Promise.reject(new ToolError("tab.waitForResponse() timed out after 15ms")); + }, + }, + new AbortController().signal, + ); + void facade.waitForResponse().then(() => undefined); + await Promise.resolve(); + await Promise.resolve(); + console.log("browser probe survived"); + `; + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("browser probe survived"); + expect(stderr).not.toContain("[Unhandled Rejection]"); + }); + it("rejects awaited facade method calls that settle after abort", async () => { const controller = new AbortController(); const deferred = Promise.withResolvers(); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 5d6234d30..fea1670fc 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -208,6 +208,44 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("keeps the tab worker alive after an unhandled waitForResponse timeout descendant", async () => { + const tool = new BrowserTool(makeSession()); + const name = `response-timeout-descendant-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const tabSession = getTabsMapForTest().get(name); + if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created"); + expect(tabSession.worker.mode).toBe("worker"); + const result = await tool.execute("run", { + action: "run", + name, + timeout: 2, + // Real worker timers are intentional: the rejection must cross an + // unhandledRejection turn while the browser run remains active. + code: ` + void tab.waitForResponse("/never", { timeout: 10 }).then(() => undefined); + await Bun.sleep(50); + return "survived timeout"; + `, + }); + expect(result.content).toEqual([{ type: "text", text: "survived timeout" }]); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("observes floating raw page promises when the target closes", async () => { const tool = new BrowserTool(makeSession()); const name = `target-close-${process.pid}`; From 56931915f11fbeb13ffc05907cb54f2b7df1c22c Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:16:55 +0800 Subject: [PATCH 114/126] feat(coding-agent): added fatal session recovery hints Registered live session resume commands with postmortem handling so a fatal rejection or exception identifies every recoverable agent before cleanup. Escaped terminal control characters in recovery output. --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/agent-session.ts | 12 ++++++ packages/utils/CHANGELOG.md | 3 ++ packages/utils/src/postmortem.ts | 42 ++++++++++++++++++- .../test/postmortem-cleanup-error.test.ts | 17 ++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ba25b3dd4..bc59f7951 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -142,6 +142,10 @@ - Fixed heavily branched conversation trees shifting linear continuations into disconnected columns. - Fixed plugin installation validation failures for legacy compatibility shims. - Removed hard-coded references to disabled or absent agents in system and tool prompts. +### Added + +- Added resumable session details to fatal crash output, including an `omp --resume ` command for every persisted live agent session. + ### Fixed - Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7573608a9..9a34d5da4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -82,6 +82,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { $env, + APP_NAME, escapeXmlText, formatDuration, getAgentDbPath, @@ -445,6 +446,7 @@ export class AgentSession { // Event subscription state #unsubscribeAgent?: () => void; #cancelExitRecorder?: () => void; + #cancelFatalRecoveryHint?: () => void; #exitRecorded = false; #unsubscribeAppendOnly?: () => void; #unsubscribeModelRoles?: () => void; @@ -1376,6 +1378,14 @@ export class AgentSession { this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => { this.#recordSessionExit(reason); }); + this.#cancelFatalRecoveryHint = postmortem.registerFatalRecoveryHint(() => { + const sessionId = this.sessionManager.getSessionId(); + if (!sessionId || !this.sessionManager.getSessionFile()) return undefined; + return { + label: this.#agentId ?? (this.#agentKind === "main" ? "Main" : "Agent"), + command: `${APP_NAME} --resume ${sessionId}`, + }; + }); const advisorsHost: SessionAdvisorsHost = { agent: this.agent, @@ -3758,6 +3768,8 @@ export class AgentSession { this.#recordSessionExit(options.reason ?? "dispose"); this.#cancelExitRecorder?.(); this.#cancelExitRecorder = undefined; + this.#cancelFatalRecoveryHint?.(); + this.#cancelFatalRecoveryHint = undefined; try { await emitSessionShutdownEvent(this.#extensionRunner); } catch (error) { diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index a05d90562..6e093b703 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -28,6 +28,9 @@ ### Changed - Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations. +### Added + +- Added postmortem fatal recovery hint providers so applications can print actionable recovery commands before cleanup starts. ## [17.2.4] - 2026-08-01 diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 62d3c76b4..2df9a7e8f 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -67,6 +67,14 @@ function exitProcess(code: number): never { let cleanupPromise: Promise | undefined; let stdioDisconnectRegistrations = 0; +export interface FatalRecoveryHint { + label: string; + command: string; +} + +type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined; +const fatalRecoveryHintProviders = new Set(); + /** * Internal: runs all registered cleanup callbacks for the given reason. * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy. @@ -196,6 +204,38 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b return () => rejectionInterceptors.delete(interceptor); } +/** + * Register a synchronous recovery command to print when the process exits + * through an uncaught exception or unhandled rejection. + */ +export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void { + fatalRecoveryHintProviders.add(provider); + return () => fatalRecoveryHintProviders.delete(provider); +} + +function escapeFatalHintText(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => { + const code = char.codePointAt(0) ?? 0; + return `\\u${code.toString(16).padStart(4, "0")}`; + }); +} + +function formatFatalRecoveryHints(): string { + const lines: string[] = []; + const seenCommands = new Set(); + for (const provider of fatalRecoveryHintProviders) { + try { + const hint = provider(); + if (!hint?.command || seenCommands.has(hint.command)) continue; + seenCommands.add(hint.command); + lines.push(` ${escapeFatalHintText(hint.label)}: ${escapeFatalHintText(hint.command)}`); + } catch (err) { + logger.warn("Fatal recovery hint provider failed", { err }); + } + } + return lines.length > 0 ? `\n[Recovery]\n${lines.join("\n")}\n` : ""; +} + function formatFatalError(label: string, err: Error): string { const name = err.name || "Error"; const message = err.message || "(no message)"; @@ -212,7 +252,7 @@ async function exitAfterFatal(label: string, logMessage: string, err: Error, rea // A revoked terminal can make stream writes raise another fatal error. Use // the descriptor directly so failure stays synchronous and contained. try { - fs.writeSync(2, formatFatalError(label, err)); + fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`); } catch {} logger.error(logMessage, { err }); await runCleanup(reason); diff --git a/packages/utils/test/postmortem-cleanup-error.test.ts b/packages/utils/test/postmortem-cleanup-error.test.ts index 840b0e760..fc8c93dcc 100644 --- a/packages/utils/test/postmortem-cleanup-error.test.ts +++ b/packages/utils/test/postmortem-cleanup-error.test.ts @@ -123,6 +123,23 @@ describe("postmortem expected cleanup errors", () => { expect(result.stderr).toContain("[Unhandled Rejection] Error: unexpected cleanup rejection"); }); + it("prints registered recovery commands before fatal cleanup", async () => { + const result = await runPostmortemProbe(` + import { postmortem } from "${postmortemModuleUrl}"; + + postmortem.registerFatalRecoveryHint(() => ({ + label: "Main", + command: "omp --resume 019cafe0-dead-beef", + })); + Promise.reject(new Error("session crashed")); + await Promise.resolve(); + `); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("[Unhandled Rejection] Error: session crashed"); + expect(result.stderr).toContain("[Recovery]\n Main: omp --resume 019cafe0-dead-beef"); + }); + it("exits after an uncaught exception when terminal stderr is revoked", async () => { const result = await runPostmortemProbe(` import { spyOn } from "bun:test"; From 01ed58d684b86400d66ce1080ae0a66b7e35e307 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:43:17 +0800 Subject: [PATCH 115/126] docs(utils): documented fatal recovery hint contract --- packages/utils/src/postmortem.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 2df9a7e8f..63fa30d02 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -67,8 +67,11 @@ function exitProcess(code: number): never { let cleanupPromise: Promise | undefined; let stdioDisconnectRegistrations = 0; +/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */ export interface FatalRecoveryHint { + /** Stable label identifying the recoverable session or process. */ label: string; + /** Complete shell command the user can execute to resume the interrupted work. */ command: string; } From 2fd469de75256c48597aa8c7ee3cff3fa50dbef9 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:43:32 +0800 Subject: [PATCH 116/126] fix(coding-agent): corrected browser rejection containment Scoped browser-error markers to each run and contained only propagated browser failures. Routed floated user continuations into failed runs and added worker coverage for native await plus every continuation method. --- .../src/tools/browser/cmux/cmux-tab.ts | 65 ++++-- .../src/tools/browser/tab-supervisor.ts | 2 +- .../src/tools/browser/tab-worker-entry.ts | 2 +- .../src/tools/browser/tab-worker.ts | 90 +++++++- packages/coding-agent/src/tools/run-scope.ts | 129 +++++++---- .../tools/browser-run-cancellation.test.ts | 214 +++++++++++++++--- .../test/tools/browser-tab-evaluate.test.ts | 40 ++++ 7 files changed, 448 insertions(+), 94 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index 49e783d0a..f13ecc13a 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -8,11 +8,20 @@ import { resizeImage } from "../../../utils/image-resize"; import type { ToolSession } from "../../index"; import { resolveToCwd } from "../../path-utils"; import { formatScreenshot } from "../../render-utils"; -import { bindRunFacade, resolvePredicateTimeout, type WaitPredicateOptions, waitForRun } from "../../run-scope"; +import { + bindRunFacade, + isBrowserRunRejection, + markBrowserRunRejection, + observeBrowserRunPromise, + resolvePredicateTimeout, + type WaitPredicateOptions, + waitForRun, +} from "../../run-scope"; import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors"; import { type AriaSnapshotOptions, assertSelectorString, buildAriaSnapshotScript } from "../aria/aria-snapshot"; import { DEFAULT_VIEWPORT } from "../launch"; import { extractReadableFromHtml, type ReadableFormat } from "../readable"; + import { cloneSafe, RunOutput } from "../run-output"; import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol"; import { @@ -1382,6 +1391,26 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi // handler to this promise; keep its armed rejection from surfacing as an // unhandled rejection — the postmortem-fatal path this run guards against. cancelRejection.catch(() => {}); + const rejectionOwner = {}; + const { promise: floatingFailure, reject: rejectFloatingFailure } = Promise.withResolvers(); + floatingFailure.catch(() => {}); + let runActive = true; + let hasFloatingFailure = false; + const recordFloatingFailure = (reason: unknown): void => { + if (!runActive || hasFloatingFailure || postmortem.isExpectedCleanupError(reason)) return; + hasFloatingFailure = true; + const message = reason instanceof Error ? reason.message : String(reason); + const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason }); + if (reason instanceof Error) error.name = reason.name; + rejectFloatingFailure(error); + }; + const uninstallRejectionInterceptor = postmortem.interceptUnhandledRejections(reason => { + if (isBrowserRunRejection(reason, rejectionOwner)) return true; + const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined; + if (stack?.includes(`cmux-run-${runId}.js`) !== true) return false; + recordFloatingFailure(reason); + return true; + }); const onAbort = (): void => { if (timeoutSignal.aborted) { reject(new ToolError(`Browser code execution timed out after ${opts.timeoutMs}ms`)); @@ -1402,24 +1431,30 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi // Keep both inside try so a concurrent in-process eval/browser run surfaces as // a rejected promise the supervisor can report, never an unhandled rejection. runtime.setCwd(opts.snapshot.cwd); - const runTab = bindRunFacade(tab, signal); + const runTab = bindRunFacade(tab, signal, rejectionOwner, recordFloatingFailure); runtime.setRunScope({ - page: bindRunFacade(tab.page, signal), - browser: bindRunFacade(tab.browser, signal), + page: bindRunFacade(tab.page, signal, rejectionOwner, recordFloatingFailure), + browser: bindRunFacade(tab.browser, signal, rejectionOwner, recordFloatingFailure), tab: runTab, assert: (cond: unknown, text?: string): void => { if (!cond) throw new ToolError(text ?? "Assertion failed"); }, wait: (msOrPredicate: number | (() => unknown), waitOpts?: WaitPredicateOptions): Promise => - waitForRun( - msOrPredicate, - signal, - typeof msOrPredicate === "number" - ? waitOpts - : { - timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout), - interval: waitOpts?.interval, - }, + observeBrowserRunPromise( + waitForRun( + msOrPredicate, + signal, + typeof msOrPredicate === "number" + ? waitOpts + : { + timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout), + interval: waitOpts?.interval, + }, + ).catch(error => { + throw markBrowserRunRejection(error, rejectionOwner); + }), + rejectionOwner, + recordFloatingFailure, ), }); @@ -1447,6 +1482,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi returnValue = await Promise.race([ runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }), cancelRejection, + floatingFailure, ]); } catch (error) { runFailed = true; @@ -1454,6 +1490,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi } // Let rejection callbacks run while this run can still own guest-created promises. await Bun.sleep(0); + if (hasFloatingFailure && !runFailed) await floatingFailure; if (runFailed) { for (const reason of activeRun.floatingRejections) { logger.warn("Unhandled rejection accompanied a failed cmux browser run", { filename, error: reason }); @@ -1470,6 +1507,8 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi } return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots }; } finally { + runActive = false; + uninstallRejectionInterceptor(); signal.removeEventListener("abort", onAbort); runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); activeCmuxRuns.delete(filename); diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 6deea3931..6da6b7be0 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -1014,7 +1014,7 @@ async function spawnInlineWorker(): Promise { close: () => {}, }; const { WorkerCore } = await import("./tab-worker"); - new WorkerCore(workerTransport); + new WorkerCore(workerTransport, false); return { mode: "inline", send: msg => diff --git a/packages/coding-agent/src/tools/browser/tab-worker-entry.ts b/packages/coding-agent/src/tools/browser/tab-worker-entry.ts index 38c0d1c7d..9c572c874 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker-entry.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker-entry.ts @@ -26,4 +26,4 @@ const transport: Transport = { }, }; -new WorkerCore(transport); +new WorkerCore(transport, true); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index 7eed8a823..db02740dd 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -23,8 +23,11 @@ import { formatScreenshot } from "../render-utils"; import { bindRunFacade, CELL_BUDGET_SLACK_MS, - markHandled, + installBrowserWorkerRejectionGuard, + isBrowserRunRejection, markBrowserRunRejection, + markHandled, + observeBrowserRunPromise, resolvePredicateTimeout, type WaitPredicateOptions, waitForRun, @@ -703,6 +706,9 @@ interface ActiveRun { output: RunOutput; screenshots: ScreenshotResult[]; pendingTools: Map; + rejectionOwner: object; + floatingRejections: unknown[]; + floatingFailure: { promise: Promise; reject(reason?: unknown): void }; /** Helper invocations currently awaiting the page/network, keyed by op id. */ inflight: Map; opCounter: number; @@ -750,17 +756,71 @@ export class WorkerCore { #active: ActiveRun | null = null; #runtime: JsRuntime | null = null; #unsub: () => void; + #isolated: boolean; + #uninstallRejectionGuard: () => void; #mode?: WorkerInitPayload["mode"]; #activateForScreenshot = true; #dialogPolicy?: DialogPolicy; #dialogHandler?: (dialog: Dialog) => void; #openDialog?: OpenDialogInfo; - constructor(transport: Transport) { + constructor(transport: Transport, isolated: boolean) { this.#transport = transport; + this.#isolated = isolated; this.#unsub = this.#transport.onMessage(msg => { void this.#handleMessage(msg as WorkerInbound); }); + this.#uninstallRejectionGuard = this.#installRejectionGuard(); + } + + #installRejectionGuard(): () => void { + if (!this.#isolated) { + return postmortem.interceptUnhandledRejections(reason => this.#consumeUnhandledRejection(reason)); + } + return installBrowserWorkerRejectionGuard(reason => this.#consumeUnhandledRejection(reason)); + } + + #consumeUnhandledRejection(reason: unknown): boolean { + const active = this.#active; + if (!active) return false; + if (isBrowserRunRejection(reason, active.rejectionOwner)) return true; + const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined; + const fromRun = stack?.includes(`browser-run-${active.id}.js`) === true; + if (!this.#isolated && !fromRun) return false; + this.#recordFloatingRejection(active, reason); + return true; + } + + #recordFloatingRejection(active: ActiveRun, reason: unknown): void { + if (this.#active !== active || postmortem.isExpectedCleanupError(reason)) return; + const isFirst = active.floatingRejections.length === 0; + active.floatingRejections.push(reason); + if (isFirst) active.floatingFailure.reject(this.#floatingRejectionError(reason)); + } + + #floatingRejectionError(reason: unknown): Error { + const message = reason instanceof Error ? reason.message : String(reason); + const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason }); + if (reason instanceof Error) error.name = reason.name; + return error; + } + + #foldFloatingRejections(active: ActiveRun, failure: { error: unknown } | undefined): { error: unknown } | undefined { + const rejections = active.floatingRejections; + if (rejections.length === 0) return failure; + let reported = rejections; + if (!failure) { + failure = { error: this.#floatingRejectionError(rejections[0]) }; + reported = rejections.slice(1); + } else if (failure.error instanceof Error && failure.error.cause === rejections[0]) { + reported = rejections.slice(1); + } + for (const reason of reported) { + this.#log("warn", "Additional unhandled browser-run rejection", { + error: reason instanceof Error ? reason.message : String(reason), + }); + } + return failure; } nextElementId(): number { @@ -975,6 +1035,7 @@ export class WorkerCore { const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]); const output = new RunOutput(); const screenshots: ScreenshotResult[] = []; + const floatingFailure = Promise.withResolvers(); const active: ActiveRun = { id: msg.id, ac, @@ -982,6 +1043,9 @@ export class WorkerCore { output, screenshots, pendingTools: new Map(), + rejectionOwner: {}, + floatingRejections: [], + floatingFailure, inflight: new Map(), opCounter: 0, }; @@ -997,10 +1061,11 @@ export class WorkerCore { const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active); const runtime = this.#ensureRuntime(msg.session); runtime.setCwd(msg.session.cwd); + const onFloatingRejection = (reason: unknown): void => this.#recordFloatingRejection(active, reason); runtime.setRunScope({ - page: bindRunFacade(runPage.page, signal), - browser: bindRunFacade(browser, signal), - tab: bindRunFacade(tabApi, signal), + page: bindRunFacade(runPage.page, signal, active.rejectionOwner, onFloatingRejection), + browser: bindRunFacade(browser, signal, active.rejectionOwner, onFloatingRejection), + tab: bindRunFacade(tabApi, signal, active.rejectionOwner, onFloatingRejection), assert: (cond: unknown, text?: string): void => { if (!cond) throw new ToolError(text ?? "Assertion failed"); }, @@ -1012,10 +1077,12 @@ export class WorkerCore { typeof msOrPredicate === "number" ? undefined : { timeout: resolvePredicateTimeout(msg.timeoutMs, opts?.timeout), interval: opts?.interval }; - return markHandled( + return observeBrowserRunPromise( this.#runOp(active, label, signal, Number.POSITIVE_INFINITY, sig => waitForRun(msOrPredicate, sig, resolved), ), + active.rejectionOwner, + onFloatingRejection, ); }, }); @@ -1056,6 +1123,7 @@ export class WorkerCore { returnValue = await Promise.race([ runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { runId: msg.id, cwd: msg.session.cwd }), cancelRejection, + floatingFailure.promise, ]); completed = true; } finally { @@ -1064,6 +1132,8 @@ export class WorkerCore { } catch (error) { failure = { error }; } finally { + await Bun.sleep(0); + failure = this.#foldFloatingRejections(active, failure); runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); try { await runPage?.cleanup(); @@ -1184,9 +1254,12 @@ export class WorkerCore { (opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError")) ) { const hint = selector ? await this.#selectorTimeoutHint(selector) : ""; - throw markBrowserRunRejection(new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`)); + throw markBrowserRunRejection( + new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`), + active.rejectionOwner, + ); } - throw markBrowserRunRejection(err); + throw markBrowserRunRejection(err, active.rejectionOwner); } finally { earlyAc.abort(); active.inflight.delete(opId); @@ -1870,6 +1943,7 @@ export class WorkerCore { async #close(): Promise { this.#unsub(); + this.#uninstallRejectionGuard(); this.#clearElementCache(); const page = this.#page; if (this.#dialogHandler && page && !page.isClosed()) page.off("dialog", this.#dialogHandler); diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index 5d44bfc06..2568c7826 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -1,77 +1,114 @@ -import { logger, postmortem } from "@oh-my-pi/pi-utils"; +import { postmortem } from "@oh-my-pi/pi-utils"; import { untilAborted } from "@oh-my-pi/pi-utils/abortable"; import { ToolError, throwIfAborted } from "./tool-errors"; -const BROWSER_RUN_REJECTION = Symbol.for("omp.browserRunRejection"); +const browserRunRejections = new WeakMap(); -export function markBrowserRunRejection(reason: T): T { +/** Associates a browser operation failure with its owning evaluated run. */ +export function markBrowserRunRejection(reason: T, owner: object): T { if (reason !== null && (typeof reason === "object" || typeof reason === "function")) { - Reflect.set(reason, BROWSER_RUN_REJECTION, true); + browserRunRejections.set(reason, owner); } return reason; } -function isBrowserRunRejection(reason: unknown): boolean { - let current = reason; - for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) { - if (Reflect.get(current, BROWSER_RUN_REJECTION) === true) return true; - current = "cause" in current ? current.cause : undefined; - } - return false; +/** Returns whether a rejection was marked for the specified evaluated run. */ +export function isBrowserRunRejection(reason: unknown, owner: object): boolean { + return ( + reason !== null && + (typeof reason === "object" || typeof reason === "function") && + browserRunRejections.get(reason) === owner + ); } -function consumeBrowserRunRejection(reason: unknown): boolean { - if (!isBrowserRunRejection(reason)) return false; - logger.warn("Contained unhandled browser-run rejection (missing await?)", { - error: reason instanceof Error ? reason.message : String(reason), - }); - return true; +type FloatingRejectionHandler = (reason: unknown) => void; + +interface ObservedPromiseState { + handled: boolean; } -postmortem.interceptUnhandledRejections(consumeBrowserRunRejection); +const observedBrowserPromises = new WeakMap, ObservedPromiseState>(); +const observedPromiseConstructor = { [Symbol.species]: Promise }; /** - * Observe every continuation created from a browser facade promise. A catch on - * only the original promise does not cover `tab.waitForResponse(...).then(...)`: - * `then` creates a fresh rejection that can otherwise kill or wedge the worker. + * Observes every explicit continuation of a browser promise without replacing + * the native promise. Browser failures remain contained; an unhandled error + * created by user continuation code is reported to the owning run. */ -const observedPromiseTrees = new WeakSet>(); - -function observePromiseTree(promise: Promise): Promise { - if (observedPromiseTrees.has(promise)) return promise; - observedPromiseTrees.add(promise); - markHandled(promise); +export function observeBrowserRunPromise( + promise: Promise, + owner: object, + onFloatingRejection: FloatingRejectionHandler, +): Promise { + if (observedBrowserPromises.has(promise)) return promise; + const state: ObservedPromiseState = { handled: false }; + observedBrowserPromises.set(promise, state); const originalThen = promise.then.bind(promise); - const originalCatch = promise.catch.bind(promise); const originalFinally = promise.finally.bind(promise); + void originalThen(undefined, reason => { + if (isBrowserRunRejection(reason, owner)) return; + setTimeout(() => { + if (!state.handled) onFloatingRejection(reason); + }, 0); + }); Object.defineProperties(promise, { + constructor: { configurable: true, value: observedPromiseConstructor }, // biome-ignore lint/suspicious/noThenProperty: native Promise continuations must remain thenable. then: { configurable: true, value: ( onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise => observePromiseTree(originalThen(onFulfilled, onRejected)), + ): Promise => { + state.handled = true; + return observeBrowserRunPromise(originalThen(onFulfilled, onRejected), owner, onFloatingRejection); + }, }, catch: { configurable: true, value: ( onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, - ): Promise => observePromiseTree(originalCatch(onRejected)), + ): Promise => { + state.handled = true; + return observeBrowserRunPromise(originalThen(undefined, onRejected), owner, onFloatingRejection); + }, }, finally: { configurable: true, - value: (onFinally?: (() => void) | null): Promise => observePromiseTree(originalFinally(onFinally)), + value: (onFinally?: (() => void) | null): Promise => { + state.handled = true; + return observeBrowserRunPromise(originalFinally(onFinally), owner, onFloatingRejection); + }, }, }); return promise; } -function trackBrowserRunPromise(promise: Promise): Promise { +function trackBrowserRunPromise( + promise: Promise, + owner?: object, + onFloatingRejection?: FloatingRejectionHandler, +): Promise { + if (!owner) return markHandled(promise); const tracked = promise.catch(error => { - throw markBrowserRunRejection(error); + throw markBrowserRunRejection(error, owner); }); - return observePromiseTree(tracked); + return onFloatingRejection ? observeBrowserRunPromise(tracked, owner, onFloatingRejection) : tracked; +} + +/** + * Installs worker-realm rejection routing. Consumed browser-run failures stay in + * the worker; unrelated failures retain the default fatal worker behavior. + */ +export function installBrowserWorkerRejectionGuard(consume: (reason: unknown) => boolean): () => void { + const onRejection = (reason: unknown): void => { + if (postmortem.isExpectedCleanupError(reason) || consume(reason)) return; + setTimeout(() => { + throw reason; + }, 0); + }; + process.on("unhandledRejection", onRejection); + return () => process.off("unhandledRejection", onRejection); } /** @@ -160,7 +197,12 @@ export function waitForRun( } /** Binds a long-lived scope facade (page/tab/desktop objects) to one evaluated run's abort signal. */ -export function bindRunFacade(target: T, signal: AbortSignal): T { +export function bindRunFacade( + target: T, + signal: AbortSignal, + rejectionOwner?: object, + onFloatingRejection?: FloatingRejectionHandler, +): T { const cache = new Map(); return new Proxy(target, { get(current, prop) { @@ -176,15 +218,12 @@ export function bindRunFacade(target: T, signal: AbortSignal): const then = Reflect.get(result, "then"); if (typeof then === "function") { return trackBrowserRunPromise( - Promise.resolve(result).then( - resolved => { - throwIfAborted(signal); - return resolved; - }, - error => { - throw markBrowserRunRejection(error); - }, - ), + Promise.resolve(result).then(resolved => { + throwIfAborted(signal); + return resolved; + }), + rejectionOwner, + onFloatingRejection, ); } } @@ -199,7 +238,7 @@ export function bindRunFacade(target: T, signal: AbortSignal): // brand-check internal slots that a Proxy cannot forward, and reading a // signal needs no abort gating anyway. if (value instanceof AbortSignal) return value; - const wrapped = bindRunFacade(value, signal); + const wrapped = bindRunFacade(value, signal, rejectionOwner, onFloatingRejection); cache.set(prop, wrapped); return wrapped; } diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index 0fa306232..a3cec3b1e 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -1,11 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; import { postmortem } from "@oh-my-pi/pi-utils"; import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime"; -import { bindRunFacade, markHandled, waitForRun } from "../../src/tools/run-scope"; +import { + bindRunFacade, + isBrowserRunRejection, + markBrowserRunRejection, + markHandled, + waitForRun, +} from "../../src/tools/run-scope"; import { ToolAbortError } from "../../src/tools/tool-errors"; -const runCancellationModuleUrl = new URL("../../src/tools/browser/run-cancellation.ts", import.meta.url).href; -const toolErrorsModuleUrl = new URL("../../src/tools/tool-errors.ts", import.meta.url).href; +const runScopeModuleUrl = new URL("../../src/tools/run-scope.ts", import.meta.url).href; async function collectUnhandledRejections(action: () => void | Promise): Promise { const reasons: unknown[] = []; @@ -130,38 +136,194 @@ describe("browser run cancellation", () => { expect(reasons).toEqual([]); }); - it("contains an unhandled descendant of a timed-out browser helper promise", async () => { - const script = ` - import { bindBrowserRunFacade } from ${JSON.stringify(runCancellationModuleUrl)}; - import { ToolError } from ${JSON.stringify(toolErrorsModuleUrl)}; + it("scopes browser rejection markers to the owning run and direct reason", () => { + const owner = {}; + const browserFailure = new Error("browser failed"); + markBrowserRunRejection(browserFailure, owner); - const facade = bindBrowserRunFacade( - { - waitForResponse() { - return Promise.reject(new ToolError("tab.waitForResponse() timed out after 15ms")); - }, - }, - new AbortController().signal, - ); - void facade.waitForResponse().then(() => undefined); + expect(isBrowserRunRejection(browserFailure, owner)).toBe(true); + expect(isBrowserRunRejection(browserFailure, {})).toBe(false); + expect(isBrowserRunRejection(new Error("unrelated", { cause: browserFailure }), owner)).toBe(false); + }); + + it("keeps a later cause-wrapped rejection on the fatal path", async () => { + vi.useRealTimers(); + const script = ` + import { markBrowserRunRejection } from ${JSON.stringify(runScopeModuleUrl)}; + + const browserFailure = new Error("browser failure"); + markBrowserRunRejection(browserFailure, {}); + Promise.reject(new Error("unrelated fatal", { cause: browserFailure })); await Promise.resolve(); - await Promise.resolve(); - console.log("browser probe survived"); `; const proc = Bun.spawn([process.execPath, "-e", script], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", }); - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); + const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); - expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("browser probe survived"); - expect(stderr).not.toContain("[Unhandled Rejection]"); + expect(exitCode).toBe(1); + expect(stderr).toContain("[Unhandled Rejection] Error: unrelated fatal"); + }); + + it("preserves a browser rejection marker through native await", async () => { + const owner = {}; + const browserFailure = new Error("browser failed"); + const facade = bindRunFacade( + { + fail(): Promise { + return Promise.reject(browserFailure); + }, + }, + new AbortController().signal, + owner, + ); + + let caught: unknown; + try { + await (async () => await facade.fail())(); + } catch (error) { + caught = error; + } + + expect(caught).toBe(browserFailure); + expect(isBrowserRunRejection(caught, owner)).toBe(true); + }); + + it("keeps a real worker alive after floating browser and continuation rejections", async () => { + vi.useRealTimers(); + const workerPath = `/tmp/omp-browser-rejections-${process.pid}.ts`; + await Bun.write( + workerPath, + ` + import { + bindRunFacade, + installBrowserWorkerRejectionGuard, + } from ${JSON.stringify(runScopeModuleUrl)}; + + const failures = []; + const uninstall = installBrowserWorkerRejectionGuard(reason => { + failures.push(reason instanceof Error ? reason.message : String(reason)); + return true; + }); + const facade = bindRunFacade( + { + waitForResponse() { + return Promise.reject(new Error("browser timeout")); + }, + title() { + return Promise.resolve("ready"); + }, + }, + new AbortController().signal, + {}, + ); + void (async () => { + await facade.waitForResponse(); + })(); + void facade.title().then(() => { + throw new Error("continuation failed"); + }); + setTimeout(() => { + uninstall(); + postMessage({ alive: true, failures }); + }, 50); + `, + ); + try { + const script = ` + const worker = new Worker(${JSON.stringify(workerPath)}, { type: "module" }); + const done = Promise.withResolvers(); + worker.onmessage = event => done.resolve(event.data); + worker.onerror = event => done.reject(new Error(event.message)); + try { + const result = await Promise.race([ + done.promise, + Bun.sleep(1000).then(() => { + throw new Error("worker timed out"); + }), + ]); + console.log(JSON.stringify(result)); + } finally { + await worker.terminate(); + } + `; + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("browser timeout"); + expect(stdout).toContain("continuation failed"); + } finally { + await fs.rm(workerPath, { force: true }); + } + }); + + it("does not mark errors thrown by user continuations", async () => { + const owner = {}; + const continuationFailure = new Error("continuation failed"); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + ok: async (): Promise => "ok", + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + const root = facade.ok(); + expect(root).toBeInstanceOf(Promise); + expect(Object.getPrototypeOf(root)).toBe(Promise.prototype); + const continuation = root.then(() => { + throw continuationFailure; + }); + + await expect(continuation).rejects.toBe(continuationFailure); + expect(isBrowserRunRejection(continuationFailure, owner)).toBe(false); + expect(floatingRejections).toEqual([]); + }); + + it("reports unhandled errors from then, catch, and finally continuations", async () => { + vi.useRealTimers(); + const owner = {}; + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail: async (): Promise => { + throw new Error("browser failure"); + }, + ok: async (): Promise => "ok", + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + void facade.ok().then(() => { + throw new Error("then failed"); + }); + void facade.fail().catch(() => { + throw new Error("catch failed"); + }); + void facade.ok().finally(() => { + throw new Error("finally failed"); + }); + await Bun.sleep(20); + + const messages = floatingRejections + .map(reason => (reason instanceof Error ? reason.message : String(reason))) + .sort(); + expect(messages).toEqual(["catch failed", "finally failed", "then failed"]); }); it("rejects awaited facade method calls that settle after abort", async () => { diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index fea1670fc..c16118a1c 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -246,6 +246,46 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("fails a floated user continuation without killing the tab worker", async () => { + const tool = new BrowserTool(makeSession()); + const name = `continuation-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` + void tab.title().then(() => { + throw new Error("continuation failed"); + }); + await Bun.sleep(50); + return "incorrect success"; + `, + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): continuation failed"); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("observes floating raw page promises when the target closes", async () => { const tool = new BrowserTool(makeSession()); const name = `target-close-${process.pid}`; From 71c26e5958452b54a1f0540608703848dd51e9c6 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:07:37 +0800 Subject: [PATCH 117/126] fix(coding-agent): reported rethrown browser continuations Tracked whether user continuation callbacks create each descendant rejection. Browser errors that user code rethrows now fail the owning run instead of being contained as propagated helper failures. --- packages/coding-agent/src/tools/run-scope.ts | 75 +++++++++++++++++-- .../tools/browser-run-cancellation.test.ts | 23 ++++++ .../test/tools/browser-tab-evaluate.test.ts | 23 +++++- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index 2568c7826..19b1123aa 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -25,6 +25,7 @@ type FloatingRejectionHandler = (reason: unknown) => void; interface ObservedPromiseState { handled: boolean; + userContinuationFailed: boolean; } const observedBrowserPromises = new WeakMap, ObservedPromiseState>(); @@ -39,16 +40,28 @@ export function observeBrowserRunPromise( promise: Promise, owner: object, onFloatingRejection: FloatingRejectionHandler, +): Promise { + return observeBrowserRunPromiseWithState(promise, owner, onFloatingRejection, { + handled: false, + userContinuationFailed: false, + }); +} + +function observeBrowserRunPromiseWithState( + promise: Promise, + owner: object, + onFloatingRejection: FloatingRejectionHandler, + state: ObservedPromiseState, ): Promise { if (observedBrowserPromises.has(promise)) return promise; - const state: ObservedPromiseState = { handled: false }; observedBrowserPromises.set(promise, state); const originalThen = promise.then.bind(promise); const originalFinally = promise.finally.bind(promise); void originalThen(undefined, reason => { - if (isBrowserRunRejection(reason, owner)) return; setTimeout(() => { - if (!state.handled) onFloatingRejection(reason); + if (!state.handled && (state.userContinuationFailed || !isBrowserRunRejection(reason, owner))) { + onFloatingRejection(reason); + } }, 0); }); Object.defineProperties(promise, { @@ -61,7 +74,16 @@ export function observeBrowserRunPromise( onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): Promise => { state.handled = true; - return observeBrowserRunPromise(originalThen(onFulfilled, onRejected), owner, onFloatingRejection); + const childState = createContinuationState(); + return observeBrowserRunPromiseWithState( + originalThen( + recordContinuationFailure(onFulfilled, childState), + recordContinuationFailure(onRejected, childState), + ), + owner, + onFloatingRejection, + childState, + ); }, }, catch: { @@ -70,20 +92,61 @@ export function observeBrowserRunPromise( onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, ): Promise => { state.handled = true; - return observeBrowserRunPromise(originalThen(undefined, onRejected), owner, onFloatingRejection); + const childState = createContinuationState(); + return observeBrowserRunPromiseWithState( + originalThen(undefined, recordContinuationFailure(onRejected, childState)), + owner, + onFloatingRejection, + childState, + ); }, }, finally: { configurable: true, value: (onFinally?: (() => void) | null): Promise => { state.handled = true; - return observeBrowserRunPromise(originalFinally(onFinally), owner, onFloatingRejection); + const childState = createContinuationState(); + return observeBrowserRunPromiseWithState( + originalFinally(recordContinuationFailure(onFinally, childState)), + owner, + onFloatingRejection, + childState, + ); }, }, }); return promise; } +function createContinuationState(): ObservedPromiseState { + return { handled: false, userContinuationFailed: false }; +} + +function recordContinuationFailure( + continuation: ((...args: TArgs) => TResult | PromiseLike) | null | undefined, + state: ObservedPromiseState, +): ((...args: TArgs) => TResult | PromiseLike) | null | undefined { + if (!continuation) return continuation; + return (...args) => { + try { + const result = continuation(...args); + if (!isThenable(result)) return result; + return Promise.resolve(result).catch(reason => { + state.userContinuationFailed = true; + throw reason; + }) as PromiseLike; + } catch (reason) { + state.userContinuationFailed = true; + throw reason; + } + }; +} + +function isThenable(value: unknown): value is PromiseLike { + if (value === null || (typeof value !== "object" && typeof value !== "function")) return false; + return typeof Reflect.get(value, "then") === "function"; +} + function trackBrowserRunPromise( promise: Promise, owner?: object, diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index a3cec3b1e..98d67847b 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -326,6 +326,29 @@ describe("browser run cancellation", () => { expect(messages).toEqual(["catch failed", "finally failed", "then failed"]); }); + it("reports a browser error rethrown by a user rejection continuation", async () => { + vi.useRealTimers(); + const owner = {}; + const browserFailure = new Error("browser failure"); + const floatingRejections: unknown[] = []; + const facade = bindBrowserRunFacade( + { + fail: (): Promise => Promise.reject(browserFailure), + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + void facade.fail().catch(reason => { + throw reason; + }); + await Bun.sleep(20); + + expect(isBrowserRunRejection(browserFailure, owner)).toBe(true); + expect(floatingRejections).toEqual([browserFailure]); + }); + it("rejects awaited facade method calls that settle after abort", async () => { const controller = new AbortController(); const deferred = Promise.withResolvers(); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index c16118a1c..13eb30cad 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -246,7 +246,7 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); - it("fails a floated user continuation without killing the tab worker", async () => { + it("fails floated user continuations without killing the tab worker", async () => { const tool = new BrowserTool(makeSession()); const name = `continuation-rejection-${process.pid}`; @@ -275,6 +275,27 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } expect(failure).toContain("Unhandled rejection (missing await?): continuation failed"); + let rethrowFailure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` + void tab.waitForResponse("/never", { timeout: 10 }).catch(reason => { + throw reason; + }); + await Bun.sleep(50); + return "incorrect success"; + `, + }); + } catch (error) { + rethrowFailure = error instanceof Error ? error.message : String(error); + } + expect(rethrowFailure).toContain( + "Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms", + ); + const followup = await tool.execute("run", { action: "run", name, From dd0b7b3176a792c1d38fe4ad382164269d2d51d5 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:24:46 +0800 Subject: [PATCH 118/126] fix(coding-agent): reported late browser continuations Logged user continuation rejections that settle after their browser run has ended. This preserves the completed result while making missing awaits visible instead of silently dropping them. --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/tools/browser/tab-worker.ts | 9 +++- .../test/tools/browser-tab-evaluate.test.ts | 41 ++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bc59f7951..d043a1845 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -148,7 +148,7 @@ ### Fixed -- Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation. +- Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation, and late user continuation errors are logged instead of dropped after the run ends. ## [17.2.4] - 2026-08-01 diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index db02740dd..fc81deacc 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -792,7 +792,14 @@ export class WorkerCore { } #recordFloatingRejection(active: ActiveRun, reason: unknown): void { - if (this.#active !== active || postmortem.isExpectedCleanupError(reason)) return; + if (postmortem.isExpectedCleanupError(reason)) return; + if (this.#active !== active) { + this.#log("warn", "Unhandled rejection after browser run ended", { + runId: active.id, + error: reason instanceof Error ? reason.message : String(reason), + }); + return; + } const isFirst = active.floatingRejections.length === 0; active.floatingRejections.push(reason); if (isFirst) active.floatingFailure.reject(this.#floatingRejectionError(reason)); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 13eb30cad..8b8a5e10d 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, vi } from "bun:test"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { BrowserTool } from "@oh-my-pi/pi-coding-agent/tools/browser"; import { getTabsMapForTest } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; +import * as logger from "@oh-my-pi/pi-utils/logger"; import { chromiumAvailable } from "./chromium-probe"; const CHROMIUM_AVAILABLE = await chromiumAvailable(); @@ -307,6 +308,44 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("logs a user continuation rejection after its browser run ends", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const tool = new BrowserTool(makeSession()); + const name = `late-continuation-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const result = await tool.execute("run", { + action: "run", + name, + code: ` + const continuationStarted = Promise.withResolvers(); + void tab.title().then(async () => { + continuationStarted.resolve(); + await Bun.sleep(50); + throw new Error("late continuation failed"); + }); + await continuationStarted.promise; + return "completed"; + `, + }); + expect(result.content).toEqual([{ type: "text", text: "completed" }]); + + await Bun.sleep(150); + expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { + runId: expect.any(String), + error: "late continuation failed", + }); + } finally { + warn.mockRestore(); + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("observes floating raw page promises when the target closes", async () => { const tool = new BrowserTool(makeSession()); const name = `target-close-${process.pid}`; From bb5d64557db3474ff4bb217cc47d17501edf54f7 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:54:32 +0800 Subject: [PATCH 119/126] fix(coding-agent): preserved late browser rejections Logged late user continuation failures in cmux runs and delayed worker rejection folding until request-interception cleanup completed. This closes both windows where missing awaits could be silently dropped. --- .../src/tools/browser/cmux/cmux-tab.ts | 8 ++- .../src/tools/browser/tab-worker.ts | 2 +- .../browser-cmux-release-mid-run.test.ts | 50 +++++++++++++++++++ .../test/tools/browser-tab-evaluate.test.ts | 39 +++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index f13ecc13a..7e48bd140 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -1397,9 +1397,13 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi let runActive = true; let hasFloatingFailure = false; const recordFloatingFailure = (reason: unknown): void => { - if (!runActive || hasFloatingFailure || postmortem.isExpectedCleanupError(reason)) return; - hasFloatingFailure = true; + if (hasFloatingFailure || postmortem.isExpectedCleanupError(reason)) return; const message = reason instanceof Error ? reason.message : String(reason); + if (!runActive) { + logger.warn("Unhandled rejection after browser run ended", { runId, error: message }); + return; + } + hasFloatingFailure = true; const error = new Error(`Unhandled rejection (missing await?): ${message}`, { cause: reason }); if (reason instanceof Error) error.name = reason.name; rejectFloatingFailure(error); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index fc81deacc..ebb1747fe 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -1140,13 +1140,13 @@ export class WorkerCore { failure = { error }; } finally { await Bun.sleep(0); - failure = this.#foldFloatingRejections(active, failure); runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); try { await runPage?.cleanup(); } catch (error) { failure = { error }; } + failure = this.#foldFloatingRejections(active, failure); if (this.#active?.id === msg.id) this.#active = null; } if (failure) { diff --git a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts index 51df6907e..94ec1e6af 100644 --- a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts @@ -42,6 +42,7 @@ import { runInTab, } from "@oh-my-pi/pi-coding-agent/tools/browser/tab-supervisor"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index"; +import * as logger from "@oh-my-pi/pi-utils/logger"; function makeKind(socketSuffix: string): CmuxKind { return { @@ -286,6 +287,55 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { } }); + it("logs a user continuation rejection after its cmux run ends", async () => { + spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); + spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-late-rejection", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + return { value: "" }; + default: + return {}; + } + }, + ); + const warn = spyOn(logger, "warn").mockImplementation(() => {}); + const browser = await acquireBrowser(makeKind("late-rejection"), { cwd: "/tmp" }); + await acquireTab("late-rejection", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-late-rejection", + }); + + const result = await runInTab("late-rejection", { + code: ` + const continuationStarted = Promise.withResolvers(); + void tab.title().then(async () => { + continuationStarted.resolve(); + await Bun.sleep(50); + throw new Error("late cmux continuation failed"); + }); + await continuationStarted.promise; + return "completed"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + expect(result.returnValue).toBe("completed"); + + await Bun.sleep(150); + expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { + runId: expect.any(String), + error: "late cmux continuation failed", + }); + }); + it("ignores the daemon screenshot path when no screenshot directory is configured", async () => { spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 8b8a5e10d..d042c8f97 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -308,6 +308,45 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("folds a user continuation rejection that settles during cleanup", async () => { + const tool = new BrowserTool(makeSession()); + const name = `cleanup-continuation-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; + try { + await tool.execute("run", { + action: "run", + name, + code: ` + await page.setRequestInterception(true); + page.setRequestInterception = async () => { + await Bun.sleep(50); + }; + const continuationStarted = Promise.withResolvers(); + void tab.title().then(async () => { + continuationStarted.resolve(); + await Bun.sleep(10); + throw new Error("cleanup continuation failed"); + }); + await continuationStarted.promise; + return "incorrect success"; + `, + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): cleanup continuation failed"); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("logs a user continuation rejection after its browser run ends", async () => { const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const tool = new BrowserTool(makeSession()); From af35be11050c5508d233893a35a3cff21269d681 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:27:04 +0800 Subject: [PATCH 120/126] fix(coding-agent): tracked browser promise combinators Observed Promise.all and Promise.race results derived from browser calls during each evaluated run. User catch continuations that rethrow browser failures now fail the owning run without changing native await behavior. --- .../src/tools/browser/cmux/cmux-tab.ts | 21 +++-- .../src/tools/browser/tab-worker.ts | 23 ++++-- packages/coding-agent/src/tools/run-scope.ts | 77 +++++++++++++++++++ .../browser-cmux-release-mid-run.test.ts | 44 +++++++++++ .../tools/browser-run-cancellation.test.ts | 72 ++++++++++++++++- .../test/tools/browser-tab-evaluate.test.ts | 42 ++++++++++ 6 files changed, 263 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index 7e48bd140..85e5e3619 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -16,12 +16,12 @@ import { resolvePredicateTimeout, type WaitPredicateOptions, waitForRun, + withBrowserPromiseCombinatorTracking, } from "../../run-scope"; import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors"; import { type AriaSnapshotOptions, assertSelectorString, buildAriaSnapshotScript } from "../aria/aria-snapshot"; import { DEFAULT_VIEWPORT } from "../launch"; import { extractReadableFromHtml, type ReadableFormat } from "../readable"; - import { cloneSafe, RunOutput } from "../run-output"; import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol"; import { @@ -1409,9 +1409,9 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi rejectFloatingFailure(error); }; const uninstallRejectionInterceptor = postmortem.interceptUnhandledRejections(reason => { - if (isBrowserRunRejection(reason, rejectionOwner)) return true; + const browserRunRejection = isBrowserRunRejection(reason, rejectionOwner); const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined; - if (stack?.includes(`cmux-run-${runId}.js`) !== true) return false; + if (!browserRunRejection && stack?.includes(`cmux-run-${runId}.js`) !== true) return false; recordFloatingFailure(reason); return true; }); @@ -1483,11 +1483,16 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi let runError: unknown; let runFailed = false; try { - returnValue = await Promise.race([ - runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }), - cancelRejection, - floatingFailure, - ]); + returnValue = await withBrowserPromiseCombinatorTracking( + rejectionOwner, + recordFloatingFailure, + async () => + await Promise.race([ + runtime.run(opts.code, filename, hooks, { runId, cwd: opts.snapshot.cwd }), + cancelRejection, + floatingFailure, + ]), + ); } catch (error) { runFailed = true; runError = error; diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index ebb1747fe..cd50e8795 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -31,6 +31,7 @@ import { resolvePredicateTimeout, type WaitPredicateOptions, waitForRun, + withBrowserPromiseCombinatorTracking, } from "../run-scope"; import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors"; import { @@ -783,10 +784,10 @@ export class WorkerCore { #consumeUnhandledRejection(reason: unknown): boolean { const active = this.#active; if (!active) return false; - if (isBrowserRunRejection(reason, active.rejectionOwner)) return true; + const browserRunRejection = isBrowserRunRejection(reason, active.rejectionOwner); const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined; const fromRun = stack?.includes(`browser-run-${active.id}.js`) === true; - if (!this.#isolated && !fromRun) return false; + if (!browserRunRejection && !this.#isolated && !fromRun) return false; this.#recordFloatingRejection(active, reason); return true; } @@ -1127,11 +1128,19 @@ export class WorkerCore { try { const hooks = this.#hooksForActiveRun(); if (!hooks) throw new ToolError("Browser runtime started without an active run"); - returnValue = await Promise.race([ - runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { runId: msg.id, cwd: msg.session.cwd }), - cancelRejection, - floatingFailure.promise, - ]); + returnValue = await withBrowserPromiseCombinatorTracking( + active.rejectionOwner, + onFloatingRejection, + async () => + await Promise.race([ + runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { + runId: msg.id, + cwd: msg.session.cwd, + }), + cancelRejection, + floatingFailure.promise, + ]), + ); completed = true; } finally { signal.removeEventListener("abort", onCancel); diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index 19b1123aa..07ac2a697 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { postmortem } from "@oh-my-pi/pi-utils"; import { untilAborted } from "@oh-my-pi/pi-utils/abortable"; import { ToolError, throwIfAborted } from "./tool-errors"; @@ -31,6 +32,82 @@ interface ObservedPromiseState { const observedBrowserPromises = new WeakMap, ObservedPromiseState>(); const observedPromiseConstructor = { [Symbol.species]: Promise }; +type PromiseCombinatorName = "all" | "race"; + +interface PromiseCombinatorTrackingContext { + owner: object; + onFloatingRejection: FloatingRejectionHandler; +} + +const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"]; +const promiseCombinatorTracking = new AsyncLocalStorage(); +const originalPromiseCombinatorDescriptors = new Map(); +let promiseCombinatorTrackingScopes = 0; + +/** + * Observes native promise-combinator results derived from browser promises for + * the duration of one evaluated run. Native `await` remains unchanged; dropped + * user continuations from `Promise.all` and `Promise.race` are routed to the + * owning run. + */ +export async function withBrowserPromiseCombinatorTracking( + owner: object, + onFloatingRejection: FloatingRejectionHandler, + run: () => Promise, +): Promise { + installPromiseCombinatorTracking(); + try { + return await promiseCombinatorTracking.run({ owner, onFloatingRejection }, run); + } finally { + restorePromiseCombinatorTracking(); + } +} + +function installPromiseCombinatorTracking(): void { + promiseCombinatorTrackingScopes++; + if (promiseCombinatorTrackingScopes !== 1) return; + for (const name of PROMISE_COMBINATORS) { + const descriptor = Object.getOwnPropertyDescriptor(Promise, name); + if (!descriptor || typeof descriptor.value !== "function") continue; + originalPromiseCombinatorDescriptors.set(name, descriptor); + const original = descriptor.value as (this: PromiseConstructor, values: Iterable) => Promise; + Object.defineProperty(Promise, name, { + ...descriptor, + value(this: PromiseConstructor, values: Iterable): Promise { + let hasObservedInput = false; + const result = Reflect.apply(original, this, [ + tapObservedBrowserPromises(values, () => { + hasObservedInput = true; + }), + ]) as Promise; + const context = promiseCombinatorTracking.getStore(); + return hasObservedInput && context + ? observeBrowserRunPromise(result, context.owner, context.onFloatingRejection) + : result; + }, + }); + } +} + +function restorePromiseCombinatorTracking(): void { + promiseCombinatorTrackingScopes--; + if (promiseCombinatorTrackingScopes !== 0) return; + for (const [name, descriptor] of originalPromiseCombinatorDescriptors) { + Object.defineProperty(Promise, name, descriptor); + } + originalPromiseCombinatorDescriptors.clear(); +} + +function* tapObservedBrowserPromises( + values: Iterable, + onObserved: () => void, +): Generator { + for (const value of values) { + if (observedBrowserPromises.has(value as Promise)) onObserved(); + yield value; + } +} + /** * Observes every explicit continuation of a browser promise without replacing * the native promise. Browser failures remain contained; an unhandled error diff --git a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts index 94ec1e6af..e41cc9103 100644 --- a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts @@ -336,6 +336,50 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { }); }); + it("fails a browser error rethrown through a native promise combinator", async () => { + spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); + spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-combinator-rejection", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + return { value: "" }; + case "browser.navigate": + throw new Error("navigation failed"); + default: + return {}; + } + }, + ); + const browser = await acquireBrowser(makeKind("combinator-rejection"), { cwd: "/tmp" }); + await acquireTab("combinator-rejection", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-combinator-rejection", + }); + + const run = runInTab("combinator-rejection", { + code: ` + void Promise.all([ + tab.goto("https://example.test"), + ]).catch(reason => { + throw reason; + }); + await wait(50); + return "incorrect success"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + + await expect(run).rejects.toThrow("Unhandled rejection (missing await?): navigation failed"); + }); + it("ignores the daemon screenshot path when no screenshot directory is configured", async () => { spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index 98d67847b..5c85bf566 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -8,6 +8,7 @@ import { markBrowserRunRejection, markHandled, waitForRun, + withBrowserPromiseCombinatorTracking, } from "../../src/tools/run-scope"; import { ToolAbortError } from "../../src/tools/tool-errors"; @@ -191,6 +192,75 @@ describe("browser run cancellation", () => { expect(isBrowserRunRejection(caught, owner)).toBe(true); }); + it("reports user rethrows from native browser-promise combinators", async () => { + vi.useRealTimers(); + for (const name of ["all", "race"] as const) { + const owner = {}; + const browserFailure = new Error(`${name} browser failure`); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail(): Promise { + return Promise.reject(browserFailure); + }, + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + const originalCombinator = Promise[name]; + + await withBrowserPromiseCombinatorTracking( + owner, + reason => floatingRejections.push(reason), + async () => { + const combined = name === "all" ? Promise.all([facade.fail()]) : Promise.race([facade.fail()]); + void combined.catch(reason => { + throw reason; + }); + await Bun.sleep(20); + }, + ); + + expect(floatingRejections).toEqual([browserFailure]); + expect(Promise[name]).toBe(originalCombinator); + } + }); + + it("preserves native await through a tracked browser-promise combinator", async () => { + vi.useRealTimers(); + const owner = {}; + const browserFailure = new Error("browser failed"); + const floatingRejections: unknown[] = []; + const facade = bindRunFacade( + { + fail(): Promise { + return Promise.reject(browserFailure); + }, + }, + new AbortController().signal, + owner, + reason => floatingRejections.push(reason), + ); + + let caught: unknown; + await withBrowserPromiseCombinatorTracking( + owner, + reason => floatingRejections.push(reason), + async () => { + try { + await Promise.all([facade.fail()]); + } catch (error) { + caught = error; + } + await Bun.sleep(10); + }, + ); + + expect(caught).toBe(browserFailure); + expect(floatingRejections).toEqual([]); + }); + it("keeps a real worker alive after floating browser and continuation rejections", async () => { vi.useRealTimers(); const workerPath = `/tmp/omp-browser-rejections-${process.pid}.ts`; @@ -331,7 +401,7 @@ describe("browser run cancellation", () => { const owner = {}; const browserFailure = new Error("browser failure"); const floatingRejections: unknown[] = []; - const facade = bindBrowserRunFacade( + const facade = bindRunFacade( { fail: (): Promise => Promise.reject(browserFailure), }, diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index d042c8f97..53a34f693 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -308,6 +308,48 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("fails a browser error rethrown through a native promise combinator", async () => { + const tool = new BrowserTool(makeSession()); + const name = `combinator-rejection-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` + void Promise.all([ + tab.waitForResponse("/never", { timeout: 10 }), + ]).catch(reason => { + throw reason; + }); + await Bun.sleep(50); + return "incorrect success"; + `, + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms"); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("folds a user continuation rejection that settles during cleanup", async () => { const tool = new BrowserTool(makeSession()); const name = `cleanup-continuation-rejection-${process.pid}`; From 5c0187dec5e629dc862972b30cef1189ab4772de Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:39:58 +0800 Subject: [PATCH 121/126] fix(coding-agent): isolated promise combinator tracking Used a run-scoped Promise subclass instead of mutating native combinator methods. Evaluated code can now freeze its Promise constructor without breaking cleanup or later browser runs. --- packages/coding-agent/src/tools/run-scope.ts | 59 +++++++++++++------ .../test/tools/browser-tab-evaluate.test.ts | 31 ++++++++++ 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index 07ac2a697..f519565c8 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -33,6 +33,7 @@ const observedBrowserPromises = new WeakMap, ObservedPromiseSta const observedPromiseConstructor = { [Symbol.species]: Promise }; type PromiseCombinatorName = "all" | "race"; +type PromiseCombinator = (this: PromiseConstructor, values: Iterable) => Promise; interface PromiseCombinatorTrackingContext { owner: object; @@ -40,8 +41,13 @@ interface PromiseCombinatorTrackingContext { } const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"]; +const NativePromise = Promise; +const nativePromiseCombinators: Record = { + all: Promise.all, + race: Promise.race, +}; const promiseCombinatorTracking = new AsyncLocalStorage(); -const originalPromiseCombinatorDescriptors = new Map(); +let previousPromiseDescriptor: PropertyDescriptor | undefined; let promiseCombinatorTrackingScopes = 0; /** @@ -64,15 +70,40 @@ export async function withBrowserPromiseCombinatorTracking( } function installPromiseCombinatorTracking(): void { - promiseCombinatorTrackingScopes++; - if (promiseCombinatorTrackingScopes !== 1) return; + if (promiseCombinatorTrackingScopes > 0) { + promiseCombinatorTrackingScopes++; + return; + } + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Promise"); + if (!descriptor) throw new Error("Global Promise descriptor is unavailable"); + const trackedPromise = createTrackedPromiseConstructor(); + Object.defineProperty(globalThis, "Promise", { ...descriptor, value: trackedPromise }); + previousPromiseDescriptor = descriptor; + promiseCombinatorTrackingScopes = 1; +} + +function restorePromiseCombinatorTracking(): void { + if (promiseCombinatorTrackingScopes > 1) { + promiseCombinatorTrackingScopes--; + return; + } + const descriptor = previousPromiseDescriptor; + try { + if (!descriptor) throw new Error("Global Promise tracking scope is not installed"); + Object.defineProperty(globalThis, "Promise", descriptor); + } finally { + previousPromiseDescriptor = undefined; + promiseCombinatorTrackingScopes = 0; + } +} + +function createTrackedPromiseConstructor(): PromiseConstructor { + class TrackedPromise extends NativePromise {} for (const name of PROMISE_COMBINATORS) { - const descriptor = Object.getOwnPropertyDescriptor(Promise, name); - if (!descriptor || typeof descriptor.value !== "function") continue; - originalPromiseCombinatorDescriptors.set(name, descriptor); - const original = descriptor.value as (this: PromiseConstructor, values: Iterable) => Promise; - Object.defineProperty(Promise, name, { - ...descriptor, + const original = nativePromiseCombinators[name]; + Object.defineProperty(TrackedPromise, name, { + configurable: true, + writable: true, value(this: PromiseConstructor, values: Iterable): Promise { let hasObservedInput = false; const result = Reflect.apply(original, this, [ @@ -87,15 +118,7 @@ function installPromiseCombinatorTracking(): void { }, }); } -} - -function restorePromiseCombinatorTracking(): void { - promiseCombinatorTrackingScopes--; - if (promiseCombinatorTrackingScopes !== 0) return; - for (const [name, descriptor] of originalPromiseCombinatorDescriptors) { - Object.defineProperty(Promise, name, descriptor); - } - originalPromiseCombinatorDescriptors.clear(); + return TrackedPromise; } function* tapObservedBrowserPromises( diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 53a34f693..08d0ed3b4 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -350,6 +350,37 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("restores promise tracking after evaluated code freezes Promise", async () => { + const tool = new BrowserTool(makeSession()); + const name = `frozen-promise-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const frozen = await tool.execute("run", { + action: "run", + name, + code: ` + Object.freeze(Promise); + return Object.isFrozen(Promise); + `, + }); + expect(frozen.content).toEqual([{ type: "text", text: "true" }]); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return (await Promise.all([42]))[0];", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("folds a user continuation rejection that settles during cleanup", async () => { const tool = new BrowserTool(makeSession()); const name = `cleanup-continuation-rejection-${process.pid}`; From a51b69ee85b7dd98fd7f3228e0daabea57d7e619 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:49:52 +0800 Subject: [PATCH 122/126] fix(coding-agent): kept unrelated worker failures fatal Classified only marked browser failures and evaluated-run stack frames as run-owned rejections. Unrelated tab-worker failures now remain on the worker guard's fatal path. --- .../src/tools/browser/cmux/cmux-tab.ts | 6 ++---- .../coding-agent/src/tools/browser/tab-worker.ts | 7 ++----- packages/coding-agent/src/tools/run-scope.ts | 6 ++++++ .../test/tools/browser-run-cancellation.test.ts | 15 +++++++++++++++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index 85e5e3619..f307cae69 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -10,7 +10,7 @@ import { resolveToCwd } from "../../path-utils"; import { formatScreenshot } from "../../render-utils"; import { bindRunFacade, - isBrowserRunRejection, + isBrowserRunOwnedRejection, markBrowserRunRejection, observeBrowserRunPromise, resolvePredicateTimeout, @@ -1409,9 +1409,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi rejectFloatingFailure(error); }; const uninstallRejectionInterceptor = postmortem.interceptUnhandledRejections(reason => { - const browserRunRejection = isBrowserRunRejection(reason, rejectionOwner); - const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined; - if (!browserRunRejection && stack?.includes(`cmux-run-${runId}.js`) !== true) return false; + if (!isBrowserRunOwnedRejection(reason, rejectionOwner, `cmux-run-${runId}.js`)) return false; recordFloatingFailure(reason); return true; }); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index cd50e8795..f8d442afe 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -24,7 +24,7 @@ import { bindRunFacade, CELL_BUDGET_SLACK_MS, installBrowserWorkerRejectionGuard, - isBrowserRunRejection, + isBrowserRunOwnedRejection, markBrowserRunRejection, markHandled, observeBrowserRunPromise, @@ -784,10 +784,7 @@ export class WorkerCore { #consumeUnhandledRejection(reason: unknown): boolean { const active = this.#active; if (!active) return false; - const browserRunRejection = isBrowserRunRejection(reason, active.rejectionOwner); - const stack = reason instanceof Error && typeof reason.stack === "string" ? reason.stack : undefined; - const fromRun = stack?.includes(`browser-run-${active.id}.js`) === true; - if (!browserRunRejection && !this.#isolated && !fromRun) return false; + if (!isBrowserRunOwnedRejection(reason, active.rejectionOwner, `browser-run-${active.id}.js`)) return false; this.#recordFloatingRejection(active, reason); return true; } diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index f519565c8..db83e1bc7 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -22,6 +22,12 @@ export function isBrowserRunRejection(reason: unknown, owner: object): boolean { ); } +/** Returns whether a rejection belongs to the marked browser run or its evaluated source file. */ +export function isBrowserRunOwnedRejection(reason: unknown, owner: object, filename: string): boolean { + if (isBrowserRunRejection(reason, owner)) return true; + return reason instanceof Error && typeof reason.stack === "string" && reason.stack.includes(filename); +} + type FloatingRejectionHandler = (reason: unknown) => void; interface ObservedPromiseState { diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index 5c85bf566..133a7bdd9 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -4,6 +4,7 @@ import { postmortem } from "@oh-my-pi/pi-utils"; import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime"; import { bindRunFacade, + isBrowserRunOwnedRejection, isBrowserRunRejection, markBrowserRunRejection, markHandled, @@ -147,6 +148,20 @@ describe("browser run cancellation", () => { expect(isBrowserRunRejection(new Error("unrelated", { cause: browserFailure }), owner)).toBe(false); }); + it("keeps unrelated worker rejections outside the active browser run", () => { + const owner = {}; + const workerFailure = new Error("transport failed"); + workerFailure.stack = "Error: transport failed\n at tab-worker.ts:1:1"; + const evaluatedFailure = new Error("evaluated failure"); + evaluatedFailure.stack = "Error: evaluated failure\n at browser-run-run-1.js:1:1"; + + expect(isBrowserRunOwnedRejection(workerFailure, owner, "browser-run-run-1.js")).toBe(false); + expect(isBrowserRunOwnedRejection(evaluatedFailure, owner, "browser-run-run-1.js")).toBe(true); + expect( + isBrowserRunOwnedRejection(markBrowserRunRejection(workerFailure, owner), owner, "browser-run-run-1.js"), + ).toBe(true); + }); + it("keeps a later cause-wrapped rejection on the fatal path", async () => { vi.useRealTimers(); const script = ` From fe086df7bfda2f3bed7c55ba70015aea28063ade Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:01:17 +0800 Subject: [PATCH 123/126] fix(coding-agent): aborted browser runs before draining Closed worker and cmux run signals before yielding for floating-rejection drainage. Stale promise continuations can no longer begin page navigation after evaluated code returns. --- .../src/tools/browser/cmux/cmux-tab.ts | 4 +- .../src/tools/browser/tab-worker.ts | 2 +- .../browser-cmux-release-mid-run.test.ts | 44 +++++++++++++++++++ .../test/tools/browser-tab-evaluate.test.ts | 37 ++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index f307cae69..e57bf97f1 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -1377,6 +1377,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi const signal = AbortSignal.any( opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal], ); + const runEndedError = postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")); const output = new RunOutput(); const screenshots: ScreenshotResult[] = []; const runId = crypto.randomUUID(); @@ -1495,6 +1496,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi runFailed = true; runError = error; } + runAc.abort(runEndedError); // Let rejection callbacks run while this run can still own guest-created promises. await Bun.sleep(0); if (hasFloatingFailure && !runFailed) await floatingFailure; @@ -1517,7 +1519,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi runActive = false; uninstallRejectionInterceptor(); signal.removeEventListener("abort", onAbort); - runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); + runAc.abort(runEndedError); activeCmuxRuns.delete(filename); rememberCmuxRunFile(filename); tab.clearRunContext(); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index f8d442afe..97eb8e497 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -1145,8 +1145,8 @@ export class WorkerCore { } catch (error) { failure = { error }; } finally { - await Bun.sleep(0); runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended"))); + await Bun.sleep(0); try { await runPage?.cleanup(); } catch (error) { diff --git a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts index e41cc9103..8e130c14b 100644 --- a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts @@ -380,6 +380,50 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { await expect(run).rejects.toThrow("Unhandled rejection (missing await?): navigation failed"); }); + it("aborts the cmux run facade before draining floated continuations", async () => { + spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); + spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + const navigatedUrls: string[] = []; + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string, params: Record): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-drain-abort", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + await Bun.sleep(0); + return { value: "ready" }; + case "browser.navigate": + navigatedUrls.push(String(params.url)); + return { url: params.url }; + default: + return {}; + } + }, + ); + const browser = await acquireBrowser(makeKind("drain-abort"), { cwd: "/tmp" }); + await acquireTab("drain-abort", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-drain-abort", + }); + + const result = await runInTab("drain-abort", { + code: ` + void tab.title().then(() => tab.goto("https://late.example")); + return "completed"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + expect(result.returnValue).toBe("completed"); + + await Bun.sleep(20); + expect(navigatedUrls).toEqual([]); + }); + it("ignores the daemon screenshot path when no screenshot directory is configured", async () => { spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 08d0ed3b4..57ad531e2 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -381,6 +381,43 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { } }, 30_000); + it("aborts the run facade before draining floated continuations", async () => { + const tool = new BrowserTool(makeSession()); + const name = `drain-abort-${process.pid}`; + const url = "data:text/html,original

ready

"; + + try { + await tool.execute("open", { + action: "open", + name, + url, + }); + const result = await tool.execute("run", { + action: "run", + name, + code: ` + page.title = async () => { + await Bun.sleep(0); + return "ready"; + }; + void tab.title().then(() => tab.goto("data:text/html,late")); + return "completed"; + `, + }); + expect(result.content).toEqual([{ type: "text", text: "completed" }]); + + await Bun.sleep(100); + const followup = await tool.execute("run", { + action: "run", + name, + code: "return tab.url();", + }); + expect(followup.content).toEqual([{ type: "text", text: url }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }, 30_000); + it("folds a user continuation rejection that settles during cleanup", async () => { const tool = new BrowserTool(makeSession()); const name = `cleanup-continuation-rejection-${process.pid}`; From adee5b9991339970d8f661abb2a4d6bff99a6292 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:41 +0800 Subject: [PATCH 124/126] fix(coding-agent): restored lazy CLI imports Imported postmortem through its direct subpath so browser run-scope loading no longer initializes the pi-utils barrel during CLI startup. --- packages/coding-agent/src/tools/run-scope.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/tools/run-scope.ts b/packages/coding-agent/src/tools/run-scope.ts index db83e1bc7..184cd367a 100644 --- a/packages/coding-agent/src/tools/run-scope.ts +++ b/packages/coding-agent/src/tools/run-scope.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { postmortem } from "@oh-my-pi/pi-utils"; import { untilAborted } from "@oh-my-pi/pi-utils/abortable"; +import * as postmortem from "@oh-my-pi/pi-utils/postmortem"; import { ToolError, throwIfAborted } from "./tool-errors"; const browserRunRejections = new WeakMap(); From 5e28f856271e3e2b5672d41a9a9657beff06118c Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:34:09 +0800 Subject: [PATCH 125/126] fix(coding-agent): stabilized browser worker startup Gave browser worker initialization an infrastructure timeout floor so short navigation budgets do not expire before a loaded runner starts the worker. --- .../src/tools/browser/tab-supervisor.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 6da6b7be0..767b48c64 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -134,6 +134,7 @@ const tabs = new Map(); // awaits) cannot interleave and leak a worker + browser refCount. const acquireChains = new Map>(); const GRACE_MS = 750; +const WORKER_INIT_TIMEOUT_MS = 15_000; // Names of tabs the supervisor force-killed (timeout past grace, failed recycle), // mapped to the kill reason. Lets the next `run` on that name explain WHY the tab // vanished instead of a bare "not alive". Cleared when the name is opened again. @@ -272,7 +273,11 @@ async function acquireTabImpl( } let info: ReadyInfo; try { - info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS); + info = await initializeTabWorker( + worker, + initPayload, + Math.max(WORKER_INIT_TIMEOUT_MS, opts.timeoutMs + GRACE_MS), + ); } catch (error) { // `BuildMessage`-class failures arrive asynchronously via the worker's `error` event, // after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to @@ -287,7 +292,11 @@ async function acquireTabImpl( }); worker = await spawnInlineWorker(); try { - info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS); + info = await initializeTabWorker( + worker, + initPayload, + Math.max(WORKER_INIT_TIMEOUT_MS, opts.timeoutMs + GRACE_MS), + ); } catch (inlineError) { await worker.terminate().catch(() => undefined); if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false }); From 271e621a02e3e8300a802127f839b0cb121a2770 Mon Sep 17 00:00:00 2001 From: brymko <47984664+brymko@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:41:40 +0800 Subject: [PATCH 126/126] test(coding-agent): synchronized browser rejection checks Waited for the observable warning event instead of assuming late continuations settle within a fixed wall-clock delay under CI load. --- .../test/tools/browser-cmux-release-mid-run.test.ts | 7 +++++-- .../coding-agent/test/tools/browser-tab-evaluate.test.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts index 8e130c14b..768021be3 100644 --- a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts @@ -306,7 +306,10 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { } }, ); - const warn = spyOn(logger, "warn").mockImplementation(() => {}); + const warningLogged = Promise.withResolvers(); + const warn = spyOn(logger, "warn").mockImplementation(message => { + if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); + }); const browser = await acquireBrowser(makeKind("late-rejection"), { cwd: "/tmp" }); await acquireTab("late-rejection", browser, { timeoutMs: 5_000, @@ -329,7 +332,7 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { }); expect(result.returnValue).toBe("completed"); - await Bun.sleep(150); + await warningLogged.promise; expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { runId: expect.any(String), error: "late cmux continuation failed", diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index 57ad531e2..30c299a20 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -458,7 +458,10 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }, 30_000); it("logs a user continuation rejection after its browser run ends", async () => { - const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const warningLogged = Promise.withResolvers(); + const warn = vi.spyOn(logger, "warn").mockImplementation(message => { + if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); + }); const tool = new BrowserTool(makeSession()); const name = `late-continuation-rejection-${process.pid}`; @@ -484,7 +487,7 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }); expect(result.content).toEqual([{ type: "text", text: "completed" }]); - await Bun.sleep(150); + await warningLogged.promise; expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { runId: expect.any(String), error: "late continuation failed",