Merge branch 'main' into reproduce/issue-1720

Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
Mayuresh K
2026-08-05 10:27:32 +01:00
committed by GitHub
1402 changed files with 157643 additions and 52975 deletions
+130
View File
@@ -1,3 +1,133 @@
## [1.18.1] - 2026-08-04
- **Providers:** signing in to an OAuth-only provider (such as Cursor) now completes in the browser — the login is stored and the provider updates instead of remaining signed out. OAuth-only providers show a Connect flow instead of an API key form, and their models stay hidden until you are signed in.
- **Sessions:** archived sessions can now be restored to the active list — from the sidebar context menu, the archived-sessions page, or the bulk-selection bar (thanks to @makeittech).
- Providers: sign-ins that need extra details (such as GitHub Copilot Enterprise) now ask for them before opening the browser, and device codes come with a working copy button.
- Chat: a manually chosen model now stays selected after a delegated subtask finishes, instead of reverting to the agent's default model.
- Chat: Ctrl/Cmd+L now adds the selected text to the chat input, or focuses it when nothing is selected.
## [1.18.0] - 2026-08-04
- **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech).
- UI/Localization: added German interface translations (thanks to @SGD-DEV).
- Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271).
- Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update.
- Settings/Skills: repository-local `.agents/skills` now appear for the active workspace (thanks to @makeittech).
- Settings/Skills: renaming a skill now preserves its instructions and supporting files; only skills in locations OpenChamber can safely rename show the action (thanks to @makeittech).
- Usage: added DeepSeek quota tracking (thanks to @airtaxi).
- Usage: Kimi for Coding now calculates usage correctly when the provider reports either used or remaining quota (thanks to @makeittech).
- Chat: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui).
- Chat: assistant messages no longer render active HTML.
- Sidebar: a worktree shared by more than one project no longer appears twice.
- Sidebar: session titles no longer clip at the ends of their rows.
- Sessions: archiving and unarchiving now stays scoped to the current workspace.
## [1.17.2] - 2026-08-01
- Per-session permission auto-accept now replies to live permission requests correctly when auto-accept is turned on.
- Chat: messages arriving at the start of the SSE stream no longer drop silently after initial connection.
- Sessions with active agents now show a live activity indicator even when the sidebar is collapsed (thanks to @pascalandr).
- Usage: all Z.ai usage windows now appear in the usage view.
- Chat: tool descriptions now show the glob pattern when a tool's input uses one.
- Chat: clicking in the padding area of the composer now correctly places the cursor (thanks to @IbrahimKhan12).
- Chat: the `/` command menu no longer lists a skill twice when a command shares its name (thanks to @IbrahimKhan12).
## [1.17.1] - 2026-07-29
- **Chat tools:** Bash tool cards now show output before a command finishes, keep it in a fixed-height pane, and follow new lines until you scroll away. Long-running commands no longer remain at a 300-second duration, and their timers continue until they finish.
- **OpenCode updates:** the extension can now update a managed OpenCode installation and restart it automatically; externally managed servers are left unchanged (thanks to @yulia-ivashko).
- Chat: a stalled response now reconnects instead of remaining stuck in the extension.
- Chat: fully loaded histories no longer show "Load older" again after a refresh.
- Chat: messages removed by reverting no longer reappear after you send another message.
- Chat: slash-command starters now include text already entered in the draft as command arguments.
- Settings: subpanels keep a visible vertical scrollbar and no longer show a horizontal scrollbar (thanks to @sergiofspedro).
- Usage: OpenAI business-account Codex usage now shows the configured spend limit (thanks to @jrandiny).
## [1.17.0] - 2026-07-28
- **Context panel:** a new surface rail brings Changes, pull requests, files, terminal, notes, plans, previews, and side chats into one resizable panel. The pull-request surface now shows live checks and comments, and can attach failed checks or comments to a chat draft.
- **Chat composer:** prompts now render Markdown emphasis, attention lines, file and agent mentions, slash commands, snippets, attachment citations, and `~path` references directly while you type. File mentions can be edited in place.
- Sessions: the sidebar now uses clearer project zones and single-line session rows, with folders shown after ungrouped sessions and archived sessions kept in their existing workspace sections.
- Sessions: fixed missing activity-based sorting; chats now move in the list when they start or finish instead of repeatedly reordering while a response streams.
- Chat: selecting text from Markdown code blocks now preserves the code fences, language, and surrounding block structure when adding it to the composer or starting a new session (thanks to @ChangeHow).
- Chat: code blocks no longer shift line layout or merge adjacent text while rendering, and copied code keeps its original text (thanks to @ChangeHow).
- Chat/Permissions: sending a message while a permission prompt is open now denies pending requests in the session and its subagents, then queues the message for the next turn (thanks to @tomzx).
- Chat/Subagents: subagent chats can be prompted when direct subagent prompting is enabled, even if the parent session has not loaded.
- Chat: jumping to messages in long conversations now lands on the intended message when earlier rows have not been rendered yet.
- Settings: added an option to hide starter suggestions on the new-session screen.
- Shortcuts: fixed a regression where double-Escape could be primed when the current session was not active.
- Usage: added Crof and NeuralWatt quota tracking, including the missing Crof switch arm that previously fell back to "Unsupported provider" (thanks to @kydorn).
## [1.16.3] - 2026-07-22
- **Chat attachments:** added Office and OpenDocument files (`.docx`, `.pptx`, `.xlsx`, `.odt`, `.odp`, and `.ods`), with readable text and supported embedded images extracted before sending. Attachments also support more source-code formats, notebooks, HAR files with credentials and cookies removed, SVG and Draw.io files, and HEIC/HEIF images; the composer warns when the selected model may ignore an attachment type.
- **Performance:** opening and switching sessions now prioritizes the selected and visible chats in large workspaces. Failed refreshes keep the existing session list, parent sessions no longer disappear when their sub-sessions load first, and session data no longer crosses between workspaces or extension views.
- Chat: assistant turns now show model, agent, thinking level, duration, and time together in the footer, and replies separated by hidden system or subagent prompts display as one continuous turn. The working indicator shows the model actually producing the active response, streaming at the bottom no longer jitters, and new user messages finish their entry animation instead of snapping into place.
- Chat/Tools: attachments returned by plugin and custom tools remain visible after streaming and refreshes, with the same image previews and file chips as chat attachments (thanks to @FrostiDrinks).
- Cursor: opening a chat no longer crashes when the editor webview does not expose its usual messaging APIs, and disposed editor tabs no longer receive late streaming messages (thanks to @makeittech).
- Startup: the active workspace is now detected before startup state is restored, preventing projects outside the editor workspace from replacing it.
- Agent Manager: worktree creation now recovers when an earlier Git operation left the repository locked, and removing a worktree while setup is still running no longer brings it back.
- Sessions: workspace groups now default to manual ordering instead of recent-activity order; explicit sorting choices remain unchanged.
## [1.16.2] - 2026-07-18
- **Settings:** pages now use a consistent responsive layout, navigation is grouped by area, and save failures are shown in the page header. Agent tool permissions now distinguish inherited and explicit rules and show session-granted rules separately (thanks to @makeittech).
- Permissions: per-session auto-accept works again, persists across extension restarts, and applies to subagent sessions while an OpenChamber view is open.
- Sessions: new drafts and sessions now stay with the workspace selected in the sidebar, including multi-root and nested workspace setups (thanks to @bashrusakh).
- Chat: if creating a session fails, the new-session draft stays open and restores the submitted prompt instead of discarding it.
## [1.16.1] - 2026-07-14
- **Performance:** large workspace session lists no longer regroup every session while chats stream. Opening a long chat after an empty or aborted agent turn also no longer repeatedly loads larger portions of its history.
- Chat: shell-mode command cards now update their status and output while the command runs, with syntax highlighting for the command and output.
- Chat: task (subagents) now track the correct subagent when several run at once, preventing one subagent's activity or "Open subtask" action from pointing to another session.
## [1.16.0] - 2026-07-13
- Chat: sessions with an active [goal](https://docs.openchamber.dev/session-goals/) (started from the web or desktop app) show the goal strip with its live status above the composer.
- Chat: code blocks highlight correctly again — the webview's security policy was blocking the syntax highlighter (thanks to @bashrusakh).
- Chat: queued messages now send when the session is already idle instead of waiting forever (thanks to @bashrusakh).
- Chat: pending agent questions stay answerable after a restart, and session renames no longer flicker back to the old title (thanks to @bashrusakh).
- Chat: tool output rendering no longer breaks on tools that return non-text results (thanks to @bashrusakh).
- Settings: a new editor font size setting for the code editor (thanks to @bashrusakh).
- Sessions: pinned sessions survive refreshes (thanks to @bashrusakh).
- Agents: saving agent settings from the UI no longer drops custom YAML frontmatter fields (thanks to @bashrusakh).
- Notifications: subagent completion notifications now follow the same settings as the main app.
- Usage: OpenCode Go usage tracking was added.
- Windows: paths no longer mismatch on drive letter casing, which could split one project into duplicates (thanks to @bashrusakh).
## [1.15.0] - 2026-07-10
- Chat/Tools: every tool call now expands to show its input, result, and errors, including MCP, plugin, and custom tools; Read and Skill stay compact links to their files. JSON results now offer navigable summary, tree, and raw views.
- Chat/Tools: expanded file-edit and patch results include per-file buttons to open the diff or jump to the first changed line in the editor.
- Chat/Thinking: reasoning parts stay separate and in chronological order instead of merging into one block, and collapsed previews no longer show empty trailing HTML comments.
- Chat: Mermaid diagrams now have zoom controls (thanks to @c-w-xiaohei).
- Chat: code blocks can show line numbers that stay aligned while streaming, and a new Wrap Code Block Lines setting controls long-line wrapping.
- Chat: with Sticky User Header enabled, user messages no longer float over earlier messages in long conversations.
- Chat: if sending a message times out or loses the connection after OpenCode accepted it, the extension now keeps the sent message instead of rolling it back as failed.
- Editor Integration: the "Add to Context" command and active-editor pin-selection now use workspace-relative filenames so the model reads the correct file when names collide (thanks to @Catan).
## [1.14.1] - 2026-07-07
- Chat: the timeline dialog can now load older messages when the current session history has not all been fetched yet.
- Chat: file references with line ranges like `src/file.ts:10-20` are now clickable in messages (thanks to @Catan).
- Chat: favorite models now stay saved after restarting the extension (thanks to @Catan).
- Settings: closing Settings returns to the previous extension view instead of always showing the sessions list (thanks to @Catan).
## [1.14.0] - 2026-07-05
- Chat: loading older messages keeps your scroll position steady.
- Chat: the stop button now aborts sessions running in a different project or worktree than the currently open one — previously those aborts silently did nothing.
- Startup: on Windows, OpenCode installed via npm now launches from paths with spaces (such as C:\Program Files\nodejs), a binary path pasted with surrounding quotes into the Opencode Binary setting works, and discovery also checks the system-wide npm prefix and Scoop's shims.
## [1.13.9] - 2026-07-02
- Agents: clearing optional agent fields now removes them from agent config instead of saving `null` values.
- Startup: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI.
- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling.
- Chat: embedded JSON examples in messages no longer render as generated-result cards.
- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state.
## [1.13.8] - 2026-06-29
- Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh).
+4 -4
View File
@@ -1,15 +1,15 @@
# OpenChamber VS Code Extension
[![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/btriapitsyn/openchamber/stargazers)
[![GitHub release](https://img.shields.io/github/v/release/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/btriapitsyn/openchamber/releases/latest)
[![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers)
[![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest)
[![Discord](https://img.shields.io/badge/Discord-join.png?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA)
[![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS)
[OpenCode](https://opencode.ai) AI coding agent, right inside your editor. No tab-switching, no context loss.
![VS Code Extension](https://github.com/btriapitsyn/openchamber/raw/HEAD/packages/vscode/extension.jpg)
![VS Code Extension](https://github.com/openchamber/openchamber/raw/HEAD/packages/vscode/extension.jpg)
**Like the extension? There's also a [desktop app and web version](https://github.com/btriapitsyn/openchamber) with even more features.**
**Like the extension? There's also a [desktop app and web version](https://github.com/openchamber/openchamber) with even more features.**
## What you get
+3 -3
View File
@@ -2,12 +2,12 @@
"name": "openchamber",
"displayName": "OpenChamber",
"description": "%extension.description%",
"version": "1.13.8",
"version": "1.18.1",
"publisher": "fedaykindev",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/btriapitsyn/openchamber.git"
"url": "https://github.com/openchamber/openchamber.git"
},
"qna": "https://discord.gg/ZYRSdnwwKA",
"license": "MIT",
@@ -244,7 +244,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.12",
"@opencode-ai/sdk": "1.18.12",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -140,6 +140,14 @@ export class AgentManagerPanelProvider {
});
}
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
this._panel?.webview.postMessage({
type: 'command',
command: 'permissionAutoAcceptSynced',
payload: snapshot,
});
}
public notifyWindowFocusChanged(focused: boolean): void {
if (!this._panel) {
return;
@@ -177,7 +185,7 @@ export class AgentManagerPanelProvider {
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
@@ -189,8 +197,11 @@ export class AgentManagerPanelProvider {
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
? requestedStreamId
: `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
this._sseStreams.set(streamId, controller);
try {
const start = await openSseProxy({
@@ -203,8 +214,6 @@ export class AgentManagerPanelProvider {
},
});
this._sseStreams.set(streamId, controller);
start.run
.then(() => {
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
@@ -230,6 +239,7 @@ export class AgentManagerPanelProvider {
},
};
} catch (error) {
this._sseStreams.delete(streamId);
const message = error instanceof Error ? error.message : String(error);
return {
id,
+14 -4
View File
@@ -318,6 +318,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
});
}
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
this._view?.webview.postMessage({
type: 'command',
command: 'permissionAutoAcceptSynced',
payload: snapshot,
});
}
/**
* Ask the webview to run the full OpenCode reload flow (overlay + managed
* restart via the bridge + config/data refresh) — the same flow used after an
@@ -528,7 +536,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
@@ -540,8 +548,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
? requestedStreamId
: `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
this._sseStreams.set(streamId, { controller, view: this._view });
try {
const start = await openSseProxy({
@@ -554,8 +565,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
},
});
this._sseStreams.set(streamId, { controller, view: this._view });
start.run
.then(() => {
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
@@ -581,6 +590,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
},
};
} catch (error) {
this._sseStreams.delete(streamId);
const message = error instanceof Error ? error.message : String(error);
return {
id,
+18
View File
@@ -21,6 +21,11 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- `bridge-git-process-runtime.ts`
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution.
- `gitService.ts`
- Owns VS Code Git and worktree operations.
- Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`.
- Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state.
- `bridge-fs-runtime.ts`
- Bridge handlers for filesystem-related message routes.
- Uses shared FS helpers via injected dependencies.
@@ -34,11 +39,15 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- dropped-file parsing and attachment reading
- models metadata fetch helper
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
- `bridge-localfs-proxy-runtime.ts`
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
- `bridge-proxy-runtime.ts`
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
- SSE routes are intentionally excluded from the generic proxy and use `sseProxy.ts`, whose upstream-only stall watchdog closes a quiet OpenCode stream so the webview can reconnect instead of trusting an open but silent response.
- The webview allocates each SSE stream ID and installs its listener before requesting the upstream stream, so immediate OpenCode replay events cannot race the bridge start response.
- `bridge-config-runtime.ts`
- Config and skills message handlers (`api:config/*`).
@@ -51,6 +60,15 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- System/editor/provider/quota/notification/update-check message handlers.
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
- `opencode-upgrade-runtime.ts`
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
- `bridge-permission-auto-accept-runtime.ts`
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
## Extension guideline
@@ -199,6 +199,16 @@ export class SessionEditorPanelProvider {
}
}
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
for (const entry of this._panels.values()) {
entry.panel.webview.postMessage({
type: 'command',
command: 'permissionAutoAcceptSynced',
payload: snapshot,
});
}
}
public notifyWindowFocusChanged(focused: boolean): void {
for (const entry of this._panels.values()) {
entry.panel.webview.postMessage({
@@ -405,7 +415,7 @@ export class SessionEditorPanelProvider {
private async _startSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!this._openCodeManager) {
@@ -417,8 +427,11 @@ export class SessionEditorPanelProvider {
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
? requestedStreamId
: `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
entry.sseStreams.set(streamId, controller);
try {
const start = await openSseProxy({
@@ -427,20 +440,19 @@ export class SessionEditorPanelProvider {
headers: this._buildSseHeaders(headers),
signal: controller.signal,
onChunk: (chunk) => {
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
// Panel may be disposed before SSE callbacks fire.
entry.panel?.webview?.postMessage({ type: 'api:sse:chunk', streamId, chunk });
},
});
entry.sseStreams.set(streamId, controller);
start.run
.then(() => {
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId });
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId });
})
.catch((error) => {
if (!controller.signal.aborted) {
const messageText = error instanceof Error ? error.message : String(error);
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId, error: messageText });
}
})
.finally(() => {
@@ -458,6 +470,7 @@ export class SessionEditorPanelProvider {
},
};
} catch (error) {
entry.sseStreams.delete(streamId);
const messageText = error instanceof Error ? error.message : String(error);
return {
id,
+35 -1
View File
@@ -25,6 +25,8 @@ import {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -652,7 +654,21 @@ export async function handleConfigBridgeMessage(
if (!name && normalizedMethod === 'GET') {
const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
return { id, type, success: true, data: { skills } };
return {
id,
type,
success: true,
data: {
skills: skills.map((skill) => ({
...skill,
renamable: Boolean(
skill.path
&& skill.path !== '<built-in>'
&& isManagedSkillPath(skill.path, workingDirectory)
),
})),
},
};
}
const skillName = typeof name === 'string' ? name.trim() : '';
@@ -693,6 +709,24 @@ export async function handleConfigBridgeMessage(
}
if (normalizedMethod === 'PATCH') {
if (typeof body?.renameTo === 'string') {
const newName = body.renameTo.trim();
renameSkill(skillName, newName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
name: newName,
requiresReload: true,
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
@@ -4,7 +4,7 @@ import * as path from 'path';
import * as vscode from 'vscode';
import { execGit } from './bridge-git-process-runtime';
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
const MAX_FILE_ATTACH_SIZE_BYTES = 20 * 1024 * 1024;
const createGitCheckIgnoreTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
@@ -99,7 +99,7 @@ export const readUriAsAttachment = async (
const size = stat.size ?? 0;
if (size > MAX_FILE_ATTACH_SIZE_BYTES) {
return { skipped: { name, reason: 'File exceeds 10MB limit' } };
return { skipped: { name, reason: 'File exceeds 20MB limit' } };
}
const bytes = await vscode.workspace.fs.readFile(uri);
+6 -1
View File
@@ -481,7 +481,11 @@ export async function handleFsBridgeMessage(
}
case 'api:files/pick': {
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
const options = payload as { allowMany?: boolean; extensions?: unknown };
const allowMany = options?.allowMany !== false;
const extensions = Array.isArray(options?.extensions)
? options.extensions.filter((extension): extension is string => typeof extension === 'string' && extension.length > 0)
: [];
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
const picks = await vscode.window.showOpenDialog({
@@ -490,6 +494,7 @@ export async function handleFsBridgeMessage(
canSelectMany: allowMany,
defaultUri,
openLabel: 'Attach',
filters: extensions.length > 0 ? { Files: extensions } : undefined,
});
if (!picks || picks.length === 0) {
@@ -7,6 +7,8 @@ const gitService = {
cherryPick: mock(),
revertCommit: mock(),
resetToCommit: mock(),
createWorktree: mock(),
getWorktreeBootstrapStatus: mock(),
};
mock.module('./gitService', () => gitService);
@@ -21,6 +23,8 @@ describe('bridge git runtime index mutations', () => {
gitService.cherryPick.mockReset();
gitService.revertCommit.mockReset();
gitService.resetToCommit.mockReset();
gitService.createWorktree.mockReset();
gitService.getWorktreeBootstrapStatus.mockReset();
});
it('accepts legacy stage path payloads', async () => {
@@ -109,4 +113,66 @@ describe('bridge git runtime index mutations', () => {
expect(gitService.revertCommit).not.toHaveBeenCalled();
expect(gitService.resetToCommit).not.toHaveBeenCalled();
});
it('preserves bootstrap phases in status responses', async () => {
const bootstrapStatus = {
status: 'pending',
phase: 'git-ready',
error: null,
updatedAt: 123,
};
gitService.getWorktreeBootstrapStatus.mockResolvedValue(bootstrapStatus);
const response = await handleStandardGitBridgeMessage({
id: 'bootstrap-status',
type: 'api:git/worktrees/bootstrap-status',
payload: { directory: '/repo-worktree' },
});
expect(response).toEqual({
id: 'bootstrap-status',
type: 'api:git/worktrees/bootstrap-status',
success: true,
data: bootstrapStatus,
});
expect(gitService.getWorktreeBootstrapStatus).toHaveBeenCalledWith('/repo-worktree');
});
it('preserves the directory-created phase in fast create responses', async () => {
const created = {
head: '',
name: 'feature',
branch: 'openchamber/feature',
path: '/repo-worktree',
directoryCreated: true,
bootstrapStatus: {
status: 'pending',
phase: 'directory-created',
error: null,
updatedAt: 123,
},
};
gitService.createWorktree.mockResolvedValue(created);
const response = await handleStandardGitBridgeMessage({
id: 'create-worktree',
type: 'api:git/worktrees',
payload: {
directory: '/repo',
method: 'POST',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
},
});
expect(response).toEqual({
id: 'create-worktree',
type: 'api:git/worktrees',
success: true,
data: created,
});
expect(gitService.createWorktree).toHaveBeenCalledWith('/repo', expect.objectContaining({
returnAfterDirectoryCreated: true,
}));
});
});
@@ -0,0 +1,70 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
const createContext = () => {
const values = new Map<string, unknown>();
return {
globalState: {
get: (key: string) => values.get(key),
update: async (key: string, value: unknown) => { values.set(key, value); },
},
};
};
describe('VS Code permission auto-accept policy bridge', () => {
test('persists policy and broadcasts the authoritative snapshot', async () => {
const context = createContext();
const broadcasts: unknown[] = [];
const dependencies = { broadcast: async (snapshot: unknown) => { broadcasts.push(snapshot); } };
const response = await handlePermissionAutoAcceptBridgeMessage({
id: '1',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'root', enabled: true },
}, context, dependencies);
assert.equal(response?.success, true);
assert.deepEqual(response?.data, { sessions: { root: true }, revision: 1 });
assert.deepEqual(broadcasts, [{ sessions: { root: true }, revision: 1 }]);
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
id: '2',
type: 'api:permission-auto-accept:get',
}, context, dependencies);
assert.deepEqual(reloaded?.data, { sessions: { root: true }, revision: 1 });
});
test('serializes concurrent writes without losing policy entries', async () => {
const context = createContext();
const dependencies = { broadcast: async () => undefined };
const first = handlePermissionAutoAcceptBridgeMessage({
id: '1',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'root', enabled: true },
}, context, dependencies);
const second = handlePermissionAutoAcceptBridgeMessage({
id: '2',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'child', enabled: false },
}, context, dependencies);
await Promise.all([first, second]);
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
id: '3',
type: 'api:permission-auto-accept:get',
}, context, dependencies);
assert.deepEqual(reloaded?.data, { sessions: { root: true, child: false }, revision: 2 });
});
test('rejects malformed policy writes', async () => {
const broadcasts: unknown[] = [];
const response = await handlePermissionAutoAcceptBridgeMessage({
id: '1',
type: 'api:permission-auto-accept:set',
payload: { sessionId: 'root', enabled: 'yes' },
}, createContext(), { broadcast: async (snapshot) => { broadcasts.push(snapshot); } });
assert.equal(response?.success, false);
assert.deepEqual(broadcasts, []);
});
});
@@ -0,0 +1,103 @@
const STORAGE_KEY = 'permissionAutoAccept';
type PolicyContext = {
globalState: {
get: (key: string) => unknown;
update: (key: string, value: unknown) => PromiseLike<void>;
};
};
export type PermissionAutoAcceptSnapshot = {
sessions: Record<string, boolean>;
revision: number;
};
const normalizeSnapshot = (value: unknown): PermissionAutoAcceptSnapshot => {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as { sessions?: unknown }
: {};
const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
? Object.entries(source.sessions)
: [];
const sessions: Record<string, boolean> = {};
for (const [sessionId, enabled] of entries) {
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
}
const revision = Number.isSafeInteger((source as { revision?: unknown }).revision)
&& Number((source as { revision?: unknown }).revision) >= 0
? Number((source as { revision?: unknown }).revision)
: 0;
return { sessions, revision };
};
const readPermissionAutoAcceptPolicy = (context: PolicyContext) =>
normalizeSnapshot(context.globalState.get(STORAGE_KEY));
const operationQueues = new WeakMap<object, Promise<void>>();
const serialize = async <T>(context: PolicyContext, operation: () => Promise<T>): Promise<T> => {
const owner = context.globalState as object;
const previous = operationQueues.get(owner) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
const queued = previous.catch(() => undefined).then(() => current);
operationQueues.set(owner, queued);
await previous.catch(() => undefined);
try {
return await operation();
} finally {
release();
if (operationQueues.get(owner) === queued) operationQueues.delete(owner);
}
};
async function setPermissionAutoAcceptPolicy(
context: PolicyContext,
sessionId: string,
enabled: boolean,
broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown>,
) {
return serialize(context, async () => {
const current = readPermissionAutoAcceptPolicy(context);
const snapshot = {
sessions: { ...current.sessions, [sessionId]: enabled },
revision: current.revision + 1,
};
await context.globalState.update(STORAGE_KEY, snapshot);
await broadcast(snapshot);
return snapshot;
});
}
export async function handlePermissionAutoAcceptBridgeMessage(
message: { id: string; type: string; payload?: unknown },
context?: PolicyContext,
dependencies?: { broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown> },
) {
if (message.type !== 'api:permission-auto-accept:get' && message.type !== 'api:permission-auto-accept:set') {
return null;
}
if (!context) return { id: message.id, type: message.type, success: false, error: 'Extension context is unavailable' };
if (message.type === 'api:permission-auto-accept:get') {
const snapshot = await serialize(context, async () => readPermissionAutoAcceptPolicy(context));
return { id: message.id, type: message.type, success: true, data: snapshot };
}
const payload = message.payload && typeof message.payload === 'object'
? message.payload as { sessionId?: unknown; enabled?: unknown }
: {};
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
if (!sessionId) return { id: message.id, type: message.type, success: false, error: 'sessionId is required' };
if (typeof payload.enabled !== 'boolean') {
return { id: message.id, type: message.type, success: false, error: 'enabled must be a boolean' };
}
const snapshot = await setPermissionAutoAcceptPolicy(
context,
sessionId,
payload.enabled,
dependencies?.broadcast ?? (() => Promise.resolve()),
);
return { id: message.id, type: message.type, success: true, data: snapshot };
}
+28 -1
View File
@@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
const keysToClear = new Set<string>();
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride', 'walkthroughModelOverride']) {
const value = restChanges[key];
if (typeof value === 'string' && value.trim().length === 0) {
keysToClear.add(key);
@@ -299,6 +299,33 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
}
}
if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') {
delete restChanges.smallModelUseDefault;
}
if ('sessionRecapEnabled' in restChanges && typeof restChanges.sessionRecapEnabled !== 'boolean') {
delete restChanges.sessionRecapEnabled;
}
if ('sessionSuggestionEnabled' in restChanges && typeof restChanges.sessionSuggestionEnabled !== 'boolean') {
delete restChanges.sessionSuggestionEnabled;
}
if ('sessionGoalEnabled' in restChanges && typeof restChanges.sessionGoalEnabled !== 'boolean') {
delete restChanges.sessionGoalEnabled;
}
if ('sessionGoalDefaultBudgetEnabled' in restChanges && typeof restChanges.sessionGoalDefaultBudgetEnabled !== 'boolean') {
delete restChanges.sessionGoalDefaultBudgetEnabled;
}
if ('sessionGoalDefaultBudget' in restChanges) {
const budget = restChanges.sessionGoalDefaultBudget;
if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
delete restChanges.sessionGoalDefaultBudget;
}
}
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
delete restChanges.usageAutoRefresh;
}
+109 -14
View File
@@ -3,10 +3,13 @@ import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { randomUUID } from 'crypto';
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig';
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
import type { BridgeContext, BridgeResponse } from './bridge';
type BridgeMessageInput = {
@@ -74,10 +77,13 @@ const getOrCreateInstallId = (scope: string): string => {
return installId;
};
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => {
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'android' | 'ios' | 'web' => {
// The webview already sends API-shaped values; Node's os.platform() is the fallback source.
if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'android' || value === 'ios' || value === 'web') {
return value;
}
if (value === 'darwin') return 'macos';
if (value === 'win32') return 'windows';
if (value === 'linux') return 'linux';
return 'web';
};
@@ -267,6 +273,15 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:opencode/upgrade-status': {
return { id, type, success: true, data: await getOpenCodeUpgradeStatus(ctx?.manager) };
}
case 'api:opencode/upgrade': {
const target = (payload as { target?: unknown } | undefined)?.target;
return { id, type, success: true, data: await upgradeManagedOpenCode(ctx?.manager, target) };
}
case 'api:session-activity:get': {
return { id, type, success: true, data: getSessionActivitySnapshot() };
}
@@ -303,7 +318,6 @@ export async function handleSystemBridgeMessage(
: os.arch();
const reportUsage = body.reportUsage !== false;
const installId = getOrCreateInstallId('vscode');
const requestBody = {
appType: 'vscode',
deviceClass,
@@ -311,7 +325,7 @@ export async function handleSystemBridgeMessage(
arch: mapNodeArchToApiArch(archRaw),
channel: 'stable',
currentVersion,
installId,
...(reportUsage ? { installId: getOrCreateInstallId('vscode') } : {}),
instanceMode,
reportUsage,
};
@@ -471,6 +485,64 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:provider:upsert': {
const {
providerID,
providerId: providerIdAlias,
config,
scope,
directory,
} = (payload || {}) as {
providerID?: string;
providerId?: string;
config?: unknown;
scope?: string;
directory?: string;
};
const providerId = (typeof providerID === 'string' && providerID.trim())
|| (typeof providerIdAlias === 'string' && providerIdAlias.trim())
|| '';
if (!providerId) {
return { id, type, success: false, error: 'Provider ID is required' };
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return { id, type, success: false, error: 'Provider config is required' };
}
const normalizedScope = typeof scope === 'string' ? scope : 'user';
if (normalizedScope !== 'user' && normalizedScope !== 'project' && normalizedScope !== 'custom') {
return { id, type, success: false, error: 'Invalid scope' };
}
try {
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
? directory.trim()
: ctx?.manager?.getWorkingDirectory();
const result = upsertProviderConfig(
providerId,
config,
workingDirectory,
normalizedScope,
{ hasStoredAuth: Boolean(getProviderAuth(providerId)) },
);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
providerId: result.providerId,
path: result.path,
config: result.config,
requiresReload: true,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: errorMessage };
}
}
case 'api:quota:providers': {
try {
const providers = listConfiguredQuotaProviders();
@@ -481,6 +553,38 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:quota:credentials': {
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
try {
if (!providerId || !['opencode-go', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
if (method === 'IMPORT') {
if (providerId !== 'cursor') return { id, type, success: false, error: 'Import unavailable' };
const credential = importCursorCredential();
await validateCredential(providerId, credential);
return { id, type, success: true, data: writeCredential(providerId, credential) };
}
if (method === 'PUT') {
const credential = normalizeCredential(providerId, input);
if (!credential) return { id, type, success: false, error: 'Invalid credential' };
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
else await validateCredential(providerId, credential);
return { id, type, success: true, data: writeCredential(providerId, credential) };
}
if (method === 'VALIDATE') {
const credential = readCredential(providerId);
if (!credential) return { id, type, success: false, error: 'Not configured' };
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
else await validateCredential(providerId, credential);
return { id, type, success: true, data: { valid: true } };
}
return { id, type, success: false, error: 'Unsupported method' };
} catch (error) {
return { id, type, success: false, error: error instanceof Error ? error.message : String(error) };
}
}
case 'api:quota:get': {
const { providerId } = (payload || {}) as { providerId?: string };
if (!providerId) {
@@ -524,15 +628,6 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:notifications/auto-accept': {
const request = (payload || {}) as { sessionId?: unknown; enabled?: unknown };
const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : '';
if (!sessionId) {
return { id, type, success: false, error: 'sessionId is required' };
}
return { id, type, success: true, data: { success: true } };
}
default:
return null;
}
+13
View File
@@ -6,6 +6,7 @@ import { handleFsBridgeMessage } from './bridge-fs-runtime';
import { handleConfigBridgeMessage } from './bridge-config-runtime';
import { handleSystemBridgeMessage } from './bridge-system-runtime';
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
import {
fetchOpenCodeSkillsFromApi,
persistSettings,
@@ -63,6 +64,18 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const { id, type, payload } = message;
try {
const permissionAutoAcceptResponse = await handlePermissionAutoAcceptBridgeMessage(
{ id, type, payload },
ctx?.context,
{
broadcast: (snapshot) => vscode.commands.executeCommand(
'openchamber.internal.permissionAutoAcceptSynced',
snapshot,
),
},
);
if (permissionAutoAcceptResponse) return permissionAutoAcceptResponse;
const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload });
if (standardGitResponse) {
return standardGitResponse;
+11 -2
View File
@@ -200,6 +200,14 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.internal.permissionAutoAcceptSynced', (snapshot: unknown) => {
chatViewProvider?.notifyPermissionAutoAcceptSynced(snapshot);
sessionEditorProvider?.notifyPermissionAutoAcceptSynced(snapshot);
agentManagerProvider?.notifyPermissionAutoAcceptSynced(snapshot);
})
);
context.subscriptions.push(
vscode.window.onDidChangeWindowState((state) => {
chatViewProvider?.notifyWindowFocusChanged(state.focused);
@@ -297,13 +305,14 @@ export async function activate(context: vscode.ExtensionContext) {
}
// Get file info for context
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
// false matches the relativePath broadcast for the active editor, so this attachment dedupes against the pin-selection suggestion.
const filePath = vscode.workspace.asRelativePath(editor.document.uri, false);
// Get line numbers (1-based for display)
const startLine = selection.start.line + 1;
const endLine = selection.end.line + 1;
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
const filename = `${editor.document.fileName.split(/[\\/]/).pop() || filePath}:${lineRange}`;
const filename = `${filePath}:${lineRange}`;
const contextSelection = {
filePath: editor.document.uri.fsPath,
filename,
+170 -131
View File
@@ -14,11 +14,25 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
let gitApi: GitAPI | null = null;
let gitExtensionEnabled = false;
const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>();
type WorktreeBootstrapStatus = {
status: 'pending' | 'ready' | 'failed';
phase: 'directory-created' | 'git-ready' | 'setup-ready';
error: string | null;
updatedAt: number;
};
const worktreeBootstrapState = new Map<string, WorktreeBootstrapStatus>();
const activeWorktreeBootstrapTasks = new Map<string, Promise<unknown>>();
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const;
const WORKTREE_PHASE_GIT_READY = 'git-ready' as const;
const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const;
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
const toBootstrapStateKey = (directory: string): string => {
const normalized = normalizeDirectoryPath(directory);
@@ -28,16 +42,35 @@ const toBootstrapStateKey = (directory: string): string => {
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (directory: string, status: 'pending' | 'ready' | 'failed', error: string | null = null): void => {
const setWorktreeBootstrapState = (
directory: string,
status: WorktreeBootstrapStatus['status'],
phase: WorktreeBootstrapStatus['phase'],
error: string | null = null,
): WorktreeBootstrapStatus | null => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
return null;
}
worktreeBootstrapState.set(key, {
const state: WorktreeBootstrapStatus = {
status,
phase,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
});
};
worktreeBootstrapState.set(key, state);
return state;
};
const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => {
const current = worktreeBootstrapState.get(toBootstrapStateKey(directory));
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error),
);
};
const clearWorktreeBootstrapState = (directory: string): void => {
@@ -48,6 +81,36 @@ const clearWorktreeBootstrapState = (directory: string): void => {
worktreeBootstrapState.delete(key);
};
const trackWorktreeBootstrapTask = (directory: string, task: Promise<unknown>): void => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
activeWorktreeBootstrapTasks.set(key, task);
const clearTask = () => {
if (activeWorktreeBootstrapTasks.get(key) === task) {
activeWorktreeBootstrapTasks.delete(key);
}
};
void task.then(clearTask, clearTask);
};
const waitForActiveWorktreeBootstrap = async (directory: string): Promise<void> => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
while (true) {
const task = activeWorktreeBootstrapTasks.get(key);
if (!task) {
return;
}
await task.catch(() => undefined);
}
};
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
@@ -778,7 +841,7 @@ export interface GitWorktreeInfo {
branch: string;
path: string;
directoryCreated?: true;
bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number };
bootstrapStatus?: WorktreeBootstrapStatus;
}
type WorktreeListEntry = {
@@ -1025,6 +1088,72 @@ const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage
return result;
};
const wait = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isIndexLockError = (result: GitCommandResult): boolean => {
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
};
const getWorktreeIndexLockPath = async (directory: string): Promise<string | null> => {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
if (!result.success) {
return null;
}
const value = String(result.stdout || '').trim();
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
};
const getFileIdentity = async (filePath: string): Promise<string | null> => {
try {
const stat = await fs.promises.stat(filePath);
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return null;
}
throw error;
}
};
const populateWorktreeWithLockRecovery = async (directory: string): Promise<void> => {
let result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
}
await fs.promises.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
};
const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise<string> => {
const gitDir = path.join(primaryWorktree, '.git');
const idFile = path.join(gitDir, 'opencode');
@@ -1256,74 +1385,11 @@ const loadProjectStartCommand = async (projectID: string): Promise<string> => {
}
};
const getProjectStoragePath = (projectID: string) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const updateProjectSandboxes = async (
projectID: string,
primaryWorktree: string,
updater: (project: {
id: string;
worktree: string;
vcs: string;
sandboxes: string[];
time: { created: number; updated: number };
}) => void
) => {
const storagePath = getProjectStoragePath(projectID);
await fs.promises.mkdir(path.dirname(storagePath), { recursive: true });
const now = Date.now();
const base = {
id: projectID,
worktree: primaryWorktree,
vcs: 'git',
sandboxes: [] as string[],
time: { created: now, updated: now },
};
const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null);
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
current.id = String(current.id || projectID);
current.worktree = String(current.worktree || primaryWorktree);
current.vcs = current.vcs || 'git';
current.sandboxes = Array.isArray(current.sandboxes)
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
const createdAt = Number(current?.time?.created);
current.time = {
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
updated: now,
};
updater(current);
current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))];
await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
};
const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
if (!project.sandboxes.includes(sandbox)) {
project.sandboxes.push(sandbox);
}
});
};
const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
});
};
// OpenCode owns its own project/sandbox registry and records a worktree as a
// sandbox itself when an instance boots for that directory. OpenChamber used to
// write that state into OpenCode's storage JSON directly, behind the back of the
// running process — and since OpenCode v2 reads sandboxes from its database, the
// JSON write did not even reach it. Registration is not ours to perform.
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
const relative = path.relative(root, target);
@@ -1348,14 +1414,6 @@ const cleanupFailedFastWorktreeCreate = async (
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
if (!isAttached) {
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
} catch (error) {
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
}
}
if (!isInsideWorktreeRoot || isAttached) {
return;
}
@@ -1417,9 +1475,9 @@ const queueWorktreeBootstrap = (args: {
ensureRemoteUrl,
startCommand,
} = args;
setTimeout(() => {
const run = async () => {
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
.then(async () => {
await populateWorktreeWithLockRecovery(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
@@ -1434,21 +1492,18 @@ const queueWorktreeBootstrap = (args: {
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY);
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
});
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
};
void run().catch((error) => {
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY);
})
.catch((error) => {
setWorktreeBootstrapFailure(directory, error);
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
}, 0);
trackWorktreeBootstrapTask(directory, task);
};
const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => {
@@ -1837,19 +1892,17 @@ async function attachGitWorktreeToCandidate(
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const shouldSetUpstream = Boolean(input?.setUpstream);
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null,
updatedAt: Date.now(),
};
@@ -1903,15 +1956,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
if (input?.returnAfterDirectoryCreated === true) {
await fs.promises.mkdir(candidate.directory, { recursive: false });
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null,
updatedAt: Date.now(),
};
@@ -1919,15 +1970,12 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
void cleanupFailedFastWorktreeCreate(context, candidate);
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
setWorktreeBootstrapFailure(candidate.directory, error);
await cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
trackWorktreeBootstrapTask(candidate.directory, task);
return {
head: '',
@@ -1942,7 +1990,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
return attachGitWorktreeToCandidate(context, candidate, input);
}
export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> {
export async function getWorktreeBootstrapStatus(directory: string): Promise<WorktreeBootstrapStatus> {
const key = toBootstrapStateKey(directory);
if (!key) {
throw new Error('Worktree directory is required');
@@ -1955,6 +2003,7 @@ export async function getWorktreeBootstrapStatus(directory: string): Promise<{ s
return {
status: WORKTREE_BOOTSTRAP_READY,
phase: WORKTREE_PHASE_SETUP_READY,
error: null,
updatedAt: Date.now(),
};
@@ -1966,6 +2015,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
throw new Error('Worktree directory is required');
}
await waitForActiveWorktreeBootstrap(targetDirectory);
const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true;
@@ -1995,12 +2046,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
@@ -2023,12 +2068,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
}
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
} catch (error) {
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
@@ -0,0 +1,18 @@
import { describe, expect, it, mock } from 'bun:test';
mock.module('vscode', () => ({
extensions: { getExtension: () => undefined },
Uri: { file: (fsPath) => ({ fsPath }) },
}));
const { getWorktreeBootstrapStatus } = await import('./gitService.ts?worktree-bootstrap-test');
describe('VS Code worktree bootstrap phases', () => {
it('treats missing bootstrap state as fully ready', async () => {
await expect(getWorktreeBootstrapStatus('/untracked-worktree')).resolves.toMatchObject({
status: 'ready',
phase: 'setup-ready',
error: null,
});
});
});
@@ -0,0 +1,91 @@
import { afterEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode, type OpenCodeUpgradeManager } from './opencode-upgrade-runtime';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const createManager = (mode: 'managed' | 'external' = 'managed') => {
let restartCount = 0;
const manager: OpenCodeUpgradeManager = {
getApiUrl: () => 'http://127.0.0.1:4096',
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
getDebugInfo: () => ({ mode }),
restart: async () => { restartCount += 1; },
};
return { manager, getRestartCount: () => restartCount };
};
describe('VS Code OpenCode upgrades', () => {
test('reports an available update for a managed OpenCode process', async () => {
const { manager } = createManager();
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
const url = String(input);
if (url.endsWith('/global/health')) return new Response(JSON.stringify({ version: '1.18.8' }));
if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.9' }));
return new Response(JSON.stringify({ tag_name: 'v1.18.9' }));
}) as typeof fetch;
assert.deepEqual(await getOpenCodeUpgradeStatus(manager), {
available: true,
currentVersion: '1.18.8',
latestVersion: '1.18.9',
upgrade: { supported: true, manager: 'opencode', reason: null },
});
});
test('fails closed for externally managed OpenCode without contacting the updater', async () => {
const { manager } = createManager('external');
let fetchCount = 0;
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response('{}');
}) as typeof fetch;
assert.deepEqual(await upgradeManagedOpenCode(manager), {
status: 409,
body: {
success: false,
code: 'OPENCODE_UPGRADE_UNSUPPORTED',
error: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
},
});
assert.equal(fetchCount, 0);
});
test('upgrades then restarts the extension-owned OpenCode process', async () => {
const { manager, getRestartCount } = createManager();
let request: RequestInit | undefined;
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
assert.equal(String(input), 'http://127.0.0.1:4096/global/upgrade');
request = init;
return new Response(JSON.stringify({ success: true, version: '1.18.9' }));
}) as typeof fetch;
assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), {
status: 200,
body: { success: true, version: '1.18.9', restarted: true },
});
assert.equal(getRestartCount(), 1);
assert.equal(request?.method, 'POST');
assert.deepEqual(JSON.parse(String(request?.body)), { target: '1.18.9' });
assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test');
});
test('serializes concurrent managed upgrades', async () => {
const { manager } = createManager();
let release: (response: Response) => void = () => {};
globalThis.fetch = (() => new Promise<Response>((resolve) => { release = resolve; })) as typeof fetch;
const first = upgradeManagedOpenCode(manager);
const second = await upgradeManagedOpenCode(manager);
assert.equal(second.status, 409);
assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS');
release(new Response(JSON.stringify({ success: true })));
assert.equal((await first).status, 200);
});
});
@@ -0,0 +1,136 @@
type UpgradeCapability = {
supported: boolean;
manager: 'opencode' | 'external' | 'openchamber' | null;
reason: 'external' | 'unavailable' | 'windows-arm64-workaround' | null;
};
export type OpenCodeUpgradeManager = {
getApiUrl(): string | null;
getOpenCodeAuthHeaders(): Record<string, string>;
getDebugInfo(): { mode: 'managed' | 'external' };
restart(): Promise<void>;
};
type UpgradeResult = { status: number; body: Record<string, unknown> };
let openCodeUpgradePromise: Promise<UpgradeResult> | null = null;
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
// Disable OpenCode self-upgrade on ARM64 so it can't overwrite the working x64
// binary with the broken ARM64 build. Remove when the upstream issue is resolved.
const isWindowsArm64 = (): boolean => process.platform === 'win32' && process.arch === 'arm64';
const parseVersion = (value: unknown): { parts: number[]; prerelease: boolean } => {
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
const prereleaseIndex = normalized.indexOf('-');
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
return {
parts: core.split('.').map((part) => {
const parsed = Number.parseInt(part || '0', 10);
return Number.isFinite(parsed) ? parsed : 0;
}),
prerelease: prereleaseIndex >= 0,
};
};
const compareVersions = (left: unknown, right: unknown): number => {
const a = parseVersion(left);
const b = parseVersion(right);
for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
const difference = (a.parts[index] || 0) - (b.parts[index] || 0);
if (difference !== 0) return difference;
}
return a.prerelease === b.prerelease ? 0 : (a.prerelease ? -1 : 1);
};
const getCapability = (manager?: OpenCodeUpgradeManager): UpgradeCapability => {
if (isWindowsArm64()) return { supported: false, manager: 'openchamber', reason: 'windows-arm64-workaround' };
if (!manager) return { supported: false, manager: null, reason: 'unavailable' };
if (manager.getDebugInfo().mode !== 'managed') return { supported: false, manager: 'external', reason: 'external' };
if (!manager.getApiUrl()) return { supported: false, manager: null, reason: 'unavailable' };
return { supported: true, manager: 'opencode', reason: null };
};
const getApiUrl = (manager?: OpenCodeUpgradeManager): string | null => {
const apiUrl = manager?.getApiUrl();
return apiUrl ? `${apiUrl.replace(/\/+$/, '')}/` : null;
};
const fetchLatestVersion = async (): Promise<string> => {
const results = await Promise.allSettled([
fetch('https://registry.npmjs.org/opencode-ai/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
.then(async (response) => {
if (!response.ok) throw new Error(`OpenCode npm registry responded with ${response.status}`);
const payload = await response.json() as { version?: unknown };
return typeof payload.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
}),
fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
.then(async (response) => {
if (!response.ok) throw new Error(`OpenCode releases responded with ${response.status}`);
const payload = await response.json() as { tag_name?: unknown };
return typeof payload.tag_name === 'string' ? payload.tag_name.trim().replace(/^v/, '') : '';
}),
]);
const versions = results.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : []);
if (versions.length === 0) throw new Error('Failed to resolve latest OpenCode version');
return versions.sort((left, right) => compareVersions(right, left))[0];
};
export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => {
const upgrade = getCapability(manager);
const apiUrl = getApiUrl(manager);
if (!upgrade.supported || !apiUrl || !manager) return { available: false, currentVersion: null, latestVersion: null, upgrade };
try {
const [healthResponse, latestVersion] = await Promise.all([
fetch(new URL('global/health', apiUrl).toString(), { method: 'GET', headers: { Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() } }),
fetchLatestVersion(),
]);
const health = await healthResponse.json().catch(() => null) as { version?: unknown; error?: unknown } | null;
if (!healthResponse.ok) {
const error = typeof health?.error === 'string' ? health.error : healthResponse.statusText || 'Failed to read OpenCode version';
return { available: null, error, upgrade };
}
const currentVersion = typeof health?.version === 'string' && health.version.trim() ? health.version.trim().replace(/^v/, '') : null;
return { available: currentVersion ? compareVersions(latestVersion, currentVersion) > 0 : null, currentVersion, latestVersion, upgrade };
} catch (error) {
return { available: null, error: error instanceof Error ? error.message : String(error), upgrade };
}
};
export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | undefined, target?: unknown): Promise<UpgradeResult> => {
const upgrade = getCapability(manager);
const apiUrl = getApiUrl(manager);
if (!upgrade.supported || !apiUrl || !manager) {
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_UNSUPPORTED', error: 'This OpenCode runtime cannot be upgraded by OpenChamber.' } };
}
if (openCodeUpgradePromise) {
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } };
}
const targetVersion = typeof target === 'string' ? target.trim() : '';
const operation = (async (): Promise<UpgradeResult> => {
try {
const response = await fetch(new URL('global/upgrade', apiUrl).toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() },
body: JSON.stringify(targetVersion ? { target: targetVersion } : {}),
});
const payload = await response.json().catch(() => null) as { error?: unknown } | null;
if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } };
try {
await manager.restart();
} catch (error) {
return { status: 500, body: { success: false, upgraded: true, error: error instanceof Error ? `OpenCode upgraded, but restart failed: ${error.message}` : 'OpenCode upgraded, but restart failed' } };
}
return { status: 200, body: { ...(payload && typeof payload === 'object' ? payload : { success: true }), restarted: true } };
} catch (error) {
return { status: 500, body: { success: false, error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode' } };
}
})();
openCodeUpgradePromise = operation;
try {
return await operation;
} finally {
if (openCodeUpgradePromise === operation) openCodeUpgradePromise = null;
}
};
+74 -27
View File
@@ -123,14 +123,40 @@ function isExecutable(filePath: string): boolean {
}
}
function shouldUseWindowsShell(binary: string): boolean {
if (process.platform !== 'win32') return false;
// Windows launch spec: .cmd/.bat shims (and bare names, which resolve to .cmd
// shims via PATHEXT) must run under cmd.exe. Spawn cmd.exe DIRECTLY with the
// shim path as its own argv element (shell:false) — `shell: true` builds an
// unquoted command line, so a space-containing path like
// "C:\Program Files\nodejs\opencode.cmd" broke with
// "'C:\Program' is not recognized as an internal or external command".
function resolveWindowsLaunchSpec(binary: string, args: string[]): { binary: string; args: string[] } {
if (process.platform !== 'win32') {
return { binary, args };
}
const trimmed = (binary || '').trim();
if (!trimmed) return true;
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat') return true;
// Bare command names often resolve to .cmd shims via PATHEXT.
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
const isBatchShim = ext === '.cmd' || ext === '.bat';
const isBareName = !ext && !trimmed.includes('\\') && !trimmed.includes('/');
if (!isBatchShim && !isBareName) {
return { binary: trimmed, args };
}
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', trimmed, ...args],
};
}
// Strip a single wrapping quote pair (Windows "Copy as path" and quoted shell
// snippets) — literal quotes are never part of a real path and break every
// executable check.
function stripWrappingQuotes(value: string): string {
const trimmed = (value || '').trim();
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function appendToPath(dir: string) {
@@ -177,7 +203,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
if (typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
const trimmed = stripWrappingQuotes(raw);
if (!trimmed) {
return null;
}
@@ -193,13 +219,33 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
}
function isMacOpenCodeAppBundlePath(candidate: string): boolean {
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
return process.platform === 'darwin' && /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate);
}
function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean {
if (process.platform !== 'win32' || typeof candidate !== 'string') {
return false;
}
const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim()
? path.resolve(process.env.LOCALAPPDATA).toLowerCase()
: '';
if (!localAppData) {
return false;
}
const normalized = path.resolve(candidate).toLowerCase();
return normalized.startsWith(`${localAppData}${path.sep}`)
&& normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`);
}
function isKnownOpenCodeDesktopAppPath(candidate: string): boolean {
return isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate);
}
function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error {
const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.';
if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) {
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`);
if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) {
const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle';
return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${normalized}. ${messageSuffix}`);
}
try {
@@ -254,7 +300,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null {
return null;
}
if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) {
return normalized;
}
@@ -271,7 +317,7 @@ function resolveOpencodeCliPath(): string | null {
}
})();
if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) {
if (configured && isExecutable(configured) && !isKnownOpenCodeDesktopAppPath(configured)) {
return configured;
}
@@ -288,7 +334,7 @@ function resolveOpencodeCliPath(): string | null {
}
})();
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) {
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) {
return sharedFromOpenChamber;
}
@@ -298,17 +344,17 @@ function resolveOpencodeCliPath(): string | null {
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.map((v) => (typeof v === 'string' ? stripWrappingQuotes(v) : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
return candidate;
}
}
if (cachedDetectedOpencodeCliPath) {
if (isExecutable(cachedDetectedOpencodeCliPath)) {
if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) {
return cachedDetectedOpencodeCliPath;
}
cachedDetectedOpencodeCliPath = undefined;
@@ -327,7 +373,6 @@ function resolveOpencodeCliPath(): string | null {
const winFallbacks = (() => {
const userProfile = process.env.USERPROFILE || home;
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData';
const npmDir = path.join(appData, 'npm');
@@ -338,20 +383,22 @@ function resolveOpencodeCliPath(): string | null {
path.join(npmDir, 'opencode.exe'),
path.join(npmDir, 'opencode.cmd'),
path.join(npmDir, 'opencode.bat'),
// System-wide Node installer keeps the global npm prefix here
// (npm i -g opencode-ai → opencode.cmd shim).
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'opencode.cmd'),
path.join(userProfile, 'scoop', 'shims', 'opencode.exe'),
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
// Bun global install
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
// Some installers use LocalAppData
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
].filter(Boolean);
})();
if (process.platform !== 'win32') {
const fromPath = findExecutableInPath('opencode');
if (fromPath) {
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
cachedDetectedOpencodeCliPath = fromPath;
return fromPath;
}
@@ -359,7 +406,7 @@ function resolveOpencodeCliPath(): string | null {
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
for (const candidate of fallbacks) {
if (isExecutable(candidate)) {
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
cachedDetectedOpencodeCliPath = candidate;
return candidate;
}
@@ -367,7 +414,7 @@ function resolveOpencodeCliPath(): string | null {
if (process.platform === 'win32') {
const fromPath = findExecutableInPath('opencode');
if (fromPath) {
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
cachedDetectedOpencodeCliPath = fromPath;
return fromPath;
}
@@ -376,13 +423,14 @@ function resolveOpencodeCliPath(): string | null {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line));
if (found) {
cachedDetectedOpencodeCliPath = found;
return found;
@@ -615,14 +663,13 @@ async function spawnManagedOpenCodeServer(
port: number,
timeoutMs: number
): Promise<{ url: string; close: () => void }> {
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
const child = spawn(binary, args, {
const binary = stripWrappingQuotes(process.env.OPENCODE_BINARY || 'opencode') || 'opencode';
const launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
const child = spawn(launch.binary, launch.args, {
cwd: workingDirectory,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
shell: shouldUseWindowsShell(binary),
});
const url = await new Promise<string>((resolve, reject) => {
+5 -2
View File
@@ -28,15 +28,18 @@ const readAuthFile = (): AuthFile => {
const writeAuthFile = (auth: AuthFile): void => {
try {
if (!fs.existsSync(OPENCODE_DATA_DIR)) {
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true, mode: 0o700 });
}
if (process.platform !== 'win32') fs.chmodSync(OPENCODE_DATA_DIR, 0o700);
if (fs.existsSync(AUTH_FILE)) {
const backupFile = `${AUTH_FILE}.openchamber.backup`;
fs.copyFileSync(AUTH_FILE, backupFile);
if (process.platform !== 'win32') fs.chmodSync(backupFile, 0o600);
}
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') fs.chmodSync(AUTH_FILE, 0o600);
} catch (error) {
console.error('Failed to write auth file:', error);
throw new Error('Failed to write OpenCode auth configuration');
@@ -0,0 +1,265 @@
import { afterEach, beforeEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
validateCustomProviderConfig,
} from './opencodeConfig';
let projectDir: string;
const writeJson = (filePath: string, value: unknown) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
};
const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
describe('custom provider config persistence (VS Code parity)', () => {
beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-provider-'));
});
afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
assert.equal(validateCustomProviderConfig('Bad Id', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok, false);
const ftp = validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'ftp://api.example.com' },
models: { m: { name: 'M' } },
});
assert.equal(ftp.ok, false);
assert.match(ftp.error ?? '', /http:\/\//);
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: {},
}).ok, false);
});
test('validateCustomProviderConfig rejects missing credentials', () => {
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok, false);
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}, { hasStoredAuth: true }).ok, true);
assert.equal(validateCustomProviderConfig('ok', {
name: 'X',
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok, true);
});
test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
assert.equal(result.providerId, 'campus-llm');
assert.equal(fs.existsSync(result.path), true);
assert.equal(result.path.startsWith(projectDir), true);
const written = readJson(result.path);
assert.deepEqual(written.provider['campus-llm'], {
npm: '@ai-sdk/openai-compatible',
name: 'Campus LLM',
env: ['CAMPUS_KEY'],
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
});
const sources = getProviderSources('campus-llm', projectDir);
assert.equal(sources.project.exists, true);
assert.equal(sources.project.path, result.path);
});
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
const configPath = path.join(projectDir, 'opencode.json');
writeJson(configPath, {
provider: {
'campus-llm': {
npm: '@ai-sdk/openai-compatible',
name: 'Old',
options: { baseURL: 'https://old.example.edu/v1' },
models: { a: { name: 'A' } },
},
},
disabled_providers: ['campus-llm', 'other'],
});
upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { b: { name: 'B' } },
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
const written = readJson(configPath);
assert.equal(written.provider['campus-llm'].name, 'Campus LLM');
assert.deepEqual(written.provider['campus-llm'].models, { b: { name: 'B' } });
assert.deepEqual(written.disabled_providers, ['other']);
});
test('upsert then remove restores absence', () => {
upsertProviderConfig('temp-provider', {
name: 'Temp',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
env: ['TEMP_KEY'],
}, projectDir, 'project');
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, true);
assert.equal(removeProviderConfig('temp-provider', projectDir, 'project'), true);
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, false);
});
test('failed validation does not write config', () => {
const configPath = path.join(projectDir, 'opencode.json');
assert.throws(
() => upsertProviderConfig('ok', {
name: 'X',
options: { baseURL: 'not-a-url' },
models: { m: { name: 'M' } },
env: ['X'],
}, projectDir, 'project'),
/Base URL/,
);
assert.equal(fs.existsSync(configPath), false);
});
test('upsert with hasStoredAuth allows config without env', () => {
const result = upsertProviderConfig('keyed-provider', {
name: 'Keyed',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
assert.equal(result.providerId, 'keyed-provider');
assert.equal(result.config.env, undefined);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
assert.deepEqual(written.provider[providerId], {
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
assert.equal(sources.project.exists, true);
assert.equal(sources.user.exists, false);
assert.equal(sources.custom.exists, false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
assert.equal(userConfig.provider?.[providerId], undefined);
assert.equal(userConfig.providers?.[providerId], undefined);
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
assert.equal(written.provider[providerId].name, 'Custom Scoped Updated');
assert.equal(written.provider[providerId].options.baseURL, 'https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
assert.equal(sources.custom.exists, true);
assert.equal(sources.user.exists, false);
assert.equal(sources.project.exists, false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
assert.equal(userConfig.provider?.[providerId], undefined);
assert.equal(userConfig.providers?.[providerId], undefined);
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
+285 -5
View File
@@ -10,9 +10,6 @@ const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
const SNIPPET_EXTENSION = '.md';
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
@@ -541,7 +538,10 @@ const getConfigPaths = (workingDirectory?: string) => ({
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
});
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
@@ -1666,6 +1666,9 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>,
const creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates || {})) {
// Skip undefined values — they would overwrite existing frontmatter fields with nothing
if (value === undefined) continue;
if (field === 'prompt') {
if (value === null) {
if (mdExists || creatingNewMd) {
@@ -2165,6 +2168,163 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri
return true;
};
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
export const validateCustomProviderConfig = (
providerId: string,
config: unknown,
options: { hasStoredAuth?: boolean } = {},
) => {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
}
if (!isPlainObject(config)) {
return { ok: false as const, error: 'Provider config must be an object' };
}
const name = typeof config.name === 'string' ? config.name.trim() : '';
if (!name) {
return { ok: false as const, error: 'Provider name is required' };
}
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (npm !== OPENAI_COMPATIBLE_NPM) {
return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!optionsBlock) {
return { ok: false as const, error: 'Provider options are required' };
}
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
if (!baseURL) {
return { ok: false as const, error: 'Base URL is required' };
}
if (!BASE_URL_PATTERN.test(baseURL)) {
return { ok: false as const, error: 'Base URL must start with http:// or https://' };
}
const models = isPlainObject(config.models) ? config.models : null;
if (!models || Object.keys(models).length === 0) {
return { ok: false as const, error: 'At least one model is required' };
}
const normalizedModels: Record<string, { name: string }> = {};
for (const [modelId, modelValue] of Object.entries(models)) {
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
if (!trimmedId) {
return { ok: false as const, error: 'Model id is required' };
}
if (!isPlainObject(modelValue)) {
return { ok: false as const, error: `Model "${trimmedId}" must be an object` };
}
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
if (!modelName) {
return { ok: false as const, error: `Model "${trimmedId}" requires a name` };
}
normalizedModels[trimmedId] = { name: modelName };
}
const normalized: Record<string, unknown> = {
npm: OPENAI_COMPATIBLE_NPM,
name,
options: {
baseURL,
},
models: normalizedModels,
};
let env: string[] = [];
if (Array.isArray(config.env)) {
env = config.env
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim());
if (env.length > 0) {
normalized.env = env;
}
}
if (env.length === 0 && !options.hasStoredAuth) {
return { ok: false as const, error: 'API key or {env:VAR} credentials are required' };
}
if (isPlainObject(optionsBlock.headers)) {
const headers: Record<string, string> = {};
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
if (typeof headerKey !== 'string' || !headerKey.trim()) {
continue;
}
if (typeof headerValue !== 'string' || !headerValue.trim()) {
return { ok: false as const, error: `Header "${headerKey}" requires a non-empty value` };
}
headers[headerKey.trim()] = headerValue.trim();
}
if (Object.keys(headers).length > 0) {
(normalized.options as Record<string, unknown>).headers = headers;
}
}
return { ok: true as const, value: { providerId, config: normalized } };
};
export const upsertProviderConfig = (
providerId: string,
config: unknown,
workingDirectory?: string,
scope: 'user' | 'project' | 'custom' = 'user',
options: { hasStoredAuth?: boolean } = {},
) => {
const validated = validateCustomProviderConfig(providerId, config, options);
if (!validated.ok) {
const error = new Error(validated.error) as Error & { statusCode?: number };
error.statusCode = 400;
throw error;
}
const layers = readConfigLayers(workingDirectory);
let targetPath: string | null | undefined = layers.paths.userPath;
if (scope === 'project') {
if (!workingDirectory) {
throw new Error('Working directory is required for project scope');
}
targetPath = layers.paths.projectPath ?? targetPath;
} else if (scope === 'custom') {
if (!layers.paths.customPath) {
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
}
targetPath = layers.paths.customPath;
} else if (scope !== 'user') {
throw new Error('Invalid scope');
}
const targetConfig = getConfigForPath(layers, targetPath) as Record<string, unknown>;
const providerConfig = isPlainObject(targetConfig.provider)
? { ...(targetConfig.provider as Record<string, unknown>) }
: {};
providerConfig[validated.value.providerId] = validated.value.config;
targetConfig.provider = providerConfig;
if (Array.isArray(targetConfig.disabled_providers)) {
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
(entry) => entry !== validated.value.providerId,
);
}
const writePath = targetPath || CONFIG_FILE;
writeConfig(targetConfig, writePath);
return {
providerId: validated.value.providerId,
path: writePath,
config: validated.value.config,
};
};
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
let deleted = false;
@@ -2758,7 +2918,7 @@ export const updateSkill = (skillName: string, updates: Record<string, unknown>,
let mdModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'scope') continue;
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') continue;
if (field === 'instructions') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
@@ -2830,3 +2990,123 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
throw new Error(`Skill "${skillName}" not found`);
}
};
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
const resolvedCandidate = path.resolve(candidatePath);
const resolvedParent = path.resolve(parentPath);
return resolvedCandidate === resolvedParent
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
};
const getManagedSkillRoots = (workingDirectory?: string): string[] => {
const roots: string[] = [];
const pushRoot = (dir?: string | null) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!roots.includes(resolved)) {
roots.push(resolved);
}
};
pushRoot(SKILL_DIR);
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
pushRoot(customConfigDir ? path.join(customConfigDir, 'skills') : null);
pushRoot(customConfigDir ? path.join(customConfigDir, 'skill') : null);
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
pushRoot(path.join(ancestor, '.opencode', 'skills'));
pushRoot(path.join(ancestor, '.opencode', 'skill'));
pushRoot(path.join(ancestor, '.claude', 'skills'));
pushRoot(path.join(ancestor, '.agents', 'skills'));
}
}
return roots;
};
const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boolean => {
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
return false;
}
const skillDir = path.dirname(path.resolve(skillMdPath));
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
};
export { isManagedSkillPath };
export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => {
ensureSkillDirs();
validateSkillName(newName);
if (oldName === newName) {
return;
}
const existing = getSkillScope(oldName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${oldName}" not found`);
}
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
throw new Error(`Skill "${oldName}" cannot be renamed`);
}
if (path.basename(existing.path) !== 'SKILL.md') {
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
}
if (!isManagedSkillPath(existing.path, workingDirectory)) {
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
}
const mdDataBeforeMove = parseMdFile(existing.path);
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
? mdDataBeforeMove.frontmatter.name
: oldName;
if (frontmatterName !== oldName) {
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
}
const conflict = getSkillScope(newName, workingDirectory);
if (conflict.path) {
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
}
const oldDir = path.dirname(existing.path);
const newDir = path.join(path.dirname(oldDir), newName);
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
if (directoriesDiffer && fs.existsSync(newDir)) {
throw new Error(`Skill directory already exists at ${newDir}`);
}
if (directoriesDiffer) {
fs.renameSync(oldDir, newDir);
}
const newPath = path.join(newDir, 'SKILL.md');
try {
const mdData = parseMdFile(newPath);
mdData.frontmatter = {
...mdData.frontmatter,
name: newName,
};
writeMdFile(newPath, mdData.frontmatter, mdData.body);
} catch (error) {
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
try {
fs.renameSync(newDir, oldDir);
} catch {
// Best-effort rollback; surface the original write failure.
}
}
throw error;
}
};
+28
View File
@@ -0,0 +1,28 @@
type OpenCodeGoCredential = { workspaceId: string; authCookie: string };
const toWindow = (usedPercent: number, resetInSec: number) => ({
usedPercent: Math.min(100, Math.max(0, usedPercent)),
remainingPercent: 100 - Math.min(100, Math.max(0, usedPercent)),
windowSeconds: null,
resetAfterSeconds: Math.max(0, resetInSec),
resetAt: Date.now() + Math.max(0, resetInSec) * 1000,
resetAtFormatted: null,
resetAfterFormatted: null,
});
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`);
const html = (await response.text()).replaceAll('&quot;', '"').replaceAll('&#34;', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
const windows: Record<string, ReturnType<typeof toWindow>> = {};
for (const [key, field] of Object.entries({ '5h': 'rollingUsage', weekly: 'weeklyUsage', monthly: 'monthlyUsage' })) {
const body = html.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, 's'))?.[1];
if (!body) continue;
const used = Number(body.match(/usagePercent\s*:\s*["']?(-?\d+(?:\.\d+)?)/)?.[1]);
const reset = Number(body.match(/resetInSec\s*:\s*["']?(-?\d+(?:\.\d+)?)/)?.[1]);
if (Number.isFinite(used) && Number.isFinite(reset)) windows[key] = toWindow(used, reset);
}
if (!Object.keys(windows).length) throw new Error('OpenCode Go usage data could not be parsed');
return windows;
};
+75
View File
@@ -0,0 +1,75 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
export type ManagedProvider = 'opencode-go' | 'ollama-cloud' | 'cursor';
export type ManagedCredential = Record<string, string>;
const providers = new Set<ManagedProvider>(['opencode-go', 'ollama-cloud', 'cursor']);
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
const target = (provider: ManagedProvider) => {
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
return path.join(directory(), `${provider}.json`);
};
const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
if (provider === 'opencode-go') {
const workspaceId = clean(data.workspaceId);
let authCookie = clean(data.authCookie);
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
}
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
const accessToken = clean(data.accessToken);
const refreshToken = clean(data.refreshToken);
return accessToken || refreshToken ? { accessToken, refreshToken } : null;
};
export const readCredential = (provider: ManagedProvider) => {
try { return normalizeCredential(provider, JSON.parse(fs.readFileSync(target(provider), 'utf8'))); }
catch (error) { if ((error as { code?: string }).code !== 'ENOENT') console.warn(`Failed to read ${provider} quota credentials`); return null; }
};
export const credentialStatus = (provider: ManagedProvider) => {
const value = readCredential(provider);
if (!value) return { configured: false };
return { configured: true, ...(provider === 'opencode-go' ? { workspaceId: value.workspaceId } : {}), ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' };
};
export const writeCredential = (provider: ManagedProvider, value: ManagedCredential) => {
const dir = directory(); const file = target(provider); const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.chmodSync(dir, 0o700);
try { fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(temp, file); fs.chmodSync(file, 0o600); }
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
return credentialStatus(provider);
};
export const deleteCredential = (provider: ManagedProvider) => { try { fs.unlinkSync(target(provider)); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } };
export const importCursorCredential = () => {
const db = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
if (process.platform !== 'darwin' || !fs.existsSync(db)) throw new Error('Cursor credential import is unavailable');
const rows = JSON.parse(execFileSync('sqlite3', ['-json', db, "SELECT key,value FROM ItemTable WHERE key IN ('cursorAuth/accessToken','cursorAuth/refreshToken');"], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) || '[]') as Array<{ key: string; value: string }>;
const credential = normalizeCredential('cursor', { accessToken: rows.find((row) => row.key.endsWith('accessToken'))?.value, refreshToken: rows.find((row) => row.key.endsWith('refreshToken'))?.value });
if (!credential) throw new Error('Cursor credentials are unavailable');
return credential;
};
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
if (provider === 'ollama-cloud') {
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
const html = await response.text();
if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed');
}
if (provider === 'cursor') {
if (!credential.accessToken && credential.refreshToken) {
const refresh = await fetch('https://api2.cursor.sh/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', client_id: 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB', refresh_token: credential.refreshToken }), signal: AbortSignal.timeout(15_000) });
const payload = await refresh.json().catch(() => null) as { access_token?: string } | null;
if (!refresh.ok || !payload?.access_token) throw new Error('Cursor authentication failed');
credential.accessToken = payload.access_token;
}
if (!credential.accessToken) throw new Error('Cursor access token is required');
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error('Cursor authentication failed');
}
};
+512
View File
@@ -0,0 +1,512 @@
import { afterEach, beforeEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
// readAuthFile reads ~/.local/share/opencode/auth.json via fs.readFileSync.
// Stub fs to serve a known auth entry so the providers treat themselves as
// configured and proceed straight to fetch.
const ORIGINAL_FS = { ...fs };
const AUTH = JSON.stringify({
openai: { access: 'test-token' },
crof: { key: 'test-token' },
neuralwatt: { key: 'test-token' },
'zai-coding-plan': { key: 'test-token' },
deepseek: { key: 'test-token' },
});
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
import { fetchQuotaForProvider } from './quotaProviders';
type MockResponseInit = { ok?: boolean; status?: number };
const mockResponse = (body: unknown, init: MockResponseInit = {}): Response => ({
ok: 'ok' in init ? init.ok! : true,
status: init.status ?? 200,
json: async () => body,
} as unknown as Response);
// Documented NeuralWatt payload from https://portal.neuralwatt.com/docs/api/quota.
// plan="standard", kwh_included=20.0, kwh_used=13.9023.
const DOCUMENTED_SUBSCRIPTION_PAYLOAD = {
snapshot_at: '2026-04-16T18:30:00Z',
balance: { credits_remaining_usd: 32.6774, total_credits_usd: 52.34, credits_used_usd: 19.6626, accounting_method: 'energy' },
usage: {
lifetime: { cost_usd: 243.9145, requests: 37801, tokens: 1235477176, energy_kwh: 15.6009 },
current_month: { cost_usd: 160.1463, requests: 23902, tokens: 1116658995, energy_kwh: 9.7278 },
},
limits: { overage_limit_usd: null, rate_limit_tier: 'standard' },
subscription: {
plan: 'standard',
status: 'active',
billing_interval: 'year',
current_period_start: '2026-04-11T05:05:25Z',
current_period_end: '2027-04-11T05:05:25Z',
auto_renew: true,
kwh_included: 20.0,
kwh_used: 13.9023,
kwh_remaining: 6.0977,
in_overage: false,
},
key: { name: 'my-production-key', allowance: null },
} as const;
let ORIGINAL_FETCH: typeof globalThis.fetch;
beforeEach(() => {
ORIGINAL_FETCH = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
});
const stubFetchReturning = (resolver: () => Promise<unknown>): void => {
globalThis.fetch = (async () => resolver()) as typeof fetch;
};
const stubFetchFailing = (json: () => Promise<unknown>, init: MockResponseInit): void => {
globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch;
};
describe('Crof quota provider (VS Code parity)', () => {
test('reports credits balance as valueLabel with null percent', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 })));
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'crof');
assert.equal(result.usage!.windows.credits!.usedPercent, null);
assert.equal(result.usage!.windows.credits!.valueLabel, '$12.35');
});
test('tolerates missing credits field', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 0 })));
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits!.valueLabel, undefined);
assert.equal(result.usage!.windows.credits!.usedPercent, null);
});
test('maps 401 to session-expired with CrofAI branding', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'Session expired — please re-authenticate with CrofAI');
});
test('reports invalid-response on JSON parse failure', async () => {
globalThis.fetch = (async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('Unexpected token'); },
}) as unknown as Response) as typeof fetch;
const result = await fetchQuotaForProvider('crof');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
});
describe('Codex quota provider (VS Code parity)', () => {
test('surfaces spend_control individual limit for business accounts', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
plan_type: 'business',
rate_limit: null,
credits: { has_credits: true, unlimited: false, balance: null },
spend_control: {
individual_limit: {
limit: '7500',
used: '2674.8724080324173',
remaining: '4825.127591967583',
used_percent: 36,
remaining_percent: 64,
},
},
})));
const result = await fetchQuotaForProvider('codex');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits!.usedPercent, 36);
assert.equal(result.usage!.windows.credits!.valueLabel, '2675 / 7500 used');
});
});
describe('Z.ai quota provider (VS Code parity)', () => {
test('surfaces 5-hour, weekly, and MCP quota windows', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
data: {
limits: [
{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 0 },
{ type: 'TOKENS_LIMIT', unit: 6, number: 1, percentage: 100, nextResetTime: 1785659659993 },
{ type: 'TIME_LIMIT', unit: 5, number: 1, percentage: 0, nextResetTime: 1787128459979 },
],
},
})));
const result = await fetchQuotaForProvider('zai-coding-plan');
const windows = result.usage!.windows;
assert.equal(result.ok, true);
assert.equal(windows['5h']!.usedPercent, 0);
assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60);
assert.equal(windows.weekly!.usedPercent, 100);
assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60);
assert.equal(windows.weekly!.resetAt, 1785659659993);
assert.equal(windows['MCP Tools']!.usedPercent, 0);
assert.equal(windows['MCP Tools']!.windowSeconds, 30 * 24 * 60 * 60);
assert.equal(windows['MCP Tools']!.resetAt, 1787128459979);
});
});
describe('NeuralWatt quota provider (VS Code parity)', () => {
test('builds subscription window keyed by plan name (windowSeconds null)', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse(DOCUMENTED_SUBSCRIPTION_PAYLOAD)));
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'neuralwatt');
// Subscription window is keyed by the plan name; windowSeconds is null
// because the API exposes no kWh window start to derive duration from.
const window = result.usage!.windows.standard;
assert.ok(window, 'subscription window should be defined');
assert.ok(Math.abs((window.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
assert.equal(window.windowSeconds, null);
assert.equal(window.resetAt, Date.parse('2027-04-11T05:05:25Z'));
// allowance is null → credits_balance also surfaced
assert.ok(result.usage!.windows.credits_balance, 'credits_balance should be defined');
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
});
test('falls back to plan_limit title when plan is missing', async () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, plan: null },
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
assert.ok(result.usage!.windows.plan_limit);
assert.ok(Math.abs((result.usage!.windows.plan_limit!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
});
test('marks in-overage subscription as 100%, still shows credits', async () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, in_overage: true, kwh_used: 25.0 },
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.standard;
assert.ok(window);
assert.equal(window!.usedPercent, 100);
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
});
test('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => {
const payload = {
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
balance: { credits_remaining_usd: 200 },
key: {
name: 'Prod',
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const subWindow = result.usage!.windows.standard;
assert.ok(subWindow);
assert.ok(Math.abs((subWindow!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
// Allowance window is keyed by the localized period label ("monthly");
// key name flows through valueLabel for identification.
const allowWindow = result.usage!.windows.monthly;
assert.ok(allowWindow);
assert.equal(allowWindow!.usedPercent, 25);
assert.equal(allowWindow!.valueLabel, 'Prod');
assert.equal(allowWindow!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
assert.equal(result.usage!.windows.credits_balance, undefined);
});
test('uses allowance effective limit = min(limit, credits_remaining + spent)', async () => {
const payload = {
balance: { credits_remaining_usd: 30 },
subscription: null,
key: {
name: 'prod-key',
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.monthly;
assert.ok(window);
// effectiveLimit = min(100, 30+25) = 55; usedPercent = 25/55 * 100 ≈ 45.4545
assert.ok(Math.abs((window!.usedPercent as number) - (25 / 55) * 100) < 1e-2);
assert.equal(window!.windowSeconds, 30 * 86400);
assert.equal(window!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
assert.equal(window!.valueLabel, 'prod-key');
assert.equal(result.usage!.windows.credits_balance, undefined);
});
test('binds allowance ceiling to limit when limit < credits_remaining + spent', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'prod-key',
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.monthly;
assert.ok(window);
assert.equal(window!.usedPercent, 25);
});
test('uses weekly as the allowance key when period is weekly', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'Prod',
allowance: { limit_usd: 100, period: 'weekly', spent_usd: 20, blocked: false, reset_at: '2026-07-04T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.weekly;
assert.ok(window);
assert.equal(window!.windowSeconds, 604800);
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
assert.equal(window!.valueLabel, 'Prod');
});
test('uses daily as the allowance key when period is daily', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'Prod',
allowance: { limit_usd: 10, period: 'daily', spent_usd: 2, blocked: false, reset_at: '2026-07-04T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.daily;
assert.ok(window);
assert.equal(window!.windowSeconds, 86400);
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
});
test('falls back to billing_cycle when allowance period is missing or unknown', async () => {
const payload = {
balance: { credits_remaining_usd: 200 },
subscription: null,
key: {
name: 'Prod',
allowance: { limit_usd: 100, period: 'fortnightly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.billing_cycle;
assert.ok(window);
assert.equal(window!.usedPercent, 25);
});
test('marks blocked allowance as 100% with valueLabel set', async () => {
const payload = {
balance: { credits_remaining_usd: 30 },
subscription: null,
key: {
name: 'sample',
allowance: { limit_usd: 50, period: 'monthly', spent_usd: 10, blocked: true, reset_at: '2026-08-01T00:00:00Z' },
},
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
const window = result.usage!.windows.monthly;
assert.ok(window);
assert.equal(window!.usedPercent, 100);
assert.equal(window!.valueLabel, 'sample');
});
test('falls back to credits_balance when neither subscription nor allowance exists', async () => {
const payload = {
balance: { credits_remaining_usd: 32.6774 },
subscription: null,
key: { name: 'sample', allowance: null },
};
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
});
test('maps 401 to session-expired', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, false);
assert.equal(result.error, 'Session expired — please re-authenticate with NeuralWatt');
});
test('reports invalid-response on JSON parse failure', async () => {
globalThis.fetch = (async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('Unexpected token'); },
}) as unknown as Response) as typeof fetch;
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
test('returns no-quota-data on a 200 payload with no usable windows', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
balance: { credits_remaining_usd: null },
subscription: null,
key: { name: 'sample', allowance: null },
})));
const result = await fetchQuotaForProvider('neuralwatt');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'No quota data in response');
assert.equal(result.usage, null);
});
// Restore fs so other test files (which use the real auth file) are unaffected.
test('teardown: restore fs', () => {
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
fsMock.existsSync = ORIGINAL_FS.existsSync;
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
});
});
describe('DeepSeek quota provider (VS Code parity)', () => {
beforeEach(() => {
const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string };
fsMock.existsSync = () => true;
fsMock.readFileSync = () => AUTH;
});
test('builds credits_balance window from documented USD payload (string balance)', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [
{ currency: 'USD', total_balance: '7.54', granted_balance: '0.00', topped_up_balance: '7.54' },
],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'deepseek');
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$7.54');
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
assert.equal(result.usage!.windows.credits_balance!.windowSeconds, null);
assert.equal(result.usage!.windows.credits_balance!.resetAt, null);
});
test('falls back to CNY entry with ¥ symbol when no USD entry is present', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [
{ currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' },
],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '¥100.00');
});
test('maps 401 to session-expired', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, false);
assert.equal(result.error, 'Session expired — please re-authenticate with DeepSeek');
});
test('reports a normalized timeout error', async () => {
stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError')));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, false);
assert.equal(result.error, 'Request timed out');
});
test('returns no-quota-data on a 200 payload with no usable balance', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'No quota data in response');
assert.equal(result.usage, null);
});
test('keeps a literal zero balance as a valid valueLabel', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
is_available: true,
balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }],
})));
const result = await fetchQuotaForProvider('deepseek');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00');
});
test('teardown: restore fs', () => {
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
fsMock.existsSync = ORIGINAL_FS.existsSync;
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
});
});
+494 -36
View File
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
import { readCredential } from './quotaCredentials';
type AuthEntry = Record<string, unknown> | string;
type AuthFile = Record<string, AuthEntry>;
@@ -38,6 +40,13 @@ type OpenAiUsagePayload = {
balance?: number | string;
unlimited?: boolean;
};
spend_control?: {
individual_limit?: {
limit?: number | string;
used?: number | string;
used_percent?: number | string;
};
};
};
type GoogleModelsPayload = {
@@ -110,6 +119,47 @@ type WaferPayload = {
plan_tier?: string;
};
type CrofPayload = {
usable_requests?: number | null;
credits?: number | string;
};
type DeepseekPayload = {
is_available?: boolean;
balance_infos?: Array<{
currency?: string;
total_balance?: number | string;
granted_balance?: number | string;
topped_up_balance?: number | string;
}>;
};
type NeuralwattPayload = {
balance?: {
credits_remaining_usd?: number | string;
};
subscription?: {
plan?: string;
billing_interval?: string;
current_period_start?: string;
current_period_end?: string;
kwh_included?: number | string;
kwh_used?: number | string;
in_overage?: boolean;
kwh_reset_date?: string;
} | null;
key?: {
name?: string;
allowance?: {
limit_usd?: number | string;
period?: string;
spent_usd?: number | string;
blocked?: boolean;
reset_at?: string;
} | null;
};
};
export type ProviderResult = {
providerId: string;
providerName: string;
@@ -123,7 +173,6 @@ export type ProviderResult = {
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
const OLLAMA_CLOUD_COOKIE_PATH = path.join(os.homedir(), '.config', 'ollama-quota', 'cookie');
const ANTIGRAVITY_ACCOUNTS_PATHS = [
@@ -183,7 +232,10 @@ const resolveGoogleWindow = (sourceId: GoogleAuthSource['sourceId'], resetAt: nu
return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS } as const;
};
const ZAI_TOKEN_WINDOW_SECONDS: Record<number, number> = { 3: 3600 };
const ZAI_TOKEN_WINDOW_SECONDS: Record<number, number> = {
3: 60 * 60,
6: 7 * 24 * 60 * 60,
};
const readAuthFile = (): AuthFile => {
if (!fs.existsSync(AUTH_FILE)) {
@@ -219,19 +271,6 @@ const readJsonFile = (filePath: string): Record<string, unknown> | null => {
}
};
const readTextFile = (filePath: string): string | null => {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf8').trim();
return content || null;
} catch (error) {
console.warn(`Failed to read text file: ${filePath}`, error);
return null;
}
};
const getAuthEntry = (auth: AuthFile, aliases: string[]) => {
for (const alias of aliases) {
if (auth[alias]) {
@@ -388,6 +427,9 @@ const durationToSeconds = (duration?: number, unit?: string) => {
export const listConfiguredQuotaProviders = () => {
const auth = readAuthFile();
const configured = new Set<string>();
if (readCredential('opencode-go')) configured.add('opencode-go');
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
if (readCredential('cursor')) configured.add('cursor');
const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude']));
if (anthropicAuth && ((anthropicAuth as Record<string, unknown>).access || (anthropicAuth as Record<string, unknown>).token)) {
@@ -444,15 +486,27 @@ export const listConfiguredQuotaProviders = () => {
configured.add('github-copilot-addon');
}
if (readTextFile(OLLAMA_CLOUD_COOKIE_PATH)) {
configured.add('ollama-cloud');
}
const waferAuth = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai']));
if (waferAuth && ((waferAuth as Record<string, unknown>).key || (waferAuth as Record<string, unknown>).token)) {
configured.add('wafer');
}
const crofAuth = normalizeAuthEntry(getAuthEntry(auth, ['crof']));
if (crofAuth && ((crofAuth as Record<string, unknown>).key || (crofAuth as Record<string, unknown>).token)) {
configured.add('crof');
}
const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt']));
if (neuralwattAuth && ((neuralwattAuth as Record<string, unknown>).key || (neuralwattAuth as Record<string, unknown>).token)) {
configured.add('neuralwatt');
}
const deepseekAuth = normalizeAuthEntry(getAuthEntry(auth, ['deepseek']));
if (deepseekAuth && ((deepseekAuth as Record<string, unknown>).key || (deepseekAuth as Record<string, unknown>).token)) {
configured.add('deepseek');
}
return Array.from(configured);
};
@@ -499,16 +553,18 @@ const fetchCodexQuota = async (): Promise<ProviderResult> => {
const windows: Record<string, UsageWindow> = {};
if (primary) {
windows['5h'] = toUsageWindow({
const windowSeconds = toNumber(primary.limit_window_seconds);
windows[resolveWindowLabel(windowSeconds)] = toUsageWindow({
usedPercent: toNumber(primary.used_percent),
windowSeconds: toNumber(primary.limit_window_seconds),
windowSeconds,
resetAt: toTimestamp(primary.reset_at),
});
}
if (secondary) {
windows['weekly'] = toUsageWindow({
const windowSeconds = toNumber(secondary.limit_window_seconds);
windows[resolveWindowLabel(windowSeconds)] = toUsageWindow({
usedPercent: toNumber(secondary.used_percent),
windowSeconds: toNumber(secondary.limit_window_seconds),
windowSeconds,
resetAt: toTimestamp(secondary.reset_at),
});
}
@@ -527,6 +583,20 @@ const fetchCodexQuota = async (): Promise<ProviderResult> => {
valueLabel,
});
}
if (payload?.spend_control?.individual_limit) {
const spendLimit = payload.spend_control.individual_limit;
const used = toNumber(spendLimit.used);
const limit = toNumber(spendLimit.limit);
const valueLabel = used !== null && limit !== null
? `${used.toFixed(0)} / ${limit.toFixed(0)} used`
: null;
windows.credits = toUsageWindow({
usedPercent: toNumber(spendLimit.used_percent),
windowSeconds: null,
resetAt: null,
valueLabel,
});
}
return buildResult({
providerId: 'codex',
@@ -1082,6 +1152,24 @@ const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
}
};
// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail`
// blocks report `remaining` instead. Neither field is guaranteed present, so
// derive usedPercent from whichever one the API actually returned.
const computeKimiUsedPercent = (
total: number | null,
used: number | null,
remaining: number | null,
): number | null => {
if (!total) return null;
if (used !== null) {
return Math.max(0, Math.min(100, (used / total) * 100));
}
if (remaining !== null) {
return Math.max(0, Math.min(100, 100 - (remaining / total) * 100));
}
return null;
};
const fetchKimiQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record<string, unknown> | null;
@@ -1121,10 +1209,9 @@ const fetchKimiQuota = async (): Promise<ProviderResult> => {
const usage = payload.usage as Record<string, unknown> | undefined;
if (usage) {
const limit = toNumber(usage.limit);
const used = toNumber(usage.used);
const remaining = toNumber(usage.remaining);
const usedPercent = limit && remaining !== null
? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100))
: null;
const usedPercent = computeKimiUsedPercent(limit, used, remaining);
windows.weekly = toUsageWindow({
usedPercent,
windowSeconds: null,
@@ -1140,10 +1227,9 @@ const fetchKimiQuota = async (): Promise<ProviderResult> => {
const windowSeconds = durationToSeconds(window?.duration as number | undefined, window?.timeUnit as string | undefined);
const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel;
const total = toNumber(detail?.limit);
const used = toNumber(detail?.used);
const remaining = toNumber(detail?.remaining);
const usedPercent = total && remaining !== null
? Math.max(0, Math.min(100, 100 - (remaining / total) * 100))
: null;
const usedPercent = computeKimiUsedPercent(total, used, remaining);
windows[label] = toUsageWindow({
usedPercent,
windowSeconds,
@@ -1344,7 +1430,7 @@ const parseOllamaSettingsHtml = (html: string) => {
};
const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
const cookie = readTextFile(OLLAMA_CLOUD_COOKIE_PATH);
const cookie = readCredential('ollama-cloud')?.cookie;
if (!cookie) {
return buildResult({
@@ -1393,6 +1479,19 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
}
};
const fetchCursorQuota = async (): Promise<ProviderResult> => {
const accessToken = readCredential('cursor')?.accessToken;
if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' });
try {
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`);
const payload = await response.json() as Record<string, unknown>;
const plan = (payload.planUsage as Record<string, unknown> | undefined) ?? {};
const usedPercent = toNumber(plan.totalPercentUsed);
return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: true, configured: true, usage: { windows: { billing_cycle: toUsageWindow({ usedPercent, windowSeconds: null, resetAt: toTimestamp(payload.billingCycleEnd) }) } } });
} catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); }
};
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
@@ -1527,14 +1626,13 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
const payload = await response.json() as ZaiPayload;
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
const tokensLimit = limits.find((limit: Record<string, unknown>) => limit?.type === 'TOKENS_LIMIT');
const windowSeconds = resolveWindowSeconds(tokensLimit as Record<string, unknown> | undefined);
const windowLabel = resolveWindowLabel(windowSeconds);
const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null;
const windows: Record<string, UsageWindow> = {};
if (tokensLimit) {
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
const windowSeconds = resolveWindowSeconds(tokensLimit as Record<string, unknown>);
const windowLabel = resolveWindowLabel(windowSeconds);
const resetAt = tokensLimit.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
const usedPercent = typeof tokensLimit.percentage === 'number' ? tokensLimit.percentage : null;
windows[windowLabel] = toUsageWindow({
usedPercent,
windowSeconds,
@@ -1542,6 +1640,15 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
});
}
const mcpToolsTimeLimit = limits.find((limit) => limit?.type === 'TIME_LIMIT');
if (mcpToolsTimeLimit) {
windows['MCP Tools'] = toUsageWindow({
usedPercent: typeof mcpToolsTimeLimit.percentage === 'number' ? mcpToolsTimeLimit.percentage : null,
windowSeconds: 30 * 24 * 60 * 60,
resetAt: mcpToolsTimeLimit.nextResetTime ? normalizeTimestamp(mcpToolsTimeLimit.nextResetTime) : null,
});
}
return buildResult({
providerId: 'zai-coding-plan',
providerName: 'z.ai',
@@ -1862,6 +1969,340 @@ const fetchWaferQuota = async (): Promise<ProviderResult> => {
}
};
const NEURALWATT_QUOTA_URL = 'https://api.neuralwatt.com/v1/quota';
// 30d month / 365d year are fixed approximations; real calendars vary but the
// window is for the UI's progress bar label, not billing decisions.
// Accepts both subscription (month/year) and allowance (monthly/weekly/daily) shapes.
const neuralwattWindowSeconds = (period: string | null | undefined): number | null => {
if (period === 'daily') return 86400;
if (period === 'weekly') return 604800;
if (period === 'monthly' || period === 'month') return 30 * 86400;
if (period === 'yearly' || period === 'year') return 365 * 86400;
return null;
};
const fetchNeuralwattQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt'])) as Record<string, unknown> | null;
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
if (!apiKey) {
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(NEURALWATT_QUOTA_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: true,
error: response.status === 401
? 'Session expired — please re-authenticate with NeuralWatt'
: `API error: ${response.status}`,
});
}
const payload = await response.json() as NeuralwattPayload;
const subscription = payload?.subscription ?? null;
const inOverage = Boolean(subscription?.in_overage);
const allowance = payload?.key?.allowance ?? null;
const keyName = payload?.key?.name ?? null;
const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd);
const windows: Record<string, UsageWindow> = {};
if (subscription) {
const kwhIncluded = toNumber(subscription.kwh_included);
const kwhUsed = toNumber(subscription.kwh_used);
const plan = typeof subscription.plan === 'string' && subscription.plan.trim()
? subscription.plan.trim()
: null;
// Subscription window title is the plan name; subscription limits reset
// monthly even on annual billing plans, but the API exposes no kWh window
// start to derive windowSeconds — pass null rather than fabricating a guess.
const subKey = plan ?? 'plan_limit';
const usedPercent = inOverage
? 100
: (kwhIncluded !== null && kwhIncluded > 0 && kwhUsed !== null
? Math.max(0, Math.min(100, (kwhUsed / kwhIncluded) * 100))
: null);
const subResetAt = toTimestamp(subscription.kwh_reset_date) ?? toTimestamp(subscription.current_period_end);
windows[subKey] = toUsageWindow({
usedPercent,
windowSeconds: null,
resetAt: subResetAt,
});
}
if (allowance) {
const spent = toNumber(allowance.spent_usd);
const limit = toNumber(allowance.limit_usd);
// Credits wallet is reduced by each period's spend before the allowance cap
// bites, so the real ceiling is min(limit, creditsRemaining + spent).
const effectiveSpent = spent ?? 0;
const effectiveLimit = limit !== null && creditsRemaining !== null
? Math.min(limit, creditsRemaining + effectiveSpent)
: (limit ?? creditsRemaining);
const period = typeof allowance.period === 'string' && allowance.period.trim()
? allowance.period.trim()
: null;
const blocked = Boolean(allowance.blocked);
const usedPercent = blocked
? 100
: (spent !== null && effectiveLimit !== null && effectiveLimit > 0
? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100))
: null);
// Window title is the localized period label (daily/weekly/monthly); key
// name is attached via valueLabel for identification (wafer precedent).
const periodKey = (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'month')
? (period === 'month' ? 'monthly' : period)
: 'billing_cycle';
const labelName = typeof keyName === 'string' && keyName.trim() ? keyName.trim() : null;
const resetAt = toTimestamp(allowance.reset_at);
const windowSeconds = period ? neuralwattWindowSeconds(period) : null;
windows[periodKey] = toUsageWindow({
usedPercent,
windowSeconds,
resetAt,
...(labelName ? { valueLabel: labelName } : {}),
});
} else if (creditsRemaining !== null) {
windows.credits_balance = toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel: `$${formatMoney(creditsRemaining)}`,
});
}
if (Object.keys(windows).length === 0) {
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted;
const isParseError = error instanceof SyntaxError;
return buildResult({
providerId: 'neuralwatt',
providerName: 'NeuralWatt',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
const CROF_USAGE_URL = 'https://crof.ai/usage_api/';
const fetchCrofQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['crof'])) as Record<string, unknown> | null;
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
if (!apiKey) {
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(CROF_USAGE_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: false,
configured: true,
error: response.status === 401
? 'Session expired — please re-authenticate with CrofAI'
: `API error: ${response.status}`,
});
}
const payload = await response.json() as CrofPayload;
const credits = toNumber(payload?.credits);
const valueLabel = credits !== null ? `$${formatMoney(credits)}` : null;
const windows: Record<string, UsageWindow> = {
credits: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel,
}),
};
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted;
const isParseError = error instanceof SyntaxError;
return buildResult({
providerId: 'crof',
providerName: 'CrofAI',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance';
const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])) as Record<string, unknown> | null;
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
if (!apiKey) {
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(DEEPSEEK_QUOTA_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: true,
error: response.status === 401 || response.status === 403
? 'Session expired — please re-authenticate with DeepSeek'
: `API error: ${response.status}`,
});
}
const payload = await response.json() as DeepseekPayload;
const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : [];
const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD')
?? balanceInfos.find((info) => info?.currency === 'CNY')
?? null;
const rawBalance = balanceInfo?.total_balance;
const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== ''))
? toNumber(rawBalance)
: null;
if (totalBalance === null) {
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
const symbol = balanceInfo?.currency === 'CNY' ? '¥' : '$';
const windows: Record<string, UsageWindow> = {
credits_balance: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
valueLabel: `${symbol}${formatMoney(totalBalance)}`,
}),
};
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: true,
configured: true,
usage: { windows },
});
} catch (error) {
const isTimeout = error instanceof DOMException && (
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
);
const isParseError = error instanceof SyntaxError;
return buildResult({
providerId: 'deepseek',
providerName: 'DeepSeek',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
export const fetchQuotaForProvider = async (providerId: string): Promise<ProviderResult> => {
switch (providerId) {
case 'claude':
@@ -1892,6 +2333,23 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
return fetchZhipuaiCodingPlanQuota();
case 'wafer':
return fetchWaferQuota();
case 'opencode-go': {
const credential = readCredential('opencode-go') as { workspaceId: string; authCookie: string } | null;
if (!credential) return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: false, error: 'Not configured' });
try {
return buildResult({ providerId, providerName: 'OpenCode Go', ok: true, configured: true, usage: { windows: await fetchOpenCodeGoUsage(credential) } });
} catch (error) {
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
}
case 'cursor':
return fetchCursorQuota();
case 'crof':
return fetchCrofQuota();
case 'deepseek':
return fetchDeepseekQuota();
case 'neuralwatt':
return fetchNeuralwattQuota();
default:
return buildResult({
providerId,
+101
View File
@@ -0,0 +1,101 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import type { OpenCodeManager } from './opencode';
import { openSseProxy } from './sseProxy';
const createManager = (): OpenCodeManager => ({
start: async () => {},
stop: async () => {},
restart: async () => {},
setWorkingDirectory: async (path) => ({ success: true, path }),
getStatus: () => 'connected',
getApiUrl: () => 'http://127.0.0.1:3902',
getOpenCodeAuthHeaders: () => ({}),
getWorkingDirectory: () => '/workspace',
isCliAvailable: () => true,
getDebugInfo: () => ({
mode: 'managed',
status: 'connected',
workingDirectory: '/workspace',
cliAvailable: true,
cliPath: null,
configuredApiUrl: null,
configuredPort: null,
detectedPort: 3902,
apiPrefix: '',
apiPrefixDetected: true,
startCount: 1,
restartCount: 0,
lastStartAt: null,
lastConnectedAt: null,
lastExitCode: null,
serverUrl: 'http://127.0.0.1:3902',
lastReadyElapsedMs: null,
lastReadyAttempts: null,
lastStartAttempts: null,
version: null,
secureConnection: false,
authSource: null,
}),
onStatusChange: (callback) => {
callback('connected');
return { dispose: () => {} };
},
});
describe('VS Code SSE proxy', () => {
test('closes a quiet upstream SSE stream after the stall timeout', async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({}), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
})) as typeof fetch;
const controller = new AbortController();
const proxy = await openSseProxy({
manager: createManager(),
path: '/global/event',
signal: controller.signal,
stallTimeoutMs: 20,
onChunk: () => assert.fail('quiet stream should not emit chunks'),
});
await assert.doesNotReject(proxy.run);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resets the stall timeout when upstream bytes arrive', async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
setTimeout(() => controller.enqueue(new TextEncoder().encode(':first\n\n')), 5);
setTimeout(() => controller.enqueue(new TextEncoder().encode('data: second\n\n')), 15);
},
}), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
})) as typeof fetch;
const chunks: string[] = [];
const controller = new AbortController();
const proxy = await openSseProxy({
manager: createManager(),
path: '/global/event',
signal: controller.signal,
stallTimeoutMs: 18,
onChunk: (chunk) => chunks.push(chunk),
});
await assert.doesNotReject(proxy.run);
assert.deepEqual(chunks, [':first\n\n', 'data: second\n\n']);
} finally {
globalThis.fetch = originalFetch;
}
});
});
+44 -3
View File
@@ -7,6 +7,7 @@ type OpenSseProxyOptions = {
headers?: Record<string, string>;
signal: AbortSignal;
onChunk: (chunk: string) => void;
stallTimeoutMs?: number;
};
type OpenSseProxyResult = {
@@ -22,6 +23,7 @@ const SSE_RESPONSE_HEADERS = {
// SSE reconnect configuration
const MAX_RECONNECTS = 3;
const BASE_RECONNECT_DELAY = 1000; // 1 second
const DEFAULT_UPSTREAM_STALL_TIMEOUT_MS = 20000;
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) => {
if (signal.aborted) {
@@ -117,21 +119,54 @@ const fetchSseResponse = async (
return response;
};
const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk: (chunk: string) => void): Promise<void> => {
const resolveStallTimeoutMs = (value: number | undefined): number => (
Number.isFinite(value) && typeof value === 'number' ? value : DEFAULT_UPSTREAM_STALL_TIMEOUT_MS
);
const pipeSseResponse = async (
response: Response,
signal: AbortSignal,
onChunk: (chunk: string) => void,
stallTimeoutMs?: number,
): Promise<void> => {
if (!response.body) {
throw new Error('OpenCode SSE response missing body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let stalled = false;
let stallTimer: ReturnType<typeof setTimeout> | null = null;
const clearStallTimer = () => {
if (!stallTimer) {
return;
}
clearTimeout(stallTimer);
stallTimer = null;
};
const resetStallTimer = () => {
clearStallTimer();
const timeoutMs = resolveStallTimeoutMs(stallTimeoutMs);
if (timeoutMs <= 0) {
return;
}
stallTimer = setTimeout(() => {
stalled = true;
void reader.cancel().catch(() => {});
}, timeoutMs);
};
try {
resetStallTimer();
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value && value.length > 0) {
resetStallTimer();
const chunk = decoder.decode(value, { stream: true });
if (chunk.length > 0) {
onChunk(chunk);
@@ -143,7 +178,12 @@ const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk:
if (!signal.aborted && remaining.length > 0) {
onChunk(remaining);
}
} catch (error) {
if (!stalled) {
throw error;
}
} finally {
clearStallTimer();
try {
await reader.cancel();
} catch {
@@ -163,6 +203,7 @@ export const openSseProxy = async ({
headers,
signal,
onChunk,
stallTimeoutMs,
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
// Reconnect logic with exponential backoff
let reconnectAttempts = 0;
@@ -208,7 +249,7 @@ export const openSseProxy = async ({
const run = (async () => {
let activeResponse = response;
try {
await pipeSseResponse(activeResponse, signal, onChunk);
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
} catch (error: unknown) {
const cause = (error as { cause?: { code?: string } } | null)?.cause;
@@ -228,7 +269,7 @@ export const openSseProxy = async ({
// Attempt to reconnect
try {
activeResponse = await connect();
await pipeSseResponse(activeResponse, signal, onChunk);
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
return; // Successfully reconnected
} catch (reconnectError) {
console.error('[SSE] Reconnect failed', reconnectError);
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
const source = readFileSync(new URL('./webviewHtml.ts', import.meta.url), 'utf8');
describe('VS Code webview content security policy', () => {
test('allows blob URLs for workers without allowing blob scripts', () => {
const workerSource = source.match(/const workerSrc = ([^\n]+);/)?.[1] ?? '';
const scriptSource = source.match(/const scriptSrc = ([^\n]+);/)?.[1] ?? '';
assert.match(workerSource, /'blob:'/);
assert.doesNotMatch(scriptSource, /'blob:'/);
assert.match(source, /worker-src \$\{workerSrc\}/);
});
});
+4 -1
View File
@@ -68,6 +68,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
// fflate's async browser inflater creates blob-backed workers. Keep blob:
// scoped to worker-src so document decompression works without allowing blob scripts.
const workerSrc = uniqueTokens([webview.cspSource, 'blob:', devServerOrigin]);
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
@@ -84,7 +87,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc};">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc}; worker-src ${workerSrc};">
<style>
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
body {
@@ -0,0 +1,51 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
/**
* When acquireVsCodeApi() returns undefined (broken Cursor/VSCodium webview slot),
* getVSCodeAPI().postMessage used to throw:
* TypeError: Cannot read properties of undefined (reading 'postMessage')
*
* The bridge must fall back to a noop API and fail via normal request timeout instead.
*/
describe('VS Code webview bridge acquireVsCodeApi fallback', () => {
test('does not throw TypeError when acquireVsCodeApi returns undefined', async () => {
const originalWindow = globalThis.window;
const originalAcquire = (globalThis as typeof globalThis & { acquireVsCodeApi?: unknown }).acquireVsCodeApi;
const originalWarn = console.warn;
const warnings: unknown[][] = [];
try {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: new EventTarget(),
});
Object.defineProperty(globalThis, 'acquireVsCodeApi', {
configurable: true,
value: () => undefined,
});
console.warn = (...args: unknown[]) => {
warnings.push(args);
};
const { sendBridgeMessageWithOptions } = await import(`./bridge?acquire-fallback-${Date.now()}`);
const result = await sendBridgeMessageWithOptions('api:proxy', { path: '/health' }, { timeoutMs: 20 }).then(
() => 'resolved' as const,
(error: unknown) => error,
);
assert.ok(result instanceof Error, `expected Error, got ${String(result)}`);
assert.notEqual((result as Error).name, 'TypeError');
assert.match((result as Error).message, /timed out/i);
assert.ok(
warnings.some((entry) => String(entry[0] ?? '').includes('VS Code API unavailable')),
'expected a one-time warning that the VS Code API was unavailable',
);
} finally {
console.warn = originalWarn;
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire });
}
});
});
+14 -1
View File
@@ -21,7 +21,7 @@ describe('VS Code webview bridge requests', () => {
}),
});
const { sendBridgeMessageWithOptions } = await import('./bridge');
const { sendBridgeMessageWithOptions, startSseProxy } = await import('./bridge');
const controller = new AbortController();
controller.abort();
@@ -36,6 +36,19 @@ describe('VS Code webview bridge requests', () => {
assert.ok(result instanceof DOMException);
assert.equal(result.name, 'AbortError');
assert.equal(messages.length, 0);
const startPromise = startSseProxy({ path: '/global/event', streamId: 'sse_webview_1_1' });
const request = messages[0] as { id: string; payload?: { streamId?: string } };
assert.equal(request.payload?.streamId, 'sse_webview_1_1');
globalThis.window.dispatchEvent(new MessageEvent('message', {
data: {
id: request.id,
type: 'api:sse:start',
success: true,
data: { status: 200, headers: {}, streamId: 'sse_webview_1_1' },
},
}));
assert.equal((await startPromise).streamId, 'sse_webview_1_1');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire });
+16 -1
View File
@@ -9,10 +9,24 @@ interface VSCodeAPI {
}
let vscodeApi: VSCodeAPI | null = null;
let noopWarned = false;
const noopVSCodeApi: VSCodeAPI = {
postMessage: (message) => {
// acquireVsCodeApi() can return undefined in broken/non-standard webview slots
// (Cursor after extension update, VSCodium, headless). Drop the message instead
// of throwing TypeError: Cannot read properties of undefined (reading 'postMessage').
if (!noopWarned) {
noopWarned = true;
console.warn('[openchamber] VS Code API unavailable; dropping postMessage', message);
}
},
};
function getVSCodeAPI(): VSCodeAPI {
if (!vscodeApi) {
vscodeApi = acquireVsCodeApi();
const acquired = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined;
vscodeApi = acquired ?? noopVSCodeApi;
}
return vscodeApi;
}
@@ -175,6 +189,7 @@ export type ProxiedSseStartResponse = {
export async function startSseProxy(options: {
path: string;
headers?: Record<string, string>;
streamId?: string;
}): Promise<ProxiedSseStartResponse> {
return sendBridgeMessage<ProxiedSseStartResponse>('api:sse:start', options);
}
+9 -8
View File
@@ -18,6 +18,7 @@ import type {
GeneratedCommitMessage,
GeneratedPullRequestDescription,
GitWorktreeInfo,
GitWorktreeBootstrapStatus,
CreateGitWorktreePayload,
GitWorktreeValidationResult,
GitWorktreeCreateResult,
@@ -41,6 +42,10 @@ import type {
ResetToCommitResponse,
} from '@openchamber/ui/lib/api/types';
const requestWorktreeBootstrapStatus = (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return sendBridgeMessage<GitWorktreeBootstrapStatus>('api:git/worktrees/bootstrap-status', { directory });
};
type GitIdentityStoreState = {
profiles: GitIdentityProfile[];
};
@@ -193,10 +198,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({
});
},
getGitWorktreeBootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
directory,
});
getGitWorktreeBootstrapStatus: async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return requestWorktreeBootstrapStatus(directory);
},
previewGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
@@ -514,10 +517,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({
...(payload || {}),
});
},
bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
directory,
});
bootstrapStatus: async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return requestWorktreeBootstrapStatus(directory);
},
preview: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', {
+13 -6
View File
@@ -9,13 +9,20 @@ import { createVSCodeActionsAPI } from './vscode';
import { createVSCodeGitHubAPI } from './github';
import { createVSCodeNotificationsAPI } from './notifications';
// Stub APIs return sensible defaults instead of throwing
const terminalUnsupported = async (): Promise<never> => {
throw new Error('Terminal is not supported in the VS Code runtime');
};
const createStubTerminalAPI = (): TerminalAPI => ({
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
listShells: terminalUnsupported,
createSession: terminalUnsupported,
connect: (_sessionId, handlers) => {
handlers.onError?.(new Error('Terminal is not supported in the VS Code runtime'), true);
return { close: () => {} };
},
sendInput: terminalUnsupported,
resize: terminalUnsupported,
close: terminalUnsupported,
});
export const createVSCodeAPIs = (): RuntimeAPIs => ({
+2 -2
View File
@@ -15,8 +15,8 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
await openVSCodeExternalUrl(url);
},
async pickFiles(): Promise<unknown> {
return sendBridgeMessage('api:files/pick');
async pickFiles(options): Promise<unknown> {
return sendBridgeMessage('api:files/pick', options);
},
async saveImage(payload: unknown): Promise<unknown> {
+183 -66
View File
@@ -13,6 +13,9 @@ import {
} from '@openchamber/ui/lib/theme/vscode/adapter';
import { getBootstrapMessages, readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n';
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
import { usePermissionStore } from '@openchamber/ui/stores/permissionStore';
import { processVSCodePermissionAutoAccept } from '@openchamber/ui/sync/vscode-permission-auto-accept';
import type { PermissionRequest } from '@opencode-ai/sdk/v2/client';
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
type PanelType = 'chat' | 'agentManager';
@@ -408,15 +411,24 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
});
}
if (normalizedPathname === '/api/notifications/auto-accept' && method === 'POST') {
if (normalizedPathname === '/api/permission-auto-accept' && method === 'GET') {
const snapshot = await sendBridgeMessage('api:permission-auto-accept:get');
return new Response(JSON.stringify(snapshot), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const permissionPolicyMatch = normalizedPathname.match(/^\/api\/permission-auto-accept\/sessions\/([^/]+)$/);
if (permissionPolicyMatch && method === 'PUT') {
const bodyText = await extractBodyText(url, init, method);
const body = bodyText
? JSON.parse(bodyText) as { sessionId?: unknown; enabled?: unknown }
: {};
const result = await sendBridgeMessage<{ success?: boolean }>('api:notifications/auto-accept', body)
.catch(() => ({ success: false }));
return new Response(JSON.stringify(result), {
status: result?.success === false ? 400 : 200,
const body = bodyText ? JSON.parse(bodyText) as { enabled?: unknown } : {};
const snapshot = await sendBridgeMessage('api:permission-auto-accept:set', {
sessionId: decodeURIComponent(permissionPolicyMatch[1]),
enabled: body.enabled,
});
return new Response(JSON.stringify(snapshot), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -520,6 +532,23 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
});
}
// Dictation runs on the OpenChamber web server (WebSocket + worker); the VS
// Code bridge has no server process, so report it deterministically
// unavailable. The mic button hides itself when capture is unsupported.
if (normalizedPathname === '/api/dictation/status' && method === 'GET') {
return new Response(JSON.stringify({ provider: 'local', available: false, reasonCode: 'unsupported_runtime', models: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedPathname.startsWith('/api/dictation/') ) {
return new Response(JSON.stringify({ error: 'Dictation is not available in VS Code runtime' }), {
status: 501,
headers: { 'Content-Type': 'application/json' },
});
}
// Health endpoints: reflect actual connection status
if (pathname === '/health' || pathname === '/api/health') {
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
@@ -964,6 +993,17 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
});
}
if (pathname === '/api/opencode/upgrade-status' && method === 'GET') {
const data = await sendBridgeMessage('api:opencode/upgrade-status');
return jsonResponse(data);
}
if (pathname === '/api/opencode/upgrade' && method === 'POST') {
const body = await extractJsonBody(input, init, method);
const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body);
return jsonResponse(result.body, result.status);
}
if (pathname === '/api/zen/models' && method === 'GET') {
try {
const data = await sendBridgeMessage('api:zen:models');
@@ -1024,6 +1064,19 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
}
}
const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(opencode-go|ollama-cloud|cursor)(?:\/(validate|import))?$/);
if (quotaCredentialMatch) {
try {
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
const bridgeMethod = quotaCredentialMatch[2]?.toUpperCase() || method;
const data = await sendBridgeMessage('api:quota:credentials', { providerId: quotaCredentialMatch[1], method: bridgeMethod, credential: body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
}
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
if (quotaMatch && method === 'GET') {
const providerId = decodeURIComponent(quotaMatch[1]);
@@ -1065,10 +1118,35 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
}
}
// Handle custom provider upsert: PUT /api/provider
if (pathname === '/api/provider' && method === 'PUT') {
try {
const body = await extractJsonBody(input, init, method);
const queryDirectory = url.searchParams.get('directory') || undefined;
const data = await sendBridgeMessage('api:provider:upsert', {
...(body && typeof body === 'object' ? body : {}),
directory: queryDirectory
?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined),
});
if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) {
const message = (data as { error?: string }).error || 'Failed to save provider config';
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify((data as { data?: unknown })?.data ?? data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
return null;
};
const originalFetch = window.fetch.bind(window);
let sseStreamCounter = 0;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
@@ -1107,12 +1185,10 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = { ...headersFromRequest, ...headersFromInit };
if (isSseApiPath(targetUrl.pathname)) {
const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers }));
if (!start.streamId) {
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
}
const streamId = start.streamId;
// Install the listener before the extension opens the upstream stream. A
// reconnect can replay an event immediately, before the start response
// has crossed the VS Code bridge.
const streamId = `sse_webview_${Date.now()}_${++sseStreamCounter}`;
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
const encoder = new TextEncoder();
let unsubscribe: (() => void) | null = null;
@@ -1171,6 +1247,18 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
},
});
let start;
try {
start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers, streamId }));
} catch (error) {
await stream.cancel();
throw error;
}
if (!start.streamId) {
void stream.cancel();
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
}
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
}
@@ -1457,6 +1545,7 @@ onCommand('windowFocusChanged', (payload) => {
});
const readyNotificationCooldowns = new Map<string, number>();
const errorNotificationCooldowns = new Map<string, number>();
const READY_NOTIFICATION_COOLDOWN_MS = 5000;
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
let notificationSettingsSyncPromise: Promise<void> | null = null;
@@ -1490,6 +1579,7 @@ const ensureNotificationSettingsSynced = async () => {
notificationSettingsSyncPromise = import('@/lib/persistence')
.then(({ syncDesktopSettings }) => syncDesktopSettings())
.catch((error) => {
notificationSettingsSyncPromise = null;
console.warn('[OpenChamber] Failed to sync notification settings:', error);
});
}
@@ -1584,7 +1674,7 @@ const fetchLastAssistantMessageText = async (sessionId: string, messageId?: stri
const getNotificationTemplate = (
settings: { notificationTemplates?: Record<string, { title?: string; message?: string }> },
key: 'completion' | 'error' | 'question',
key: 'completion' | 'subtask' | 'error' | 'question',
fallback: { title: string; message: string },
) => {
const candidate = settings.notificationTemplates?.[key];
@@ -1618,8 +1708,14 @@ const getNotificationSessionId = (payload: Record<string, unknown>): string => {
return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session);
};
const getNotificationDirectory = (payload: Record<string, unknown>): string | null => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
return getPayloadString(properties.directory ?? info?.directory) || null;
};
window.addEventListener('openchamber:vscode-notification-event', (event) => {
const detail = (event as CustomEvent<{ payload?: unknown }>).detail;
const detail = (event as CustomEvent<{ directory?: string; payload?: unknown }>).detail;
const payload = detail?.payload;
if (!payload || typeof payload !== 'object') {
return;
@@ -1636,68 +1732,71 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => {
Promise.all([
import('@/stores/useUIStore'),
import('@/stores/permissionStore'),
]).then(async ([{ useUIStore }, { usePermissionStore }]) => {
const localSettings = useUIStore.getState();
]).then(async ([{ useUIStore }]) => {
await ensureNotificationSettingsSynced();
const syncedSettings = useUIStore.getState();
const settings = {
...syncedSettings,
nativeNotificationsEnabled: localSettings.nativeNotificationsEnabled,
notificationMode: localSettings.notificationMode,
notifyOnCompletion: localSettings.notifyOnCompletion,
notifyOnError: localSettings.notifyOnError,
notifyOnQuestion: localSettings.notifyOnQuestion,
notificationTemplates: localSettings.notificationTemplates,
summarizeLastMessage: localSettings.summarizeLastMessage,
summaryThreshold: localSettings.summaryThreshold,
summaryLength: localSettings.summaryLength,
maxLastMessageLength: localSettings.maxLastMessageLength,
};
const settings = useUIStore.getState();
if (!settings.nativeNotificationsEnabled) {
return;
}
const requireHidden = settings.notificationMode !== 'always';
const messageId = getPayloadString(info?.id);
const rawLastMessage = extractNotificationLastMessage(record) || await fetchLastAssistantMessageText(sessionId, messageId);
const error = properties.error;
const errorMessage = getPayloadString(
typeof error === 'object' && error
? (error as { message?: unknown }).message
: error,
);
const rawLastMessage = extractNotificationLastMessage(record)
|| errorMessage
|| await fetchLastAssistantMessageText(sessionId, messageId);
const lastMessage = prepareNotificationLastMessage(
rawLastMessage,
settings,
);
const variables = buildNotificationVariables(record, sessionId, lastMessage);
if (type === 'message.updated' && getPayloadString(info?.role) === 'assistant') {
const finish = getPayloadString(info?.finish);
if (finish === 'stop') {
if (!settings.notifyOnCompletion) return;
const now = Date.now();
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
readyNotificationCooldowns.set(sessionId, now);
const template = getNotificationTemplate(settings, 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
const body = resolveTemplate(template.message, variables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
sessionId,
requireHidden,
});
return;
}
const isAssistantMessage = type === 'message.updated' && getPayloadString(info?.role) === 'assistant';
const finish = isAssistantMessage ? getPayloadString(info?.finish) : '';
const isCompletion = type === 'session.idle' || finish === 'stop';
const isError = type === 'session.error' || finish === 'error';
if (finish === 'error') {
if (!settings.notifyOnError) return;
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
const title = resolveTemplate(template.title, variables) || 'Tool error';
const body = resolveTemplate(template.message, variables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
sessionId,
requireHidden,
});
}
if (isCompletion) {
const session = await opencodeClient.getSession(sessionId, getNotificationDirectory(record)).catch(() => undefined);
if (!session) return;
const isSubtask = Boolean(session?.parentID);
if (isSubtask ? !settings.notifyOnSubtasks : !settings.notifyOnCompletion) return;
const now = Date.now();
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
readyNotificationCooldowns.set(sessionId, now);
const template = getNotificationTemplate(settings, isSubtask ? 'subtask' : 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
const body = resolveTemplate(template.message, variables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
sessionId,
requireHidden,
});
return;
}
if (isError) {
if (!settings.notifyOnError) return;
const now = Date.now();
const lastAt = errorNotificationCooldowns.get(sessionId) ?? 0;
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
errorNotificationCooldowns.set(sessionId, now);
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
const title = resolveTemplate(template.title, variables) || 'Tool error';
const body = resolveTemplate(template.message, variables);
showOpenChamberNotification({
title,
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
sessionId,
requireHidden,
});
return;
}
if (type === 'question.asked') {
@@ -1721,7 +1820,14 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => {
if (type === 'permission.asked') {
if (!settings.notifyOnQuestion) return;
if (usePermissionStore.getState().isSessionAutoAccepting(sessionId)) return;
const requestId = getPayloadString(properties.id);
if (requestId) {
const accepted = await processVSCodePermissionAutoAccept(
properties as unknown as PermissionRequest,
detail?.directory,
);
if (accepted) return;
}
const permission = getPayloadString(properties.permission);
const sessionTitle = getPayloadString(properties.sessionTitle);
const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval';
@@ -1746,6 +1852,17 @@ onCommand('settingsSynced', () => {
});
});
onCommand('permissionAutoAcceptSynced', (payload) => {
if (!payload || typeof payload !== 'object') return;
const snapshot = payload as { sessions?: unknown; revision?: unknown };
const sessions = snapshot.sessions;
if (!sessions || typeof sessions !== 'object') return;
usePermissionStore.getState().applySnapshot({
sessions: sessions as Record<string, boolean>,
revision: typeof snapshot.revision === 'number' ? snapshot.revision : undefined,
});
});
// Listen for active editor file changes from the extension
onCommand('activeEditorFile', (payload) => {
import('@/sync/input-store').then(({ useInputStore }) => {