perf: harden sync architecture and modularize runtimes (#803)
* fix: added desktop app background throttling
* perf: add streaming debug metrics panel
- Show streaming performance metrics in the debug panel
- Auto-enable stream profiling while the panel is open
- Add JSON export for sharing UI and VS Code metrics
* perf: batch streaming updates more aggressively
- Buffer message deltas and metadata updates to cut render churn
- Skip no-op part updates before they touch the message store
- Fix the desktop debug panel shortcut binding
* perf: split streaming event handling and coalesce deltas
- Move streaming content events onto a dedicated fast path
- Defer non-critical stream side effects off the hot path
- Merge repeated message delta events before they reach the UI
* perf: isolate streaming rows from chat rerenders
- Memoize chat rows against render-relevant message changes only
- Read live assistant text directly from store to narrow streaming updates
- Split the active streaming entry from the stable message list path
* perf: streamline chat streaming and SSE proxying
- Reduce chat rerenders around the active streaming path
- Simplify server SSE forwarding to avoid duplicate proxy work
* fix: preserve the first streaming text chunk
- Show the initial text chunk immediately before batched deltas arrive
- Bypass batching for the first text or reasoning part update
- Keep later streaming updates buffered for performance
* perf: align streaming/render hot paths with opencode parity
* perf: harden turn/cache stability and stale delta suppression
* fix: stabilize chat rendering and disable timeline interactions
- Disabled timeline dialog access from shortcuts, commands, and chat input
- Reduced chat render churn by simplifying message list and turn staging behavior
- Improved session-switch stability to prevent update-depth crashes
* perf: track static message rerenders during streaming
* perf: reduce sorted-mode activity rerender fanout
* perf: reduce chat rerender fanout and add active-turn metrics
- Reduced sorted-mode rerender coupling by tightening turn context propagation
- Added a metric for static rerenders outside the active turn during streaming
- Exposed new chat render counters in the debug panel for parity tracking
* fix: keep sorted activity mounted while stream grows
* fix: stabilize session and history scroll rendering
* refactor: decouple server routes from index
* refactor: extract fs module from server index
* refactor: move opencode route ownership into module
* refactor: extract notification route registration
* refactor: extract opencode and notification runtimes from index
* refactor: extract settings runtime and complete server modularization pass
* refactor: modularize server config, skills, icons, and tunnel routes
* refactor: extract server modules from monolithic index.js
Split proxy, routes, runtime helpers, and notification emitter
into dedicated modules under packages/web/server/lib/.
* refactor: replace session/message stores with SSE-driven sync layer
Delete ~9200 lines of old architecture (useEventStream, messageStore,
sessionStore, useSessionStore, questionStore, useTodoStore, client SSE).
New sync layer: event pipeline with coalescing + 16ms flush, pure event
reducer, per-directory child stores with LRU eviction, cursor pagination,
optimistic updates, deferred timeline staging, text throttle.
Migrate all UI consumers to sync hooks (useSessionMessages,
useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc).
Strip session-ui-store to UI-only state, delegate SDK ops to
session-actions with abort-if-busy, optimistic store updates, and
response merging for revert/fork/archive/delete.
Add notification-store for SSE-driven session attention tracking,
cross-directory GlobalSessionStatusStore for sidebar indicators,
client-side diff snapshot sanitization to prevent memory bloat,
and revert message filtering via useVisibleSessionMessages.
* feat: notification store, session actions, activity detection
Add notification-store.ts for SSE-driven attention tracking.
Add sanitize.ts to strip diff snapshot memory bloat.
Add session-actions.ts with optimistic revert/fork/archive/delete.
Improve useSessionActivity with incomplete-message fallback.
Delete useServerSessionStatus polling hook.
* fix: add directory param to all SDK calls, fix command/shell/abort routing
All SDK calls in session-actions.ts now pass directory parameter —
required by OpenCode server to scope session operations. Without it,
abort, commands, revert, fork, and other operations returned 500.
Add routeMessage() in session-ui-store for shell mode (session.shell),
slash commands (session.command), and normal prompts. Command lookup
checks both sync child store and useCommandsStore. Handle /compact
locally via session.summarize().
Implement getContextUsage() to restore header context usage display —
reads token counts from last assistant message in sync store.
* refactor: replace custom API proxy with http-proxy-middleware
Remove ~280 lines of custom proxy code: forwardSseRequest,
forwardGenericApiRequest, collectRequestBodyBuffer, header
manipulation, hop-by-hop filtering, SSE block buffering.
Replace with single createProxyMiddleware() call that handles
SSE streaming, large bodies, and timeouts out of the box.
Dynamic router for OpenCode port changes after restarts.
Auth headers injected via proxyReq hook.
Keep: readiness gate, Windows session merge, API prefix detection.
* perf: targeted event draft cloning to fix streaming render cascade
Event handler was eagerly cloning all state slices on every event,
breaking Zustand selector referential equality. During streaming
(~60 events/sec), this caused every subscriber to re-render regardless
of which slice actually changed.
Now only clones fields the specific event type mutates. Also extracts
StatusRowContainer to isolate high-frequency useAssistantStatus
subscription, removes dead messageStreamStatesMap subscription from
ChatContainer, and narrows useAssistantStatus to only track last
assistant message parts.
MessageList renders: 1972 → 296 per streaming session (-85%).
* fix: null safety for sync state slices
Add defensive ?? {} guards on permission, question, session_status,
and message record access. Prevents crashes when child store state
is partially initialized during bootstrap.
* perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking
Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight
dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK
calls. Delay initial PR background tracking by 5s to reduce startup
CPU burst.
* fix: header session lookup across all child stores
Session title and context panel click failed when session belonged to
a different directory than the current child store. Fall back to
getAllSyncSessions() to search all initialized stores.
* chore: bump @opencode-ai/sdk to 1.3.5
* docs: add sync event handling guide
* Optimize session prefetch and improve delete/archive UX
- Add settlement delay to session prefetch to avoid race conditions on
rapid session switches
- Reduce git diff prefetch and session cache limits for better performance
- Implement optimistic UI updates for session delete/archive operations
with proper rollback on failure
- Wire session prefetch hook into SessionSidebar with sync integration
* Add file content cache and sync optimizations
- Wrap FilesAPI with in-memory LRU cache for file content with dual
constraints (entry count and byte size)
- Optimize chat timeline scroll restoration using useLayoutEffect
- Preserve React references in message and part arrays to prevent
unnecessary re-renders when prepending history
- Add session prefetch TTL cache to prevent redundant fetches
- Integrate session prefetch cache clearing with eviction flow
* Improve session sidebar error handling and add diff prefetch filtering
Load active and archived sessions independently using Promise.allSettled
to prevent one failure from blocking the other. Add retry logic to session
API calls and skip large files during diff prefetch to improve performance.
* Replace sendMessage with optimisticSend wrapper
Introduces optimistic UI updates for normal chat messages to provide
instant feedback. Messages appear immediately in the UI while the API
call executes in the background, with automatic rollback on errors.
* perf: split stores, proper optimistic send, fix revert/directory bugs
- split session-ui-store into voice/input/selection/viewport stores
to reduce subscriber re-evaluation during streaming
- wire optimisticSend through useSync shadow Map infrastructure
matching OpenCode's pattern (no heuristic part detection)
- port OpenCode Identifier.ascending ID format for correct sorting
- pass messageID to promptAsync to prevent duplicate messages
- fix worktree directory not propagating to session actions
(dynamic dir() via opencodeClient.getDirectory)
- fix setCurrentSession accepting directoryHint for new sessions
- fix revert not hiding messages (session limit was 5, bumped to match loaded count)
- fix revert optimistic message removal from store
- fix load-more flicker (useLayoutEffect scroll compensation)
- add prefetch TTL cache, file content LRU cache
- add session prefetch for adjacent sessions
- add instant archive/delete (optimistic before SDK call)
- migrate legacy window.__zustand_session_store__ to session-ui-store
- add retry + independent error handling for archived sessions
- add AGENTS.md performance rules
* perf: startup optimization — dedup, caching, light git status, diff rendering gates
- defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines
- cap project git checks concurrency (2), directory status probe (3)
- dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches)
- delay PR tracking 5s, cache 403 search failures per-repo
- coalesce settings PUT (200ms debounce), cache settings GET (2s TTL)
- cache canonical directory resolution (60s TTL)
- persist missing directory status to localStorage (10min TTL)
- light/heavy git status: polling skips numstat+line counting+rev-list
- large diff rendering gate (>500 lines → "render anyway" button)
- tokenization degradation for >500KB files in Pierre
- parallelize main.tsx pre-render awaits
- batch sidebar file tree expanded paths restoration (3 at a time)
- remove bare useConfigStore() subscription in AgentsPage
- sync worktree sandboxes to OpenCode SQLite DB
- fix RightSidebarTabs ternary → explicit tab matching
- defensive guards on sync state (session_status, permission, question, message)
* fix: add defensive guards on remaining sync state field accesses
guard session_status, permission, message, todo, part, config with ?? {}
in useDirectorySync selectors, session-cache, and bootstrap
* fix: add missing directory dep to useCallback in use-sync.ts
* fix: preserve diffStats when light-mode polling overwrites status
* perf: optimize startup git status polling and diff rendering
Preserves diff stats when lightweight polling updates repository status
Reduces startup overhead with smarter git polling and store updates
Adds detailed optimization and migration docs for next performance steps
* fix: keep chat diff stats stable during git status updates
Prevents lightweight git polling from dropping diff statistics
Keeps MessageList diff indicators consistent while status refreshes
Improves reliability of git-aware chat rendering
* fix: user animation replay, queued message variant, startup provider loading
- consume animation ID after first play to prevent re-animation
on neighbor assistant message completion
- capture send config (model/agent/variant) at queue time matching
OpenCode's FollowupDraft pattern instead of re-resolving at send time
- replace one-shot startup recovery effect with polling interval
that retries every 2s until providers and agents load
- fix optimistic bridge to avoid re-render loop (stable ref wrappers)
* chore: update tauri to 2.10.3 and all plugins to latest
- tauri 2.9.4 → 2.10.3
- tauri-build 2.5.3 → 2.5.6
- tauri-plugin-dialog 2.4.2 → 2.6.0
- tauri-plugin-log 2.7.1 → 2.8.0
- tauri-plugin-shell 2.3.3 → 2.3.5
- tauri-plugin-updater 2 (floating) → 2.10.0 (pinned)
- @tauri-apps/api ^2.9.0 → ^2.10.1
- wry 0.53.5 → 0.54.4 (transitive)
* refactor: decouple web server index orchestration runtimes
* fix: align VS Code runtime behavior with web and reduce draft view CPU load
- Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures
- Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling
- Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage
* fix: restore auto-selected file sending in chat input
- Send server-selected files as proper file URLs in the message payload
- Include server-backed attachments in submit flow instead of dropping them
- Restore queued-message attachments through the refactored input store
* fix: restore session model selection consistently on session switch
- Restore agent, model, and variant from the latest loaded user message for each session
- Wait for session messages before applying restored selections to avoid stale or missing state
- Remove legacy session-choice inference paths that caused overlap and instability
* fix: restore permission replies and auto-accept across sessions
- Scope permission and question replies to the target session directory so answers take effect reliably
- Make permission auto-accept immediately handle pending requests and react to new permission prompts
- Keep parent-session handling working for child-session requests through the shared response path
* feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798)
* feat: add reusable fuzzy branch search for worktrees
* chore: drop planning docs from feature branch
* feat: make worktree branch refresh manual
* feat: add configurable session retention action
* refactor: centralize global session state in ui store
* fix: cancel debounced permission push after reply
* docs: clarify global and directory session store architecture
* docs: refine agent development rules and session activity guidance
- Clarify agent code of conduct and durable development patterns
- Add explicit shared-store rerender and live-state guidance
- Narrow session activity fallback to avoid stale working state
* chore: updated .gitignore
---------
Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
8dfe833faf
commit
c9e31a0e6c
@@ -0,0 +1,61 @@
|
||||
# VS Code Backend Modules
|
||||
|
||||
This document describes backend runtime modules used by the VS Code extension bridge (`packages/vscode/src/bridge.ts`).
|
||||
|
||||
## Purpose
|
||||
|
||||
Keep `bridge.ts` as a thin orchestration layer that delegates message handling to cohesive domain runtimes while preserving API behavior.
|
||||
|
||||
## Runtime modules
|
||||
|
||||
- `bridge.ts`
|
||||
- Entry orchestration layer for bridge messages.
|
||||
- Delegates to specialized runtimes in order and handles only unmatched fallthrough cases.
|
||||
|
||||
- `bridge-git-runtime.ts`
|
||||
- Standard Git message handlers.
|
||||
|
||||
- `bridge-git-special-runtime.ts`
|
||||
- Specialized Git flows (`pr-description`, `conflict-details`) and generation helpers.
|
||||
|
||||
- `bridge-git-process-runtime.ts`
|
||||
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution.
|
||||
|
||||
- `bridge-fs-runtime.ts`
|
||||
- Bridge handlers for filesystem-related message routes.
|
||||
- Uses shared FS helpers via injected dependencies.
|
||||
|
||||
- `bridge-fs-helpers-runtime.ts`
|
||||
- Filesystem/path/search helper functions:
|
||||
- path normalization and resolution
|
||||
- directory listing
|
||||
- file search
|
||||
- file read path safety checks
|
||||
- dropped-file parsing and attachment reading
|
||||
- models metadata fetch helper
|
||||
|
||||
- `bridge-localfs-proxy-runtime.ts`
|
||||
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
|
||||
|
||||
- `bridge-proxy-runtime.ts`
|
||||
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
|
||||
|
||||
- `bridge-config-runtime.ts`
|
||||
- Config and skills message handlers (`api:config/*`).
|
||||
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
|
||||
|
||||
- `bridge-settings-runtime.ts`
|
||||
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
|
||||
|
||||
- `bridge-system-runtime.ts`
|
||||
- System/editor/provider/quota/notification/update-check message handlers.
|
||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
|
||||
## Extension guideline
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,595 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
createAgent,
|
||||
createCommand,
|
||||
deleteAgent,
|
||||
deleteCommand,
|
||||
getAgentSources,
|
||||
getCommandSources,
|
||||
updateAgent,
|
||||
updateCommand,
|
||||
type AgentScope,
|
||||
type CommandScope,
|
||||
AGENT_SCOPE,
|
||||
COMMAND_SCOPE,
|
||||
discoverSkills,
|
||||
getSkillSources,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
type SkillScope,
|
||||
type DiscoveredSkill,
|
||||
SKILL_SCOPE,
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
} from './opencodeConfig';
|
||||
import {
|
||||
getSkillsCatalog,
|
||||
scanSkillsRepository as scanSkillsRepositoryFromGit,
|
||||
installSkillsFromRepository as installSkillsFromGit,
|
||||
type SkillsCatalogSourceConfig,
|
||||
} from './skillsCatalog';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type ConfigRuntimeDeps = {
|
||||
readSettings: (ctx?: BridgeContext) => Record<string, unknown>;
|
||||
persistSettings: (changes: Record<string, unknown>, ctx?: BridgeContext) => Promise<Record<string, unknown>>;
|
||||
fetchOpenCodeSkillsFromApi: (ctx: BridgeContext | undefined, workingDirectory?: string) => Promise<DiscoveredSkill[] | null>;
|
||||
clientReloadDelayMs: number;
|
||||
};
|
||||
|
||||
const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => (
|
||||
(typeof directory === 'string' && directory.trim())
|
||||
? directory.trim()
|
||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
|
||||
);
|
||||
|
||||
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
|
||||
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
||||
if (!Array.isArray(rawCatalogs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawCatalogs
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
|
||||
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
|
||||
if (!id || !label || !source) return null;
|
||||
const normalized: SkillsCatalogSourceConfig = {
|
||||
id,
|
||||
label,
|
||||
description: source,
|
||||
source,
|
||||
...(subpath ? { defaultSubpath: subpath } : {}),
|
||||
};
|
||||
return normalized;
|
||||
})
|
||||
.filter((value): value is SkillsCatalogSourceConfig => value !== null);
|
||||
};
|
||||
|
||||
export async function handleConfigBridgeMessage(
|
||||
message: BridgeMessageInput,
|
||||
ctx: BridgeContext | undefined,
|
||||
deps: ConfigRuntimeDeps,
|
||||
): Promise<BridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'api:config/opencode-resolution:get': {
|
||||
const debugInfo = ctx?.manager?.getDebugInfo();
|
||||
const configuredFromWorkspace = vscode.workspace.getConfiguration('openchamber').get<string>('opencodeBinary');
|
||||
const configured = typeof configuredFromWorkspace === 'string' && configuredFromWorkspace.trim().length > 0
|
||||
? configuredFromWorkspace.trim()
|
||||
: null;
|
||||
const resolved = debugInfo?.cliPath ?? null;
|
||||
const source = (() => {
|
||||
if (!resolved) return null;
|
||||
if (configured && configured === resolved) return 'settings';
|
||||
const envBinary = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
|
||||
if (envBinary && envBinary === resolved) return 'env';
|
||||
return 'path';
|
||||
})();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
configured,
|
||||
resolved,
|
||||
resolvedDir: resolved ? path.dirname(resolved) : null,
|
||||
source,
|
||||
detectedNow: resolved,
|
||||
detectedSourceNow: source,
|
||||
shim: null,
|
||||
viaWsl: false,
|
||||
wslBinary: null,
|
||||
wslPath: null,
|
||||
wslDistro: null,
|
||||
node: process.execPath || null,
|
||||
bun: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'api:config/settings:get': {
|
||||
const settings = deps.readSettings(ctx);
|
||||
return { id, type, success: true, data: settings };
|
||||
}
|
||||
|
||||
case 'api:config/settings:save': {
|
||||
const changes = (payload as Record<string, unknown>) || {};
|
||||
const updated = await deps.persistSettings(changes, ctx);
|
||||
return { id, type, success: true, data: updated };
|
||||
}
|
||||
|
||||
case 'api:config/reload': {
|
||||
await ctx?.manager?.restart();
|
||||
return { id, type, success: true, data: { restarted: true } };
|
||||
}
|
||||
|
||||
case 'api:config/agents': {
|
||||
const { method, name, body, directory } = (payload || {}) as {
|
||||
method?: string;
|
||||
name?: string;
|
||||
body?: Record<string, unknown>;
|
||||
directory?: string;
|
||||
};
|
||||
const agentName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!agentName) {
|
||||
return { id, type, success: false, error: 'Agent name is required' };
|
||||
}
|
||||
|
||||
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getAgentSources(agentName, workingDirectory);
|
||||
const scope = sources.md.exists
|
||||
? sources.md.scope
|
||||
: (sources.json.exists ? sources.json.scope : null);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteAgent(agentName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/commands': {
|
||||
const { method, name, body, directory } = (payload || {}) as {
|
||||
method?: string;
|
||||
name?: string;
|
||||
body?: Record<string, unknown>;
|
||||
directory?: string;
|
||||
};
|
||||
const commandName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!commandName) {
|
||||
return { id, type, success: false, error: 'Command name is required' };
|
||||
}
|
||||
|
||||
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getCommandSources(commandName, workingDirectory);
|
||||
const scope = sources.md.exists
|
||||
? sources.md.scope
|
||||
: (sources.json.exists ? sources.json.scope : null);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
|
||||
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteCommand(commandName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/mcp': {
|
||||
const { method, name, body, directory } = (payload || {}) as {
|
||||
method?: string;
|
||||
name?: string;
|
||||
body?: Record<string, unknown>;
|
||||
directory?: string;
|
||||
};
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
const mcpName = typeof name === 'string' ? name.trim() : '';
|
||||
const workingDirectory = resolveWorkingDirectory(ctx, directory);
|
||||
|
||||
if (normalizedMethod === 'GET' && !mcpName) {
|
||||
const configs = listMcpConfigs(workingDirectory);
|
||||
return { id, type, success: true, data: configs };
|
||||
}
|
||||
|
||||
if (!mcpName) {
|
||||
return { id, type, success: false, error: 'MCP server name is required' };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const config = getMcpConfig(mcpName, workingDirectory);
|
||||
if (!config) {
|
||||
return { id, type, success: false, error: `MCP server "${mcpName}" not found` };
|
||||
}
|
||||
return { id, type, success: true, data: config };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scope = body?.scope as 'user' | 'project' | undefined;
|
||||
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" created. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" updated. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteMcpConfig(mcpName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/skills': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
|
||||
if (!name && normalizedMethod === 'GET') {
|
||||
const skills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory);
|
||||
return { id, type, success: true, data: { skills } };
|
||||
}
|
||||
|
||||
const skillName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!skillName) {
|
||||
return { id, type, success: false, error: 'Skill name is required' };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
|
||||
.find((skill) => skill.name === skillName);
|
||||
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source },
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const sourceValue = body?.source as string | undefined;
|
||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
||||
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteSkill(skillName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/skills:catalog': {
|
||||
const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh);
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const settings = deps.readSettings(ctx);
|
||||
const additionalSources = parseSkillsCatalogSources(settings);
|
||||
const installedSkills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined;
|
||||
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
case 'api:config/skills:scan': {
|
||||
const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string };
|
||||
const data = await scanSkillsRepositoryFromGit({
|
||||
source: String(body.source || ''),
|
||||
subpath: body.subpath,
|
||||
});
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
case 'api:config/skills:install': {
|
||||
const body = (payload || {}) as {
|
||||
source?: string;
|
||||
subpath?: string;
|
||||
scope?: 'user' | 'project';
|
||||
targetSource?: 'opencode' | 'agents';
|
||||
selections?: Array<{ skillDir: string }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
};
|
||||
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const data = await installSkillsFromGit({
|
||||
source: String(body.source || ''),
|
||||
subpath: body.subpath,
|
||||
scope: body.scope === 'project' ? 'project' : 'user',
|
||||
targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode',
|
||||
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
|
||||
selections: Array.isArray(body.selections) ? body.selections : [],
|
||||
conflictPolicy: body.conflictPolicy,
|
||||
conflictDecisions: body.conflictDecisions,
|
||||
});
|
||||
|
||||
if (data.ok) {
|
||||
const installed = data.installed || [];
|
||||
const skipped = data.skipped || [];
|
||||
const requiresReload = installed.length > 0;
|
||||
|
||||
if (requiresReload) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
ok: true,
|
||||
installed,
|
||||
skipped,
|
||||
requiresReload,
|
||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||
reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
case 'api:config/skills/files': {
|
||||
const { method, name, filePath, content } = (payload || {}) as {
|
||||
method?: string;
|
||||
name?: string;
|
||||
filePath?: string;
|
||||
content?: string;
|
||||
};
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const skillName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!skillName) {
|
||||
return { id, type, success: false, error: 'Skill name is required' };
|
||||
}
|
||||
|
||||
const relativePath = typeof filePath === 'string' ? filePath.trim() : '';
|
||||
if (!relativePath) {
|
||||
return { id, type, success: false, error: 'File path is required' };
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
|
||||
.find((skill) => skill.name === skillName);
|
||||
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
||||
if (!sources.md.dir) {
|
||||
return { id, type, success: false, error: `Skill "${skillName}" not found` };
|
||||
}
|
||||
|
||||
const skillDir = sources.md.dir;
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const fileContent = readSkillSupportingFile(skillDir, relativePath);
|
||||
if (fileContent === null) {
|
||||
return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` };
|
||||
}
|
||||
return { id, type, success: true, data: { content: fileContent } };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PUT') {
|
||||
writeSkillSupportingFile(skillDir, relativePath, content || '');
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteSkillSupportingFile(skillDir, relativePath);
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { execGit } from './bridge-git-process-runtime';
|
||||
|
||||
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const guessMimeTypeFromExtension = (ext: string) => {
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
case '.gif':
|
||||
case '.bmp':
|
||||
case '.webp':
|
||||
return `image/${ext.replace('.', '')}`;
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
case '.txt':
|
||||
case '.log':
|
||||
return 'text/plain';
|
||||
case '.json':
|
||||
return 'application/json';
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'text/markdown';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value);
|
||||
|
||||
export const parseDroppedFileReference = (rawReference: string):
|
||||
| { uri: vscode.Uri }
|
||||
| { skipped: { name: string; reason: string } } => {
|
||||
const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, '');
|
||||
if (!trimmed) {
|
||||
return { skipped: { name: rawReference, reason: 'Empty drop reference' } };
|
||||
}
|
||||
|
||||
if (hasUriScheme(trimmed)) {
|
||||
try {
|
||||
const parsed = vscode.Uri.parse(trimmed, true);
|
||||
if (parsed.scheme !== 'file') {
|
||||
return {
|
||||
skipped: {
|
||||
name: trimmed,
|
||||
reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { uri: parsed };
|
||||
} catch (error) {
|
||||
return {
|
||||
skipped: {
|
||||
name: trimmed,
|
||||
reason: error instanceof Error ? error.message : 'Invalid URI',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(trimmed)) {
|
||||
return {
|
||||
skipped: {
|
||||
name: trimmed,
|
||||
reason: 'Drop reference is not an absolute file path',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { uri: vscode.Uri.file(trimmed) };
|
||||
};
|
||||
|
||||
export const readUriAsAttachment = async (
|
||||
uri: vscode.Uri,
|
||||
fallbackName?: string,
|
||||
): Promise<
|
||||
| { file: { name: string; mimeType: string; size: number; dataUrl: string } }
|
||||
| { skipped: { name: string; reason: string } }
|
||||
> => {
|
||||
const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file');
|
||||
|
||||
try {
|
||||
const stat = await vscode.workspace.fs.stat(uri);
|
||||
if ((stat.type & vscode.FileType.Directory) !== 0) {
|
||||
return { skipped: { name, reason: 'Folders are not supported' } };
|
||||
}
|
||||
|
||||
const size = stat.size ?? 0;
|
||||
if (size > MAX_FILE_ATTACH_SIZE_BYTES) {
|
||||
return { skipped: { name, reason: 'File exceeds 10MB limit' } };
|
||||
}
|
||||
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const mimeType = guessMimeTypeFromExtension(ext);
|
||||
const base64 = Buffer.from(bytes).toString('base64');
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
return { file: { name, mimeType, size, dataUrl } };
|
||||
} catch (error) {
|
||||
return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } };
|
||||
}
|
||||
};
|
||||
|
||||
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||
const normalizedCandidate = path.resolve(candidatePath);
|
||||
const normalizedParent = path.resolve(parentPath);
|
||||
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
|
||||
};
|
||||
|
||||
export const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
|
||||
|
||||
const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise<Set<string>> => {
|
||||
if (names.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const result = await execGit(['check-ignore', '--', ...names], cwd);
|
||||
if (result.exitCode !== 0 || !result.stdout) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
result.stdout
|
||||
.split('\n')
|
||||
.map((name: string) => name.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise<Set<string>> => {
|
||||
if (paths.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const result = await execGit(['check-ignore', '--', ...paths], cwd);
|
||||
if (result.exitCode !== 0 || !result.stdout) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
result.stdout
|
||||
.split('\n')
|
||||
.map((name: string) => name.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
const expandTildePath = (value: string) => {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const resolveUserPath = (value: string, baseDirectory: string) => {
|
||||
const expanded = expandTildePath(value);
|
||||
if (!expanded) {
|
||||
return expanded;
|
||||
}
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
return path.resolve(baseDirectory, expanded);
|
||||
};
|
||||
|
||||
export const listDirectoryEntries = async (dirPath: string) => {
|
||||
const uri = vscode.Uri.file(dirPath);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
return entries.map(([name, fileType]) => ({
|
||||
name,
|
||||
path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath),
|
||||
isDirectory: fileType === vscode.FileType.Directory,
|
||||
}));
|
||||
};
|
||||
|
||||
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'tmp',
|
||||
'logs',
|
||||
]);
|
||||
|
||||
const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (!includeHidden && name.startsWith('.')) {
|
||||
return true;
|
||||
}
|
||||
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
||||
};
|
||||
|
||||
const fuzzyMatchScore = (query: string, candidate: string): number | null => {
|
||||
if (!query) return 0;
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const c = candidate.toLowerCase();
|
||||
|
||||
if (c.includes(q)) {
|
||||
const idx = c.indexOf(q);
|
||||
let bonus = 0;
|
||||
if (idx === 0) {
|
||||
bonus = 20;
|
||||
} else {
|
||||
const prev = c[idx - 1];
|
||||
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||
bonus = 15;
|
||||
}
|
||||
}
|
||||
return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5);
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
let lastIndex = -1;
|
||||
let consecutive = 0;
|
||||
|
||||
for (let i = 0; i < q.length; i++) {
|
||||
const ch = q[i];
|
||||
if (!ch || ch === ' ') continue;
|
||||
|
||||
const idx = c.indexOf(ch, lastIndex + 1);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gap = idx - lastIndex - 1;
|
||||
if (gap === 0) {
|
||||
consecutive++;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
|
||||
score += 10;
|
||||
score += Math.max(0, 18 - idx);
|
||||
score -= Math.min(gap, 10);
|
||||
|
||||
if (idx === 0) {
|
||||
score += 12;
|
||||
} else {
|
||||
const prev = c[idx - 1];
|
||||
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
score += consecutive > 0 ? 12 : 0;
|
||||
lastIndex = idx;
|
||||
}
|
||||
|
||||
score += Math.max(0, 24 - Math.floor(c.length / 3));
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
const searchFilesystemFiles = async (
|
||||
rootPath: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
includeHidden: boolean,
|
||||
respectGitignore: boolean,
|
||||
timeBudgetMs?: number,
|
||||
) => {
|
||||
const normalizedQuery = (query || '').trim().toLowerCase();
|
||||
const matchAll = normalizedQuery.length === 0;
|
||||
const deadline = typeof timeBudgetMs === 'number' && timeBudgetMs > 0 ? Date.now() + timeBudgetMs : null;
|
||||
|
||||
const rootUri = vscode.Uri.file(rootPath);
|
||||
const queue: vscode.Uri[] = [rootUri];
|
||||
const visited = new Set<string>([normalizeFsPath(rootUri.fsPath)]);
|
||||
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
|
||||
const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = [];
|
||||
const MAX_CONCURRENCY = 10;
|
||||
|
||||
while (queue.length > 0 && candidates.length < collectLimit) {
|
||||
if (deadline && Date.now() > deadline) {
|
||||
break;
|
||||
}
|
||||
const batch = queue.splice(0, MAX_CONCURRENCY);
|
||||
const dirLists = await Promise.all(
|
||||
batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])),
|
||||
);
|
||||
|
||||
for (let index = 0; index < batch.length; index += 1) {
|
||||
if (deadline && Date.now() > deadline) {
|
||||
break;
|
||||
}
|
||||
const currentDir = batch[index];
|
||||
const dirents = dirLists[index];
|
||||
|
||||
const ignoredNames = respectGitignore
|
||||
? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name))
|
||||
: new Set<string>();
|
||||
|
||||
for (const [entryName, entryType] of dirents) {
|
||||
if (!entryName || (!includeHidden && entryName.startsWith('.'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (respectGitignore && ignoredNames.has(entryName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryUri = vscode.Uri.joinPath(currentDir, entryName);
|
||||
const absolute = normalizeFsPath(entryUri.fsPath);
|
||||
|
||||
if (entryType === vscode.FileType.Directory) {
|
||||
if (shouldSkipSearchDirectory(entryName, includeHidden)) {
|
||||
continue;
|
||||
}
|
||||
if (!visited.has(absolute)) {
|
||||
visited.add(absolute);
|
||||
queue.push(entryUri);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entryType !== vscode.FileType.File) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute));
|
||||
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
|
||||
|
||||
if (matchAll) {
|
||||
candidates.push({
|
||||
name: entryName,
|
||||
path: absolute,
|
||||
relativePath,
|
||||
extension,
|
||||
score: 0,
|
||||
});
|
||||
} else {
|
||||
const score = fuzzyMatchScore(normalizedQuery, relativePath);
|
||||
if (score !== null) {
|
||||
candidates.push({
|
||||
name: entryName,
|
||||
path: absolute,
|
||||
relativePath,
|
||||
extension,
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length >= collectLimit) {
|
||||
queue.length = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length >= collectLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchAll) {
|
||||
candidates.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.relativePath.length !== b.relativePath.length) {
|
||||
return a.relativePath.length - b.relativePath.length;
|
||||
}
|
||||
return a.relativePath.localeCompare(b.relativePath);
|
||||
});
|
||||
}
|
||||
|
||||
return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
|
||||
name,
|
||||
path: filePath,
|
||||
relativePath,
|
||||
extension,
|
||||
}));
|
||||
};
|
||||
|
||||
export const searchDirectory = async (
|
||||
directory: string,
|
||||
query: string,
|
||||
limit = 60,
|
||||
includeHidden = false,
|
||||
respectGitignore = true,
|
||||
) => {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const rootPath = directory
|
||||
? resolveUserPath(directory, workspaceRoot)
|
||||
: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
if (!rootPath) return [];
|
||||
|
||||
const sanitizedQuery = query?.trim() || '';
|
||||
if (!sanitizedQuery) {
|
||||
return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore);
|
||||
}
|
||||
|
||||
const escapeGlob = (value: string) => value
|
||||
.replace(/[\\{}()?*]/g, '\\$&')
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]');
|
||||
const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**';
|
||||
const mapResults = (results: vscode.Uri[]) => results.map((file) => {
|
||||
const absolute = normalizeFsPath(file.fsPath);
|
||||
const relative = normalizeFsPath(path.relative(rootPath, absolute));
|
||||
const name = path.basename(absolute);
|
||||
return {
|
||||
name,
|
||||
path: absolute,
|
||||
relativePath: relative || name,
|
||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||
};
|
||||
});
|
||||
const filterGitIgnored = async (results: vscode.Uri[]) => {
|
||||
if (!respectGitignore || results.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const relativePaths = results.map((file) => {
|
||||
const relative = normalizeFsPath(path.relative(rootPath, file.fsPath));
|
||||
return relative || path.basename(file.fsPath);
|
||||
});
|
||||
|
||||
const ignored = await gitCheckIgnorePaths(rootPath, relativePaths);
|
||||
if (ignored.size === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
return results.filter((_, index) => !ignored.has(relativePaths[index]));
|
||||
};
|
||||
|
||||
try {
|
||||
const escapedQuery = escapeGlob(sanitizedQuery);
|
||||
const pattern = `**/*${escapedQuery}*`;
|
||||
const results = await vscode.workspace.findFiles(
|
||||
new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern),
|
||||
exclude,
|
||||
limit,
|
||||
);
|
||||
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
const visible = includeHidden ? results : results.filter((file) => !path.basename(file.fsPath).startsWith('.'));
|
||||
const filtered = await filterGitIgnored(visible);
|
||||
if (filtered.length > 0) {
|
||||
return mapResults(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedQuery.length >= 2 && sanitizedQuery.length <= 32) {
|
||||
const fuzzyPattern = `**/*${escapedQuery.split('').join('*')}*`;
|
||||
const fuzzyResults = await vscode.workspace.findFiles(
|
||||
new vscode.RelativePattern(vscode.Uri.file(rootPath), fuzzyPattern),
|
||||
exclude,
|
||||
limit,
|
||||
);
|
||||
|
||||
if (Array.isArray(fuzzyResults) && fuzzyResults.length > 0) {
|
||||
const visible = includeHidden ? fuzzyResults : fuzzyResults.filter((file) => !path.basename(file.fsPath).startsWith('.'));
|
||||
const filtered = await filterGitIgnored(visible);
|
||||
if (filtered.length > 0) {
|
||||
return mapResults(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to filesystem traversal.
|
||||
}
|
||||
|
||||
return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore, 1500);
|
||||
};
|
||||
|
||||
export const fetchModelsMetadata = async () => {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
|
||||
try {
|
||||
const response = await fetch('https://models.dev/api.json', {
|
||||
signal: controller?.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`models.dev responded with ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
export const getFsMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.markdown': 'text/markdown; charset=utf-8',
|
||||
'.mmd': 'text/plain; charset=utf-8',
|
||||
'.mermaid': 'text/plain; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
return mimeMap[ext] || 'application/octet-stream';
|
||||
};
|
||||
|
||||
export type FsReadPathResolution =
|
||||
| { ok: true; resolvedPath: string }
|
||||
| { ok: false; status: number; error: string };
|
||||
|
||||
export const resolveFileReadPath = async (targetPath: string): Promise<FsReadPathResolution> => {
|
||||
const trimmed = targetPath.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, status: 400, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const baseRoot = getFsAccessRoot();
|
||||
const resolved = resolveUserPath(trimmed, baseRoot);
|
||||
if (!resolved) {
|
||||
return { ok: false, status: 400, error: 'Path is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fs.promises.realpath(resolved),
|
||||
fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)),
|
||||
]);
|
||||
|
||||
if (!isPathInside(canonicalPath, canonicalBase)) {
|
||||
return { ok: false, status: 403, error: 'Access to file denied' };
|
||||
}
|
||||
|
||||
return { ok: true, resolvedPath: canonicalPath };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err?.code === 'ENOENT') {
|
||||
return { ok: false, status: 404, error: 'File not found' };
|
||||
}
|
||||
return { ok: false, status: 500, error: 'Failed to resolve file path' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,419 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import type { BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type FsAttachment = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
type SkippedAttachment = {
|
||||
name: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type DroppedReferenceParse =
|
||||
| { uri: vscode.Uri }
|
||||
| { skipped: SkippedAttachment };
|
||||
|
||||
type ReadUriAsAttachmentResult =
|
||||
| { file: FsAttachment }
|
||||
| { skipped: SkippedAttachment };
|
||||
|
||||
type DirectoryEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type FsDeps = {
|
||||
resolveUserPath: (value: string, baseDirectory: string) => string;
|
||||
listDirectoryEntries: (directoryPath: string) => Promise<DirectoryEntry[]>;
|
||||
normalizeFsPath: (value: string) => string;
|
||||
execGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
searchDirectory: (
|
||||
directory: string,
|
||||
query: string,
|
||||
limit: number | undefined,
|
||||
includeHidden: boolean,
|
||||
respectGitignore: boolean,
|
||||
) => Promise<Array<{ name: string; path: string; relativePath: string; extension: string | undefined }>>;
|
||||
resolveFileReadPath: (inputPath: string) => Promise<
|
||||
| { ok: true; resolvedPath: string }
|
||||
| { ok: false; status: number; error: string }
|
||||
>;
|
||||
parseDroppedFileReference: (rawReference: string) => DroppedReferenceParse;
|
||||
readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise<ReadUriAsAttachmentResult>;
|
||||
};
|
||||
|
||||
export async function handleFsBridgeMessage(
|
||||
message: BridgeMessageInput,
|
||||
deps: FsDeps,
|
||||
): Promise<BridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'files:list': {
|
||||
const { path: dirPath } = payload as { path: string };
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = deps.resolveUserPath(dirPath, workspaceRoot);
|
||||
const uri = vscode.Uri.file(resolvedPath);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
const result = entries.map(([name, fileType]) => ({
|
||||
name,
|
||||
path: vscode.Uri.joinPath(uri, name).fsPath,
|
||||
isDirectory: fileType === vscode.FileType.Directory,
|
||||
}));
|
||||
return { id, type, success: true, data: { directory: deps.normalizeFsPath(resolvedPath), entries: result } };
|
||||
}
|
||||
|
||||
case 'files:search': {
|
||||
const { query, maxResults = 50 } = payload as { query: string; maxResults?: number };
|
||||
const pattern = `**/*${query}*`;
|
||||
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults);
|
||||
const results = files.map((file) => ({
|
||||
path: file.fsPath,
|
||||
}));
|
||||
return { id, type, success: true, data: results };
|
||||
}
|
||||
|
||||
case 'workspace:folder': {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
return { id, type, success: true, data: { folder } };
|
||||
}
|
||||
|
||||
case 'config:get': {
|
||||
const { key } = payload as { key: string };
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const value = config.get(key);
|
||||
return { id, type, success: true, data: { value } };
|
||||
}
|
||||
|
||||
case 'api:fs:list': {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean };
|
||||
const target = targetPath || workspaceRoot;
|
||||
const resolvedPath = deps.resolveUserPath(target, workspaceRoot) || workspaceRoot;
|
||||
|
||||
const entries = await deps.listDirectoryEntries(resolvedPath);
|
||||
const normalized = deps.normalizeFsPath(resolvedPath);
|
||||
|
||||
if (!respectGitignore) {
|
||||
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
||||
}
|
||||
|
||||
const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean);
|
||||
if (pathsToCheck.length === 0) {
|
||||
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.execGit(['check-ignore', '--', ...pathsToCheck], normalized);
|
||||
const ignoredNames = new Set(
|
||||
result.stdout
|
||||
.split('\n')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name));
|
||||
return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } };
|
||||
} catch {
|
||||
return { id, type, success: true, data: { entries, directory: normalized, path: normalized } };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:search': {
|
||||
const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as {
|
||||
directory?: string;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
includeHidden?: boolean;
|
||||
respectGitignore?: boolean;
|
||||
};
|
||||
const files = await deps.searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false);
|
||||
return { id, type, success: true, data: { files } };
|
||||
}
|
||||
|
||||
case 'api:fs:mkdir': {
|
||||
const target = (payload as { path: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = deps.resolveUserPath(target, workspaceRoot);
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath));
|
||||
return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } };
|
||||
}
|
||||
|
||||
case 'api:fs/home': {
|
||||
return { id, type, success: true, data: { home: deps.normalizeFsPath(os.homedir()) } };
|
||||
}
|
||||
|
||||
case 'api:fs:read': {
|
||||
const target = (payload as { path: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const resolution = await deps.resolveFileReadPath(target);
|
||||
if (!resolution.ok) {
|
||||
return { id, type, success: false, error: resolution.error };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8');
|
||||
return { id, type, success: true, data: { content, path: deps.normalizeFsPath(resolution.resolvedPath) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read file';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:write': {
|
||||
const { path: targetPath, content } = (payload as { path: string; content: string }) || {};
|
||||
if (!targetPath) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
return { id, type, success: false, error: 'Content is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot);
|
||||
const uri = vscode.Uri.file(resolvedPath);
|
||||
const parentUri = vscode.Uri.file(path.dirname(resolvedPath));
|
||||
try {
|
||||
await vscode.workspace.fs.createDirectory(parentUri);
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
}
|
||||
await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8'));
|
||||
return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write file';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:delete': {
|
||||
const targetPath = (payload as { path: string })?.path;
|
||||
if (!targetPath) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot);
|
||||
const uri = vscode.Uri.file(resolvedPath);
|
||||
await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false });
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete file';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:rename': {
|
||||
const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {};
|
||||
if (!oldPath) {
|
||||
return { id, type, success: false, error: 'oldPath is required' };
|
||||
}
|
||||
if (!newPath) {
|
||||
return { id, type, success: false, error: 'newPath is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedOld = deps.resolveUserPath(oldPath, workspaceRoot);
|
||||
const resolvedNew = deps.resolveUserPath(newPath, workspaceRoot);
|
||||
const oldUri = vscode.Uri.file(resolvedOld);
|
||||
const newUri = vscode.Uri.file(resolvedNew);
|
||||
await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false });
|
||||
return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedNew) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to rename file';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:exec': {
|
||||
const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {};
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
return { id, type, success: false, error: 'Commands array is required' };
|
||||
}
|
||||
if (!cwd) {
|
||||
return { id, type, success: false, error: 'Working directory (cwd) is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedCwd = deps.resolveUserPath(cwd, workspaceRoot);
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
||||
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
||||
|
||||
const augmentedEnv = {
|
||||
...process.env,
|
||||
PATH: process.env.PATH,
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (typeof cmd !== 'string' || !cmd.trim()) {
|
||||
results.push({ command: cmd, success: false, error: 'Invalid command' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, {
|
||||
cwd: resolvedCwd,
|
||||
env: augmentedEnv,
|
||||
timeout: 300000,
|
||||
});
|
||||
results.push({
|
||||
command: cmd,
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: (stdout || '').trim(),
|
||||
stderr: (stderr || '').trim(),
|
||||
});
|
||||
} catch (execError) {
|
||||
const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string };
|
||||
results.push({
|
||||
command: cmd,
|
||||
success: false,
|
||||
exitCode: typeof err.code === 'number' ? err.code : 1,
|
||||
stdout: (err.stdout || '').trim(),
|
||||
stderr: (err.stderr || '').trim(),
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allSucceeded = results.every((r) => r.success);
|
||||
return { id, type, success: true, data: { success: allSucceeded, results } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to execute commands';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:files/pick': {
|
||||
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
||||
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
|
||||
|
||||
const picks = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: allowMany,
|
||||
defaultUri,
|
||||
openLabel: 'Attach',
|
||||
});
|
||||
|
||||
if (!picks || picks.length === 0) {
|
||||
return { id, type, success: true, data: { files: [], skipped: [] } };
|
||||
}
|
||||
|
||||
const files: FsAttachment[] = [];
|
||||
const skipped: SkippedAttachment[] = [];
|
||||
for (const uri of picks) {
|
||||
const result = await deps.readUriAsAttachment(uri, path.basename(uri.fsPath || uri.path || uri.toString()));
|
||||
if ('file' in result) {
|
||||
files.push(result.file);
|
||||
} else {
|
||||
skipped.push(result.skipped);
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, success: true, data: { files, skipped } };
|
||||
}
|
||||
|
||||
case 'api:files/drop': {
|
||||
const references = (payload as { uris?: string[] })?.uris;
|
||||
const sourceUris = Array.isArray(references) ? references.filter((entry) => typeof entry === 'string') : [];
|
||||
if (sourceUris.length === 0) {
|
||||
return { id, type, success: true, data: { files: [], skipped: [] } };
|
||||
}
|
||||
|
||||
const files: FsAttachment[] = [];
|
||||
const skipped: SkippedAttachment[] = [];
|
||||
const dedupedUris = Array.from(new Set(sourceUris.map((entry) => entry.trim()).filter(Boolean)));
|
||||
|
||||
for (const rawUri of dedupedUris) {
|
||||
const parsed = deps.parseDroppedFileReference(rawUri);
|
||||
if ('skipped' in parsed) {
|
||||
skipped.push(parsed.skipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
const uri = parsed.uri;
|
||||
const name = path.basename(uri.fsPath || uri.path || rawUri);
|
||||
|
||||
const result = await deps.readUriAsAttachment(uri, name);
|
||||
if ('file' in result) {
|
||||
files.push(result.file);
|
||||
} else {
|
||||
skipped.push(result.skipped);
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, success: true, data: { files, skipped } };
|
||||
}
|
||||
|
||||
case 'api:files/save-image': {
|
||||
const rawFileName = (payload as { fileName?: unknown })?.fileName;
|
||||
const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl;
|
||||
const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : '';
|
||||
if (!dataUrl.startsWith('data:image/')) {
|
||||
return { id, type, success: false, error: 'Invalid image payload' };
|
||||
}
|
||||
|
||||
const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0
|
||||
? rawFileName.trim()
|
||||
: `message-${Date.now()}.png`;
|
||||
|
||||
const saveUri = await vscode.window.showSaveDialog({
|
||||
saveLabel: 'Save image',
|
||||
defaultUri: vscode.workspace.workspaceFolders?.[0]
|
||||
? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName)
|
||||
: undefined,
|
||||
filters: { Images: ['png'] },
|
||||
});
|
||||
|
||||
if (!saveUri) {
|
||||
return { id, type, success: true, data: { saved: false, canceled: true } };
|
||||
}
|
||||
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
if (commaIndex === -1) {
|
||||
return { id, type, success: false, error: 'Invalid image data URL' };
|
||||
}
|
||||
|
||||
const base64 = dataUrl.slice(commaIndex + 1);
|
||||
const bytes = Buffer.from(base64, 'base64');
|
||||
await vscode.workspace.fs.writeFile(saveUri, bytes);
|
||||
|
||||
return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawn, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||
|
||||
const isSocketPath = async (candidate: string): Promise<boolean> => {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.stat(candidate);
|
||||
return typeof stat.isSocket === 'function' && stat.isSocket();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSshAuthSock = async (): Promise<string | undefined> => {
|
||||
const existing = (process.env.SSH_AUTH_SOCK || '').trim();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh');
|
||||
if (await isSocketPath(gpgSock)) {
|
||||
return gpgSock;
|
||||
}
|
||||
|
||||
const runGpgconf = async (args: string[]): Promise<string> => {
|
||||
for (const candidate of gpgconfCandidates) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(candidate, args);
|
||||
return String(stdout || '');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
|
||||
if (candidate && await isSocketPath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (candidate) {
|
||||
await runGpgconf(['--launch', 'gpg-agent']);
|
||||
const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
|
||||
if (retried && await isSocketPath(retried)) {
|
||||
return retried;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildGitEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
||||
if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) {
|
||||
const resolved = await resolveSshAuthSock();
|
||||
if (resolved) {
|
||||
env.SSH_AUTH_SOCK = resolved;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
};
|
||||
|
||||
export const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
||||
const env = await buildGitEnv();
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('git', args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 });
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
import * as gitService from './gitService';
|
||||
import type { BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
const requireDirectory = (id: string, type: string, directory?: string): BridgeResponse | null => {
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function handleStandardGitBridgeMessage(message: BridgeMessageInput): Promise<BridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'api:git/check': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const isRepo = await gitService.checkIsGitRepository(directory!);
|
||||
return { id, type, success: true, data: isRepo };
|
||||
}
|
||||
|
||||
case 'api:git/worktree-type': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const isLinked = await gitService.isLinkedWorktree(directory!);
|
||||
return { id, type, success: true, data: isLinked };
|
||||
}
|
||||
|
||||
case 'api:git/status': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const status = await gitService.getGitStatus(directory!);
|
||||
return { id, type, success: true, data: status };
|
||||
}
|
||||
|
||||
case 'api:git/branches': {
|
||||
const { directory, method, name, startPoint, force } = (payload || {}) as {
|
||||
directory?: string;
|
||||
method?: string;
|
||||
name?: string;
|
||||
startPoint?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
|
||||
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const branches = await gitService.getGitBranches(directory!);
|
||||
return { id, type, success: true, data: branches };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
if (!name) {
|
||||
return { id, type, success: false, error: 'Branch name is required' };
|
||||
}
|
||||
const result = await gitService.createBranch(directory!, name, startPoint);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
if (!name) {
|
||||
return { id, type, success: false, error: 'Branch name is required' };
|
||||
}
|
||||
const result = await gitService.deleteGitBranch(directory!, name, force);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:git/remote-branches': {
|
||||
const { directory, branch, remote } = (payload || {}) as {
|
||||
directory?: string;
|
||||
branch?: string;
|
||||
remote?: string;
|
||||
};
|
||||
if (!directory || !branch) {
|
||||
return { id, type, success: false, error: 'Directory and branch are required' };
|
||||
}
|
||||
const result = await gitService.deleteRemoteBranch(directory, branch, remote);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/checkout': {
|
||||
const { directory, branch } = (payload || {}) as { directory?: string; branch?: string };
|
||||
if (!directory || !branch) {
|
||||
return { id, type, success: false, error: 'Directory and branch are required' };
|
||||
}
|
||||
const result = await gitService.checkoutBranch(directory, branch);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/worktrees': {
|
||||
const { directory, method } = (payload || {}) as {
|
||||
directory?: string;
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
directoryPath?: string;
|
||||
deleteLocalBranch?: boolean;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
|
||||
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const worktrees = await gitService.listGitWorktrees(directory!);
|
||||
return { id, type, success: true, data: worktrees };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const created = await gitService.createWorktree(directory!, (payload || {}) as gitService.CreateGitWorktreePayload);
|
||||
return { id, type, success: true, data: created };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
const removePayload = payload as {
|
||||
body?: { directory?: string; deleteLocalBranch?: boolean };
|
||||
directory?: string;
|
||||
deleteLocalBranch?: boolean;
|
||||
};
|
||||
const bodyDirectory = typeof removePayload?.body?.directory === 'string'
|
||||
? removePayload.body.directory
|
||||
: '';
|
||||
const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : '';
|
||||
const worktreeDirectory = bodyDirectory || legacyDirectory || '';
|
||||
|
||||
if (!worktreeDirectory) {
|
||||
return { id, type, success: false, error: 'Worktree directory is required' };
|
||||
}
|
||||
const removed = await gitService.removeWorktree(directory!, {
|
||||
directory: worktreeDirectory,
|
||||
deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true,
|
||||
});
|
||||
return { id, type, success: true, data: { success: Boolean(removed) } };
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:git/worktrees/validate': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.validateWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/worktrees/bootstrap-status': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.getWorktreeBootstrapStatus(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/worktrees/preview': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.previewWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/diff': {
|
||||
const { directory, path: filePath, staged, contextLines } = (payload || {}) as {
|
||||
directory?: string;
|
||||
path?: string;
|
||||
staged?: boolean;
|
||||
contextLines?: number;
|
||||
};
|
||||
if (!directory || !filePath) {
|
||||
return { id, type, success: false, error: 'Directory and path are required' };
|
||||
}
|
||||
const result = await gitService.getGitDiff(directory, filePath, staged, contextLines);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/file-diff': {
|
||||
const { directory, path: filePath, staged } = (payload || {}) as {
|
||||
directory?: string;
|
||||
path?: string;
|
||||
staged?: boolean;
|
||||
};
|
||||
if (!directory || !filePath) {
|
||||
return { id, type, success: false, error: 'Directory and path are required' };
|
||||
}
|
||||
const result = await gitService.getGitFileDiff(directory, filePath, staged);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/revert': {
|
||||
const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string };
|
||||
if (!directory || !filePath) {
|
||||
return { id, type, success: false, error: 'Directory and path are required' };
|
||||
}
|
||||
await gitService.revertGitFile(directory, filePath);
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
case 'api:git/commit': {
|
||||
const { directory, message, addAll, files } = (payload || {}) as {
|
||||
directory?: string;
|
||||
message?: string;
|
||||
addAll?: boolean;
|
||||
files?: string[];
|
||||
};
|
||||
if (!directory || !message) {
|
||||
return { id, type, success: false, error: 'Directory and message are required' };
|
||||
}
|
||||
const result = await gitService.createGitCommit(directory, message, { addAll, files });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/push': {
|
||||
const { directory, remote, branch, options } = (payload || {}) as {
|
||||
directory?: string;
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
options?: string[] | Record<string, unknown>;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.gitPush(directory!, { remote, branch, options });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/pull': {
|
||||
const { directory, remote, branch } = (payload || {}) as {
|
||||
directory?: string;
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.gitPull(directory!, { remote, branch });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/fetch': {
|
||||
const { directory, remote, branch } = (payload || {}) as {
|
||||
directory?: string;
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.gitFetch(directory!, { remote, branch });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/remotes': {
|
||||
const { directory, method, remote } = (payload || {}) as {
|
||||
directory?: string;
|
||||
method?: string;
|
||||
remote?: string;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
|
||||
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
|
||||
if (normalizedMethod === 'GET') {
|
||||
const result = await gitService.getRemotes(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
if (!remote) {
|
||||
return { id, type, success: false, error: 'Remote name is required' };
|
||||
}
|
||||
const result = await gitService.removeRemote(directory!, remote);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:git/rebase': {
|
||||
const { directory, onto } = (payload || {}) as { directory?: string; onto?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
if (!onto) {
|
||||
return { id, type, success: false, error: 'onto is required' };
|
||||
}
|
||||
const result = await gitService.rebase(directory!, { onto });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/rebase/abort': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.abortRebase(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/merge': {
|
||||
const { directory, branch } = (payload || {}) as { directory?: string; branch?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
if (!branch) {
|
||||
return { id, type, success: false, error: 'branch is required' };
|
||||
}
|
||||
const result = await gitService.merge(directory!, { branch });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/merge/abort': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.abortMerge(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/rebase/continue': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.continueRebase(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/merge/continue': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.continueMerge(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/stash': {
|
||||
const { directory, message, includeUntracked } = (payload || {}) as {
|
||||
directory?: string;
|
||||
message?: string;
|
||||
includeUntracked?: boolean;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.stash(directory!, { message, includeUntracked });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/stash/pop': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.stashPop(directory!);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/log': {
|
||||
const { directory, maxCount, from, to, file } = (payload || {}) as {
|
||||
directory?: string;
|
||||
maxCount?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
file?: string;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.getGitLog(directory!, { maxCount, from, to, file });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/commit-files': {
|
||||
const { directory, hash } = (payload || {}) as { directory?: string; hash?: string };
|
||||
if (!directory || !hash) {
|
||||
return { id, type, success: false, error: 'Directory and hash are required' };
|
||||
}
|
||||
const result = await gitService.getCommitFiles(directory, hash);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/identity': {
|
||||
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
||||
directory?: string;
|
||||
method?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
sshKey?: string | null;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
|
||||
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const identity = await gitService.getCurrentGitIdentity(directory!);
|
||||
return { id, type, success: true, data: identity };
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
if (!userName || !userEmail) {
|
||||
return { id, type, success: false, error: 'userName and userEmail are required' };
|
||||
}
|
||||
const result = await gitService.setGitIdentity(directory!, userName, userEmail, sshKey);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:git/ignore-openchamber': {
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as gitService from './gitService';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type ExecGitResult = { stdout: string; stderr: string; exitCode: number };
|
||||
|
||||
type SpecialGitDeps = {
|
||||
readSettings: (ctx?: BridgeContext) => Record<string, unknown>;
|
||||
execGit: (args: string[], cwd: string) => Promise<ExecGitResult>;
|
||||
};
|
||||
|
||||
const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
|
||||
const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500;
|
||||
const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
let bridgeGitModelCatalogCache: Set<string> | null = null;
|
||||
let bridgeGitModelCatalogCacheAt = 0;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const readStringField = (value: unknown, key: string): string => {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidate = record[key];
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
};
|
||||
|
||||
const fetchBridgeGitModelCatalog = async (
|
||||
apiUrl: string,
|
||||
authHeaders?: Record<string, string>
|
||||
): Promise<Set<string>> => {
|
||||
const now = Date.now();
|
||||
if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) {
|
||||
return bridgeGitModelCatalogCache;
|
||||
}
|
||||
|
||||
const headers = authHeaders || {};
|
||||
const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`);
|
||||
const response = await fetch(modelsUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch model catalog');
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null) as unknown;
|
||||
const refs = new Set<string>();
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : '';
|
||||
const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : '';
|
||||
if (providerID && modelID) {
|
||||
refs.add(`${providerID}/${modelID}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bridgeGitModelCatalogCache = refs;
|
||||
bridgeGitModelCatalogCacheAt = now;
|
||||
return refs;
|
||||
};
|
||||
|
||||
const resolveBridgeGitGenerationModel = async (
|
||||
payloadModel: { providerId?: string; modelId?: string; zenModel?: string },
|
||||
settings: Record<string, unknown>,
|
||||
apiUrl: string,
|
||||
authHeaders?: Record<string, string>
|
||||
): Promise<{ providerID: string; modelID: string }> => {
|
||||
let catalog: Set<string> | null = null;
|
||||
try {
|
||||
catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders);
|
||||
} catch {
|
||||
catalog = null;
|
||||
}
|
||||
|
||||
const hasModel = (providerID: string, modelID: string): boolean => {
|
||||
if (!catalog) {
|
||||
return false;
|
||||
}
|
||||
return catalog.has(`${providerID}/${modelID}`);
|
||||
};
|
||||
|
||||
const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : '';
|
||||
const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : '';
|
||||
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
|
||||
return { providerID: requestProviderId, modelID: requestModelId };
|
||||
}
|
||||
|
||||
const settingsProviderId = readStringField(settings, 'gitProviderId');
|
||||
const settingsModelId = readStringField(settings, 'gitModelId');
|
||||
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
|
||||
return { providerID: settingsProviderId, modelID: settingsModelId };
|
||||
}
|
||||
|
||||
const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : '';
|
||||
const settingsZenModel = readStringField(settings, 'zenModel');
|
||||
return {
|
||||
providerID: 'zen',
|
||||
modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL,
|
||||
};
|
||||
};
|
||||
|
||||
const extractTextFromMessageParts = (parts: unknown): string => {
|
||||
if (!Array.isArray(parts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const textParts = parts
|
||||
.filter((part) => {
|
||||
if (!part || typeof part !== 'object') return false;
|
||||
const record = part as Record<string, unknown>;
|
||||
return record.type === 'text' && typeof record.text === 'string';
|
||||
})
|
||||
.map((part) => (part as Record<string, unknown>).text as string)
|
||||
.map((text) => text.trim())
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
return textParts.join('\n').trim();
|
||||
};
|
||||
|
||||
const generateBridgeTextWithSessionFlow = async ({
|
||||
apiUrl,
|
||||
directory,
|
||||
prompt,
|
||||
providerID,
|
||||
modelID,
|
||||
authHeaders,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
directory: string;
|
||||
prompt: string;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
authHeaders?: Record<string, string>;
|
||||
}): Promise<string> => {
|
||||
const headers = authHeaders || {};
|
||||
const apiBase = apiUrl.replace(/\/+$/, '');
|
||||
const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS;
|
||||
const remainingMs = () => Math.max(1_000, deadlineAt - Date.now());
|
||||
let sessionId: string | null = null;
|
||||
|
||||
try {
|
||||
const sessionUrl = new URL(`${apiBase}/session`);
|
||||
if (directory) {
|
||||
sessionUrl.searchParams.set('directory', directory);
|
||||
}
|
||||
|
||||
const createResponse = await fetch(sessionUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ title: 'Git Generation' }),
|
||||
signal: AbortSignal.timeout(remainingMs()),
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error('Failed to create OpenCode session');
|
||||
}
|
||||
|
||||
const session = await createResponse.json().catch(() => null) as unknown;
|
||||
const sessionObj = session && typeof session === 'object' ? session as Record<string, unknown> : null;
|
||||
const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : '';
|
||||
if (!createdSessionId) {
|
||||
throw new Error('Invalid session response');
|
||||
}
|
||||
sessionId = createdSessionId;
|
||||
|
||||
const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`);
|
||||
if (directory) {
|
||||
promptUrl.searchParams.set('directory', directory);
|
||||
}
|
||||
|
||||
const promptResponse = await fetch(promptUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
parts: [{ type: 'text', text: prompt }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(remainingMs()),
|
||||
});
|
||||
|
||||
if (!promptResponse.ok) {
|
||||
throw new Error('Failed to send prompt');
|
||||
}
|
||||
|
||||
const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`);
|
||||
if (directory) {
|
||||
messagesUrl.searchParams.set('directory', directory);
|
||||
}
|
||||
messagesUrl.searchParams.set('limit', '10');
|
||||
|
||||
while (Date.now() < deadlineAt) {
|
||||
await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS);
|
||||
|
||||
const messagesResponse = await fetch(messagesUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
},
|
||||
signal: AbortSignal.timeout(remainingMs()),
|
||||
});
|
||||
|
||||
if (!messagesResponse.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const messages = await messagesResponse.json().catch(() => null) as unknown;
|
||||
if (!Array.isArray(messages)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i] as Record<string, unknown> | null;
|
||||
if (!message || typeof message !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const info = message.info as Record<string, unknown> | undefined;
|
||||
if (info?.role !== 'assistant' || info?.finish !== 'stop') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = extractTextFromMessageParts(message.parts);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timeout waiting for generation to complete');
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`);
|
||||
try {
|
||||
await fetch(deleteUrl.toString(), {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonObjectSafe = (value: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function handleSpecialGitBridgeMessage(
|
||||
message: BridgeMessageInput,
|
||||
ctx: BridgeContext | undefined,
|
||||
deps: SpecialGitDeps,
|
||||
): Promise<BridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'api:git/pr-description': {
|
||||
const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as {
|
||||
directory?: string;
|
||||
base?: string;
|
||||
head?: string;
|
||||
context?: string;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
zenModel?: string;
|
||||
};
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
if (!base || !head) {
|
||||
return { id, type, success: false, error: 'base and head are required' };
|
||||
}
|
||||
|
||||
let files: string[] = [];
|
||||
try {
|
||||
const listed = await gitService.getGitRangeFiles(directory, base, head);
|
||||
files = Array.isArray(listed) ? listed : [];
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return { id, type, success: false, error: 'No diffs available for base...head' };
|
||||
}
|
||||
|
||||
let diffSummaries = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3);
|
||||
const raw = typeof diff?.diff === 'string' ? diff.diff : '';
|
||||
if (!raw.trim()) continue;
|
||||
diffSummaries += `FILE: ${file}\n${raw}\n\n`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!diffSummaries.trim()) {
|
||||
return { id, type, success: false, error: 'No diffs available for selected files' };
|
||||
}
|
||||
|
||||
const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`;
|
||||
|
||||
try {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
return { id, type, success: false, error: 'OpenCode API unavailable' };
|
||||
}
|
||||
|
||||
const settings = deps.readSettings(ctx) as Record<string, unknown>;
|
||||
const { providerID, modelID } = await resolveBridgeGitGenerationModel(
|
||||
{ providerId, modelId, zenModel: payloadZenModel },
|
||||
settings,
|
||||
apiUrl,
|
||||
ctx?.manager?.getOpenCodeAuthHeaders()
|
||||
);
|
||||
const raw = await generateBridgeTextWithSessionFlow({
|
||||
apiUrl,
|
||||
directory,
|
||||
prompt,
|
||||
providerID,
|
||||
modelID,
|
||||
authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(),
|
||||
});
|
||||
if (!raw) {
|
||||
return { id, type, success: false, error: 'No PR description returned by generator' };
|
||||
}
|
||||
|
||||
const cleaned = String(raw)
|
||||
.trim()
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/^```\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
.trim();
|
||||
|
||||
const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw);
|
||||
if (parsed) {
|
||||
const title = typeof parsed.title === 'string' ? parsed.title : '';
|
||||
const body = typeof parsed.body === 'string' ? parsed.body : '';
|
||||
return { id, type, success: true, data: { title, body } };
|
||||
}
|
||||
|
||||
return { id, type, success: true, data: { title: '', body: String(raw) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:git/conflict-details': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await deps.execGit(['status', '--porcelain'], directory);
|
||||
const statusPorcelain = statusResult.stdout;
|
||||
|
||||
const unmergedResult = await deps.execGit(['diff', '--name-only', '--diff-filter=U'], directory);
|
||||
const unmergedFiles = unmergedResult.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const diffResult = await deps.execGit(['diff'], directory);
|
||||
const diff = diffResult.stdout;
|
||||
|
||||
let operation: 'merge' | 'rebase' = 'merge';
|
||||
let headInfo = '';
|
||||
|
||||
const mergeHeadResult = await deps.execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory);
|
||||
const mergeHeadExists = mergeHeadResult.exitCode === 0;
|
||||
|
||||
if (mergeHeadExists) {
|
||||
operation = 'merge';
|
||||
const mergeHead = mergeHeadResult.stdout.trim();
|
||||
let mergeMsg = '';
|
||||
try {
|
||||
const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG');
|
||||
mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8');
|
||||
} catch {
|
||||
// MERGE_MSG may not exist
|
||||
}
|
||||
headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`;
|
||||
} else {
|
||||
const rebaseHeadResult = await deps.execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory);
|
||||
const rebaseHeadExists = rebaseHeadResult.exitCode === 0;
|
||||
|
||||
if (rebaseHeadExists) {
|
||||
operation = 'rebase';
|
||||
const rebaseHead = rebaseHeadResult.stdout.trim();
|
||||
headInfo = `REBASE_HEAD: ${rebaseHead}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
statusPorcelain: statusPorcelain.trim(),
|
||||
unmergedFiles,
|
||||
diff: diff.trim(),
|
||||
headInfo: headInfo.trim(),
|
||||
operation,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as fs from 'fs';
|
||||
import { getFsMimeType, resolveFileReadPath, type FsReadPathResolution } from './bridge-fs-helpers-runtime';
|
||||
|
||||
type ApiProxyResponsePayload = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
};
|
||||
|
||||
export const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
|
||||
|
||||
export const collectHeaders = (headers: Headers): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const buildUnavailableApiResponse = (): ApiProxyResponsePayload => {
|
||||
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
|
||||
return {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
};
|
||||
|
||||
export const sanitizeForwardHeaders = (input: Record<string, string> | undefined): Record<string, string> => {
|
||||
const headers: Record<string, string> = { ...(input || {}) };
|
||||
delete headers['content-length'];
|
||||
delete headers['host'];
|
||||
delete headers['connection'];
|
||||
return headers;
|
||||
};
|
||||
|
||||
const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePayload => ({
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(JSON.stringify({ error })),
|
||||
});
|
||||
|
||||
export const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise<ApiProxyResponsePayload | null> => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(requestPath, 'https://openchamber.local');
|
||||
} catch {
|
||||
return buildProxyJsonError(400, 'Invalid request path');
|
||||
}
|
||||
|
||||
if (parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
return buildProxyJsonError(405, 'Method not allowed');
|
||||
}
|
||||
|
||||
const targetPath = parsed.searchParams.get('path') || '';
|
||||
const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath);
|
||||
if (!resolution.ok) {
|
||||
return buildProxyJsonError(resolution.status, resolution.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.stat(resolution.resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
return buildProxyJsonError(400, 'Specified path is not a file');
|
||||
}
|
||||
|
||||
if (parsed.pathname === '/api/fs/read') {
|
||||
const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8');
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
bodyBase64: base64EncodeUtf8(content),
|
||||
};
|
||||
}
|
||||
|
||||
const raw = await fs.promises.readFile(resolution.resolvedPath);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': getFsMimeType(resolution.resolvedPath),
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
bodyBase64: Buffer.from(raw).toString('base64'),
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err?.code === 'ENOENT') {
|
||||
return buildProxyJsonError(404, 'File not found');
|
||||
}
|
||||
return buildProxyJsonError(500, 'Unable to read file');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
import { waitForApiUrl } from './opencode-ready';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type ApiProxyRequestPayload = {
|
||||
method?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyBase64?: string;
|
||||
};
|
||||
|
||||
type ApiSessionMessageRequestPayload = {
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyText?: string;
|
||||
};
|
||||
|
||||
type ApiProxyResponsePayload = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
};
|
||||
|
||||
type ProxyRuntimeDeps = {
|
||||
tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise<ApiProxyResponsePayload | null>;
|
||||
buildUnavailableApiResponse: () => ApiProxyResponsePayload;
|
||||
sanitizeForwardHeaders: (input: Record<string, string> | undefined) => Record<string, string>;
|
||||
collectHeaders: (headers: Headers) => Record<string, string>;
|
||||
base64EncodeUtf8: (text: string) => string;
|
||||
};
|
||||
|
||||
export async function handleProxyBridgeMessage(
|
||||
message: BridgeMessageInput,
|
||||
ctx: BridgeContext | undefined,
|
||||
deps: ProxyRuntimeDeps,
|
||||
): Promise<BridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'api:proxy': {
|
||||
const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload;
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
const normalizedPath =
|
||||
typeof requestPath === 'string' && requestPath.trim().length > 0
|
||||
? requestPath.trim().startsWith('/')
|
||||
? requestPath.trim()
|
||||
: `/${requestPath.trim()}`
|
||||
: '/';
|
||||
|
||||
const localFsResponse = await deps.tryHandleLocalFsProxy(normalizedMethod, normalizedPath);
|
||||
if (localFsResponse) {
|
||||
return { id, type, success: true, data: localFsResponse };
|
||||
}
|
||||
|
||||
const apiUrl = await waitForApiUrl(ctx?.manager);
|
||||
if (!apiUrl) {
|
||||
const data = deps.buildUnavailableApiResponse();
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...deps.sanitizeForwardHeaders(headers),
|
||||
...ctx?.manager?.getOpenCodeAuthHeaders(),
|
||||
};
|
||||
|
||||
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
||||
if (!requestHeaders.Accept) {
|
||||
requestHeaders.Accept = 'text/event-stream';
|
||||
}
|
||||
requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache';
|
||||
requestHeaders.Connection = requestHeaders.Connection || 'keep-alive';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: normalizedMethod,
|
||||
headers: requestHeaders,
|
||||
body:
|
||||
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
|
||||
? Buffer.from(bodyBase64, 'base64')
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: response.status,
|
||||
headers: deps.collectHeaders(response.headers),
|
||||
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
|
||||
};
|
||||
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const body = JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Failed to reach OpenCode API',
|
||||
});
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: deps.base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:session:message': {
|
||||
const apiUrl = await waitForApiUrl(ctx?.manager);
|
||||
if (!apiUrl) {
|
||||
const data = deps.buildUnavailableApiResponse();
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload;
|
||||
const normalizedPath =
|
||||
typeof requestPath === 'string' && requestPath.trim().length > 0
|
||||
? requestPath.trim().startsWith('/')
|
||||
? requestPath.trim()
|
||||
: `/${requestPath.trim()}`
|
||||
: '/';
|
||||
|
||||
if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) {
|
||||
const body = JSON.stringify({ error: 'Invalid session message proxy path' });
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: deps.base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...deps.sanitizeForwardHeaders(headers),
|
||||
...ctx?.manager?.getOpenCodeAuthHeaders(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: typeof bodyText === 'string' ? bodyText : '',
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: response.status,
|
||||
headers: deps.collectHeaders(response.headers),
|
||||
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
|
||||
};
|
||||
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error instanceof Error &&
|
||||
((error as Error & { name?: string }).name === 'TimeoutError' ||
|
||||
(error as Error & { name?: string }).name === 'AbortError');
|
||||
const body = JSON.stringify({
|
||||
error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed',
|
||||
});
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: isTimeout ? 504 : 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: deps.base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
|
||||
import type { BridgeContext } from './bridge';
|
||||
|
||||
const SETTINGS_KEY = 'openchamber.settings';
|
||||
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
|
||||
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||
const relative = path.relative(parentPath, candidatePath);
|
||||
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
};
|
||||
|
||||
const findWorktreeRootForSkills = (workingDirectory?: string): string | null => {
|
||||
if (!workingDirectory) return null;
|
||||
let current = path.resolve(workingDirectory);
|
||||
while (true) {
|
||||
const gitPath = path.join(current, '.git');
|
||||
try {
|
||||
const stat = fs.statSync(gitPath);
|
||||
if (stat.isFile()) {
|
||||
return current;
|
||||
}
|
||||
} catch {
|
||||
// Continue climbing.
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectAncestors = (workingDirectory?: string): string[] => {
|
||||
if (!workingDirectory) return [];
|
||||
const result: string[] = [];
|
||||
let current = path.resolve(workingDirectory);
|
||||
const stop = findWorktreeRootForSkills(workingDirectory) || current;
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (current === stop) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => {
|
||||
const resolvedPath = path.resolve(location);
|
||||
const source: SkillSource = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
|
||||
? 'agents'
|
||||
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
|
||||
? 'claude'
|
||||
: 'opencode';
|
||||
|
||||
const projectAncestors = getProjectAncestors(workingDirectory);
|
||||
const isProjectScoped = projectAncestors.some((ancestor) => {
|
||||
const candidates = [
|
||||
path.join(ancestor, '.opencode'),
|
||||
path.join(ancestor, '.claude', 'skills'),
|
||||
path.join(ancestor, '.agents', 'skills'),
|
||||
];
|
||||
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
|
||||
});
|
||||
|
||||
if (isProjectScoped) {
|
||||
return { scope: 'project', source };
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const userRoots = [
|
||||
path.join(home, '.config', 'opencode'),
|
||||
path.join(home, '.opencode'),
|
||||
path.join(home, '.claude', 'skills'),
|
||||
path.join(home, '.agents', 'skills'),
|
||||
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
|
||||
return { scope: 'user', source };
|
||||
}
|
||||
|
||||
return { scope: 'user', source };
|
||||
};
|
||||
|
||||
export const fetchOpenCodeSkillsFromApi = async (
|
||||
ctx: BridgeContext | undefined,
|
||||
workingDirectory?: string,
|
||||
): Promise<DiscoveredSkill[] | null> => {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
|
||||
const url = new URL('skill', base);
|
||||
if (workingDirectory) {
|
||||
url.searchParams.set('directory', workingDirectory);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(ctx?.manager?.getOpenCodeAuthHeaders() || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((item) => {
|
||||
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const location = typeof item?.location === 'string' ? item.location : '';
|
||||
const description = typeof item?.description === 'string' ? item.description : '';
|
||||
if (!name || !location) {
|
||||
return null;
|
||||
}
|
||||
const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory);
|
||||
return {
|
||||
name,
|
||||
path: location,
|
||||
scope: inferred.scope,
|
||||
source: inferred.source,
|
||||
description,
|
||||
} as DiscoveredSkill;
|
||||
})
|
||||
.filter((item): item is DiscoveredSkill => item !== null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
try {
|
||||
const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Promise<void> => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
|
||||
const current = readSharedSettingsFromDisk();
|
||||
const next: Record<string, unknown> = { ...current, ...changes };
|
||||
await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
||||
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
||||
const restStored = { ...stored };
|
||||
delete (restStored as Record<string, unknown>).lastDirectory;
|
||||
const shared = readSharedSettingsFromDisk();
|
||||
const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : '';
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
const themeVariant =
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight
|
||||
? 'light'
|
||||
: 'dark';
|
||||
|
||||
return {
|
||||
themeVariant,
|
||||
lastDirectory: workspaceFolder,
|
||||
...restStored,
|
||||
opencodeBinary:
|
||||
typeof restStored.opencodeBinary === 'string'
|
||||
? String(restStored.opencodeBinary).trim()
|
||||
: (sharedOpencodeBinary || undefined),
|
||||
};
|
||||
};
|
||||
|
||||
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
|
||||
const current = readSettings(ctx);
|
||||
const restChanges = { ...(changes || {}) };
|
||||
delete restChanges.lastDirectory;
|
||||
|
||||
const keysToClear = new Set<string>();
|
||||
|
||||
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
|
||||
const value = restChanges[key];
|
||||
if (typeof value === 'string' && value.trim().length === 0) {
|
||||
keysToClear.add(key);
|
||||
delete restChanges[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
|
||||
delete restChanges.usageAutoRefresh;
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) {
|
||||
restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs)));
|
||||
} else {
|
||||
delete restChanges.usageRefreshIntervalMs;
|
||||
}
|
||||
|
||||
const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record<string, unknown>;
|
||||
for (const key of keysToClear) {
|
||||
delete merged[key];
|
||||
}
|
||||
await ctx?.context?.globalState.update(SETTINGS_KEY, merged);
|
||||
|
||||
if (keysToClear.has('opencodeBinary')) {
|
||||
await writeSharedSettingsToDisk({ opencodeBinary: '' });
|
||||
} else if (typeof restChanges.opencodeBinary === 'string') {
|
||||
await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() });
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
@@ -0,0 +1,558 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type SystemRuntimeDeps = {
|
||||
resolveUserPath: (value: string, baseDirectory: string) => string;
|
||||
fetchModelsMetadata: () => Promise<unknown>;
|
||||
updateCheckUrl: string;
|
||||
clientReloadDelayMs: number;
|
||||
};
|
||||
|
||||
type NotificationBridgePayload = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
type NotificationsNotifyRequestPayload = {
|
||||
payload?: NotificationBridgePayload;
|
||||
};
|
||||
|
||||
const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
||||
const ZEN_MODELS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let cachedZenModels: { models: Array<{ id: string; owned_by?: string }>; at: number } | null = null;
|
||||
|
||||
const getOpenChamberConfigDir = (): string => {
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA;
|
||||
if (appData) return path.join(appData, 'openchamber');
|
||||
}
|
||||
return path.join(os.homedir(), '.config', 'openchamber');
|
||||
};
|
||||
|
||||
const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => {
|
||||
if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
||||
return 'web';
|
||||
};
|
||||
|
||||
const getOrCreateInstallId = (scope: string): string => {
|
||||
const configDir = getOpenChamberConfigDir();
|
||||
const normalizedScope = sanitizeInstallScope(scope);
|
||||
const idPath = path.join(configDir, `install-id-${normalizedScope}`);
|
||||
|
||||
try {
|
||||
const existing = fs.readFileSync(idPath, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// Generate new id.
|
||||
}
|
||||
|
||||
const installId = randomUUID();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
return installId;
|
||||
};
|
||||
|
||||
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => {
|
||||
if (value === 'darwin') return 'macos';
|
||||
if (value === 'win32') return 'windows';
|
||||
if (value === 'linux') return 'linux';
|
||||
return 'web';
|
||||
};
|
||||
|
||||
const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => {
|
||||
if (value === 'arm64' || value === 'aarch64') return 'arm64';
|
||||
if (value === 'x64' || value === 'amd64') return 'x64';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
type ParsedDiffHunk = {
|
||||
newStart: number;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
};
|
||||
|
||||
const VIRTUAL_DIFF_SCHEME = 'openchamber-diff';
|
||||
const virtualDiffContents = new Map<string, string>();
|
||||
let virtualDiffCounter = 0;
|
||||
let virtualDiffProviderDisposable: vscode.Disposable | null = null;
|
||||
|
||||
const asObject = (value: unknown): Record<string, unknown> | null =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => {
|
||||
if (virtualDiffProviderDisposable) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider(
|
||||
VIRTUAL_DIFF_SCHEME,
|
||||
{
|
||||
provideTextDocumentContent: (uri: vscode.Uri) => {
|
||||
const key = new URLSearchParams(uri.query).get('key') || '';
|
||||
return virtualDiffContents.get(key) ?? '';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (ctx?.context) {
|
||||
ctx.context.subscriptions.push(virtualDiffProviderDisposable);
|
||||
}
|
||||
};
|
||||
|
||||
const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => {
|
||||
const key = `${Date.now()}-${++virtualDiffCounter}`;
|
||||
virtualDiffContents.set(key, content);
|
||||
|
||||
if (virtualDiffContents.size > 100) {
|
||||
const firstKey = virtualDiffContents.keys().next().value;
|
||||
if (firstKey) {
|
||||
virtualDiffContents.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
return vscode.Uri.from({
|
||||
scheme: VIRTUAL_DIFF_SCHEME,
|
||||
path: `/${path.basename(modifiedPath) || 'original'}`,
|
||||
query: `key=${encodeURIComponent(key)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => {
|
||||
const lines = patch.split(/\r?\n/);
|
||||
const hunks: ParsedDiffHunk[] = [];
|
||||
|
||||
let current: ParsedDiffHunk | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const headerMatch = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
||||
if (headerMatch) {
|
||||
if (current) {
|
||||
hunks.push(current);
|
||||
}
|
||||
current = {
|
||||
newStart: Number(headerMatch[1] || 1),
|
||||
oldLines: [],
|
||||
newLines: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('\\ No newline')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
current.oldLines.push(line.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
current.newLines.push(line.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(' ')) {
|
||||
const content = line.slice(1);
|
||||
current.oldLines.push(content);
|
||||
current.newLines.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
hunks.push(current);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => {
|
||||
const hunks = parseUnifiedDiffHunks(patch);
|
||||
if (hunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = modifiedContent.split('\n');
|
||||
for (let index = hunks.length - 1; index >= 0; index -= 1) {
|
||||
const hunk = hunks[index];
|
||||
if (!hunk) {
|
||||
continue;
|
||||
}
|
||||
const startIndex = Math.max(0, hunk.newStart - 1);
|
||||
const replaceCount = hunk.newLines.length;
|
||||
lines.splice(startIndex, replaceCount, ...hunk.oldLines);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const fetchFreeZenModels = async (): Promise<Array<{ id: string; owned_by?: string }>> => {
|
||||
const now = Date.now();
|
||||
if (cachedZenModels && now - cachedZenModels.at < ZEN_MODELS_CACHE_TTL_MS) {
|
||||
return cachedZenModels.models;
|
||||
}
|
||||
|
||||
const response = await fetch(ZEN_MODELS_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`zen models request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const rawPayload = await response.json().catch(() => null);
|
||||
const payload = asObject(rawPayload);
|
||||
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const models = rows
|
||||
.map((entry) => {
|
||||
const id = typeof (entry as { id?: unknown })?.id === 'string'
|
||||
? (entry as { id: string }).id.trim()
|
||||
: '';
|
||||
const ownedBy = typeof (entry as { owned_by?: unknown })?.owned_by === 'string'
|
||||
? (entry as { owned_by: string }).owned_by
|
||||
: undefined;
|
||||
if (!id || !id.endsWith('-free')) return null;
|
||||
return ownedBy ? { id, owned_by: ownedBy } : { id };
|
||||
})
|
||||
.filter((entry): entry is { id: string; owned_by?: string } => entry !== null);
|
||||
|
||||
cachedZenModels = { models, at: Date.now() };
|
||||
return models;
|
||||
};
|
||||
|
||||
export async function handleSystemBridgeMessage(
|
||||
message: BridgeMessageInput,
|
||||
ctx: BridgeContext | undefined,
|
||||
deps: SystemRuntimeDeps,
|
||||
): Promise<BridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'api:opencode/directory': {
|
||||
const target = (payload as { path?: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
const baseDirectory =
|
||||
ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = deps.resolveUserPath(target, baseDirectory);
|
||||
const result = await ctx?.manager?.setWorkingDirectory(resolvedPath);
|
||||
if (!result) {
|
||||
return { id, type, success: false, error: 'OpenCode manager unavailable' };
|
||||
}
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:models/metadata': {
|
||||
try {
|
||||
const data = await deps.fetchModelsMetadata();
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:session-activity:get': {
|
||||
return { id, type, success: true, data: getSessionActivitySnapshot() };
|
||||
}
|
||||
|
||||
case 'api:zen:models': {
|
||||
try {
|
||||
const models = await fetchFreeZenModels();
|
||||
return { id, type, success: true, data: { models } };
|
||||
} catch (error) {
|
||||
if (cachedZenModels) {
|
||||
return { id, type, success: true, data: { models: cachedZenModels.models } };
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:openchamber:update-check': {
|
||||
try {
|
||||
const body = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
|
||||
const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0
|
||||
? body.currentVersion.trim()
|
||||
: 'unknown';
|
||||
const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0
|
||||
? body.instanceMode.trim()
|
||||
: 'local';
|
||||
const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0
|
||||
? body.deviceClass.trim()
|
||||
: 'desktop';
|
||||
const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0
|
||||
? body.platform.trim()
|
||||
: os.platform();
|
||||
const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0
|
||||
? body.arch.trim()
|
||||
: os.arch();
|
||||
const reportUsage = body.reportUsage !== false;
|
||||
|
||||
const installId = getOrCreateInstallId('vscode');
|
||||
const requestBody = {
|
||||
appType: 'vscode',
|
||||
deviceClass,
|
||||
platform: mapNodePlatformToApiPlatform(platformRaw),
|
||||
arch: mapNodeArchToApiArch(archRaw),
|
||||
channel: 'stable',
|
||||
currentVersion,
|
||||
installId,
|
||||
instanceMode,
|
||||
reportUsage,
|
||||
};
|
||||
|
||||
const response = await fetch(deps.updateCheckUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'update check failed');
|
||||
return { id, type, success: false, error: text || `Update check failed with ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'editor:openFile': {
|
||||
const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number };
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
||||
const options: vscode.TextDocumentShowOptions = {};
|
||||
if (typeof line === 'number') {
|
||||
const pos = new vscode.Position(Math.max(0, line - 1), column || 0);
|
||||
options.selection = new vscode.Range(pos, pos);
|
||||
}
|
||||
await vscode.window.showTextDocument(doc, options);
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'editor:openDiff': {
|
||||
const { original, modified, label, line, patch } = payload as {
|
||||
original: string;
|
||||
modified: string;
|
||||
label?: string;
|
||||
line?: number;
|
||||
patch?: string;
|
||||
};
|
||||
try {
|
||||
const modifiedUri = vscode.Uri.file(modified);
|
||||
const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri);
|
||||
let originalUri = original ? vscode.Uri.file(original) : modifiedUri;
|
||||
|
||||
if (typeof patch === 'string' && patch.trim().length > 0) {
|
||||
const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch);
|
||||
if (typeof originalContent === 'string') {
|
||||
ensureVirtualDiffProviderRegistered(ctx);
|
||||
originalUri = createVirtualOriginalDiffUri(modified, originalContent);
|
||||
}
|
||||
}
|
||||
|
||||
const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`;
|
||||
const title = label || `${leftLabel} ↔ ${path.basename(modified)}`;
|
||||
|
||||
await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
|
||||
|
||||
if (typeof line === 'number' && Number.isFinite(line)) {
|
||||
const targetLine = Math.max(0, Math.trunc(line) - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const targetEditor = vscode.window.visibleTextEditors.find(
|
||||
(editor) => editor.document.uri.toString() === modifiedUri.toString(),
|
||||
);
|
||||
if (targetEditor) {
|
||||
const target = new vscode.Position(targetLine, 0);
|
||||
targetEditor.selection = new vscode.Selection(target, target);
|
||||
targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter);
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:provider/auth:delete': {
|
||||
const { providerId, scope, directory } = (payload || {}) as { providerId?: string; scope?: string; directory?: string };
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
const normalizedScope = typeof scope === 'string' ? scope : 'auth';
|
||||
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim()
|
||||
: ctx?.manager?.getWorkingDirectory();
|
||||
try {
|
||||
let removed = false;
|
||||
if (normalizedScope === 'auth') {
|
||||
removed = removeProviderAuth(providerId);
|
||||
} else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') {
|
||||
removed = removeProviderConfig(providerId, workingDirectory, normalizedScope);
|
||||
} else if (normalizedScope === 'all') {
|
||||
const authRemoved = removeProviderAuth(providerId);
|
||||
const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user');
|
||||
const projectRemoved = workingDirectory
|
||||
? removeProviderConfig(providerId, workingDirectory, 'project')
|
||||
: false;
|
||||
const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom');
|
||||
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
||||
} else {
|
||||
return { id, type, success: false, error: 'Invalid scope' };
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
removed,
|
||||
requiresReload: removed,
|
||||
message: removed
|
||||
? `Provider ${providerId} disconnected successfully. Reloading interface…`
|
||||
: `Provider ${providerId} was not configured.`,
|
||||
reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:provider/source:get': {
|
||||
const { providerId, directory } = (payload || {}) as { providerId?: string; directory?: string };
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
try {
|
||||
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim()
|
||||
: ctx?.manager?.getWorkingDirectory();
|
||||
const sources = getProviderSources(providerId, workingDirectory);
|
||||
const auth = getProviderAuth(providerId);
|
||||
sources.auth.exists = Boolean(auth);
|
||||
return { id, type, success: true, data: { providerId, sources } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:providers': {
|
||||
try {
|
||||
const providers = listConfiguredQuotaProviders();
|
||||
return { id, type, success: true, data: { providers } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:get': {
|
||||
const { providerId } = (payload || {}) as { providerId?: string };
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
try {
|
||||
const result = await fetchQuotaForProvider(providerId);
|
||||
return { id, type, success: true, data: result };
|
||||
} 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) {
|
||||
return { id, type, success: false, error: 'Command is required' };
|
||||
}
|
||||
try {
|
||||
const result = await vscode.commands.executeCommand(command, ...(args || []));
|
||||
return { id, type, success: true, data: { result } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'vscode:openExternalUrl': {
|
||||
const { url } = (payload || {}) as { url?: string };
|
||||
const target = typeof url === 'string' ? url.trim() : '';
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'URL is required' };
|
||||
}
|
||||
try {
|
||||
await vscode.env.openExternal(vscode.Uri.parse(target));
|
||||
return { id, type, success: true, data: { opened: true } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'notifications:can-notify': {
|
||||
return { id, type, success: true, data: true };
|
||||
}
|
||||
|
||||
case 'notifications:notify': {
|
||||
const request = (payload || {}) as NotificationsNotifyRequestPayload;
|
||||
const notification = request.payload || {};
|
||||
const title = typeof notification.title === 'string' ? notification.title.trim() : '';
|
||||
const body = typeof notification.body === 'string' ? notification.body.trim() : '';
|
||||
|
||||
const message = title && body
|
||||
? `${title}: ${body}`
|
||||
: title || body;
|
||||
|
||||
if (!message) {
|
||||
return { id, type, success: true, data: { shown: false } };
|
||||
}
|
||||
|
||||
void vscode.window.showInformationMessage(message);
|
||||
return { id, type, success: true, data: { shown: true } };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+109
-3784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
|
||||
export const API_URL_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export async function waitForApiUrl(
|
||||
manager: OpenCodeManager | undefined,
|
||||
timeoutMs = API_URL_WAIT_TIMEOUT_MS,
|
||||
): Promise<string | null> {
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialUrl = manager.getApiUrl();
|
||||
if (initialUrl) {
|
||||
return initialUrl;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let subscription: { dispose(): void } | null = null;
|
||||
let disposeAfterSubscribe = false;
|
||||
|
||||
const handleStatusChange = () => {
|
||||
const nextUrl = manager.getApiUrl();
|
||||
if (!nextUrl || settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (subscription) {
|
||||
subscription.dispose();
|
||||
} else {
|
||||
disposeAfterSubscribe = true;
|
||||
}
|
||||
resolve(nextUrl);
|
||||
};
|
||||
|
||||
subscription = manager.onStatusChange(handleStatusChange);
|
||||
if (disposeAfterSubscribe) {
|
||||
subscription.dispose();
|
||||
return;
|
||||
}
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
subscription?.dispose();
|
||||
resolve(manager.getApiUrl());
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
@@ -64,6 +64,14 @@ const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void
|
||||
}
|
||||
};
|
||||
|
||||
export const getSessionActivitySnapshot = (): Record<string, { type: ActivityPhase }> => {
|
||||
const snapshot: Record<string, { type: ActivityPhase }> = {};
|
||||
for (const [sessionId, data] of sessionActivityPhases.entries()) {
|
||||
snapshot[sessionId] = { type: data.phase };
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const deriveSessionActivity = (payload: Record<string, unknown>): SessionActivity | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
import { waitForApiUrl } from './opencode-ready';
|
||||
|
||||
type StreamEvent<TData = unknown> = {
|
||||
data: TData;
|
||||
@@ -55,8 +56,8 @@ const resolveDefaultDirectory = (manager: OpenCodeManager): string => {
|
||||
return manager.getWorkingDirectory() || 'global';
|
||||
};
|
||||
|
||||
const createAuthedClient = (manager: OpenCodeManager, headers?: Record<string, string>) => {
|
||||
const baseUrl = manager.getApiUrl();
|
||||
const createAuthedClient = async (manager: OpenCodeManager, headers?: Record<string, string>) => {
|
||||
const baseUrl = await waitForApiUrl(manager);
|
||||
if (!baseUrl) {
|
||||
throw new Error('OpenCode API URL not available');
|
||||
}
|
||||
@@ -98,7 +99,7 @@ export const openSseProxy = async ({
|
||||
signal,
|
||||
onChunk,
|
||||
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
|
||||
const client = createAuthedClient(manager, headers);
|
||||
const client = await createAuthedClient(manager, headers);
|
||||
const { pathname, directory } = normalizeSsePath(path);
|
||||
const resolvedDirectory = directory || resolveDefaultDirectory(manager);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user