Merge remote-tracking branch 'origin/main' into feat/nested-git-repos

# Conflicts:
#	packages/ui/src/components/views/GitView.tsx
#	packages/ui/src/stores/DOCUMENTATION.md
#	packages/ui/src/stores/useGitStore.ts
This commit is contained in:
jaygupta17
2026-08-30 09:48:35 +05:30
640 changed files with 46571 additions and 5636 deletions
+38 -4
View File
@@ -1,15 +1,47 @@
## [Unreleased]
- The chat view no longer stays stuck on its loading screen on slow or remote connections (for example code-server behind a reverse proxy) — the connection status is re-sent until the webview is ready to hear it (thanks @VinciYan).
- Switching sessions is faster: the clicked session highlights at once, and its conversation appears as one finished view — text, tool cards, and the recap together — instead of arriving in pieces with a moment of unstyled code blocks.
- Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors and rejected sends.
- Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later.
## [1.21.1] - 2026-08-29
- **Turkish interface:** OpenChamber can now be used in Turkish (thanks to @fitzgpt).
- `/btw` side questions: a btw session now answers the side question instead of carrying on with the parent's plan, and forks at the last completed turn so a reply that is still streaming is never inherited (thanks to @pocharlies).
- Chat scrolling: with "Follow new content while streaming" off, sending while scrolled up leaves the view where it is; a middle-button pan or Shift+Space stops auto-follow like the wheel does (thanks to @pascalandr); PageUp/PageDown in the prompt box no longer shifts the whole panel up.
- Chat no longer crashes or freezes on: very large tool results, which are capped before rendering (thanks to @JSap0914); a code block with JavaScript template strings that sent the highlighter into endless backtracking (thanks to @makeittech); a diff with a truncated header (thanks to @pascalandr); and a draft or recalled message with Windows line endings, which threw "Selection points outside of document" on every visit (thanks to @yulia-ivashko).
- Chat: a session no longer looks frozen after the webview reloads or is opened late — pending permission and question cards come back (thanks to @yangyaofei) — nor after dismissing the agent's questions and sending a new task (thanks to @bashrusakh).
- Context usage now reports the session cost including everything its subagents spent (thanks to @igorvelho), and undoing or redoing a parent session keeps its subagents at the same point in history (thanks to @alexandrereyes).
- Chat rendering: question prompts render Markdown (thanks to @pascalandr); bare links next to CJK or full-width punctuation no longer absorb it (thanks to @gaojunran); inline code, chips, and model-picker highlights stay readable in high-contrast themes (thanks to @difagume and @bashrusakh); a completed reasoning block shows in full instead of replaying, the text-selection menu stays inside the viewport, and the sticky user-message header no longer fades over the reply (thanks to @makeittech).
- Chat actions: tool cards with a file path get a quick-open button that opens the file in the editor (thanks to @robertoberto); sending without a selected model explains what is missing (thanks to @rvaldemar); `/init` stays in slash-command autocomplete after the conversation starts (thanks to @Dawnfz-Lenfeng); copying a message keeps Markdown spacing (thanks to @ChangeHow); a manually chosen model survives switching between Build and Plan (thanks to @makeittech).
- Composer: pasting a large block of text now offers to attach it as a `pasted-context-N.txt` file instead of flooding the input, with a reference left at the caret; Settings → Chat can make it always attach or always paste inline (thanks to @makeittech).
- Chat: the text the model writes before asking a question is shown right away instead of staying hidden until the turn ends (thanks to @makeittech).
- Chat: when the turn-ending signal from OpenCode is lost, the working spinner now clears within about a second instead of up to ten (thanks to @makeittech).
- Composer: typing three backticks leaves the caret inside the completed code fence, empty inputs keep a visible caret, and platform autocorrect behavior is preserved (thanks to @franzudev, @TTTPOB, and @IbrahimKhan12).
- GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota (thanks to @jakoss).
- Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install and shows OpenCode's own reason when an update is refused (thanks to @mdatsev and @yulia-ivashko).
- "Add Project" now adds the chosen folder to the workspace instead of failing (thanks to @bashrusakh), and the extension starts in the current workspace folder instead of one restored from storage (thanks to @makeittech).
- Multi-Run groups can now contain more than five models (thanks to @tomzx).
- Sidebar: pending permission and question badges are no longer covered by the hover actions (thanks to @makeittech); worktree branch search hides non-matching branches (thanks to @bashrusakh).
- Settings/Providers: after saving an API key or signing in, the provider no longer shows "Credentials missing" with its models hidden until you switch away and back (thanks to @herjarsa).
- Settings: number fields and selects no longer clip at large font sizes (thanks to @makeittech), and Windows skill paths are classified correctly, so disabled and duplicate skills are hidden as intended (thanks to @Ttungx).
- Windows: closing VS Code now stops the managed OpenCode process instead of leaving it running (thanks to @a0000001).
- The extension reuses its OpenCode output channel across managed-server restarts instead of creating duplicates (thanks to @TTTPOB).
## [1.21.0] - 2026-08-26
- **Chat context attachments:** diff and file comments, terminal selections, and linked issues/PRs now show in the conversation as compact context cards — source header, captured content behind an expander, your comment below — instead of raw text inside the message.
- **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type. Add to chat is now Add to input.
- Diff: hovering a line shows a + button that opens a comment for the line; clicking a line or dragging across lines opens the editor for that range. The comment editor and saved-comment cards match the chat's comment style.
- **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type.
- Chat: the view no longer stays stuck on its loading screen on slow or remote connections, including code-server behind a reverse proxy (thanks to @VinciYan).
- Composer: hovering a context chip above the input opens a stacked preview of everything attached, where comments can be edited in place or items removed before sending.
- Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible.
- Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it.
- Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o").
- Permissions: cards answer to the keyboard with Alt+Enter to allow once, Alt+Shift+Enter to allow always, and Alt+Backspace to deny; the keys are printed on the buttons.
- Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks to @ChangeHow).
- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren).
- Chat: OpenCode notices now share one style.
- The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran).
- Chat: the timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran).
## [1.20.0] - 2026-08-23
@@ -44,6 +76,8 @@
- Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context.
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept.
- Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).
- Add Project now adds the chosen folder to the workspace instead of showing a "Failed to add project" toast.
- The model selection menu no longer shows white text on a white highlight when a high-contrast theme is active, so the hovered or selected model stays legible (thanks to @bashrusakh).
## [1.18.4] - 2026-08-14
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "openchamber",
"displayName": "OpenChamber",
"description": "%extension.description%",
"version": "1.20.0",
"version": "1.21.1",
"publisher": "fedaykindev",
"private": true,
"repository": {
@@ -245,7 +245,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.21",
"@opencode-ai/sdk": "1.18.25",
"adm-zip": "^0.6.0",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
+79
View File
@@ -91,3 +91,82 @@ When adding new bridge route families:
1. Prefer creating or extending a domain runtime module under `packages/vscode/src/bridge-*-runtime.ts`.
2. Keep `bridge.ts` focused on delegation order and minimal fallthrough behavior.
3. Inject dependencies into runtimes instead of reaching into unrelated modules directly.
## VS Code surface reachability map
Verified 2026-08-28 against `8f5eb231b`.
Three webview hosts, all rendering `renderVSCodeApp``VSCodeApp`
(`packages/ui/src/apps/VSCodeApp.tsx`):
- `ChatViewProvider.ts` — sidebar view, `panelType: 'chat'`, `viewMode: 'sidebar'`.
- `SessionEditorPanelProvider.ts` — editor tab, `panelType: 'chat'`, `viewMode: 'editor'`.
- `AgentManagerPanelProvider.ts` — editor tab, `panelType: 'agentManager'``AgentManagerView`, no `VSCodeLayout`.
`VSCodeLayout` has exactly three views: `sessions`, `chat`, `settings`
(`packages/ui/src/components/layout/VSCodeLayout.tsx:76`). There is no
`MainLayout`, no `ContextPanel`, and no `ContextPanelRail` in this runtime, so
every surface reached only through those is unreachable.
### Surfaces
| Surface | Status | Mount chain / cut-off |
|---|---|---|
| Chat timeline | MOUNTED | `VSCodeLayout``ChatView``ChatContainer``MessageList` |
| Composer | MOUNTED | `ChatContainer``ChatInput` (model/agent controls, autocomplete, attachments, dictation, GitHub issue/PR pickers, `ReviewFlowDialog`, `PendingChangesBar`) |
| Work status panel | MOUNTED | `ChatContainer``WorkStatusPanel` |
| Permission / question cards | MOUNTED | `ChatContainer``PermissionCard`, `QuestionCard` |
| Timeline dialog | MOUNTED | `ChatContainer``TimelineDialog` |
| Tool output / inline diff preview | MOUNTED | `MessageList``ToolPart`, `ToolOutputDialog` (`DiffViewToggle`, not `DiffView`) |
| Sessions sidebar | MOUNTED | `VSCodeLayout``SessionSidebar` with `mobileVariant hideDirectoryControls` |
| Session dialogs | MOUNTED | `VSCodeLayout``SessionDialogs` |
| Session switcher | MOUNTED | `VSCodeHeader``SessionSwitcherDropdown` |
| MCP dropdown | MOUNTED | `VSCodeHeader` `showMcp``McpDropdown` |
| Context usage / rate limits | MOUNTED | `VSCodeHeader` `showContextUsage` / `showRateLimits``ContextUsageDisplay`, `UsageProgressBar` |
| Agent manager | MOUNTED | `VSCodeApp` `panelType === 'agentManager'``AgentManagerView` |
| Settings | PARTIAL | `VSCodeLayout` → lazy `SettingsView`. `metadata.ts` `isAvailable: (ctx) => !ctx.isVSCode` hides `remote-instances`, `git`, `shortcuts`, `magic-prompts`, `voice`, `tunnel`, `about` |
| Usage / quota page | MOUNTED | `SettingsView``UsagePage` (slug `usage`, no VS Code gate) |
| Notifications settings | MOUNTED | `SettingsView` → slug `notifications` (no VS Code gate) |
| MCP settings | MOUNTED | `SettingsView``McpSidebar` / `McpPage` |
| Agents / commands / skills / plugins / providers / projects settings | MOUNTED | `SettingsView` page registry |
| Worktrees | PARTIAL | Create/remove reachable via `SessionSidebar``NewWorktreeDialog` and `sessionWorktreeMenu`. `WorktreesView` is `MainLayout`-only |
| Git | PARTIAL | Read-only status/branches/log via `useGitStore` in `SessionSidebar`, `ChatInput`, `WorkStatusPrimaryGroup`. Stage/commit/push/history/merge/rebase live in `GitView` + `views/git/*`, cut off with `ContextPanel` |
| Voice / dictation | PARTIAL | `ComposerDictation` renders in `ChatInput`; the `voice` settings page is VS Code-gated |
| Command palette | PARTIAL | `useKeyboardShortcuts` runs from `SyncAppEffects` and `open_command_palette` toggles `isCommandPaletteOpen`, but `CommandPalette` renders only in `MainLayout` — the shortcut opens nothing |
| ContextPanel / project context (notes, todos, plans tabs) | NOT MOUNTED | `ContextPanel`, `ContextPanelRail`, `RightSidebarTabs` imported only by `MainLayout` and `MobileWorkspaceDrawer` |
| Terminal | NOT MOUNTED | `TerminalView` imported only by `ContextPanel` and `MobileWorkspaceDrawer`. `webview/api/index.ts` ships `createStubTerminalAPI()` whose every method throws unsupported |
| Files view | NOT MOUNTED | lazy `FilesView` in `ContextPanel`; `SidebarFilesTree` is `MainLayout`-only |
| Diff view | NOT MOUNTED | lazy `DiffView` in `ContextPanel` |
| Git view | NOT MOUNTED | lazy `GitView` in `ContextPanel` |
| Plan view | NOT MOUNTED | lazy `PlanView` in `ContextPanel`, `ProjectNotesTodoPanel`, `MobileApp` |
| Pull request view | NOT MOUNTED | `PullRequestView` imported only by `ContextPanel` |
| Browser panel | NOT MOUNTED | `BrowserPane` imported only by `ContextPanel`; `RuntimeAPIs` has no browser member in `webview/api/index.ts` |
| Walkthrough | NOT MOUNTED | `WalkthroughView` imported only by `ContextPanel` |
| Archive view | NOT MOUNTED | `ArchiveView` imported only by `MainLayout` |
| Scheduled tasks | NOT MOUNTED | `ScheduledTasksDialog` imported only by `MainLayout` |
| Memory debug panel | NOT MOUNTED | `MemoryDebugPanel` imported only by `App.tsx` (web/desktop root) |
| Mini chat | NOT MOUNTED | `MiniChatLayout` imported only by `ElectronMiniChatApp` |
### Dead bridge surface
Handlers with no reachable caller in the VS Code webview.
| Handler | Why unreachable |
|---|---|
| `api:git/ignore-openchamber` | No reference anywhere in `packages/vscode/webview` |
| `api:git/commit`, `api:git/commit-files`, `api:git/commit-file-diff` | Only `GitView` and `views/git/*` call them |
| `api:git/log` (write paths), `api:git/checkout`, `api:git/checkout-commit`, `api:git/reset-to-commit`, `api:git/revert-commit`, `api:git/cherry-pick` | `views/git/HistoryCommitRow.tsx` only |
| `api:git/merge`, `api:git/merge/abort`, `api:git/merge/continue`, `api:git/rebase`, `api:git/rebase/abort`, `api:git/rebase/continue`, `api:git/conflict-details` | `GitView` only |
| `api:git/push`, `api:git/pull`, `api:git/fetch` | `GitView` and `MobileChangesSurface` only |
| `api:git/diff`, `api:git/file-diff` | `DiffView` only |
| `api:git/pr-description` | `views/git/PullRequestSection.tsx` only |
| `api:git/identity` | `git` settings page is VS Code-gated |
| `api:github/pr:create`, `api:github/pr:merge`, `api:github/pr:ready`, `api:github/pr:update` | `views/git/PullRequestSection.tsx` only. `api:github/pr:status` stays reachable through `useGitHubPrStatusStore` in the sidebar |
| `api:fs:write`, `api:fs:rename`, `api:fs:delete`, `api:fs:reveal`, `api:fs:mkdir` | `FilesView`, `SidebarFilesTree`, `PlanView` only |
| `api:fs:exec` | Terminal API is a throwing stub; no other caller |
Reachable filesystem routes: `api:fs:read` (attachments, config), `api:fs:search`
(`useFileSearchStore` behind composer file mentions), `api:fs:list`, `api:fs:stat`.
Maintenance: reviews, changelog entries, and parity claims consult this map;
whoever mounts or unmounts a surface updates it in the same change.
@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
const executeCommand = mock(async () => undefined);
const updateWorkspaceFolders = mock(async (start, deleteCount, ...foldersToAdd) => {
for (const folder of foldersToAdd) {
currentWorkspaceFolders = [...currentWorkspaceFolders, { name: folder.uri.fsPath.split('/').pop(), uri: folder.uri }];
}
return true;
});
let currentWorkspaceFolders = [];
class Position {
constructor(line, character) {
@@ -19,7 +26,10 @@ class Range {
mock.module('vscode', () => ({
commands: { executeCommand },
workspace: {
workspaceFolders: [],
get workspaceFolders() {
return currentWorkspaceFolders;
},
updateWorkspaceFolders,
},
Uri: {
file: (fsPath) => ({ scheme: 'file', fsPath }),
@@ -65,6 +75,8 @@ const deps = {
describe('VS Code system bridge editor:openFile', () => {
beforeEach(() => {
executeCommand.mockClear();
updateWorkspaceFolders.mockClear();
currentWorkspaceFolders = [];
});
test('uses vscode.open so VS Code can select the notebook editor', async () => {
@@ -97,3 +109,113 @@ describe('VS Code system bridge editor:openFile', () => {
);
});
});
describe('VS Code system bridge api:workspace:addFolder', () => {
beforeEach(() => {
updateWorkspaceFolders.mockClear();
currentWorkspaceFolders = [];
});
test('adds a folder to the workspace and returns the folder list', async () => {
currentWorkspaceFolders = [{ name: 'project-one', uri: { fsPath: '/workspace/project-one' } }];
const response = await handleSystemBridgeMessage({
id: 'add-folder',
type: 'api:workspace:addFolder',
payload: { path: '/home/user/my-project' },
}, undefined, deps);
expect(response).toEqual({
id: 'add-folder',
type: 'api:workspace:addFolder',
success: true,
data: {
workspaceFolders: [
{ name: 'my-project', path: '/home/user/my-project' },
{ name: 'project-one', path: '/workspace/project-one' },
],
},
});
expect(updateWorkspaceFolders).toHaveBeenCalledWith(
1,
null,
{ uri: { scheme: 'file', fsPath: '/home/user/my-project' } },
);
});
test('does not duplicate an already-open workspace folder', async () => {
currentWorkspaceFolders = [{ name: 'project-one', uri: { fsPath: '/workspace/project-one' } }];
const response = await handleSystemBridgeMessage({
id: 'add-existing',
type: 'api:workspace:addFolder',
payload: { path: '/workspace/project-one' },
}, undefined, deps);
expect(response).toEqual({
id: 'add-existing',
type: 'api:workspace:addFolder',
success: true,
data: {
workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }],
},
});
expect(updateWorkspaceFolders).not.toHaveBeenCalled();
});
test('returns an error when VS Code rejects the folder add', async () => {
updateWorkspaceFolders.mockResolvedValue(false);
const response = await handleSystemBridgeMessage({
id: 'add-rejected',
type: 'api:workspace:addFolder',
payload: { path: '/home/user/other' },
}, undefined, deps);
expect(response).toEqual({
id: 'add-rejected',
type: 'api:workspace:addFolder',
success: false,
error: 'Failed to add workspace folder',
});
});
test('dedupes an already-open folder with a lowercase Windows drive letter', async () => {
// VS Code reports workspace folder paths with lowercase drive letters
// (d:\...), while the bridge normalizes the incoming path to uppercase
// (D:\...). The comparison must normalize both sides.
currentWorkspaceFolders = [{ name: 'project-one', uri: { fsPath: 'd:\\work\\project-one' } }];
const response = await handleSystemBridgeMessage({
id: 'add-win-dedupe',
type: 'api:workspace:addFolder',
payload: { path: 'D:\\work\\project-one' },
}, undefined, deps);
expect(response).toEqual({
id: 'add-win-dedupe',
type: 'api:workspace:addFolder',
success: true,
data: {
workspaceFolders: [{ name: 'project-one', path: 'D:\\work\\project-one' }],
},
});
expect(updateWorkspaceFolders).not.toHaveBeenCalled();
});
test('rejects a missing path', async () => {
const response = await handleSystemBridgeMessage({
id: 'add-missing',
type: 'api:workspace:addFolder',
payload: {},
}, undefined, deps);
expect(response).toEqual({
id: 'add-missing',
type: 'api:workspace:addFolder',
success: false,
error: 'Directory path is required',
});
expect(updateWorkspaceFolders).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,8 @@ import { credentialStatus, deleteCredential, importCursorCredential, normalizeCr
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
import { buildDeferredRestartResponse } from './config-mutation-response';
import { normalizeWindowsDriveLetter } from './pathUtils';
import { resolveWorkspaceFolders } from './workspaceResolver';
import type { BridgeContext, BridgeResponse } from './bridge';
type BridgeMessageInput = {
@@ -594,6 +596,41 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:workspace:addFolder': {
try {
// SAFETY: bridge payloads are untrusted JSON from the webview; the
// cast only reads the optional path field, and non-string values fail
// the emptiness check below (or throw inside the try, which the catch
// converts into a clean failure response).
const { path: targetPath } = (payload || {}) as { path?: string };
if (!targetPath || targetPath.trim().length === 0) {
return { id, type, success: false, error: 'Directory path is required' };
}
const folders = vscode.workspace.workspaceFolders ?? [];
const uri = vscode.Uri.file(normalizeWindowsDriveLetter(targetPath.trim()));
// VS Code reports workspace folder paths with lowercase Windows drive
// letters (see pathUtils), so normalize both sides before comparing.
const alreadyAdded = folders.some(
(folder) => normalizeWindowsDriveLetter(folder.uri.fsPath) === uri.fsPath,
);
if (!alreadyAdded) {
const updated = await vscode.workspace.updateWorkspaceFolders(folders.length, null, { uri });
if (!updated) {
return { id, type, success: false, error: 'Failed to add workspace folder' };
}
}
return {
id,
type,
success: true,
data: { workspaceFolders: resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []) },
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: errorMessage };
}
}
case 'vscode:command': {
const { command, args } = (payload || {}) as { command?: string; args?: unknown[] };
if (!command) {
-66
View File
@@ -1,66 +0,0 @@
type CommandCodeCredits = {
credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number };
windowLimits?: {
fiveHour?: { used?: number; cap?: number; resetAt?: number };
weekly?: { used?: number; cap?: number; resetAt?: number };
};
};
type WindowData = { usedPercent: number | null; resetAt: number | null; windowSeconds: number | null; valueLabel: string };
const toWindow = (data: WindowData) => ({
usedPercent: data.usedPercent,
remainingPercent: data.usedPercent === null ? null : Math.max(0, 100 - data.usedPercent),
windowSeconds: data.windowSeconds,
resetAfterSeconds: data.resetAt === null ? null : Math.max(0, Math.floor((data.resetAt - Date.now()) / 1000)),
resetAt: data.resetAt,
resetAtFormatted: null,
resetAfterFormatted: null,
valueLabel: data.valueLabel,
});
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100);
const parseCredits = (value: unknown): CommandCodeCredits | null => {
if (!value || typeof value !== 'object') return null;
const payload = value as CommandCodeCredits;
return payload;
};
const parseOrgId = (value: unknown): string | null | undefined => {
if (!value || typeof value !== 'object') return undefined;
const org = (value as { org?: { id?: unknown } }).org;
return typeof org?.id === 'string' && org.id.trim() ? org.id.trim() : null;
};
const parseCommandCodeCredits = (payload: CommandCodeCredits) => {
const windows: Record<string, ReturnType<typeof toWindow>> = {};
for (const [label, value] of [['monthly_credits', payload.credits?.monthlyCredits], ['purchased_credits', payload.credits?.purchasedCredits], ['free_credits', payload.credits?.freeCredits]] as const) {
if (isFiniteNumber(value)) windows[label] = toWindow({ usedPercent: null, resetAt: null, windowSeconds: null, valueLabel: formatCredits(value) });
}
for (const [label, limit, seconds] of [['5h', payload.windowLimits?.fiveHour, 5 * 60 * 60], ['weekly', payload.windowLimits?.weekly, 7 * 24 * 60 * 60]] as const) {
if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue;
const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null;
windows[label] = toWindow({ usedPercent: Math.min(100, Math.max(0, limit.used / limit.cap * 100)), resetAt, windowSeconds: seconds, valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}` });
}
return windows;
};
const requestJson = async (path: string, apiKey: string): Promise<unknown> => {
const response = await fetch(`https://api.commandcode.ai${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15_000) });
if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed');
if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`);
return response.json().catch(() => null);
};
export const fetchCommandCodeUsage = async (apiKey: string) => {
const orgId = parseOrgId(await requestJson('/alpha/whoami', apiKey));
if (orgId === undefined) throw new Error('Command Code account could not be determined');
const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : '/alpha/billing/credits';
const payload = parseCredits(await requestJson(creditsPath, apiKey));
if (!payload) throw new Error('Command Code usage data could not be parsed');
const windows = parseCommandCodeCredits(payload);
if (!Object.keys(windows).length) throw new Error('Command Code usage data could not be parsed');
return windows;
};
+1 -1
View File
@@ -768,7 +768,7 @@ async function getGitBranchesRaw(directory: string): Promise<GitBranchResult> {
*/
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
const repo = await getRepository(directory);
if (repo) {
try {
await repo.checkout(branch);
@@ -75,16 +75,72 @@ describe('VS Code OpenCode upgrades', () => {
assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test');
});
test('names the latest release when the caller sends no target', async () => {
const { manager } = createManager();
let upgradeBody: unknown;
// SAFETY: the stub answers the only two call shapes this test exercises —
// a URL string and an init bag — which is all `fetch` is used with here.
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = String(input);
if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.23' }));
if (url.includes('api.github.com')) return new Response(JSON.stringify({ tag_name: 'v1.18.23' }));
upgradeBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ success: true, version: '1.18.23' }));
}) as typeof fetch;
assert.equal((await upgradeManagedOpenCode(manager)).status, 200);
assert.deepEqual(upgradeBody, { target: '1.18.23' });
});
test('fails without calling the updater when the latest release cannot be resolved', async () => {
const { manager, getRestartCount } = createManager();
// SAFETY: the stub answers the only call shape this test exercises — a URL
// string — and fails loudly if the updater is reached at all.
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
if (String(input).endsWith('/global/upgrade')) throw new Error('the updater must not be called without a target');
return new Response('nope', { status: 503 });
}) as typeof fetch;
const result = await upgradeManagedOpenCode(manager);
assert.equal(result.status, 502);
assert.equal(result.body.code, 'OPENCODE_UPGRADE_TARGET_UNRESOLVED');
assert.equal(getRestartCount(), 0);
});
test('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => {
const { manager } = createManager();
// SAFETY: the stub ignores its arguments and answers every call with the
// rejection shape under test, so no call signature is misrepresented.
globalThis.fetch = (async () => new Response(
JSON.stringify({ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } }),
{ status: 400 },
)) as typeof fetch;
assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), {
status: 400,
body: { success: false, error: 'Expected a semantic version' },
});
});
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;
let upgradeCalled: () => void = () => {};
const upgradeReached = new Promise<void>((resolve) => { upgradeCalled = resolve; });
globalThis.fetch = ((input: Parameters<typeof fetch>[0]) => {
if (!String(input).endsWith('/global/upgrade')) {
return Promise.resolve(new Response(JSON.stringify({ version: '1.18.9' })));
}
upgradeCalled();
return 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');
await upgradeReached;
release(new Response(JSON.stringify({ success: true })));
assert.equal((await first).status, 200);
});
@@ -77,6 +77,19 @@ const fetchLatestVersion = async (): Promise<string> => {
return versions.sort((left, right) => compareVersions(right, left))[0];
};
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
// which carries no `error` field. Reading only `error` left the user with the
// bare HTTP status text ("Bad Request") and nothing to act on.
const readUpgradeErrorMessage = (
payload: { error?: unknown; message?: unknown; data?: { message?: unknown } } | null,
response: Response,
): string => {
for (const candidate of [payload?.error, payload?.data?.message, payload?.message]) {
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim();
}
return response.statusText || 'Failed to upgrade OpenCode';
};
export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => {
const upgrade = getCapability(manager);
const apiUrl = getApiUrl(manager);
@@ -107,16 +120,33 @@ export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | u
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 requestedTarget = typeof target === 'string' ? target.trim() : '';
const operation = (async (): Promise<UpgradeResult> => {
// The lookup runs inside the operation so the in-flight lock above already
// holds while the release version is resolved.
let targetVersion = requestedTarget;
if (!targetVersion) {
try {
targetVersion = await fetchLatestVersion();
} catch (error) {
return {
status: 502,
body: {
success: false,
code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED',
error: `Could not determine which OpenCode version to install: ${error instanceof Error ? error.message : String(error)}`,
},
};
}
}
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 } : {}),
body: JSON.stringify({ 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' } };
const payload = await response.json().catch(() => null) as { error?: unknown; message?: unknown; data?: { message?: unknown } } | null;
if (!response.ok) return { status: response.status, body: { success: false, error: readUpgradeErrorMessage(payload, response) } };
try {
await manager.restart();
} catch (error) {
+43 -9
View File
@@ -15,6 +15,17 @@ import { applyProviderEnvAliases } from './provider-env-aliases';
const t = vscode.l10n.t;
const READY_CHECK_TIMEOUT_MS = 30000;
// Reuse a single output channel across restarts instead of creating (and
// leaking) a new one on every waitForReady call.
let managerOutputChannel: vscode.OutputChannel | null = null;
function getManagerOutputChannel(): vscode.OutputChannel {
if (!managerOutputChannel) {
managerOutputChannel = vscode.window.createOutputChannel('OpenChamberManager');
}
return managerOutputChannel;
}
const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
.split(';')
.map((ext) => ext.trim().toLowerCase())
@@ -160,6 +171,19 @@ function stripWrappingQuotes(value: string): string {
return trimmed;
}
function killProcessTree(pid: number | undefined): void {
if (!Number.isInteger(pid)) return;
if (process.platform === 'win32') {
try {
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore', timeout: 5000, windowsHide: true,
});
} catch {
// ignore
}
}
}
function appendToPath(dir: string) {
const trimmed = (dir || '').trim();
if (!trimmed) return;
@@ -613,7 +637,6 @@ async function waitForReady(
timeoutMs = 15000,
authHeaders: Record<string, string> = {}
): Promise<ReadyResult> {
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
const start = Date.now();
const candidates = getCandidateBaseUrls(serverUrl);
let attempts = 0;
@@ -641,7 +664,7 @@ async function waitForReady(
}
clearTimeout(timeout);
outputChannel?.appendLine(
getManagerOutputChannel().appendLine(
`Health check to ${url.toString()} returned ${res.status} with body: ${JSON.stringify(body)}`
);
@@ -663,7 +686,7 @@ async function spawnManagedOpenCodeServer(
workingDirectory: string,
port: number,
timeoutMs: number
): Promise<{ url: string; close: () => void }> {
): Promise<{ url: string; close: () => Promise<void> }> {
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, {
@@ -738,17 +761,28 @@ async function spawnManagedOpenCodeServer(
});
// Record this child so a future run can reap it if we crash before teardown.
registerManagedProcess({ pid: child.pid, ownerPid: process.pid, port, binary, runtime: 'vscode' });
const registration = registerManagedProcess({
pid: child.pid,
ownerPid: process.pid,
port,
binary,
runtime: 'vscode',
}).catch(() => {});
return {
url,
close: () => {
close: async () => {
killProcessTree(child.pid);
try {
child.kill('SIGTERM');
} catch {
// ignore
}
unregisterManagedProcess(child.pid);
// Both writes touch the same registry file. Unordered, the removal can
// land before the registration and leave a stale entry pointing at a dead
// pid; awaiting keeps the extension host alive until the file is gone.
await registration;
await unregisterManagedProcess(child.pid).catch(() => {});
},
};
}
@@ -778,7 +812,7 @@ async function allocateManagedOpenCodePort(): Promise<number> {
}
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
let server: { url: string; close: () => void } | null = null;
let server: { url: string; close: () => Promise<void> } | null = null;
let reapedOrphansOnce = false;
let managedApiUrlOverride: string | null = null;
let managedPassword: string | null = null;
@@ -993,7 +1027,7 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
setStatus('connected');
} else {
try {
server.close();
await server.close();
} catch {
// ignore
}
@@ -1033,7 +1067,7 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
if (server) {
try {
server.close();
await server.close();
} catch {
// Ignore close errors
}
+16 -236
View File
@@ -1,236 +1,16 @@
// Managed OpenCode process registry + orphan reaper — VS Code parity copy.
//
// The VS Code extension does NOT bundle the web package, so it cannot import
// the web runtime's registry module. This is a parity implementation that
// reads/writes the SAME on-disk registry directory and uses the SAME algorithm,
// so a process spawned by any runtime (web, desktop, VS Code) can be reaped by
// any other.
//
// Storage is ONE FILE PER SPAWNED PROCESS (`<childPid>.json`) in a registry
// directory — never a single shared JSON file — because multiple runtimes and
// windows run concurrently and a shared file would be clobbered by the
// read-modify-write race. Per-process files mean each instance only ever writes
// or deletes its OWN file.
//
// See packages/web/server/lib/opencode/managed-process-registry.js for the full
// rationale and safety model. In short: we only ever kill pids THIS product
// recorded, re-verified as a live `opencode serve`, and only when their spawner
// is provably gone (reparented to pid 1, or recorded owner pid dead).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
type ManagedProcessEntry = {
pid: number;
ownerPid: number;
port: number | null;
binary: string | null;
runtime: string;
startedAt: string;
};
const resolveRegistryDir = (): string => {
const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
if (override && override.trim()) return override.trim();
return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode');
};
const entryFilePath = (pid: number): string => path.join(resolveRegistryDir(), `${pid}.json`);
const writeEntryFile = (entry: ManagedProcessEntry): void => {
const dir = resolveRegistryDir();
try {
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${entry.pid}.json`);
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
fs.renameSync(tmp, filePath);
} catch {
// Best-effort: a failed registry write must never break spawn/shutdown.
}
};
const readAllEntries = (): Array<{ entry: ManagedProcessEntry; filePath: string }> => {
const dir = resolveRegistryDir();
let names: string[] = [];
try {
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
} catch {
return [];
}
const out: Array<{ entry: ManagedProcessEntry; filePath: string }> = [];
for (const name of names) {
const filePath = path.join(dir, name);
try {
const entry = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (entry && Number.isInteger(entry.pid)) {
out.push({ entry: entry as ManagedProcessEntry, filePath });
} else {
fs.rmSync(filePath, { force: true });
}
} catch {
try { fs.rmSync(filePath, { force: true }); } catch { /* ignore */ }
}
}
return out;
};
export const registerManagedProcess = (input: {
pid: number | undefined;
ownerPid?: number;
port?: number | null;
binary?: string | null;
runtime?: string;
}): void => {
const pid = input.pid;
if (!Number.isInteger(pid)) return;
writeEntryFile({
pid: pid as number,
ownerPid: Number.isInteger(input.ownerPid) ? (input.ownerPid as number) : process.pid,
port: Number.isInteger(input.port as number) ? (input.port as number) : null,
binary: typeof input.binary === 'string' ? input.binary : null,
runtime: typeof input.runtime === 'string' ? input.runtime : 'vscode',
startedAt: new Date().toISOString(),
});
};
export const unregisterManagedProcess = (pid: number | undefined): void => {
if (!Number.isInteger(pid)) return;
try {
fs.rmSync(entryFilePath(pid as number), { force: true });
} catch {
// ignore
}
};
const isPidAlive = (pid: number): boolean => {
if (!Number.isInteger(pid)) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException)?.code === 'EPERM';
}
};
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const readUnixProcInfo = (pid: number): { ppid: number; command: string } | null => {
try {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
const line = (result.stdout || '').trim();
if (!line) return null;
const match = line.match(/^\s*(\d+)\s+(.*)$/);
if (!match) return null;
return { ppid: Number.parseInt(match[1], 10), command: match[2] };
} catch {
return null;
}
};
const readWindowsImageName = (pid: number): string | null => {
try {
const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
return (result.stdout || '').trim() || null;
} catch {
return null;
}
};
const commandIdentifiesOurServer = (command: string, entry: ManagedProcessEntry): boolean => {
if (typeof command !== 'string') return false;
const lower = command.toLowerCase();
if (!lower.includes('opencode') || !lower.includes('serve')) return false;
if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false;
return true;
};
const killOrphan = async (pid: number): Promise<void> => {
if (process.platform === 'win32') {
try {
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
} catch {
// ignore
}
return;
}
const signalTree = (signal: NodeJS.Signals) => {
try { process.kill(-pid, signal); } catch { /* ignore */ }
try { process.kill(pid, signal); } catch { /* ignore */ }
};
signalTree('SIGTERM');
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
await sleep(150);
}
if (isPidAlive(pid)) {
signalTree('SIGKILL');
await sleep(300);
}
};
const processEntry = async (
entry: ManagedProcessEntry,
log?: (message: string) => void,
): Promise<boolean> => {
if (!isPidAlive(entry.pid)) return false;
const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid);
if (process.platform === 'win32') {
const image = readWindowsImageName(entry.pid);
const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode');
if (looksLikeOpencode && ownerGone) {
await killOrphan(entry.pid);
log?.(`[opencode] reaped orphaned process pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
return true;
}
return false;
}
const info = readUnixProcInfo(entry.pid);
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
const orphaned = info.ppid === 1 || ownerGone;
if (!orphaned) return false;
await killOrphan(entry.pid);
log?.(`[opencode] reaped orphaned process pid ${entry.pid} (reparented/owner gone)`);
return true;
};
export const reapOrphanedProcesses = async (
options: { log?: (message: string) => void } = {},
): Promise<{ inspected: number; reaped: number }> => {
const { log } = options;
const records = readAllEntries();
if (records.length === 0) return { inspected: 0, reaped: 0 };
let reaped = 0;
for (const { entry, filePath } of records) {
let drop = false;
try {
const wasReaped = await processEntry(entry, log);
if (wasReaped) reaped += 1;
drop = wasReaped || !isPidAlive(entry.pid);
} catch (error) {
log?.(`[opencode] reap check failed for pid ${entry.pid}: ${error instanceof Error ? error.message : error}`);
}
if (drop) {
try { fs.rmSync(filePath, { force: true }); } catch { /* ignore */ }
}
}
return { inspected: records.length, reaped };
};
/**
* Managed OpenCode process registry + orphan reaper.
*
* Shared with packages/web/server/lib/opencode/managed-process-registry.js via
* esbuild bundling. Keep this module as a thin re-export so web and VS Code
* cannot diverge: a process spawned by any runtime (web, desktop, VS Code) must
* be reapable by any other, which only holds while all runtimes read and write
* the same on-disk registry with the same algorithm.
*
* Callers here pass `runtime: 'vscode'`; the shared module defaults to 'web'.
*/
export {
registerManagedProcess,
unregisterManagedProcess,
reapOrphanedProcesses,
} from '../../web/server/lib/opencode/managed-process-registry.js';
+66 -52
View File
@@ -17,9 +17,9 @@ const AUTH = JSON.stringify({
crof: { key: 'test-token' },
neuralwatt: { key: 'test-token' },
'opencode-go': { key: 'test-token' },
'command-code': { type: 'oauth', access: 'test-token' },
'zai-coding-plan': { key: 'test-token' },
deepseek: { key: 'test-token' },
'github-copilot': { access: 'test-token' },
anthropic: { access: 'test-token', refresh: 'test-refresh' },
});
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
@@ -104,57 +104,6 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
});
});
describe('Command Code quota provider (VS Code parity)', () => {
test('uses the OAuth access token and resolves server-backed limits', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
requests.push({ url, init });
return mockResponse(url.endsWith('/alpha/whoami')
? { org: { id: 'org/a' } }
: { credits: { monthlyCredits: 120 }, windowLimits: { fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 } } });
}) as typeof fetch;
const result = await fetchQuotaForProvider('command-code');
assert.equal(result.ok, true);
assert.deepEqual(requests.map(({ url }) => url), [
'https://api.commandcode.ai/alpha/whoami',
'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa',
]);
assert.equal((requests[0].init?.headers as Record<string, string>).Authorization, 'Bearer test-token');
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '120');
});
test('omits orgId for personal accounts', async () => {
const urls: string[] = [];
globalThis.fetch = (async (url: string) => {
urls.push(url);
return mockResponse(url.endsWith('/alpha/whoami')
? { user: { id: 'user-1' }, org: null }
: { credits: { monthlyCredits: 120 } });
}) as typeof fetch;
const result = await fetchQuotaForProvider('command-code');
assert.equal(result.ok, true);
assert.deepEqual(urls, [
'https://api.commandcode.ai/alpha/whoami',
'https://api.commandcode.ai/alpha/billing/credits',
]);
});
test('formats fractional credit values for display', async () => {
globalThis.fetch = (async (url: string) => mockResponse(url.endsWith('/alpha/whoami')
? { org: null }
: { credits: { monthlyCredits: 69.7947070034 }, windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } } })) as typeof fetch;
const result = await fetchQuotaForProvider('command-code');
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '69.79');
assert.equal(result.usage!.windows['5h']!.valueLabel, '0.21 / 14');
});
});
describe('Crof quota provider (VS Code parity)', () => {
test('reports credits balance as valueLabel with null percent', async () => {
@@ -248,6 +197,71 @@ describe('Codex quota provider (VS Code parity)', () => {
});
});
describe('GitHub Copilot quota provider (VS Code parity)', () => {
test('exposes only premium interactions as the primary usage window', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
chat: { entitlement: 100, remaining: 80 },
completions: { entitlement: 1000, remaining: 900 },
premium_interactions: { entitlement: 300, remaining: 225 },
},
})));
const result = await fetchQuotaForProvider('github-copilot');
assert.equal(result.ok, true);
assert.deepEqual(Object.keys(result.usage!.windows), ['premium_interactions']);
assert.equal(result.usage!.windows.premium_interactions!.usedPercent, 25);
assert.equal(result.usage!.windows.premium_interactions!.valueLabel, '225 / 300 left');
});
test('add-on path mirrors the primary window shaping', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
premium_interactions: { entitlement: 300, remaining: 225 },
},
})));
const result = await fetchQuotaForProvider('github-copilot-addon');
assert.equal(result.ok, true);
assert.deepEqual(Object.keys(result.usage!.windows), ['premium_interactions']);
assert.equal(result.usage!.windows.premium_interactions!.usedPercent, 25);
});
test('reports unlimited plans without a percent', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
premium_interactions: { unlimited: true, entitlement: -1, remaining: -1 },
},
})));
const result = await fetchQuotaForProvider('github-copilot');
assert.equal(result.ok, true);
assert.equal(result.usage!.windows.premium_interactions!.usedPercent, null);
assert.equal(result.usage!.windows.premium_interactions!.valueLabel, 'Unlimited');
});
test('falls back to percent_remaining when entitlement is unusable', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
quota_reset_date: '2026-09-01T00:00:00Z',
quota_snapshots: {
premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 75.5 },
},
})));
const result = await fetchQuotaForProvider('github-copilot');
assert.equal(result.ok, true);
assert.ok(Math.abs(result.usage!.windows.premium_interactions!.usedPercent! - 24.5) < 1e-9);
assert.equal(result.usage!.windows.premium_interactions!.valueLabel, undefined);
});
});
describe('Claude quota provider (VS Code parity)', () => {
test('parses current limits, model-scoped limits, and extra usage', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({
+26 -26
View File
@@ -2,7 +2,6 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
import { fetchCommandCodeUsage } from './commandCodeQuota';
import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials';
import { getProviderAuth, updateProviderAuth } from './opencodeAuth';
@@ -773,9 +772,6 @@ export const listConfiguredQuotaProviders = () => {
const configured = new Set<string>();
const openCodeGoAuth = normalizeAuthEntry(getAuthEntry(auth, ['opencode-go']));
if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go');
const commandCodeAuth = normalizeAuthEntry(getAuthEntry(auth, ['command-code']));
if (commandCodeAuth && (typeof commandCodeAuth.key === 'string' || typeof commandCodeAuth.access === 'string' || typeof commandCodeAuth.token === 'string')) configured.add('command-code');
if (process.env.COMMAND_CODE_API_KEY?.trim()) configured.add('command-code');
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
if (readCredential('cursor')) configured.add('cursor');
@@ -1469,14 +1465,35 @@ const buildCopilotWindows = (payload: Record<string, unknown>) => {
const resetAt = toTimestamp(payload.quota_reset_date);
const windows: Record<string, UsageWindow> = {};
// Mirrors the quota semantics of microsoft/vscode-copilot-chat
// (CopilotUserQuotaInfo): each snapshot carries entitlement, remaining,
// unlimited, and percent_remaining. Unlimited plans report no usable
// entitlement; percent_remaining is a server-computed fallback.
const addWindow = (label: string, snapshot?: Record<string, unknown>) => {
if (!snapshot) return;
if (snapshot.unlimited === true) {
windows[label] = toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt,
valueLabel: 'Unlimited',
});
return;
}
const entitlement = toNumber(snapshot.entitlement);
const remaining = toNumber(snapshot.remaining);
const usedPercent = entitlement && remaining !== null
? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100))
let usedPercent = entitlement !== null && entitlement > 0 && remaining !== null
? Math.min(100, Math.max(0, 100 - (remaining / entitlement) * 100))
: null;
const valueLabel = entitlement !== null && remaining !== null
if (usedPercent === null) {
const percentRemaining = toNumber(snapshot.percent_remaining);
if (percentRemaining !== null) {
usedPercent = Math.min(100, Math.max(0, 100 - percentRemaining));
}
}
const valueLabel = entitlement !== null && entitlement > 0 && remaining !== null
? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left`
: null;
windows[label] = toUsageWindow({
@@ -1487,9 +1504,7 @@ const buildCopilotWindows = (payload: Record<string, unknown>) => {
});
};
addWindow('chat', quota.chat as Record<string, unknown> | undefined);
addWindow('completions', quota.completions as Record<string, unknown> | undefined);
addWindow('premium', quota.premium_interactions as Record<string, unknown> | undefined);
addWindow('premium_interactions', quota.premium_interactions as Record<string, unknown> | undefined);
return windows;
};
@@ -1586,15 +1601,12 @@ const fetchCopilotAddonQuota = async (): Promise<ProviderResult> => {
}
const payload = await response.json() as Record<string, unknown>;
const windows = buildCopilotWindows(payload);
const premium = windows.premium ? { premium: windows.premium } : windows;
return buildResult({
providerId: 'github-copilot-addon',
providerName: 'GitHub Copilot Add-on',
ok: true,
configured: true,
usage: { windows: premium },
usage: { windows: buildCopilotWindows(payload) },
});
} catch (error) {
return buildResult({
@@ -2875,18 +2887,6 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
}
case 'command-code': {
try {
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), ['command-code']));
const stored = typeof entry?.key === 'string' ? entry.key : typeof entry?.access === 'string' ? entry.access : typeof entry?.token === 'string' ? entry.token : null;
const environment = process.env.COMMAND_CODE_API_KEY?.trim() || null;
const apiKey = stored?.trim() || environment;
if (!apiKey) return buildResult({ providerId, providerName: 'Command Code', ok: false, configured: false, error: 'Not configured' });
return buildResult({ providerId, providerName: 'Command Code', ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } });
} catch (error) {
return buildResult({ providerId, providerName: 'Command Code', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
}
case 'cursor':
return fetchCursorQuota();
case 'crof':
+8
View File
@@ -15,6 +15,14 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
await openVSCodeExternalUrl(url);
},
async addWorkspaceFolder(path: string): Promise<Array<{ name: string; path: string }>> {
const result = await sendBridgeMessage<{ workspaceFolders: Array<{ name: string; path: string }> }>(
'api:workspace:addFolder',
{ path },
);
return Array.isArray(result?.workspaceFolders) ? result.workspaceFolders : [];
},
async pickFiles(options): Promise<unknown> {
return sendBridgeMessage('api:files/pick', options);
},