diff --git a/packages/ai/test/thinking-loop.test.ts b/packages/ai/test/thinking-loop.test.ts index 4736a7866..5ff74df21 100644 --- a/packages/ai/test/thinking-loop.test.ts +++ b/packages/ai/test/thinking-loop.test.ts @@ -461,6 +461,9 @@ describe("isLoopGuardedModel", () => { const grok45 = createMockModel({ provider: "cursor", id: "cursor-grok-4.5-high" }).model; const opaqueDeepseek = createMockModel({ provider: "deepseek", id: "opaque-model" }).model; const other = createMockModel({ provider: "openai", id: "gpt-4o" }).model; + const openaiNamespacedGemini = createMockModel({ provider: "custom", id: "openai/gemini-pro" }).model; + const openaiNamespacedDeepseek = createMockModel({ provider: "custom", id: "openai/deepseek-r1" }).model; + const openaiNamespacedGrok = createMockModel({ provider: "custom", id: "openai/grok-4.6" }).model; expect(isLoopGuardedModel(gemini)).toBe(true); expect(isLoopGuardedModel(deepseek)).toBe(true); @@ -471,6 +474,9 @@ describe("isLoopGuardedModel", () => { expect(isLoopGuardedModel(opaqueDeepseek)).toBe(false); expect(isLoopGuardedModel(other)).toBe(false); + expect(isLoopGuardedModel(openaiNamespacedGemini)).toBe(true); + expect(isLoopGuardedModel(openaiNamespacedDeepseek)).toBe(true); + expect(isLoopGuardedModel(openaiNamespacedGrok)).toBe(true); // enabled: false disables every guarded family. expect(isLoopGuardedModel(gemini, { loopGuard: { enabled: false } })).toBe(false); expect(isLoopGuardedModel(deepseek, { loopGuard: { enabled: false } })).toBe(false); diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index 0080c0aea..59e0ae26e 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -269,14 +269,14 @@ export const modelFamilyToken = memo((modelId: string): string => { const parsed = parseKnownModel(modelId); if (parsed.family !== "unknown") return parsed.family; if (isClaudeModelId(modelId) || isAnthropicNamespacedModelId(modelId)) return "anthropic"; - if (isOpenAIModelId(modelId)) return "openai"; if (isGeminiModelId(modelId)) return "gemini"; if (isGrokModelId(modelId)) return "grok"; + if (isDeepseekModelIdOrName(modelId)) return "deepseek"; + if (isOpenAIModelId(modelId)) return "openai"; if (isKimiModelId(modelId)) return "kimi"; if (isQwenModelId(modelId)) return "qwen"; if (isMinimaxM2FamilyModelId(modelId) || isMinimaxM3FamilyModelId(modelId)) return "minimax"; if (isOpenAIGptOssModelId(modelId)) return "gpt-oss"; - if (isDeepseekModelIdOrName(modelId)) return "deepseek"; if (isMimoModelIdOrName(modelId)) return "mimo"; if (isGemmaModelId(modelId)) return "gemma"; if (parseGlmModel(bareModelId(modelId))) return "glm"; diff --git a/packages/catalog/test/identity-family.test.ts b/packages/catalog/test/identity-family.test.ts index a6d256e8e..ffba1b095 100644 --- a/packages/catalog/test/identity-family.test.ts +++ b/packages/catalog/test/identity-family.test.ts @@ -298,6 +298,9 @@ describe("modelFamilyToken", () => { expect(modelFamilyToken("qwen/qwen3-coder")).toBe("qwen"); expect(modelFamilyToken("google/gemini-2.5-flash")).toBe("gemini"); expect(modelFamilyToken("xai/grok-4.6")).toBe("grok"); + expect(modelFamilyToken("openai/gemini-pro")).toBe("gemini"); + expect(modelFamilyToken("openai/deepseek-r1")).toBe("deepseek"); + expect(modelFamilyToken("openai/grok-4.6")).toBe("grok"); }); test("classifies GLM across provider mirrors so same-lineage SKUs fold together", () => { expect(modelFamilyToken("glm-5.2")).toBe("glm"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 92a6ce536..2f01bace3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,55 +40,7 @@ - Fixed the Windows console disappearing when running commands like `/stats`. - Fixed retry-fallback selection switching to a fallback model with a context window too small to hold the current session context. - Fixed OpenCode discovery ignoring `opencode.jsonc` files and rejecting comments in `opencode.json`. -- Removed the `advisor.subagents` setting; subagent advisors are now configured per agent (frontmatter `advisor` / `task.agentAdvisor`). An existing `advisor.subagents: true` migrates to `task.agentAdvisor: { task: "on" }` — the bundled generic `task` agent keeps its advisor, other agents start unadvised. - -### Changed - -- `/usage`, `omp usage`, and the status line now show authoritative OpenCode Go quota from the official `GET /zen/go/v1/usage` endpoint — including usage made outside OMP — instead of dollar estimates summed from OMP-observed request costs. The status line renders all three windows (`5h` / `7d` / `mo`), and the per-turn cost recording special case for `opencode-go` sessions is gone along with the "OMP-observed spend only" disclaimer ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)). -- Clarified that the production collab relay source and binaries are not currently published, and documented the source-available local protocol relay ([#8165](https://github.com/can1357/oh-my-pi/issues/8165)). -- Enabled bounded Anthropic prompt-cache refreshes for the main agent loop while keeping advisor and side-channel requests from taking over the shared refresh timer. -- Fixed snapcompact compaction shipping its redundant frame archive out of `SessionMaintenance.compact()` on both the manual RPC response (which hard-failed protocol v1 with a transport error after the compaction had already persisted) and the `auto_compaction_end` event payload (which forced the shrink ladder on every unattended pass); the archive is now stripped from both exits while the persisted compaction entry keeps it ([#8168](https://github.com/can1357/oh-my-pi/issues/8168)). -- Fixed the edit tool showing no diff preview in `apply_patch` mode: the built-in `edit` tool presents on the wire as `apply_patch`, but the renderer-provenance gate did not resolve that alias to its built-in owner, so the edit renderer was skipped ([#8184](https://github.com/can1357/oh-my-pi/issues/8184)). - -### Fixed - -- Fixed WSL2 startup hanging forever when the Windows interop pipe is wedged: the host-home probe added for #3779 ran `Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"])` (and `wslpath`) with no timeout, so a stuck `cmd.exe` blocked the whole startup thread before the TUI painted or any log was written. Best-effort discovery probes now run under a 500ms hard timeout (SIGKILL) and treat a killed/non-zero exit as "host home unavailable", falling back to the Linux `$HOME`/`~/.omp` candidates ([#8402](https://github.com/can1357/oh-my-pi/issues/8402)). -- Refined interrupted-turn continuity prompts by omitting reasoning fragments under 60 characters, relying on native signed or encrypted thinking when available, and framing preserved text as a natural user interruption. -- Fixed session-title generation regressing after prompt condensation: the telegraphic rewrite of `title-system.md` garbled small-model output (invented names, punctuation-only titles). Restored plain-sentence phrasing with a name-fidelity instruction, pinned the online title request to greedy decoding, and rejected punctuation-only titles in normalization. -- Fixed Agent Control Center failing to open when an agent model override is configured as a YAML array. ([#8201](https://github.com/can1357/oh-my-pi/issues/8201)) -- Fixed streaming and finalized transcript blocks exposing width-independent source boundaries so multiplexer pane resizes retain output queued during settlement without duplicating prior transcript history. -- Fixed command-backed provider API keys (`!command`) staying pinned to their process-cached value after HTTP 401; auth retry now reruns the command, updates live authorization headers, and retries with the refreshed bearer. -- Fixed print mode claiming `plan.defaultOnStartup` was ignored and recommending `--plan-yolo` when that headless plan flow was already active ([#8312](https://github.com/can1357/oh-my-pi/issues/8312)). -- Fixed MCP startup failures omitting the originating configuration path, so stale imported server entries can be traced to their source file. -- Fixed custom STB-backed vision providers retaining decoder metadata and sending WebP bytes that llama.cpp cannot decode. Image format is now detected from bytes, attached and historical WebP blocks are normalized on the actual provider-request path, and corrupt WebP is omitted instead of making the turn unrecoverable ([#8339](https://github.com/can1357/oh-my-pi/pull/8339) by [@ethancawse](https://github.com/ethancawse)). -- Fixed Python Eval `parallel()` and `pipeline()` rejecting `await` after their work completed, allowing synchronous and awaited calls without repeating operations. -- Contained Mnemopi bank failures at agent lifecycle boundaries so optional recall and retention cannot terminate the host session ([#8351](https://github.com/can1357/oh-my-pi/issues/8351)). -- Fixed bash utility builtins resolving MSYS-style `/c/...` paths against a phantom rooted path instead of the live Windows drive, preventing stale reads and lost writes ([#8355](https://github.com/can1357/oh-my-pi/issues/8355)). -- Fixed open browser behaviour on Windows, OMP console no longer disappears on commands such as `/stats` -- Fixed long streaming `write` previews repeatedly scanning and splitting the full accumulated file content, which could stall the TUI. -- Fixed custom commands losing the documented `api.arktype.type(...)` compatibility surface while retaining the callable `api.arktype(...)` builder. ([#7968](https://github.com/can1357/oh-my-pi/issues/7968)) -- Fixed retry-fallback selection switching a live session from a large-context primary onto a smaller-context fallback and immediately sending a predictably oversized request; candidate selection now skips any fallback whose usable window cannot hold the current context and advances to the first configured candidate that fits ([#8065](https://github.com/can1357/oh-my-pi/issues/8065)). -- Fixed advisor recovery selecting another role's fallback chain when both roles use the same model. ([#8075](https://github.com/can1357/oh-my-pi/issues/8075)) -- Fixed `retry_fallback_applied` and `retry_fallback_succeeded` not being forwarded to extensions: `AgentSession.#emitExtensionEvent` had no branch for either event and `ExtensionAPI.on(...)` lacked overloads, so extension handlers could not observe model/advisor fallback transitions or successes that the TUI and RPC paths already received ([#8079](https://github.com/can1357/oh-my-pi/issues/8079)). -- Fixed the CLI crashing at startup with a raw uncaught `AuthBrokerError` when a configured auth broker (`auth.broker.url` / `OMP_AUTH_BROKER_URL`) is unreachable and no fresh cached snapshot exists. Startup auth discovery now fails with an actionable message naming the broker URL and the recovery options (`omp auth-broker serve`, or resetting `auth.broker.url` / `auth.broker.token`) and exits non-zero, instead of dumping a stack trace ([#8096](https://github.com/can1357/oh-my-pi/issues/8096)). -- Fixed OpenCode discovery ignoring `opencode.jsonc` files and rejecting comments in `opencode.json`, which omitted imported settings and MCP servers. ([#8104](https://github.com/can1357/oh-my-pi/issues/8104)) -- Fixed the launch broker staying alive indefinitely after its last persistent daemon exited with no clients connected: the idle-shutdown timer that fired while the daemon was still live returned without rearming, and terminal settlement never scheduled another idle check, so the broker process, endpoint, timers, and record maps leaked. Terminal settlement now rearms idle shutdown, which re-checks clients, remaining live persistent daemons, and detached project presence before exiting ([#8110](https://github.com/can1357/oh-my-pi/issues/8110)). -- Fixed a cold persisted-agent revival racing with lifecycle teardown: a revive whose reviver factory or session resolved after `AgentLifecycleManager.dispose()` could attach a live session and arm a TTL on the disposed manager, leaking a session graph and timers past teardown. Late revivals now reject deterministically and dispose any session they built ([#8114](https://github.com/can1357/oh-my-pi/issues/8114)). -- Fixed stale or unhealthy initial MCP connections leaving child processes or sockets open after `disconnectAll()` or a failed `tools/list` request. ([#8112](https://github.com/can1357/oh-my-pi/issues/8112)) -- Fixed the DAP `runInTerminal` reverse request leaving the spawned debuggee's stdout undrained: the child was spawned with a piped stdout that was never consumed, so a chatty debuggee's output buffered unboundedly in the omp process (toward OOM) and was lost from the session output. Its stdout is now continuously drained into the session output buffer. ([#8111](https://github.com/can1357/oh-my-pi/issues/8111)) -- Fixed the `/ssh add` inline hint omitting the `--scope project|user` option. -- Fixed `omp://` throwing `ENOENT` for npm/SDK consumers: `@oh-my-pi/pi-coding-agent`'s `exports` resolve to TypeScript source where the build-time `PI_DOCS_EMBED` is empty, and the dev-tree fallback pointed at an unreachable `node_modules/docs`, so `OmpProtocolHandler.complete()`/`.resolve()` crashed for any consumer importing the package from npm. `gen:bundle` now also ships the docs corpus as `dist/docs-index.generated.txt`, the source path reads it when the env embed is empty and the on-disk `docs/` is absent, and a missing corpus degrades to an empty index (with a warning) instead of propagating `ENOENT` ([#8134](https://github.com/can1357/oh-my-pi/issues/8134)). -- Fixed the legacy TypeBox facade rejecting `Type.Optional(Type.Unsafe(...))`, losing optional object properties when raw schemas were present, dropping JSON-Schema-only keywords (e.g. `patternProperties`) from nested `Type.Unsafe` wire schemas, and plugin installs accepting extension factories that fail during initialization ([#8143](https://github.com/can1357/oh-my-pi/issues/8143)). -- Fixed `omp install` failing extension validation for pi extensions that import the `isToolResult` event guards from `@earendil-works/pi-coding-agent` (e.g. `pi-lean-ctx@3.9.18`, which uses `isEditToolResult`/`isWriteToolResult`). The legacy shim's `export * from "../index"` never forwarded the guard family (dropped from the public API in 10.2.3), so a named import threw Bun's static "Export named 'isEditToolResult' not found" error. Restored `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, and `isLsToolResult` on the shim to match the upstream pi surface ([#8161](https://github.com/can1357/oh-my-pi/issues/8161)). -- Fixed profile aliases generated by standalone binaries invoking Bun's embedded virtual script instead of the installed `omp` command ([#8233](https://github.com/can1357/oh-my-pi/issues/8233)). -- Fixed large-session restore and `/tree` navigation blocking input while rebuilding the transcript by chunking idle rebuilds and terminal paints across event-loop turns ([#8133](https://github.com/can1357/oh-my-pi/issues/8133)). -- Fixed the system prompt unconditionally requiring browser verification for UI changes even when the `browser` tool is unavailable; verification now follows the actual UI surface and available tools, with a behavioral/smoke-test fallback when no runtime tool exists ([#8139](https://github.com/can1357/oh-my-pi/issues/8139)). -- Fixed agent-facing prompts mentioning tools that may be absent from the session catalog: `todo`/`grep` workflow guidance in the system prompt, `glob`/`read`/`edit` drill-in hints in the project prompt, `ask` directives in plan mode, and the orchestrate notice's `task`/`edit`/`write`/`lsp`/`bash`/`todo` budget are now gated on tool availability. -- Fixed a `/skill:` token embedded in a `/plan` or `/vibe` inline prompt being sent to the agent as literal text instead of loading the skill; mode-command inline prompts now dispatch skill invocations through the same custom-message path as the editor submit flow ([#8137](https://github.com/can1357/oh-my-pi/issues/8137)). -- Fixed Codex reset fireworks triggering on ordinary weekly-usage decreases when the provider had not advanced the quota reset deadline. -- Fixed below-threshold tool turns waiting for asynchronous session persistence when no mid-run compaction will run, while preserving journal writes when a `message_end` listener fails and isolating notification-only handler payloads from late context mutations ([#8283](https://github.com/can1357/oh-my-pi/pull/8283) by [@ethancawse](https://github.com/ethancawse)). -- Manual `/shake` now keeps a small recent tail of tool results instead of stripping every eligible result, so the agent does not lose the context it is currently working from ([#7776](https://github.com/can1357/oh-my-pi/issues/7776)). -- Fixed Hindsight `per-project` and `per-project-tagged` scoping splitting one repository across two memory scopes when the checkout directory carries capitals: the project label is now lowercased, so a checkout at `~/code/General` writes and recalls under `project:general` like every other client of the same bank instead of opening a private `project:General` scope. **Migration note:** mixed-case checkouts previously stored memories under the case-preserving label (`per-project` bank id, `per-project-tagged` `project:` tags); after upgrading, those sessions read and write the lowercased scope, so memories retained under the old capitalized scope stay in the old bank/tag until re-retained there ([#8158](https://github.com/can1357/oh-my-pi/issues/8158)). +- Fixed WSL2 startup hanging forever when the Windows interop pipe is wedged: the WSL host-home discovery probes (`cmd.exe`, `wslpath`) now run under a 500ms hard timeout and fall back to the Linux `$HOME`/`~/.omp` candidates ([#8402](https://github.com/can1357/oh-my-pi/issues/8402)). ## [17.2.15] - 2026-08-12 diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index b6d3837bd..9c1f615c2 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -108,9 +108,29 @@ export function getWslWindowsHomeCandidate(options: UserPathCandidateOptions = { return (options.wslPath ?? resolveWithWslPath)(userProfile) ?? convertWindowsPathToDefaultWslMount(userProfile); } +/** + * Memo for the default-probe WSL home resolution, keyed by the inputs that + * decide it (platform + WSL markers + `USERPROFILE`). Discovery calls + * {@link getUserPathCandidates} from every loader (skills, rules, prompts, + * commands, AGENTS.md, SYSTEM.md); the host-home probe spawns `cmd.exe` over + * the WSL interop pipe, so without the memo a wedged pipe costs one + * {@link HOST_PROBE_TIMEOUT_MS} stall per loader. Keying by inputs keeps + * test/SDK environment changes visible instead of pinning the first answer + * for the process lifetime. + */ +const wslHomeMemo = new Map(); + function getUserHomeCandidates(ctx: LoadContext): string[] { const homes = [ctx.home]; - const wslHome = getWslWindowsHomeCandidate(); + const env = process.env; + const key = `${process.platform}\0${env.WSL_DISTRO_NAME ?? ""}\0${env.WSL_INTEROP ?? ""}\0${env.USERPROFILE ?? ""}`; + let wslHome: string | undefined; + if (wslHomeMemo.has(key)) { + wslHome = wslHomeMemo.get(key); + } else { + wslHome = getWslWindowsHomeCandidate(); + wslHomeMemo.set(key, wslHome); + } if (wslHome && !homes.includes(wslHome)) homes.push(wslHome); return homes; } diff --git a/scripts/gen-nix-bun.test.ts b/scripts/gen-nix-bun.test.ts new file mode 100644 index 000000000..2cf1c36a9 --- /dev/null +++ b/scripts/gen-nix-bun.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { resolveNixBunDepsGenerator } from "./gen-nix-bun"; + +describe("resolveNixBunDepsGenerator", () => { + test("prefers bun2nix from the active development shell", () => { + const generator = resolveNixBunDepsGenerator(command => { + if (command === "bun2nix") return "/nix/store/bun2nix"; + return "/nix/store/nix"; + }); + + expect(generator).toEqual({ kind: "bun2nix", executable: "/nix/store/bun2nix" }); + }); + + test("falls back to entering the Nix development shell", () => { + const generator = resolveNixBunDepsGenerator(command => (command === "nix" ? "/usr/bin/nix" : null)); + + expect(generator).toEqual({ kind: "nix", executable: "/usr/bin/nix" }); + }); + + test("fails before a release mutates files when no generator is available", () => { + expect(() => resolveNixBunDepsGenerator(() => null)).toThrow( + "Generating nix/bun.nix requires bun2nix from `nix develop`, or Nix to enter that shell.", + ); + }); +}); diff --git a/scripts/gen-nix-bun.ts b/scripts/gen-nix-bun.ts index 4ccdd5544..32e68eacd 100755 --- a/scripts/gen-nix-bun.ts +++ b/scripts/gen-nix-bun.ts @@ -6,20 +6,30 @@ import { $which } from "../packages/utils/src/which"; const repoRoot = path.join(import.meta.dir, ".."); +type FindExecutable = (command: string) => string | null; + +/** The executable path and invocation mode for the pinned Bun dependency generator. */ +export type NixBunDepsGenerator = { kind: "bun2nix"; executable: string } | { kind: "nix"; executable: string }; + +/** Resolve the generator needed by releases before they mutate repository state. */ +export function resolveNixBunDepsGenerator(findExecutable: FindExecutable = $which): NixBunDepsGenerator { + const bun2nix = findExecutable("bun2nix"); + if (bun2nix) return { kind: "bun2nix", executable: bun2nix }; + + const nix = findExecutable("nix"); + if (nix) return { kind: "nix", executable: nix }; + + throw new Error("Generating nix/bun.nix requires bun2nix from `nix develop`, or Nix to enter that shell."); +} + /** Regenerate the checked-in Bun dependency expression with the pinned bun2nix input. */ -export async function generateNixBunDeps(): Promise { - const bun2nix = $which("bun2nix"); - if (bun2nix) { - await $`${bun2nix} -l bun.lock -c ../ -o nix/bun.nix`.cwd(repoRoot); +export async function generateNixBunDeps(generator: NixBunDepsGenerator = resolveNixBunDepsGenerator()): Promise { + if (generator.kind === "bun2nix") { + await $`${generator.executable} -l bun.lock -c ../ -o nix/bun.nix`.cwd(repoRoot); return; } - const nix = $which("nix"); - if (!nix) { - throw new Error("Generating nix/bun.nix requires bun2nix from `nix develop`, or Nix to enter that shell."); - } - - await $`${nix} --extra-experimental-features ${"nix-command flakes"} --accept-flake-config develop --command bun2nix -l bun.lock -c ../ -o nix/bun.nix`.cwd( + await $`${generator.executable} --extra-experimental-features ${"nix-command flakes"} --accept-flake-config develop --command bun2nix -l bun.lock -c ../ -o nix/bun.nix`.cwd( repoRoot, ); } diff --git a/scripts/release.ts b/scripts/release.ts index 49abc9cf1..0ac70949e 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -11,7 +11,7 @@ import { $, Glob } from "bun"; import { compareVersions } from "../packages/utils/src/version.ts"; import { runChangelogFixer } from "./fix-changelogs"; -import { generateNixBunDeps } from "./gen-nix-bun"; +import { generateNixBunDeps, resolveNixBunDepsGenerator } from "./gen-nix-bun"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); @@ -242,6 +242,9 @@ async function cmdRelease(versionOrBump: string): Promise { } console.log(" Working directory clean"); + const nixBunDepsGenerator = resolveNixBunDepsGenerator(); + console.log(` Nix dependency generator: ${nixBunDepsGenerator.kind}`); + const latestTag = (await git(["describe", "--tags", "--abbrev=0", "--match", "v*"]).text()).trim(); let version = versionOrBump; if (version === "major" || version === "minor" || version === "patch") { @@ -342,7 +345,7 @@ async function cmdRelease(versionOrBump: string): Promise { await $`rm -f bun.lock`; await $`bun install`; await $`cargo generate-lockfile`; - await generateNixBunDeps(); + await generateNixBunDeps(nixBunDepsGenerator); console.log(); // 5. Update changelogs