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:
@@ -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,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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user