24 Commits

Author SHA1 Message Date
Pawan Osman cc0b9687d8 Update CHANGELOG for version 0.0.5 with new features, fixes, and changes
- Added live timeout countdown badges, redesigned shell tool cards, and implemented hard budgets for subagents to prevent hangs.
- Fixed issues with tools getting stuck, improved error handling for invalid paths, and addressed UI freezes from high-frequency events.
- Changed default timeouts for Todo operations and tightened read timeouts to enhance performance consistency.
2026-07-15 18:38:34 +03:00
Pawan Osman e9d0b96931 Update tool timeout settings for Todo operations to improve performance consistency
- Increased the timeout values for `TodoWrite` and `TodoRead` from 5 seconds to 15 seconds in both the agent and UI components, ensuring better handling of longer operations and reducing potential timeouts.
2026-07-15 18:32:20 +03:00
Pawan Osman 4286a28ce3 Refactor file reading options to streamline abort signal handling
- Updated the `readFileTool` to use a unified `readOpts` object for managing abort signals, improving code clarity and consistency.
- Removed redundant signal parameters in file system operations, ensuring cleaner function calls while maintaining existing timeout management.
- Enhanced error handling during file reads to provide clearer feedback on issues encountered with file paths.
2026-07-15 18:25:02 +03:00
Pawan Osman affe3e1c21 Enhance file reading functionality with improved error handling and timeout management
- Refactored the `readFileTool` to incorporate a timeout mechanism, preventing hangs on network or missing paths.
- Introduced a new `withAbortTimeout` function to manage promise timeouts and abort signals effectively.
- Enhanced error messages for various file read scenarios, providing clearer feedback on issues like missing paths and permission errors.
- Updated the `safePath` function to utilize a new `normalizePathInput` utility for better path normalization.
- Adjusted tool timeout settings in both the agent and UI to ensure consistent timeout behavior across components.
2026-07-15 18:24:11 +03:00
Pawan Osman 01a0da3bc9 Refactor subStatus handling in SidebarProvider for improved clarity
- Updated the logic for determining the `subStatus` in the `SidebarProvider` to streamline the assignment process.
- Enhanced readability by consolidating the conditional checks for `subStatus`, ensuring consistent status updates for tasks and cancellations.
- Maintained existing functionality while improving code maintainability and clarity.
2026-07-15 18:17:48 +03:00
Pawan Osman b63ac10f8f Enhance tool timeout management and UI integration
- Updated the `runAgent` function to improve timeout handling for MCP tools, ensuring shared budget management and better execution limits.
- Introduced a countdown mechanism for tool calls, allowing for real-time UI feedback on execution status and tool cancellations.
- Enhanced the UI components to display timeout badges and countdowns for running tools, improving user awareness and responsiveness.
- Implemented a silent countdown feature for collapsed tool groups, ensuring timely cancellations without UI clutter.
- Refactored the `Tool` component to integrate timeout badges, providing clearer visual indicators of tool status.
2026-07-15 18:04:43 +03:00
Pawan Osman 6b8301a28e Enhance tool timeout management and UI feedback for subagents
- Updated the `runAgent` function to include timeout management for tool calls, allowing for better handling of execution limits and user feedback.
- Introduced `timeoutMs` and `startedAt` properties in the `tool-call-started` event to facilitate countdown displays in the UI.
- Enhanced the UI to show countdown timers for running tools, improving user awareness of execution status.
- Implemented immediate UI updates on tool cancellation or timeout, ensuring a responsive experience even during long-running operations.
- Refactored the `SidebarProvider` to handle subagent cancellations more effectively, providing clearer status updates in the UI.
2026-07-15 17:56:30 +03:00
Pawan Osman 5dab80f764 Implement coalescing for high-frequency UI events and enhance subagent budget management
- Introduced a coalescing mechanism for UI events in the sidebar and agent loop to prevent performance issues caused by high-frequency updates.
- Added budget management for foreground and background subagents, ensuring timely execution and preventing timeouts.
- Enhanced the `runAgent` function to handle budget hits and provide appropriate feedback on subagent execution status.
- Updated the UI to batch process events, improving responsiveness and reducing the likelihood of UI freezes during agent operations.
2026-07-15 16:45:46 +03:00
Pawan Osman f01e337cb6 Enhance error handling for file path validation in tools
- Improved error handling in `readFileTool`, `listDirTool`, `globTool`, and other tools to catch invalid path errors and return user-friendly messages.
- Refactored path handling to ensure consistent validation across multiple tools, enhancing robustness and user feedback.
- Updated `safePath` function to normalize input paths and prevent potential security issues by checking workspace boundaries.
- Added utility functions for quoting paths in shell commands to handle spaces and special characters correctly.
2026-07-15 16:43:54 +03:00
Pawan Osman 7f000c254c Refactor tool timeout handling and error management
- Updated timeout settings for various tools to improve responsiveness and prevent blocking the agent loop.
- Enhanced error handling in tools to provide clearer feedback on invalid paths and execution failures.
- Implemented abort signal support in multiple tools, allowing for graceful cancellation of long-running operations.
- Adjusted the `walk` function to respect abort signals and limit the number of files processed, preventing hangs during directory traversals.
- Improved the `grepTool`, `globTool`, and `fileSearchTool` to handle timeouts and abort signals effectively, enhancing overall tool performance.
2026-07-15 16:35:26 +03:00
Pawan Osman 76993e645b Update CHANGELOG for version 0.0.4 with new features and improvements
- Added per-tool hard timeouts and abort-signal support for long-running tools to enhance responsiveness.
- Introduced configurable timeout settings in the agent's configuration.
- Implemented GPU-accelerated local embeddings with fallback options, along with detailed indexing page information.
- Expanded ignored directories for tools and indexing to improve performance and efficiency.
2026-07-15 15:41:03 +03:00
Pawan Osman d47e978be7 Enhance semantic indexing and UI status display
- Updated the `semanticIndex` module to support GPU acceleration with ONNX Runtime, improving performance on compatible devices.
- Introduced new functions to determine preferred embedding devices and handle device status, enhancing the flexibility of the embedding process.
- Expanded the `IndexStatus` interface to include backend type, device information, and runtime details for better tracking of indexing status.
- Enhanced the UI in the settings panel to display runtime and device information, providing users with clearer insights into the indexing process and model usage.
2026-07-15 15:37:43 +03:00
Pawan Osman a3d917d1b5 Enhance file indexing and exclusion logic for improved performance
- Updated the `indexWatch` module to refine the criteria for skipping non-source directories and files, including a comprehensive list of ignored paths.
- Enhanced the `semanticIndex` module with additional file extensions and directory segments to ensure only relevant source files are indexed.
- Introduced new utility functions to determine indexable paths, improving the accuracy of the indexing process and reducing unnecessary overhead.
- Expanded the `IGNORE` set in the `shared` module to include more build and cache directories, streamlining the file walking process.
2026-07-15 15:28:56 +03:00
Pawan Osman 205c6cd1b7 Implement per-tool timeout configuration in settings
- Added support for per-tool timeout overrides in seconds, allowing users to customize tool execution limits.
- Introduced `setToolTimeoutOverrides` function to apply user-defined timeout settings.
- Updated the `FeatureConfig` interface and default settings to include `toolTimeoutsSec`.
- Enhanced the settings UI to display and manage tool timeout values, ensuring a better user experience for configuring tool behavior.
2026-07-15 15:25:24 +03:00
Pawan Osman 3b246ec787 Implement per-tool timeout and abort signal handling for agent tools
- Introduced a timeout mechanism for various tools (e.g., Grep, Glob, Shell) to prevent blocking the agent loop due to hung processes.
- Enhanced the `walk` function to accept an optional AbortSignal, allowing for graceful cancellation of directory traversals.
- Updated the `runAgent` function to utilize the new timeout and abort handling, ensuring tools can be aborted without affecting the overall agent execution.
- Refactored the `grepTool`, `globTool`, and `fileSearchTool` to incorporate abort signal handling, improving responsiveness during long-running operations.
- Added utility functions for managing tool timeouts and signal handling across the agent's tools.
2026-07-15 15:19:38 +03:00
Pawan Osman 1bfe813edd Update CHANGELOG for version 0.0.3 with new features, changes, and fixes
- Added indexing toggle in settings, persistent semantic index, incremental re-indexing, real-time auto-indexing, and context size dropdown for models.
- Changed smart conversation summarization triggers, subagent history management, multitask handling, and stop/cancel behavior.
- Fixed issues with stuck subagent spinners, orphaned shell processes, and context ring defaults.
- Updated version in package.json to 0.0.3.
2026-07-15 14:30:40 +03:00
Pawan Osman 506a8e14c8 Refactor sidebar context picker and enhance styling
- Updated the `ContextPicker` component to improve button styling and dropdown functionality, replacing the `ctx-chip` class with `ctx-pill` for better visual consistency.
- Enhanced dropdown item layout with new classes for improved accessibility and user experience.
- Adjusted positioning logic for the dropdown menu to ensure it remains within viewport bounds.
2026-07-15 14:28:23 +03:00
Pawan Osman 75955a8033 Enhance context management and UI components in agent and sidebar
- Updated the `runAgent` function to improve context handling for subagents, ensuring isolated execution and clearer event emission.
- Refactored context budget logic to optimize summarization at 80% usage, enhancing performance during agent runs.
- Introduced a new `ContextPicker` component in the sidebar for selecting context sizes, improving user interaction with model options.
- Added styling for context chips and dropdowns to enhance the UI experience in the sidebar.
2026-07-15 14:24:16 +03:00
Pawan Osman 268b0e28de Enhance agent context handling and feature store options
- Updated the `runAgent` function to include additional parameters for context management, ensuring isolated execution for subagents.
- Introduced a new `parseContextLabel` function to convert context size labels into token counts, improving flexibility for model configurations.
- Enhanced the `optionsFor` method in `FeatureStore` to ensure every model has a context window option, providing a fallback for models without preset context values.
- Refactored sidebar provider logic to utilize the new context parsing function, streamlining context size handling across the application.
2026-07-15 14:21:53 +03:00
Pawan Osman 1baa6f8c58 Refactor agent run logic and enhance cancellation handling
- Improved the `runAgent` function to handle parent and child abort signals more robustly, ensuring proper cleanup and cancellation of subagents.
- Introduced a new `raceAbort` function to manage timeouts and abort conditions for running promises, preventing hangs during execution.
- Enhanced the `forceSettleOpenWork` function to mark open tools and subagents as cancelled or closed, improving the user experience during interruptions.
- Updated the UI components to reflect the new cancellation logic, ensuring that the status of ongoing operations is accurately communicated to users.
- Refactored various sections of the codebase to streamline the handling of agent runs and their associated states.
2026-07-15 14:14:50 +03:00
Pawan Osman 3da6b8675b Enhance background subagent handling in runAgent function
- Introduced a new function `awaitPendingBg` to manage background subagents more effectively, ensuring that the main agent waits for all unreported subagents to settle before proceeding.
- Updated the logic to handle in-flight background tasks, preventing premature model calls while background work is still ongoing.
- Improved status notifications to inform users about the waiting state for background subagents.
- Refactored existing code to streamline the handling of background tasks and their results.
2026-07-15 14:05:32 +03:00
Pawan Osman 2f04612e8d Implement incremental indexing and file watching for semantic updates
- Introduced a new `indexWatch` module to handle debounced file watching for incremental updates to the semantic index.
- Updated `activate` function to initialize the index watcher and replace the previous build index call with a warm disk and incremental sync.
- Enhanced the `semanticIndex` module to support enabling/disabling indexing and improved index path normalization.
- Modified the settings panel and UI components to include an option for enabling/disabling indexing, reflecting changes in the feature store.
- Ensured that indexing operations respect the new `indexingEnabled` flag across various functionalities, including search and document indexing.
2026-07-15 13:56:26 +03:00
Pawan Osman 72cbd0b120 Add VS Code configuration files for extension development
- Created .vscode/extensions.json to recommend essential extensions.
- Added .vscode/launch.json for launch configurations to run the extension.
- Introduced .vscode/settings.json to customize workspace settings, including file exclusion.
- Implemented .vscode/tasks.json to define build and watch tasks for TypeScript and esbuild.
2026-07-15 13:48:55 +03:00
Pawan Osman f8ea587099 Update CHANGELOG for version 0.0.2 and implement workspace-specific conversation storage
- Added per-workspace conversations with automatic migration from global conversations.
- Implemented auto-loading of GGUF models with a loading card in chat.
- Enhanced llama.cpp server to use random free ports with retry on bind failure.
- Improved composer dropdown positioning and shared model selection across composers.
- Fixed production error related to missing '@huggingface/hub' package.
- Removed MCP tool marketplace.
- Updated version in package.json to 0.0.2.
2026-07-05 23:30:49 +03:00
38 changed files with 3916 additions and 979 deletions
+5
View File
@@ -0,0 +1,5 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": ["dbaeumer.vscode-eslint", "connor4312.esbuild-problem-matchers", "ms-vscode.extension-test-runner"]
}
+41
View File
@@ -0,0 +1,41 @@
// A launch configuration that compiles the extension and then opens it inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--new-window",
"--disable-extensions",
"--trace-deprecation"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}"
},
{
"name": "Run Extension (clean profile)",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--user-data-dir=${workspaceFolder}/.vscode-dev-profile",
"--extensions-dir=${workspaceFolder}/.vscode-dev-extensions",
"--profile-temp",
"--new-window",
"--disable-extensions"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.exclude": {
"out": false, // set this to true to hide the "out" folder with the compiled JS files
"dist": false // set this to true to hide the "dist" folder with the compiled JS files
},
"search.exclude": {
"out": true, // set this to false to include "out" folder in search results
"dist": true // set this to false to include "dist" folder in search results
},
// Turn off tsc task auto detection since we have the necessary tasks as npm scripts
"js/ts.tsc.autoDetect": "off"
}
+79
View File
@@ -0,0 +1,79 @@
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
"version": "2.0.0",
"tasks": [
{
"label": "watch",
"dependsOn": [
"npm: watch:tsc",
"npm: watch:esbuild"
],
"presentation": {
"reveal": "never"
},
"group": {
"kind": "build",
"isDefault": true
}
},
{
"type": "npm",
"script": "watch:esbuild",
"group": "build",
"problemMatcher": {
"owner": "esbuild",
"fileLocation": "absolute",
"pattern": {
"regexp": "^✘ \\[ERROR\\] (.+)\\n\\n\\s+(.+):(\\d+):(\\d+):$",
"message": 1,
"file": 2,
"line": 3,
"column": 4
},
"background": {
"activeOnStart": true,
"beginsPattern": "\\[watch\\] build started",
"endsPattern": "\\[watch\\] build finished"
}
},
"isBackground": true,
"label": "npm: watch:esbuild",
"presentation": {
"group": "watch",
"reveal": "never"
}
},
{
"type": "npm",
"script": "watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"label": "npm: watch:tsc",
"presentation": {
"group": "watch",
"reveal": "never"
}
},
{
"type": "npm",
"script": "watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"presentation": {
"reveal": "never",
"group": "watchers"
},
"group": "build"
},
{
"label": "tasks: watch-tests",
"dependsOn": [
"npm: watch",
"npm: watch-tests"
],
"problemMatcher": []
}
]
}
+89
View File
@@ -4,6 +4,95 @@ All notable changes to the "ocursor" extension will be documented in this file.
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [0.0.5] - 2026-07-15
### Added
- Live timeout countdown badges on tools/tasks; kill at zero via host abort
- Shell tool card redesign: full command wrap, meta/body/footer, copy-command button
- Hard budgets for foreground/background subagents so Tasks cannot hang forever
- Stream coalescing for high-frequency agent/UI events (text/thinking/tool args)
- Read tool wall-clock timeouts (`stat` + I/O) and abort-aware path access
- Path normalizer for spaces, quotes, `file://` URIs, and mixed separators
### Fixed
- Tools stuck “Working” after timeout (immediate UI settle + cancel path)
- Read hanging on missing/unreachable/network paths (timeout could not terminate)
- Shell stuck on paths with spaces; PowerShell framing + session queue races
- Invalid path throws in Read/ListDir/Glob and related tools (user-friendly errors)
- Directory paths on Read return a clear error (suggest ListDir/Glob)
- UI freezes from high-frequency stream postMessage / React re-renders
- Read-only tools thrashing CPU/IO when many run in parallel (concurrency cap)
### Changed
- TodoWrite / TodoRead default timeout 5s → 15s
- Read default timeout tightened to match inner I/O budget
- Task tool included in configurable timeouts with countdown UI
## [0.0.4] - 2026-07-15
### Added
- Per-tool hard timeouts so hung Grep/Glob/Shell/etc. cannot block the agent loop forever
- Abort-signal support for long-running tools (walk, grep, shell) so Stop cancels mid-work
- Configurable per-tool timeout seconds in Settings → Agents
- GPU-accelerated local embeddings when available (DirectML / CUDA / CoreML / WebGPU), with CPU fallback
- Indexing page shows GPU/CPU badge plus model and runtime technical details (repo, dtype, ONNX EP, platform)
- Stricter indexable-file filters (source extensions only; skip lockfiles, minified bundles, binaries)
### Changed
- Expanded ignored directories for tools and indexing (`node_modules`, build caches, venvs, vendor, etc.)
- Semantic index walk and file watcher skip non-source trees earlier for faster indexing
## [0.0.3] - 2026-07-15
### Added
- Indexing enable/disable toggle in settings (fully turns off semantic indexing)
- Persistent semantic index across VS Code restarts (warm load from disk)
- Incremental re-index of only changed files on sync/reopen
- Real-time auto-index of new/modified files via workspace file watcher
- Context size dropdown beside the model picker for models without catalog presets
- Default context options (`32k``1m`) injected for uncatalogued models
### Changed
- Smart conversation summarization triggers at 80% of the usable context budget
- Subagents run with isolated history (empty parent context); parent only receives the final Task result
- Multitask/background Task waves wait for completion before the parent continues
- Stop/cancel aborts all linked subagents and force-settles open tools, thinking, and compaction UI
### Fixed
- Stuck “working” subagent spinners and unresponsive stop in multitask mode
- Orphaned shell processes when a run is aborted mid-command
- Context ring default aligned with resolved `max_context` (fallback `128k`)
## [0.0.2] - 2026-07-05
### Added
- Per-workspace conversations (existing global conversations migrate automatically)
- GGUF models auto-load on first message with a "loading model" card in chat
- llama.cpp server uses random free ports with retry on bind failure
### Changed
- Composer dropdowns (model picker, mode menu) now position themselves within the viewport and work in edit mode
- All composers share one selected model and mode
- Auto model selection hidden for now; first enabled model is the default
### Fixed
- Production error: `Cannot find package '@huggingface/hub'` (runtime deps now resolved via file URLs)
### Removed
- MCP tool marketplace
## [0.0.1] - 2026-07-05
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ocursor",
"displayName": "OpenCursor Agent",
"description": "AI coding agent chat inside VS Code",
"version": "0.0.1",
"version": "0.0.5",
"publisher": "pkrd",
"license": "MIT",
"icon": "media/icon.png",
+121
View File
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
*
* This file is part of OpenCursor — AI coding agent chat inside VS Code.
* https://github.com/PawanOsman/OpenCursor
*
* Licensed under the MIT License. See LICENSE file in the project root.
*/
// Debounced workspace file watcher → incremental semantic index updates.
import * as vscode from "vscode";
import * as path from "path";
import { upsertFile, removeFile, buildIndex, setIndexingEnabled, warmIndex, isIndexingEnabled } from "./semanticIndex";
import { getWorkspaceRoot } from "../context/workspaceUtils";
import type { FeatureStore } from "../stores/featureStore";
const DEBOUNCE_MS = 800;
const pending = new Map<string, "up" | "del">(); // abs path -> action
let timer: ReturnType<typeof setTimeout> | null = null;
let featureStore: FeatureStore | null = null;
let flushing = false;
function scheduleFlush(): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
void flush();
}, DEBOUNCE_MS);
}
async function flush(): Promise<void> {
if (flushing) {
scheduleFlush();
return;
}
if (!isIndexingEnabled()) {
pending.clear();
return;
}
const root = getWorkspaceRoot();
if (!root || !pending.size) return;
flushing = true;
try {
const batch = new Map(pending);
pending.clear();
for (const [abs, action] of batch) {
try {
if (action === "del") await removeFile(root, abs);
else await upsertFile(root, abs);
} catch {
/* ignore single-file failures */
}
}
} finally {
flushing = false;
if (pending.size) scheduleFlush();
}
}
function onFs(uri: vscode.Uri, action: "up" | "del"): void {
if (!isIndexingEnabled()) return;
if (uri.scheme !== "file") return;
const root = getWorkspaceRoot();
if (!root) return;
const abs = uri.fsPath;
if (!abs.startsWith(root) && !abs.toLowerCase().startsWith(root.toLowerCase())) return;
// Skip vendor/build/non-source (upsertFile also filters; early-out saves work).
const rel = path.relative(root, abs).split(path.sep).join("/");
if (!rel || rel.startsWith("..")) return;
if (
/(^|\/)(node_modules|\.git|dist|out|build|\.next|\.nuxt|\.output|\.turbo|\.cache|coverage|\.venv|venv|__pycache__|target|vendor|Pods|\.gradle|\.idea|\.vscode|bower_components|jspm_packages|\.pnpm-store|\.yarn|site-packages|\.terraform|\.svelte-kit|\.angular|storybook-static)(\/|$)/.test(
rel,
)
) {
return;
}
pending.set(abs, action);
scheduleFlush();
}
/** Wire indexing enable flag, warm disk index, incremental sync, file watcher. */
export function initIndexWatch(context: vscode.ExtensionContext, store: FeatureStore): void {
featureStore = store;
let lastEnabled = store.get().indexingEnabled !== false;
setIndexingEnabled(lastEnabled);
const root = getWorkspaceRoot();
void warmIndex(root).then(() => {
if (lastEnabled) void buildIndex(root).catch(() => {});
});
const watcher = vscode.workspace.createFileSystemWatcher("**/*");
context.subscriptions.push(
watcher,
watcher.onDidCreate((u) => onFs(u, "up")),
watcher.onDidChange((u) => onFs(u, "up")),
watcher.onDidDelete((u) => onFs(u, "del")),
vscode.workspace.onDidSaveTextDocument((doc) => onFs(doc.uri, "up")),
store.onDidChange(() => {
const on = store.get().indexingEnabled !== false;
if (on === lastEnabled) return;
lastEnabled = on;
setIndexingEnabled(on);
if (on) void buildIndex(getWorkspaceRoot()).catch(() => {});
}),
vscode.workspace.onDidChangeWorkspaceFolders(() => {
if (!isIndexingEnabled()) return;
const f = featureStore?.get();
if (f && f.indexNewFolders === false) return;
const r = getWorkspaceRoot();
void warmIndex(r).then(() => buildIndex(r).catch(() => {}));
}),
{
dispose: () => {
if (timer) clearTimeout(timer);
pending.clear();
},
},
);
}
+61 -18
View File
@@ -20,9 +20,10 @@ import * as fs from "fs/promises";
import { createWriteStream } from "fs";
import * as path from "path";
import * as os from "os";
import * as net from "net";
import { spawn, execFile } from "child_process";
import * as vscode from "vscode";
import { ensureRuntimeDeps } from "../runtimeDeps";
import { importRuntimeDep } from "../runtimeDeps";
/**
* llama-server launch configuration. Used both as the global default and as a
@@ -132,7 +133,18 @@ const LEGACY_BIN = "llama-server";
/** Resolved at install-check time: argv prefix to launch the server. */
let serverCmd: { bin: string; pre: string[] } = { bin: UNIFIED_BIN, pre: ["serve"] };
const MAX_LOG_LINES = 500;
let BASE_PORT = 8080;
/** Ask the OS for a free ephemeral port (bind :0, read the assigned port). */
function getFreePort(host = "127.0.0.1"): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once("error", reject);
srv.listen(0, host, () => {
const port = (srv.address() as net.AddressInfo).port;
srv.close(() => resolve(port));
});
});
}
let modelsDir: string | undefined;
let extCtx: vscode.ExtensionContext | undefined;
@@ -225,8 +237,7 @@ export async function installLlamacpp(): Promise<void> {
// ---- HF GGUF search ----
export async function searchGguf(query: string, limit = 20): Promise<HfGgufResult[]> {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
const hub = await import("@huggingface/hub");
const hub = await importRuntimeDep("@huggingface/hub");
const out: HfGgufResult[] = [];
for await (const m of hub.listModels({
search: { query, tags: ["gguf"] },
@@ -240,8 +251,7 @@ export async function searchGguf(query: string, limit = 20): Promise<HfGgufResul
/** List the .gguf files inside a repo so the user can pick a quantization. */
export async function listRepoGgufFiles(repo: string): Promise<HfGgufResult[]> {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
const hub = await import("@huggingface/hub");
const hub = await importRuntimeDep("@huggingface/hub");
const files: HfGgufResult[] = [];
for await (const f of hub.listFiles({ repo, recursive: true })) {
if (f.type === "file" && /\.gguf$/i.test(f.path)) {
@@ -297,7 +307,6 @@ export async function importGguf(srcPath: string): Promise<LlamacppModel> {
return makeModel({ file: base, filePath: dest, sizeBytes: stat.size, name: path.basename(base, ".gguf") });
}
let portCursor = 0;
function makeModel(p: { repo?: string; file: string; filePath: string; sizeBytes?: number; name: string }): LlamacppModel {
return {
id: modelId(p.repo, p.file),
@@ -306,7 +315,7 @@ function makeModel(p: { repo?: string; file: string; filePath: string; sizeBytes
repo: p.repo,
file: p.file,
sizeBytes: p.sizeBytes,
port: BASE_PORT + (portCursor++ % 100),
port: 0, // assigned per-load: a fresh random free port every time
autoLoad: false,
};
}
@@ -347,9 +356,11 @@ export function effectiveContextLength(m: LlamacppModel, globalCtx: number): num
return cfg.ctxSize ?? globalCtx;
}
/** Effective bind port: per-model config override (when set) else the model's assigned port. */
/** Effective bind port: the running server's port, else a per-model config override. */
function effectivePort(m: LlamacppModel, cfg: LlamacppServerConfig): number {
return m.useCustomConfig && cfg.port ? cfg.port : m.port;
const r = running.get(m.id);
if (r) return r.port;
return (m.useCustomConfig && cfg.port) || 0;
}
/** Base URL of a model's local OpenAI-compatible server (no trailing slash). */
@@ -360,8 +371,8 @@ export function serverUrlFor(m: LlamacppModel, globalCfg?: LlamacppServerConfig)
}
/** Build the llama-server argv from a model + effective config. */
function buildArgs(m: LlamacppModel, cfg: LlamacppServerConfig): string[] {
const args: string[] = ["-m", m.filePath, "--port", String(effectivePort(m, cfg)), "--host", cfg.host || "127.0.0.1"];
function buildArgs(m: LlamacppModel, cfg: LlamacppServerConfig, port: number): string[] {
const args: string[] = ["-m", m.filePath, "--port", String(port), "--host", cfg.host || "127.0.0.1"];
if (cfg.ctxSize != null) args.push("--ctx-size", String(cfg.ctxSize));
args.push(cfg.jinja === false ? "--no-jinja" : "--jinja");
if (cfg.flashAttn) args.push("-fa", cfg.flashAttn);
@@ -390,19 +401,53 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
errors.delete(m.id);
logs.set(m.id, []); // fresh log per load
loading.set(m.id, true);
emit(); // surface loading state immediately
// Back-compat: callers used to pass a global context length number.
const gcfg: LlamacppServerConfig | undefined =
typeof globalCfg === "number" ? { ctxSize: globalCfg } : globalCfg;
const cfg = effectiveConfig(m, gcfg);
const port = effectivePort(m, cfg);
const host = cfg.host && cfg.host !== "0.0.0.0" ? cfg.host : "127.0.0.1";
const argv = [...serverCmd.pre, ...buildArgs(m, cfg)];
// Always launch on a fresh OS-assigned random port. If the server still
// fails to bind (TOCTOU race with another process), retry with a new one.
const MAX_BIND_TRIES = 3;
let lastErr: Error | null = null;
for (let attempt = 1; attempt <= MAX_BIND_TRIES; attempt++) {
let port: number;
try {
port = await getFreePort(cfg.host || "127.0.0.1");
} catch (e: any) {
lastErr = new Error(`could not find a free port: ${e?.message || e}`);
break;
}
try {
await spawnServer(m, cfg, host, port);
return; // loaded
} catch (e: any) {
lastErr = e instanceof Error ? e : new Error(String(e));
// Bind failure → retry on a new random port; anything else is fatal.
const bindFail = /couldn't bind|address already in use|EADDRINUSE|HTTP server error/i.test(lastErr.message) ||
(logs.get(m.id) || []).some((l) => /couldn't bind|address already in use/i.test(l));
if (!bindFail) break;
appendLog(m.id, `[retry] port ${port} unavailable, trying a new random port (${attempt}/${MAX_BIND_TRIES})`);
}
}
loading.delete(m.id);
const msg = lastErr?.message || "failed to start server";
errors.set(m.id, msg);
emit();
throw new Error(msg);
}
/** Spawn one llama-server on `port` and resolve when /health reports ready. */
function spawnServer(m: LlamacppModel, cfg: LlamacppServerConfig, host: string, port: number): Promise<void> {
const argv = [...serverCmd.pre, ...buildArgs(m, cfg, port)];
appendLog(m.id, `$ ${serverCmd.bin} ${argv.join(" ")}`);
const proc = spawn(serverCmd.bin, argv, { stdio: ["ignore", "pipe", "pipe"] });
running.set(m.id, { proc, port });
proc.stdout?.on("data", (b) => appendLog(m.id, b.toString()));
proc.stderr?.on("data", (b) => appendLog(m.id, b.toString()));
emit(); // surface loading state immediately
emit();
let resolved = false;
return new Promise<void>((resolve, reject) => {
@@ -410,8 +455,6 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
if (resolved) return;
resolved = true;
running.delete(m.id);
loading.delete(m.id);
errors.set(m.id, msg);
appendLog(m.id, `[error] ${msg}`);
emit();
reject(new Error(msg));
@@ -432,7 +475,7 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
if (r.ok) {
resolved = true;
loading.delete(m.id);
appendLog(m.id, "[ready] model loaded");
appendLog(m.id, `[ready] model loaded on port ${port}`);
emit();
resolve();
return;
+454 -73
View File
@@ -9,7 +9,7 @@
import { streamChat, SamplingParams, ModelParams } from "./provider";
import type { OAuthKind } from "./oauth";
import { TOOLS, schemasForMode, toolsForMode, resetTodos, getTodos, disposeShellSession, EDIT_TOOLS, type AskQuestionItem, type ToolContext } from "./tools";
import { TOOLS, schemasForMode, toolsForMode, resetTodos, getTodos, disposeShellSession, EDIT_TOOLS, toolTimeoutMs, withToolTimeout, type AskQuestionItem, type ToolContext } from "./tools";
import { actionTypeForCall } from "./approvalPolicy";
import { getWorkspaceRoot } from "../context/workspaceUtils";
import { systemPrompt } from "./prompt";
@@ -30,9 +30,98 @@ const MULTITASK_REMINDER =
"(run_in_background=true), launching multiple subagents AT THE SAME TIME in a single turn.\n</reminder>";
const MAX_STEPS = 50;
/** Foreground subagent hard budget (abort + settle). */
const SUBAGENT_MAX_MS = 6 * 60_000;
/** Background subagent hard budget. */
const BG_SUBAGENT_MAX_MS = 10 * 60_000;
/** Coalesce high-frequency stream UI events (ms). */
const STREAM_COALESCE_MS = 40;
/**
* Batch text/thinking/tool-args deltas so streaming cannot flood the host
* reducer + webview (main cause of UI freezes that look like "stuck" tools).
* Terminal events flush pending deltas first to preserve order.
*/
function coalesceEmit(raw: (e: AgentEvent) => void): (e: AgentEvent) => void {
const pending = new Map<string, AgentEvent>();
let timer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
timer = undefined;
if (!pending.size) return;
const batch = [...pending.values()];
pending.clear();
for (const e of batch) {
try { raw(e); } catch { /* ignore */ }
}
};
const schedule = () => {
if (!timer) timer = setTimeout(flush, STREAM_COALESCE_MS);
};
return (event: AgentEvent) => {
if (event.type === "text-delta") {
const prev = pending.get("text");
if (prev && prev.type === "text-delta") {
pending.set("text", { type: "text-delta", text: prev.text + event.text });
} else {
pending.set("text", event);
}
schedule();
return;
}
if (event.type === "thinking-delta") {
const prev = pending.get("think");
if (prev && prev.type === "thinking-delta") {
pending.set("think", { type: "thinking-delta", text: prev.text + event.text });
} else {
pending.set("think", event);
}
schedule();
return;
}
if (event.type === "tool-call-args") {
// Latest full argsText wins (provider sends cumulative chunks).
pending.set(`args:${event.callId}`, event);
schedule();
return;
}
if (event.type === "subagent-event") {
const child = event.event;
// Coalesce nested high-freq child stream events per parent call.
if (child.type === "text-delta" || child.type === "thinking-delta" || child.type === "tool-call-args") {
const key =
child.type === "tool-call-args"
? `sub:${event.callId}:args:${child.callId}`
: `sub:${event.callId}:${child.type}`;
if (child.type === "text-delta" || child.type === "thinking-delta") {
const prev = pending.get(key);
if (prev && prev.type === "subagent-event" && prev.event.type === child.type) {
pending.set(key, {
type: "subagent-event",
callId: event.callId,
event: { type: child.type, text: (prev.event as { text: string }).text + child.text },
});
} else {
pending.set(key, event);
}
} else {
pending.set(key, event);
}
schedule();
return;
}
}
// Ordering: flush coalesced deltas before discrete events.
if (pending.size) {
if (timer) { clearTimeout(timer); timer = undefined; }
flush();
}
try { raw(event); } catch { /* ignore */ }
};
}
export async function runAgent(opts: RunAgentOptions): Promise<void> {
const { apiBaseUrl, apiKey, model, prompt, attachments, history, maxTokens, maxSteps, autoContinue, contextTokens, sampling, modelParams, anthropic, oauthKind, systemPromptOverride, extraInstructions, enableFileReading, enableTerminalSuggestions, enableWorkspaceContext, approve, isSubagent, customSubagents, subagentModel, registerSubagentAbort, askUser, onAfterRun, onBeforeShell, onAfterEdit, onHook, signal, emit } = opts;
const { apiBaseUrl, apiKey, model, prompt, attachments, history, maxTokens, maxSteps, autoContinue, contextTokens, sampling, modelParams, anthropic, oauthKind, systemPromptOverride, extraInstructions, enableFileReading, enableTerminalSuggestions, enableWorkspaceContext, approve, isSubagent, customSubagents, subagentModel, registerSubagentAbort, askUser, onAfterRun, onBeforeShell, onAfterEdit, onHook, signal, emit: rawEmit } = opts;
const emit = coalesceEmit(rawEmit);
// Mutable so the SwitchMode tool can change it mid-run.
let mode = opts.mode;
// multitask is agentic (full tool access); treat it like agent for gating.
@@ -84,12 +173,26 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// Per-subagent abort: child controller linked to the parent signal so the
// user can stop just this subagent and return to the parent.
const childAC = new AbortController();
const onParentAbort = () => childAC.abort();
(subSignal ?? signal).addEventListener("abort", onParentAbort);
const parentSig = subSignal ?? signal;
const onParentAbort = () => {
try { childAC.abort(); } catch { /* ignore */ }
};
if (parentSig.aborted) onParentAbort();
else parentSig.addEventListener("abort", onParentAbort, { once: true });
if (callId && registerSubagentAbort) {
registerSubagentAbort(callId, () => childAC.abort());
registerSubagentAbort(callId, () => {
try { childAC.abort(); } catch { /* ignore */ }
});
}
let finalText = "";
const budgetMs = opts?.runInBackground ? BG_SUBAGENT_MAX_MS : SUBAGENT_MAX_MS;
let budgetHit = false;
const budgetTimer = setTimeout(() => {
budgetHit = true;
try { childAC.abort(); } catch { /* ignore */ }
}, budgetMs);
// Isolated chat: empty history, own context budget. Parent only gets
// the final summary string (tool result / run-result) — never sub steps.
const runP = runAgent({
apiBaseUrl,
apiKey,
@@ -97,25 +200,26 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
mode: subReadonly ? "ask" : "agent",
prompt: subPrompt,
history: [],
maxTokens,
contextTokens,
sampling,
modelParams,
anthropic,
oauthKind,
systemPromptOverride: subSystemOverride,
enableFileReading,
enableTerminalSuggestions,
enableWorkspaceContext,
approve,
isSubagent: true,
signal: childAC.signal,
emit: (e) => {
if (e.type === "run-result") {
finalText = e.text;
}
// Forward the subagent's stream to the parent so the UI can render it
// as a nested read-only sub-chat keyed by the task call id.
if (callId) {
emit({ type: "subagent-event", callId, event: e });
}
if (e.type === "run-result") finalText = e.text;
// UI stream only — not parent history. Coalesced via parent emit.
if (callId) emit({ type: "subagent-event", callId, event: e });
},
}).finally(() => {
clearTimeout(budgetTimer);
});
// Background subagents return immediately; they keep streaming via emit.
if (opts?.runInBackground) {
@@ -124,19 +228,43 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// and capture its summary so it can be fed back into the loop on completion.
const idx = bgSubagents.length;
const tracked = runP
.then(() => ({ title, text: finalText || "(subagent finished with no summary)" }))
.catch((e) => ({ title, text: `(subagent failed: ${e instanceof Error ? e.message : String(e)})` }))
.then(() => ({
title,
text: budgetHit
? `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`
: (finalText || "(subagent finished with no summary)"),
}))
.catch((e) => ({
title,
text: budgetHit
? `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`
: `(subagent failed: ${e instanceof Error ? e.message : String(e)})`,
}))
.finally(() => {
(subSignal ?? signal).removeEventListener("abort", onParentAbort);
parentSig.removeEventListener("abort", onParentAbort);
onHook?.("subagentStop", { subagent: title });
});
bgSubagents.push(tracked);
void tracked.then((v) => { bgSettled[idx] = v; });
// Mark nested status running so UI countdown keeps ticking after parent tool completes.
if (callId) emit({ type: "subagent-event", callId, event: { type: "run-status", status: "running" } });
return `Launched ${title} in the background${callId ? ` (call ${callId})` : ""}. It will keep working and stream its results; you do not need to wait or poll for it. When all background subagents finish, their summaries will be delivered to you automatically and you can continue.`;
}
await runP;
(subSignal ?? signal).removeEventListener("abort", onParentAbort);
onHook?.("subagentStop", { subagent: subagentName || "subagent" });
try {
await runP;
} catch (e) {
if (budgetHit) return `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`;
if (childAC.signal.aborted || parentSig.aborted) {
return "(subagent cancelled)";
}
return `(subagent failed: ${e instanceof Error ? e.message : String(e)})`;
} finally {
clearTimeout(budgetTimer);
parentSig.removeEventListener("abort", onParentAbort);
onHook?.("subagentStop", { subagent: subagentName || "subagent" });
}
if (budgetHit) return `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`;
if (childAC.signal.aborted || parentSig.aborted) return "(subagent cancelled)";
return finalText || "(subagent finished with no summary)";
};
}
@@ -197,6 +325,14 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
);
history.push({ kind: "user", text: prompt, attachments: attachments && attachments.length ? attachments : undefined });
let settledEmitted = false;
const emitSettled = (status: "finished" | "cancelled" | "error") => {
if (settledEmitted) return;
settledEmitted = true;
try {
emit({ type: "run-status", status });
} catch { /* never throw from settle */ }
};
emit({ type: "run-status", status: "running" });
// Last request's usage = actual context occupancy (cumulative sums overstate
@@ -228,6 +364,58 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
return done.length;
};
const bgPending = () => bgSubagents.length > bgReported;
/** Race a promise against abort + hard timeout so a stuck subagent cannot hang the chat forever. */
const raceAbort = <T,>(p: Promise<T>, ms = 120_000): Promise<T | "aborted" | "timeout"> =>
new Promise((resolve) => {
let done = false;
const finish = (v: T | "aborted" | "timeout") => {
if (done) return;
done = true;
resolve(v);
};
if (signal.aborted) { finish("aborted"); return; }
const onAbort = () => finish("aborted");
signal.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => finish("timeout"), ms);
p.then(
(v) => { clearTimeout(timer); signal.removeEventListener("abort", onAbort); finish(v); },
() => { clearTimeout(timer); signal.removeEventListener("abort", onAbort); finish("aborted"); },
);
});
/** Block until all unreported background subagents settle, then flush into history. */
const awaitPendingBg = async (): Promise<boolean> => {
if (!bgPending()) return false;
flushSettledBg();
if (!bgPending()) return true;
const pending = bgSubagents.slice(bgReported);
const n = pending.length;
emit({ type: "run-status", status: "running" });
emit({
type: "shell-notify",
message: `Waiting for ${n} background subagent${n > 1 ? "s" : ""} to finish — will resume when done…`,
});
const outcome = await raceAbort(Promise.allSettled(pending), 10 * 60_000);
if (outcome === "aborted" || signal.aborted) {
for (let i = bgReported; i < bgSubagents.length; i++) {
if (bgSettled[i] === undefined) bgSettled[i] = { title: "subagent", text: "(cancelled)" };
}
flushSettledBg();
return true;
}
if (outcome === "timeout") {
for (let i = bgReported; i < bgSubagents.length; i++) {
if (bgSettled[i] === undefined) bgSettled[i] = { title: "subagent", text: "(timed out waiting for subagent)" };
}
flushSettledBg();
return true;
}
flushSettledBg();
return true;
};
// Summarize older steps with the same model (non-streaming aggregate) so
// compaction keeps task intent, decisions, file paths and unfinished work.
const summarizeSteps = async (steps: Step[]): Promise<string> => {
@@ -265,7 +453,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
break;
}
if (signal.aborted) {
emit({ type: "run-status", status: "cancelled" });
emitSettled("cancelled");
return;
}
@@ -283,8 +471,11 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// so the compaction persists across steps and runs.
// Trigger on either the local estimate or the provider-reported prompt
// size of the previous request (authoritative when available).
if (budget > 0 && Math.max(stepsTokens(history) + Math.ceil(system.length / 4), lastPrompt) > budget * 0.8) {
const { prefix, tail } = splitForCompaction(history, Math.floor(budget * 0.3));
// Smart summarize at 80% of usable context budget.
const usedEst = stepsTokens(history) + Math.ceil(system.length / 4);
const fill = Math.max(usedEst, lastPrompt);
if (budget > 0 && fill >= budget * 0.8) {
const { prefix, tail } = splitForCompaction(history, Math.floor(budget * 0.35));
if (prefix.length >= 2) {
onHook?.("preCompact", { dropped: String(prefix.length), reason: "auto-summarize" });
// Visible in-chat marker while the summary is being generated.
@@ -348,9 +539,17 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
emit({ type: "thinking-delta", text: ev.text });
} else if (ev.type === "tool-call-start") {
// Surface the tool card the moment the model commits to a call.
// No startedAt yet — countdown begins when execute actually starts.
callIdByIndex.set(ev.index, ev.id);
argsByIndex.set(ev.index, "");
emit({ type: "tool-call-started", callId: ev.id, name: ev.name, input: {} });
const tMs = toolTimeoutMs(ev.name);
emit({
type: "tool-call-started",
callId: ev.id,
name: ev.name,
input: {},
timeoutMs: tMs > 0 ? tMs : undefined,
});
} else if (ev.type === "tool-call-args-delta") {
const id = callIdByIndex.get(ev.index);
const acc = (argsByIndex.get(ev.index) ?? "") + ev.delta;
@@ -385,6 +584,17 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
});
continue;
}
// In-flight background Task subagents: wait + feed results before any
// "continue" nudge. Otherwise the model gets another turn while workers
// are still running and often spawns a second wave of subagents.
if (bgPending()) {
await awaitPendingBg();
if (signal.aborted) {
emitSettled("cancelled");
return;
}
continue;
}
// Truncated response (hit max output tokens): the model didn't choose to
// stop — never treat this as a final answer. Ask it to continue.
if (isAgentic() && /length|max_tokens|max_output_tokens/i.test(finishReason)) {
@@ -425,24 +635,6 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
continue;
}
}
// Before truly finishing, if background subagents are still in flight (or
// completed but not yet reported), wait for them and feed their summaries
// back so the model continues its own loop and synthesizes the results.
if (bgSubagents.length > bgReported) {
// Report whatever already finished without blocking; only wait for the rest.
if (flushSettledBg() > 0) continue;
const pending = bgSubagents.slice(bgReported);
const n = pending.length;
emit({ type: "run-status", status: "running" });
emit({ type: "shell-notify", message: `Waiting for ${n} background subagent${n > 1 ? "s" : ""} to finish — will resume when done…` });
await Promise.allSettled(pending);
if (signal.aborted) {
emit({ type: "run-status", status: "cancelled" });
return;
}
flushSettledBg();
continue;
}
finalText = assistantText;
break;
}
@@ -460,12 +652,57 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// with {} would call tools with missing params — fail the call instead.
badArgs = true;
}
emit({ type: "tool-call-started", callId: call.id, name: call.name, input });
return { call, input, badArgs };
// MCP tools share CallMcpTool budget when no per-name override.
const tMs = call.name.startsWith("mcp__")
? toolTimeoutMs("CallMcpTool")
: toolTimeoutMs(call.name);
// Shell: countdown uses block_until_ms when shorter than tool budget.
let timeoutMs = tMs > 0 ? tMs : undefined;
if ((call.name === "Shell" || call.name === "AwaitShell") && !badArgs) {
const raw = typeof input?.block_until_ms === "number" ? input.block_until_ms : undefined;
if (raw !== undefined && raw > 0) {
const block = Math.min(raw, call.name === "Shell" ? 30_000 : 45_000);
timeoutMs = timeoutMs ? Math.min(timeoutMs, block) : block;
} else if (call.name === "Shell" && (raw === undefined || raw === null)) {
// Default foreground shell wait.
timeoutMs = timeoutMs ? Math.min(timeoutMs, 15_000) : 15_000;
}
}
// Task: use Task budget (foreground); bg still has BG_SUBAGENT_MAX_MS.
if (call.name === "Task" && input?.run_in_background === true) {
timeoutMs = BG_SUBAGENT_MAX_MS;
}
// Announce card + budget; startedAt set when exec actually begins.
emit({
type: "tool-call-started",
callId: call.id,
name: call.name,
input,
timeoutMs,
});
return { call, input, badArgs, timeoutMs };
});
const results = new Array<{ status: "completed" | "error"; output: string; diff?: string; startLine?: number; endLine?: number; image?: { mime: string; base64: string } }>(parsed.length);
const ro: Promise<void>[] = [];
const completedUi = new Set<number>();
const finishUi = (i: number) => {
if (completedUi.has(i) || !results[i]) return;
completedUi.add(i);
const { call } = parsed[i];
const r = results[i];
// Surface completion as soon as the tool settles — don't wait for
// siblings. Prevents one slow tool from freezing the whole card strip.
emit({
type: "tool-call-completed",
callId: call.id,
name: call.name,
status: r.status,
result: r.output,
diff: r.diff,
startLine: r.startLine,
endLine: r.endLine,
});
};
const exec = async (i: number) => {
const { call, input, badArgs } = parsed[i];
@@ -474,9 +711,10 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
status: "error",
output: `error: tool arguments were not valid JSON (likely truncated — the payload was too large). Retry with a smaller edit: split the change into multiple smaller ${call.name} calls.`,
};
finishUi(i);
return;
}
// MCP tool dispatch.
// MCP tool dispatch (same hard timeout + countdown as built-ins).
if (call.name.startsWith("mcp__")) {
if (!isAgentic()) {
// MCP tools may mutate; only allow in agentic modes.
@@ -497,8 +735,49 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
results[i] = { status: "error", output: `blocked by hook: ${mcpVeto}` };
return;
}
const out = await mcpManager.callTool(call.name, input);
results[i] = { status: out.startsWith("error:") ? "error" : "completed", output: out };
const limitMs = parsed[i].timeoutMs ?? toolTimeoutMs("CallMcpTool");
const toolAc = new AbortController();
const killTool = () => { try { toolAc.abort(); } catch { /* ignore */ } };
if (registerSubagentAbort) registerSubagentAbort(call.id, killTool);
emit({
type: "tool-call-started",
callId: call.id,
name: call.name,
input,
timeoutMs: limitMs > 0 ? limitMs : undefined,
startedAt: Date.now(),
});
const onParentAbort = () => killTool();
if (signal.aborted) onParentAbort();
else signal.addEventListener("abort", onParentAbort, { once: true });
try {
const out = await withToolTimeout(
Promise.resolve().then(() => mcpManager.callTool(call.name, input)),
limitMs,
call.name,
() => {
killTool();
results[i] = {
status: "error",
output: `error: timeout: ${call.name} exceeded ${Math.round((limitMs || 0) / 1000)}s. Tool aborted.`,
};
finishUi(i);
},
);
results[i] = { status: out.startsWith("error:") ? "error" : "completed", output: out };
finishUi(i);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
results[i] = {
status: "error",
output: msg.startsWith("timeout:")
? `error: ${msg}. Tool aborted.`
: `error: ${msg}`,
};
finishUi(i);
} finally {
signal.removeEventListener("abort", onParentAbort);
}
return;
}
const tool = TOOLS[call.name];
@@ -540,53 +819,143 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
}
}
try {
const r = await tool.execute(input, signal, call.id, toolCtx);
// Per-tool hard timeout + linked abort. On timeout: kill immediately
// and settle UI — never leave the card spinning "Working".
const limitMs = parsed[i].timeoutMs ?? toolTimeoutMs(call.name);
const toolAc = new AbortController();
const killTool = () => {
try { toolAc.abort(); } catch { /* ignore */ }
};
// Register so UI countdown-0 / cancelSubagent can kill any tool.
if (registerSubagentAbort) {
registerSubagentAbort(call.id, killTool);
}
// Countdown clock starts now (not when the card was announced).
emit({
type: "tool-call-started",
callId: call.id,
name: call.name,
input,
timeoutMs: limitMs > 0 ? limitMs : undefined,
startedAt: Date.now(),
});
const onParentAbort = () => killTool();
if (signal.aborted) onParentAbort();
else signal.addEventListener("abort", onParentAbort, { once: true });
let r: Awaited<ReturnType<typeof tool.execute>>;
let timedOut = false;
try {
r = await withToolTimeout(
Promise.resolve().then(() => tool.execute(input, toolAc.signal, call.id, toolCtx)),
limitMs,
call.name,
() => {
timedOut = true;
killTool();
// Immediate UI settle on timeout — don't wait for tool cleanup.
results[i] = {
status: "error",
output: `error: timeout: ${call.name} exceeded ${Math.round((limitMs || 0) / 1000)}s. Tool aborted - retry with a narrower scope or shorter command.`,
};
finishUi(i);
},
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
try { toolAc.abort(); } catch { /* ignore */ }
const isTo = timedOut || msg.startsWith("timeout:");
r = {
output: isTo
? `error: timeout: ${call.name} exceeded ${Math.round((limitMs || 0) / 1000)}s. Tool aborted - retry with a narrower scope or shorter command.`
: `error: ${msg}`,
};
} finally {
signal.removeEventListener("abort", onParentAbort);
}
const status: "completed" | "error" = r.output.startsWith("error:") ? "error" : "completed";
results[i] = { status, output: r.output, diff: r.diff, startLine: r.startLine, endLine: r.endLine, image: r.image };
// afterEdit hook on successful edits.
if (status === "completed" && isEditTool && onAfterEdit) {
onAfterEdit(String(input?.path ?? ""));
}
// Immediate UI settle (especially on timeout) — do not wait for siblings.
finishUi(i);
} catch (e) {
results[i] = { status: "error", output: `error: ${e instanceof Error ? e.message : String(e)}` };
finishUi(i);
}
};
// Early-exit paths inside exec that set results without finishUi.
const wrapExec = async (i: number) => {
try {
await exec(i);
} finally {
// Guarantee UI settles even if a branch forgot finishUi.
if (results[i]) finishUi(i);
else {
results[i] = { status: "error", output: "error: tool produced no result" };
finishUi(i);
}
}
};
// Cap parallel RO tools so a burst of Grep/Glob/Task can't thrash CPU/IO.
// Worker pool (not batch-wait): a long Task doesn't block the next free slot.
const RO_CONCURRENCY = 8;
const roIdx: number[] = [];
for (let i = 0; i < parsed.length; i++) {
const name = parsed[i].call.name;
const tool = TOOLS[name];
// Run read-only built-in tools in parallel; MCP + mutating tools serialize below.
if (tool && !tool.mutating && !name.startsWith("mcp__")) {
ro.push(exec(i));
}
if (tool && !tool.mutating && !name.startsWith("mcp__")) roIdx.push(i);
}
if (roIdx.length) {
let cursor = 0;
const workers = Array.from(
{ length: Math.min(RO_CONCURRENCY, roIdx.length) },
async () => {
while (cursor < roIdx.length) {
const i = roIdx[cursor++];
await wrapExec(i);
}
},
);
await Promise.all(workers);
}
await Promise.all(ro);
for (let i = 0; i < parsed.length; i++) {
const name = parsed[i].call.name;
const tool = TOOLS[name];
if (!tool || tool.mutating || name.startsWith("mcp__")) {
await exec(i);
await wrapExec(i);
}
}
for (let i = 0; i < parsed.length; i++) {
const { call } = parsed[i];
const r = results[i];
const r = results[i] ?? { status: "error" as const, output: "error: tool produced no result" };
if (call.name === "WritePlan" && r.status === "completed") {
planWritten = true;
}
emit({
type: "tool-call-completed",
callId: call.id,
name: call.name,
status: r.status,
result: r.output,
diff: r.diff,
startLine: r.startLine,
endLine: r.endLine,
});
// History in call order (model expects stable tool-result sequencing).
history.push({ kind: "tool-result", callId: call.id, name: call.name, output: r.output, status: r.status, image: r.image });
}
// After launching background Task(s), wait for that wave before calling the
// model again. Otherwise the next turn (or empty-turn / todo nudge) races
// ahead and the coordinator spawns more subagents while workers still run.
if (bgPending()) {
const launchedBg = parsed.some((p, i) => {
if (p.call.name !== "Task") return false;
const out = results[i]?.output || "";
return /Launched .+ in the background/i.test(out);
});
if (launchedBg) {
await awaitPendingBg();
if (signal.aborted) {
emitSettled("cancelled");
return;
}
}
}
}
// Paused at the step limit with work still in flight → surface a Continue
@@ -601,27 +970,39 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// conversation only contains "launched in background…" and a follow-up message
// makes the model believe the subagent is still running.
if (bgSubagents.length > bgReported) {
await Promise.allSettled(bgSubagents.slice(bgReported));
await awaitPendingBg();
if (signal.aborted) {
emit({ type: "run-status", status: "cancelled" });
emitSettled("cancelled");
return;
}
flushSettledBg();
}
emit({ type: "run-status", status: "finished" });
if (signal.aborted) {
emitSettled("cancelled");
return;
}
emitSettled("finished");
emit({ type: "run-result", text: finalText, durationMs: Date.now() - started });
if (!isSubagent && onAfterRun) {
onAfterRun();
}
} catch (e) {
if (signal.aborted) {
emit({ type: "run-status", status: "cancelled" });
emitSettled("cancelled");
return;
}
emit({ type: "error", message: e instanceof Error ? e.message : String(e) });
emit({ type: "run-status", status: "error" });
try { emit({ type: "error", message: e instanceof Error ? e.message : String(e) }); } catch { /* ignore */ }
emitSettled("error");
} finally {
// Force-mark any still-unsettled bg slots so we never hang a follow-up wait.
if (signal.aborted || !settledEmitted) {
for (let i = bgReported; i < bgSubagents.length; i++) {
if (bgSettled[i] === undefined) bgSettled[i] = { title: "subagent", text: "(cancelled)" };
}
bgReported = bgSubagents.length;
}
// Guarantee a terminal status even if the loop exited without one.
if (!settledEmitted) emitSettled(signal.aborted ? "cancelled" : "finished");
// Tear down this run's persistent shell session.
disposeShellSession(shellSessionKey);
try { disposeShellSession(shellSessionKey); } catch { /* ignore */ }
}
}
+87 -17
View File
@@ -170,28 +170,76 @@ async function* streamWithRetry(
}
}
/**
* OpenAI's `tool` role content is string-only, so a tool result that carries an
* image is split: the tool message keeps the text, and the image is forwarded
* in a trailing `user` message right after it.
*/
function normalizeOpenAIMessages(messages: WireMessage[]): WireMessage[] {
const out: WireMessage[] = [];
for (const m of messages) {
if (m.role === "tool" && Array.isArray(m.content)) {
const texts = m.content.filter((p): p is Extract<WireContentPart, { type: "text" }> => p.type === "text");
const images = m.content.filter((p): p is Extract<WireContentPart, { type: "image_url" }> => p.type === "image_url");
out.push({ role: "tool", tool_call_id: m.tool_call_id, content: texts.map((t) => t.text).join("\n") || "(image)" });
if (images.length) {
out.push({ role: "user", content: images as WireContentPart[] });
}
/** Drop Anthropic-only `cache_control` and empty text parts (xAI/Grok 400: Empty content block). */
function stripOpenAIParts(parts: WireContentPart[]): WireContentPart[] {
const out: WireContentPart[] = [];
for (const p of parts) {
if (p.type === "text") {
if (!p.text) continue;
out.push({ type: "text", text: p.text });
} else {
out.push(m);
out.push({ type: "image_url", image_url: p.image_url });
}
}
return out;
}
function openAIContent(content: string | WireContentPart[] | null | undefined): string | WireContentPart[] {
if (content == null) return "";
if (typeof content === "string") return content;
const parts = stripOpenAIParts(content);
if (!parts.length) return "";
if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
return parts;
}
/**
* OpenAI chat shape: string tool content, no Anthropic cache_control, no null/empty
* content blocks. xAI/Grok rejects those with `Empty content block`.
* Tool images → tool text + trailing user image message.
* Returns plain objects (not only WireMessage) so tool-only assistant can omit `content`.
*/
function normalizeOpenAIMessages(messages: WireMessage[]): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (const m of messages) {
if (m.role === "tool") {
if (Array.isArray(m.content)) {
const texts = m.content.filter((p): p is Extract<WireContentPart, { type: "text" }> => p.type === "text");
const images = m.content.filter((p): p is Extract<WireContentPart, { type: "image_url" }> => p.type === "image_url");
out.push({
role: "tool",
tool_call_id: m.tool_call_id,
content: texts.map((t) => t.text).join("\n") || (images.length ? "(image)" : "(empty)"),
});
if (images.length) {
out.push({ role: "user", content: stripOpenAIParts(images) });
}
} else {
out.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content || "(empty)" });
}
continue;
}
if (m.role === "assistant") {
const text = (typeof m.content === "string" ? m.content : "") || "";
const msg: Record<string, unknown> = { role: "assistant" };
// Never send null/empty content — Grok 400 "Empty content block".
if (text) msg.content = text;
else if (!m.tool_calls?.length) msg.content = "(empty)";
if (m.tool_calls?.length) msg.tool_calls = m.tool_calls;
out.push(msg);
continue;
}
const content = openAIContent(m.content);
if (content === "" || (Array.isArray(content) && content.length === 0)) {
if (m.role === "system") continue;
out.push({ role: "user", content: "(empty)" });
continue;
}
out.push({ role: m.role, content });
}
return out;
}
/** Generate a short conversation title from the first user message using the model. */
export async function generateTitle(apiBaseUrl: string, apiKey: string, model: string, userText: string, anthropic?: boolean, oauthKind?: OAuthKind): Promise<string> {
const sys = "Generate a concise 3-6 word title for a chat that starts with the user's message. The title must summarize the topic, not repeat the message.";
@@ -311,10 +359,32 @@ function parseTitle(content: string): string {
}
/** Auto mode judge: pick the best-suited model id from candidates for a task. */
export async function pickModel(apiBaseUrl: string, apiKey: string, judge: string, candidates: string[], task: string, anthropic?: boolean): Promise<string> {
export async function pickModel(apiBaseUrl: string, apiKey: string, judge: string, candidates: string[], task: string, anthropic?: boolean, oauthKind?: OAuthKind): Promise<string> {
const useAnthropic = anthropic ?? isAnthropic(apiBaseUrl);
const sys = `You route a coding task to the best model. Available models: ${candidates.join(", ")}. Reply with EXACTLY one model id from the list, nothing else.`;
const prompt = task.slice(0, 2000);
if (oauthKind) {
// OAuth judges (Claude Code / Codex) have no raw HTTP endpoint; stream a tiny completion.
let text = "";
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30_000);
try {
const gen = streamOAuthChat(oauthKind, {
model: judge,
messages: [{ role: "system", content: sys }, { role: "user", content: prompt }],
maxTokens: 64,
signal: ctrl.signal,
});
for await (const ev of gen) {
if (ev.type === "text-delta") text += ev.text;
}
} finally {
clearTimeout(timer);
}
// Reasoning models may emit <think> blocks; last non-empty line is the answer.
const lines = text.replace(/<think>[\s\S]*?<\/think>/gi, "").split("\n").map((l) => l.trim()).filter(Boolean);
return lines.length ? lines[lines.length - 1] : text.trim();
}
if (useAnthropic) {
const r = await fetch(`${apiBaseUrl}/messages`, {
method: "POST",
+347 -37
View File
@@ -8,8 +8,9 @@
*/
// Real local semantic codebase index.
// - Embeddings: @huggingface/transformers (Xenova/all-MiniLM-L6-v2, q8 ONNX/WASM)
// → 100% local, no server, no API key. Model downloads once to globalStorage.
// - Embeddings: @huggingface/transformers + onnxruntime-node
// (Xenova/all-MiniLM-L6-v2, q8). GPU when available (DML/CUDA/CoreML/WebGPU),
// else CPU. 100% local; model downloads once to globalStorage.
// - Chunking: sliding line-window per file (simple, language-agnostic).
// ponytail: line-window chunking; upgrade to tree-sitter AST chunks when
// ranking quality on large funcs matters.
@@ -21,7 +22,7 @@ import * as fs from "fs/promises";
import * as path from "path";
import * as crypto from "crypto";
import { walk } from "./tools/shared";
import { ensureRuntimeDeps } from "../runtimeDeps";
import { importRuntimeDep } from "../runtimeDeps";
// Selectable local embedding models. Add entries here to offer more choices.
export interface EmbedModel {
@@ -59,6 +60,7 @@ export function setEmbedModel(id: string): void {
if (m.id !== activeModel.id) {
activeModel = m;
extractorP = null; // force reload with new repo/dtype
embedDevice = null;
}
if (changed) {
memIndex = null; // index built with old model is stale
@@ -79,10 +81,41 @@ export function setRemoteEmbedModel(cfg: RemoteEmbedConfig): void {
const CHUNK_LINES = 40;
const CHUNK_OVERLAP = 10;
/** Source / doc extensions worth embedding. No binaries, lockfiles, or assets. */
const EMBED_EXTS = new Set([
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".java",
".c", ".h", ".cpp", ".cc", ".hpp", ".cs", ".rb", ".php", ".swift", ".kt",
".scala", ".md", ".json", ".html", ".css", ".scss", ".vue", ".svelte", ".sql",
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
".py", ".pyi", ".rs", ".go", ".java", ".kt", ".kts", ".scala",
".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".cs",
".rb", ".php", ".swift", ".m", ".mm",
".vue", ".svelte", ".astro",
".css", ".scss", ".less", ".sass",
".html", ".htm", ".sql", ".graphql", ".gql",
".md", ".mdx", ".rst", ".txt",
".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd",
".toml", ".yaml", ".yml", ".ini", ".cfg", ".conf",
".json", ".jsonc",
".proto", ".thrift", ".r", ".lua", ".ex", ".exs", ".erl", ".hs", ".clj", ".cljs",
".zig", ".nim", ".dart", ".tf", ".hcl",
]);
/** Path segment names to never index (modules, build, caches, VCS). */
const SKIP_DIR_SEGMENTS = new Set([
"node_modules", ".git", "dist", "out", "build", ".next", ".nuxt", ".output",
".turbo", ".cache", "coverage", ".venv", "venv", "__pycache__", ".tox",
".mypy_cache", ".pytest_cache", ".ruff_cache", "target", "vendor", "Pods",
".gradle", ".idea", ".vscode", "bower_components", "jspm_packages",
".pnpm-store", ".yarn", "site-packages", ".svn", ".hg", ".hgcheck",
"DerivedData", "xcuserdata", ".terraform", ".serverless", ".parcel-cache",
".svelte-kit", ".angular", "storybook-static", "cypress", "playwright-report",
"test-results", ".nyc_output", "htmlcov",
]);
/** Exact basenames that are never source to embed. */
const SKIP_BASENAMES = new Set([
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock",
"composer.lock", "Cargo.lock", "Gemfile.lock", "poetry.lock", "Pipfile.lock",
"go.sum", "flake.lock", "uv.lock",
".DS_Store", "Thumbs.db", "desktop.ini",
"LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING", "CHANGELOG.md", "CHANGELOG",
"package-lock.json",
]);
const MAX_FILE_BYTES = 512 * 1024;
@@ -105,20 +138,66 @@ export function setIndexStorageDir(dir: string): void {
}
// ---- Embedder (lazy, singleton) ----
// ONNX Runtime Node EPs: Win→dml, Linux x64→cuda, macOS→coreml, then webgpu, then cpu.
// transformers.js defaults to CPU only; we try GPU when available and fall back.
function preferredEmbedDevices(): string[] {
const order: string[] = [];
switch (process.platform) {
case "win32":
order.push("dml"); // DirectML (any DX12 GPU)
break;
case "linux":
if (process.arch === "x64") order.push("cuda"); // needs CUDA 12 + cuDNN
break;
case "darwin":
order.push("coreml");
break;
}
order.push("webgpu", "cpu");
return order;
}
let extractorP: Promise<any> | null = null;
let embedDevice: string | null = null;
/** Device the live embedder is using (null until first load). */
export function getEmbedDevice(): string | null {
return embedDevice;
}
async function getExtractor(): Promise<any | null> {
if (!storageDir) return null;
if (!extractorP) {
const m = activeModel;
extractorP = (async () => {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
const t = await import("@huggingface/transformers");
const t = await importRuntimeDep("@huggingface/transformers");
t.env.allowRemoteModels = true;
t.env.cacheDir = path.join(storageDir!, "models");
return t.pipeline("feature-extraction", m.repo, { dtype: m.dtype });
// Try GPU EPs first; ORT often fails hard if a provider's libs are missing,
// so probe one device at a time instead of device:"auto".
const devices = preferredEmbedDevices();
let lastErr: unknown;
for (const device of devices) {
try {
const pipe = await t.pipeline("feature-extraction", m.repo, {
dtype: m.dtype,
device,
});
embedDevice = device;
console.log(`[semanticIndex] embedder on ${device} (${m.id})`);
// UI may already be open; push device once the pipeline is ready.
if (memRoot) emitStatus(memRoot);
return pipe;
} catch (e) {
lastErr = e;
console.warn(`[semanticIndex] embedder device "${device}" unavailable, trying next…`);
}
}
throw lastErr ?? new Error("no embedder device available");
})().catch((e) => {
console.error("[semanticIndex] embedder load failed:", e);
extractorP = null;
embedDevice = null;
return null;
});
}
@@ -170,34 +249,61 @@ export async function embedTexts(texts: string[]): Promise<number[][] | null> {
}
// ---- Index lifecycle ----
/** Stable workspace key so Windows drive-letter case / trailing slashes don't orphan the index. */
function normRoot(root: string): string {
const r = path.resolve(root);
return process.platform === "win32" ? r.toLowerCase() : r;
}
function indexPath(root: string): string {
const id = crypto.createHash("sha1").update(root).digest("hex").slice(0, 16);
const id = crypto.createHash("sha1").update(normRoot(root)).digest("hex").slice(0, 16);
const mid = getEmbedModelId().replace(/[^\w.-]+/g, "_");
return path.join(storageDir!, `index-${id}-${mid}.json`);
}
let memIndex: IndexFile | null = null;
let memRoot: string | null = null;
let memRoot: string | null = null; // normRoot key
let indexingEnabled = true;
export function setIndexingEnabled(on: boolean): void {
indexingEnabled = on;
}
export function isIndexingEnabled(): boolean {
return indexingEnabled;
}
async function load(root: string): Promise<IndexFile> {
if (memIndex && memRoot === root) return memIndex;
const key = normRoot(root);
if (memIndex && memRoot === key) return memIndex;
try {
const raw = await fs.readFile(indexPath(root), "utf8");
const parsed = JSON.parse(raw) as IndexFile;
if (parsed.model === getEmbedModelId()) {
if (parsed.model === getEmbedModelId() && parsed.files && Array.isArray(parsed.chunks)) {
memIndex = parsed;
memRoot = root;
memRoot = key;
return parsed;
}
} catch {}
memIndex = { model: getEmbedModelId(), files: {}, chunks: [] };
memRoot = root;
memRoot = key;
return memIndex;
}
async function save(root: string, idx: IndexFile): Promise<void> {
await fs.mkdir(storageDir!, { recursive: true });
if (!storageDir) return;
await fs.mkdir(storageDir, { recursive: true });
await fs.writeFile(indexPath(root), JSON.stringify(idx), "utf8");
memIndex = idx;
memRoot = normRoot(root);
}
/** Load persisted index into memory (no embed work). Call on activate so status/UI show prior work. */
export async function warmIndex(root: string): Promise<IndexStatus> {
if (!storageDir || !root) return getStatus(root);
await load(root);
emitStatus(root);
return getStatus(root);
}
function chunkFile(rel: string, text: string): { start: number; end: number; text: string }[] {
@@ -225,7 +331,24 @@ export interface IndexStatus {
total: number;
files: number; // indexed files in store
chunks: number;
model: string; // active EmbedModel.id
model: string; // active EmbedModel.id or remote model id
/** "local" = onnxruntime-node; "remote" = provider /embeddings API. */
backend: "local" | "remote";
/** ONNX EP in use: dml | cuda | coreml | webgpu | cpu. Null until first load / remote. */
device: string | null;
/** GPU-class EP vs CPU (remote counts as neither — uses provider). */
accelerator: "gpu" | "cpu" | "remote" | "pending";
/** Human-readable device label for the UI. */
deviceLabel: string;
/** Active local model technical fields (undefined when remote). */
modelRepo?: string;
modelDtype?: string;
modelPooling?: string;
modelDim?: number;
/** Remote endpoint host when using a provider embedding model. */
remoteBaseUrl?: string;
runtime: string;
platform: string;
}
let progress = { done: 0, total: 0 };
const statusSubs = new Set<(s: IndexStatus) => void>();
@@ -233,8 +356,36 @@ export function onIndexStatus(fn: (s: IndexStatus) => void): () => void {
statusSubs.add(fn);
return () => statusSubs.delete(fn);
}
function deviceLabelOf(device: string | null, backend: "local" | "remote"): string {
if (backend === "remote") return "Remote API";
if (!device) return "Not loaded yet";
switch (device) {
case "dml":
return "GPU · DirectML";
case "cuda":
return "GPU · CUDA";
case "coreml":
return "GPU · CoreML";
case "webgpu":
return "GPU · WebGPU";
case "cpu":
return "CPU";
default:
return device;
}
}
function acceleratorOf(device: string | null, backend: "local" | "remote"): IndexStatus["accelerator"] {
if (backend === "remote") return "remote";
if (!device) return "pending";
return device === "cpu" ? "cpu" : "gpu";
}
export function getStatus(root: string): IndexStatus {
const idx = memRoot === root ? memIndex : null;
const idx = memRoot === normRoot(root) ? memIndex : null;
const backend: "local" | "remote" = remoteCfg ? "remote" : "local";
const device = backend === "remote" ? null : embedDevice;
return {
indexing,
done: progress.done,
@@ -242,6 +393,17 @@ export function getStatus(root: string): IndexStatus {
files: idx ? Object.keys(idx.files).length : 0,
chunks: idx ? idx.chunks.length : 0,
model: getEmbedModelId(),
backend,
device,
accelerator: acceleratorOf(device, backend),
deviceLabel: deviceLabelOf(device, backend),
modelRepo: backend === "local" ? activeModel.repo : undefined,
modelDtype: backend === "local" ? activeModel.dtype : undefined,
modelPooling: backend === "local" ? activeModel.pooling : undefined,
modelDim: backend === "local" ? activeModel.dim : undefined,
remoteBaseUrl: remoteCfg?.baseUrl,
runtime: backend === "local" ? "onnxruntime-node + @huggingface/transformers" : "OpenAI-compatible /embeddings",
platform: `${process.platform}-${process.arch}`,
};
}
function emitStatus(root: string): void {
@@ -252,15 +414,161 @@ function emitStatus(root: string): void {
/** Delete the persisted index for a workspace. */
export async function deleteIndex(root: string): Promise<void> {
memIndex = { model: getEmbedModelId(), files: {}, chunks: [] };
memRoot = root;
memRoot = normRoot(root);
progress = { done: 0, total: 0 };
try { await fs.unlink(indexPath(root)); } catch {}
emitStatus(root);
}
/** (Re)build the index incrementally. Safe to call repeatedly; no-op if busy. */
/** True when a workspace-relative path is real source (not vendor/build/junk). */
function isIndexableRel(rel: string): boolean {
if (!rel || rel.startsWith("..")) return false;
const parts = rel.split("/");
const base = parts[parts.length - 1] || "";
// Hidden files at any depth except common source dots (.env.example etc. skipped too).
if (base.startsWith(".") && base !== ".gitignore" && base !== ".editorconfig") return false;
for (const seg of parts) {
if (!seg) continue;
if (SKIP_DIR_SEGMENTS.has(seg)) return false;
// Nested deps / generated trees often use these prefixes.
if (seg.startsWith(".") && (seg === ".git" || seg.endsWith("_cache") || seg.endsWith("-cache"))) return false;
}
if (SKIP_BASENAMES.has(base)) return false;
// Minified / bundled / source maps (not author source).
if (/\.(min|bundle|chunk)\.(js|css|mjs|cjs)$/i.test(base)) return false;
if (/\.map$/i.test(base)) return false;
if (/\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|eot|mp[34]|wav|zip|gz|tgz|7z|rar|pdf|wasm|exe|dll|so|dylib|bin|o|a|class|jar|war|ear|pyc|pyo|whl|lock)$/i.test(base)) {
return false;
}
const ext = path.extname(base).toLowerCase();
if (!EMBED_EXTS.has(ext)) return false;
// package.json etc. OK; skip huge generated JSON dumps by name pattern.
if (ext === ".json" && /(^|[-_.])(lock|bundle|manifest|sourcemap)([-_.]|$)/i.test(base)) return false;
return true;
}
async function embedFileInto(idx: IndexFile, root: string, rel: string): Promise<boolean> {
const abs = path.join(root, rel);
let st;
try { st = await fs.stat(abs); } catch { return false; }
if (st.size > MAX_FILE_BYTES) return false;
let text: string;
try { text = await fs.readFile(abs, "utf8"); } catch { return false; }
// Drop old chunks for this path first.
idx.chunks = idx.chunks.filter((c) => c.path !== rel);
const pieces = chunkFile(rel, text);
if (pieces.length) {
const vecs = await embed(pieces.map((p) => p.text));
if (vecs) {
pieces.forEach((p, i) => idx.chunks.push({ path: rel, start: p.start, end: p.end, text: p.text, vec: vecs[i] }));
}
}
idx.files[rel] = `${Math.round(st.mtimeMs)}:${st.size}`;
return true;
}
/** Pending single-file updates while a full build runs (or coalesced watcher queue). */
const pendingUpserts = new Map<string, Set<string>>(); // rootKey -> rel paths
const pendingDeletes = new Map<string, Set<string>>();
let drainRunning = false;
function queueKey(root: string): string {
return normRoot(root);
}
/** Index/update one file immediately (or queue if full build busy). */
export async function upsertFile(root: string, absOrRel: string): Promise<void> {
if (!storageDir || !indexingEnabled || !root) return;
const abs = path.isAbsolute(absOrRel) ? absOrRel : path.join(root, absOrRel);
const rel = path.relative(root, abs).split(path.sep).join("/");
if (!rel || rel.startsWith("..") || !isIndexableRel(rel)) return;
const key = queueKey(root);
if (indexing || drainRunning) {
if (!pendingUpserts.has(key)) pendingUpserts.set(key, new Set());
pendingUpserts.get(key)!.add(rel);
pendingDeletes.get(key)?.delete(rel);
return;
}
const idx = await load(root);
let st;
try { st = await fs.stat(abs); } catch {
await removeFile(root, abs);
return;
}
const hash = `${Math.round(st.mtimeMs)}:${st.size}`;
if (idx.files[rel] === hash || idx.files[rel] === `${st.mtimeMs}:${st.size}`) return;
await embedFileInto(idx, root, rel);
await save(root, idx);
emitStatus(root);
}
/** Remove a file from the index (delete/rename). */
export async function removeFile(root: string, absOrRel: string): Promise<void> {
if (!storageDir || !indexingEnabled || !root) return;
const abs = path.isAbsolute(absOrRel) ? absOrRel : path.join(root, absOrRel);
const rel = path.relative(root, abs).split(path.sep).join("/");
if (!rel || rel.startsWith("..")) return;
const key = queueKey(root);
if (indexing || drainRunning) {
if (!pendingDeletes.has(key)) pendingDeletes.set(key, new Set());
pendingDeletes.get(key)!.add(rel);
pendingUpserts.get(key)?.delete(rel);
return;
}
const idx = await load(root);
if (!idx.files[rel] && !idx.chunks.some((c) => c.path === rel)) return;
idx.chunks = idx.chunks.filter((c) => c.path !== rel);
delete idx.files[rel];
await save(root, idx);
emitStatus(root);
}
async function drainPending(root: string): Promise<void> {
if (drainRunning || indexing || !indexingEnabled) return;
const key = queueKey(root);
const ups = pendingUpserts.get(key);
const dels = pendingDeletes.get(key);
if ((!ups || !ups.size) && (!dels || !dels.size)) return;
drainRunning = true;
try {
const idx = await load(root);
if (dels?.size) {
for (const rel of dels) {
idx.chunks = idx.chunks.filter((c) => c.path !== rel);
delete idx.files[rel];
}
dels.clear();
}
if (ups?.size) {
const list = [...ups];
ups.clear();
progress = { done: 0, total: list.length };
indexing = true;
emitStatus(root);
let done = 0;
for (const rel of list) {
await embedFileInto(idx, root, rel);
done++;
progress = { done, total: list.length };
emitStatus(root);
}
indexing = false;
}
await save(root, idx);
emitStatus(root);
} finally {
drainRunning = false;
indexing = false;
// More events may have arrived.
if ((pendingUpserts.get(key)?.size || 0) + (pendingDeletes.get(key)?.size || 0) > 0) {
void drainPending(root);
}
}
}
/** (Re)build the index incrementally. Only re-embeds changed files. No-op if disabled/busy. */
export async function buildIndex(root: string, onProgress?: (done: number, total: number) => void): Promise<void> {
if (!storageDir || indexing) return;
if (!storageDir || !root || indexing || !indexingEnabled) return;
indexing = true;
progress = { done: 0, total: 0 };
emitStatus(root);
@@ -271,47 +579,49 @@ export async function buildIndex(root: string, onProgress?: (done: number, total
const targets: string[] = [];
const seen = new Set<string>();
for (const f of all) {
if (!EMBED_EXTS.has(path.extname(f).toLowerCase())) continue;
const rel = path.relative(root, f).split(path.sep).join("/");
if (!isIndexableRel(rel)) continue;
let st;
try { st = await fs.stat(f); } catch { continue; }
if (st.size > MAX_FILE_BYTES) continue;
seen.add(rel);
const hash = `${st.mtimeMs}:${st.size}`;
if (idx.files[rel] !== hash) targets.push(rel);
const hash = `${Math.round(st.mtimeMs)}:${st.size}`;
const prev = idx.files[rel];
// Accept either rounded or raw mtime strings from older indexes.
if (prev !== hash && prev !== `${st.mtimeMs}:${st.size}`) targets.push(rel);
}
// Drop chunks for deleted/changed files.
const changed = new Set(targets);
idx.chunks = idx.chunks.filter((c) => seen.has(c.path) && !changed.has(c.path));
for (const rel of Object.keys(idx.files)) {
if (!seen.has(rel)) delete idx.files[rel];
}
// Nothing to do — still save cleaned deletions if any, emit status.
if (!targets.length) {
await save(root, idx);
return;
}
let done = 0;
progress = { done: 0, total: targets.length };
emitStatus(root);
for (const rel of targets) {
const abs = path.join(root, rel);
let text: string;
try { text = await fs.readFile(abs, "utf8"); } catch { done++; continue; }
const pieces = chunkFile(rel, text);
if (pieces.length) {
const vecs = await embed(pieces.map((p) => p.text));
if (vecs) {
pieces.forEach((p, i) => idx.chunks.push({ path: rel, start: p.start, end: p.end, text: p.text, vec: vecs[i] }));
}
}
const st = await fs.stat(abs).catch(() => null);
if (st) idx.files[rel] = `${st.mtimeMs}:${st.size}`;
await embedFileInto(idx, root, rel);
// Prefer stable rounded hash going forward.
const st = await fs.stat(path.join(root, rel)).catch(() => null);
if (st) idx.files[rel] = `${Math.round(st.mtimeMs)}:${st.size}`;
done++;
progress = { done, total: targets.length };
onProgress?.(done, targets.length);
emitStatus(root);
// Persist periodically so reopen mid-index keeps progress.
if (done % 25 === 0) await save(root, idx);
}
await save(root, idx);
} finally {
indexing = false;
emitStatus(root);
void drainPending(root);
}
}
+557 -330
View File
@@ -17,113 +17,333 @@ import { IGNORE, walk, globToRe, sortByMtime, fuzzyScore, makeDiff, firstDiffLin
// Image extensions the Read tool returns as base64 blocks to the model.
const IMAGE_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};
/** Race a promise against abort + wall clock so network/missing paths never hang the agent. */
function withAbortTimeout<T>(p: Promise<T>, ms: number, signal?: AbortSignal, label = "read"): Promise<T> {
return new Promise<T>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(`aborted: ${label}`));
return;
}
let settled = false;
const done = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
fn();
};
const onAbort = () => done(() => reject(new Error(`aborted: ${label}`)));
const timer = setTimeout(() => done(() => reject(new Error(`timeout: ${label} exceeded ${Math.round(ms / 1000)}s`))), ms);
signal?.addEventListener("abort", onAbort, { once: true });
Promise.resolve(p).then(
(v) => done(() => resolve(v)),
(e) => done(() => reject(e instanceof Error ? e : new Error(String(e)))),
);
});
}
const READ_STAT_MS = 3_000;
const READ_IO_MS = 12_000;
const READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB
const BINARY_EXTS = new Set([".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".o", ".a", ".lib", ".zip", ".gz", ".7z", ".rar", ".tar", ".bz2", ".xz", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv", ".webm", ".class", ".pyc", ".pyo", ".wasm", ".node", ".pdb", ".obj"]);
function readErrMsg(e: unknown, pathHint: string): string {
const err = e as NodeJS.ErrnoException & Error;
const code = err?.code;
const msg = err instanceof Error ? err.message : String(e);
if (msg.startsWith("timeout:") || msg.startsWith("aborted:")) {
return `error: ${msg}. Path may be missing, locked, or on a slow/unreachable share: ${pathHint}`;
}
switch (code) {
case "ENOENT":
return `error: path not found: ${pathHint}`;
case "EACCES":
case "EPERM":
return `error: permission denied: ${pathHint}`;
case "EISDIR":
return `error: path is a directory, not a file: ${pathHint}`;
case "ENOTDIR":
return `error: parent path is not a directory: ${pathHint}`;
case "EBUSY":
case "EAGAIN":
return `error: file busy/locked: ${pathHint}`;
case "EINVAL":
return `error: invalid path or device: ${pathHint}`;
case "ENAMETOOLONG":
return `error: path too long: ${pathHint}`;
case "ELOOP":
return `error: too many symlinks: ${pathHint}`;
case "ENOTSUP":
case "EOPNOTSUPP":
return `error: operation not supported for this path: ${pathHint}`;
default:
return `error: cannot read file${code ? ` (${code})` : ""}: ${msg}`;
}
}
function looksBinary(buf: Buffer): boolean {
const n = Math.min(buf.length, 8_192);
let odd = 0;
for (let i = 0; i < n; i++) {
const b = buf[i];
if (b === 0) return true;
// High ratio of non-text control bytes → binary
if (b < 7 || (b > 13 && b < 32 && b !== 27)) odd++;
}
return n > 0 && odd / n > 0.3;
}
// ---- Read ----
export const readFileTool = defineTool("Read", false, async (input) => {
if (typeof input.path !== "string" || !input.path) return { output: "error: path is required and must be a string" };
const p = safePath(input.path);
const ext = path.extname(p).toLowerCase();
export const readFileTool = defineTool("Read", false, async (input, abortSignal) => {
try {
if (typeof input.path !== "string" || !input.path) {
return { output: "error: path is required and must be a string" };
}
if (abortSignal?.aborted) return { output: "error: aborted" };
// Image files: return a base64 image block so it reaches the model.
if (IMAGE_MIME[ext]) {
const buf = await fs.readFile(p);
return {
output: `[image ${path.basename(p)} (${IMAGE_MIME[ext]}, ${buf.length} bytes)]`,
image: { mime: IMAGE_MIME[ext], base64: buf.toString("base64") },
};
}
const pathHint = String(input.path);
let p: string;
try {
// safePath strips quotes, keeps spaces in folder names.
p = safePath(pathHint);
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
// PDF files: extract text (honoring the same char cap as text reads).
if (ext === ".pdf") {
try {
const { PDFParse } = await import("pdf-parse");
const buf = await fs.readFile(p);
const parser = new PDFParse({ data: new Uint8Array(buf) });
const res = await parser.getText();
await parser.destroy?.();
const text = (res?.text ?? "").slice(0, 100_000);
return { output: text || "(no extractable text in PDF)" };
} catch (e) {
return { output: `error: cannot read PDF: ${e instanceof Error ? e.message : String(e)}` };
}
}
// fs.stat has no AbortSignal in @types/node — abort via withAbortTimeout only.
const readOpts = abortSignal ? { signal: abortSignal as AbortSignal } : undefined;
const content = await fs.readFile(p, "utf8");
if (content === "") return { output: "File is empty." };
// Fast existence/type check first (before realpath) so directories error cleanly
// and missing/network paths fail within READ_STAT_MS.
let st: Awaited<ReturnType<typeof fs.stat>>;
try {
st = await withAbortTimeout(fs.stat(p), READ_STAT_MS, abortSignal, "stat");
} catch (e) {
return { output: readErrMsg(e, pathHint) };
}
if (st.isDirectory()) {
return {
output: `error: path is a directory, not a file. Use ListDir or Glob instead. Path: ${pathHint}`,
};
}
if (st.isFIFO?.() || st.isSocket?.() || st.isCharacterDevice?.() || st.isBlockDevice?.()) {
return { output: `error: path is a special device/socket/pipe, not a regular file: ${pathHint}` };
}
const lines = content.split("\n");
const totalLines = lines.length;
// Map Cursor's offset/limit (whole-file by default) to a line window.
let start = 1;
let end = totalLines;
if (input.offset !== undefined && input.offset !== null) {
const off = Number(input.offset);
start = off < 0 ? Math.max(1, totalLines + off + 1) : Math.max(1, off);
}
if (input.limit !== undefined && input.limit !== null) {
end = Math.min(totalLines, start + Math.max(1, Number(input.limit)) - 1);
} else if (input.offset !== undefined && input.offset !== null) {
end = totalLines;
}
if (end < start) end = start;
// Resolve symlinks with a short wall (broken/network links hang otherwise).
try {
const resolved = await withAbortTimeout(fs.realpath(p), READ_STAT_MS, abortSignal, "realpath");
if (resolved !== p) {
p = resolved;
try {
st = await withAbortTimeout(fs.stat(p), READ_STAT_MS, abortSignal, "stat");
} catch (e) {
return { output: readErrMsg(e, pathHint) };
}
if (st.isDirectory()) {
return {
output: `error: path resolves to a directory, not a file. Use ListDir or Glob instead. Path: ${pathHint}`,
};
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.startsWith("timeout:") || msg.startsWith("aborted:")) {
return { output: readErrMsg(e, pathHint) };
}
// keep original p; read below will surface errors
}
if (st.size > READ_MAX_BYTES) {
return {
output: `error: file too large (${st.size} bytes, max ${READ_MAX_BYTES}). Use offset/limit on a text file or pick a smaller path.`,
};
}
const out = lines
.slice(start - 1, end)
.map((l, idx) => `${start + idx}|${l}`)
.join("\n");
return { output: out, startLine: start, endLine: end };
const ext = path.extname(p).toLowerCase();
// Image files: return a base64 image block so it reaches the model.
if (IMAGE_MIME[ext]) {
try {
const buf = await withAbortTimeout(fs.readFile(p, readOpts), READ_IO_MS, abortSignal, "Read");
return {
output: `[image ${path.basename(p)} (${IMAGE_MIME[ext]}, ${buf.length} bytes)]`,
image: { mime: IMAGE_MIME[ext], base64: buf.toString("base64") },
};
} catch (e) {
return { output: readErrMsg(e, String(input.path)) };
}
}
// PDF files: extract text (honoring the same char cap as text reads).
if (ext === ".pdf") {
try {
const { PDFParse } = await import("pdf-parse");
const buf = await withAbortTimeout(fs.readFile(p, readOpts), READ_IO_MS, abortSignal, "Read");
const parser = new PDFParse({ data: new Uint8Array(buf) });
const res = await withAbortTimeout(parser.getText(), READ_IO_MS, abortSignal, "PDF parse");
try {
await parser.destroy?.();
} catch {
/* ignore */
}
const text = (res?.text ?? "").slice(0, 100_000);
return { output: text || "(no extractable text in PDF)" };
} catch (e) {
return { output: `error: cannot read PDF: ${e instanceof Error ? e.message : String(e)}` };
}
}
if (BINARY_EXTS.has(ext)) {
return {
output: `error: binary file (${ext}, ${st.size} bytes) — cannot display as text. Path: ${input.path}`,
};
}
let buf: Buffer;
try {
buf = await withAbortTimeout(fs.readFile(p, readOpts), READ_IO_MS, abortSignal, "Read");
} catch (e) {
return { output: readErrMsg(e, String(input.path)) };
}
if (buf.length === 0) return { output: "File is empty." };
if (looksBinary(buf)) {
return {
output: `error: binary content detected (${buf.length} bytes) — cannot display as text. Path: ${input.path}`,
};
}
// Decode as UTF-8 (replacement for invalid sequences so latin-1-ish files still open).
let content = buf.toString("utf8");
// Strip UTF-8 BOM if present.
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1);
const lines = content.split(/\r?\n/);
const totalLines = lines.length;
// Map Cursor's offset/limit (whole-file by default) to a line window.
let start = 1;
let end = totalLines;
if (input.offset !== undefined && input.offset !== null) {
const off = Number(input.offset);
if (!Number.isFinite(off)) {
return { output: `error: invalid offset: ${input.offset}` };
}
start = off < 0 ? Math.max(1, totalLines + off + 1) : Math.max(1, Math.floor(off));
}
if (input.limit !== undefined && input.limit !== null) {
const lim = Number(input.limit);
if (!Number.isFinite(lim) || lim < 1) {
return { output: `error: invalid limit: ${input.limit}` };
}
end = Math.min(totalLines, start + Math.floor(lim) - 1);
} else if (input.offset !== undefined && input.offset !== null) {
end = totalLines;
}
if (end < start) end = start;
if (start > totalLines) {
return { output: `error: offset ${start} past end of file (${totalLines} lines)` };
}
const out = lines
.slice(start - 1, end)
.map((l, idx) => `${start + idx}|${l}`)
.join("\n");
return { output: out, startLine: start, endLine: end };
} catch (e) {
return { output: readErrMsg(e, String((input as { path?: string })?.path ?? "")) };
}
});
// ---- ListDir ----
export const listDirTool = defineTool("ListDir", false, async (input) => {
const p = safePath(input.path ?? ".");
const entries = await fs.readdir(p, { withFileTypes: true });
const out =
entries
.filter((e) => !IGNORE.has(e.name))
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.join("\n") || "(empty)";
return { output: out };
export const listDirTool = defineTool("ListDir", false, async (input, abortSignal) => {
try {
if (abortSignal?.aborted) return { output: "error: aborted" };
let p: string;
try {
p = safePath(input.path ?? ".");
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
const opts: { withFileTypes: true; signal?: AbortSignal } = { withFileTypes: true };
if (abortSignal) opts.signal = abortSignal;
const entries = await withAbortTimeout(fs.readdir(p, opts), READ_IO_MS, abortSignal, "ListDir");
const out =
entries
.filter((e) => !IGNORE.has(e.name))
.slice(0, 2_000)
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.join("\n") || "(empty)";
return { output: out };
} catch (e) {
return { output: `error: ListDir failed: ${e instanceof Error ? e.message : String(e)}` };
}
});
// ---- Glob ----
export const globTool = defineTool("Glob", false, async (input) => {
const root = input.target_directory ? safePath(input.target_directory) : getWorkspaceRoot();
// Include ignored dirs so patterns like "**/node_modules/**" can match.
const all: string[] = [];
await walk(root, all, 0, true);
// Prepend "**/" when the pattern isn't already rooted (schema behavior).
let pattern: string = String(input.glob_pattern ?? "");
if (pattern && !pattern.startsWith("**/")) pattern = "**/" + pattern;
const re = globToRe(pattern);
export const globTool = defineTool("Glob", false, async (input, abortSignal) => {
try {
let root: string;
try {
root = input.target_directory ? safePath(input.target_directory) : getWorkspaceRoot();
} catch (e) {
return { output: `error: invalid target_directory: ${e instanceof Error ? e.message : String(e)}` };
}
// Only walk ignored dirs when the pattern explicitly targets them
// (e.g. "**/node_modules/**") - otherwise node_modules hangs the tool.
let pattern: string = String(input.glob_pattern ?? "");
if (pattern && !pattern.startsWith("**/")) pattern = "**/" + pattern;
const wantsIgnored = /node_modules|\.git|[/\\]dist[/\\]|[/\\]out[/\\]|[/\\]build[/\\]/.test(pattern);
const all: string[] = [];
await walk(root, all, 0, wantsIgnored, abortSignal, 20_000);
if (abortSignal?.aborted) return { output: "(glob aborted)" };
const re = globToRe(pattern);
// Filter, then return matches sorted by modification time (schema promise).
const matched = all.filter((f) => re.test(path.relative(root, f).split(path.sep).join("/")));
const sorted = await sortByMtime(matched);
const hits = sorted.slice(0, 200).map((f) => path.relative(root, f).split(path.sep).join("/"));
return { output: hits.join("\n") || "(no matches)" };
const matched = all.filter((f) => {
try {
return re.test(path.relative(root, f).split(path.sep).join("/"));
} catch {
return false;
}
});
// Cap mtime sort work — huge match sets made Glob look stuck.
const toSort = matched.slice(0, 2_000);
const sorted = await sortByMtime(toSort);
const hits = sorted.slice(0, 200).map((f) => path.relative(root, f).split(path.sep).join("/"));
const extra = matched.length > hits.length ? `\n… (${matched.length - hits.length} more)` : "";
return { output: (hits.join("\n") || "(no matches)") + extra };
} catch (e) {
return { output: `error: Glob failed: ${e instanceof Error ? e.message : String(e)}` };
}
});
// ---- FileSearch (fuzzy filename search) ----
export const fileSearchTool = defineTool("FileSearch", false, async (input) => {
const root = getWorkspaceRoot();
const all: string[] = [];
await walk(root, all, 0);
const q = String(input.query || "").toLowerCase();
const rel = all.map((f) => path.relative(root, f).split(path.sep).join("/"));
const scored = rel
.map((f) => ({ f, score: fuzzyScore(f.toLowerCase(), q) }))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 30)
.map((x) => x.f);
return { output: scored.join("\n") || "(no matches)" };
export const fileSearchTool = defineTool("FileSearch", false, async (input, abortSignal) => {
try {
const root = getWorkspaceRoot();
const all: string[] = [];
await walk(root, all, 0, false, abortSignal, 20_000);
if (abortSignal?.aborted) return { output: "(FileSearch aborted)" };
const q = String(input.query || "").toLowerCase();
if (!q) return { output: "(empty query)" };
const rel = all.map((f) => path.relative(root, f).split(path.sep).join("/"));
const scored = rel
.map((f) => ({ f, score: fuzzyScore(f.toLowerCase(), q) }))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 30)
.map((x) => x.f);
return { output: scored.join("\n") || "(no matches)" };
} catch (e) {
return { output: `error: FileSearch failed: ${e instanceof Error ? e.message : String(e)}` };
}
});
// ---- StrReplace / Write (shared edit handler) ----
@@ -132,90 +352,89 @@ export const fileSearchTool = defineTool("FileSearch", false, async (input) => {
// In multitask mode the agent is a COORDINATOR: it must NOT edit anything itself.
// Edit tools refuse and instruct it to delegate to parallel subagents instead.
const MULTITASK_BLOCK: ToolResult = {
output:
"error: editing is disabled in multitask mode — you are a COORDINATOR and must NOT edit files yourself. " +
"Delegate ALL implementation work to subagents: call the Task tool (run_in_background=true) for each " +
"independent unit of work and launch multiple subagents AT THE SAME TIME in a single turn. " +
"Have the subagents make these edits in parallel; do not call edit tools directly.",
output: "error: editing is disabled in multitask mode — you are a COORDINATOR and must NOT edit files yourself. " + "Delegate ALL implementation work to subagents: call the Task tool (run_in_background=true) for each " + "independent unit of work and launch multiple subagents AT THE SAME TIME in a single turn. " + "Have the subagents make these edits in parallel; do not call edit tools directly.",
};
function blockedInMultitask(ctx?: ToolContext): boolean {
return ctx?.getMode?.() === "multitask";
return ctx?.getMode?.() === "multitask";
}
const editExecute: Tool["execute"] = async (input, _signal, _callId, ctx) => {
if (blockedInMultitask(ctx)) return MULTITASK_BLOCK;
if (typeof input.path !== "string" || !input.path) return { output: "error: path is required and must be a string" };
const p = safePath(input.path);
let existedBefore = false;
try {
await fs.access(p);
existedBefore = true;
} catch {}
const original = existedBefore ? await fs.readFile(p, "utf8") : "";
if (blockedInMultitask(ctx)) return MULTITASK_BLOCK;
if (typeof input.path !== "string" || !input.path) return { output: "error: path is required and must be a string" };
let p: string;
try {
p = safePath(input.path);
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
let existedBefore = false;
try {
await fs.access(p);
existedBefore = true;
} catch {}
const original = existedBefore ? await fs.readFile(p, "utf8") : "";
// Write: full create / overwrite.
if (input.contents !== undefined && input.old_string === undefined) {
await fs.mkdir(path.dirname(p), { recursive: true });
await fs.writeFile(p, input.contents, "utf8");
pendingChanges.record(input.path, original, input.contents, existedBefore);
return {
output: `wrote ${input.path} (${input.contents.split("\n").length} lines)`,
diff: makeDiff(input.path, original, input.contents),
startLine: firstDiffLine(original, input.contents),
};
}
// Write: full create / overwrite.
if (input.contents !== undefined && input.old_string === undefined) {
await fs.mkdir(path.dirname(p), { recursive: true });
await fs.writeFile(p, input.contents, "utf8");
pendingChanges.record(input.path, original, input.contents, existedBefore);
return {
output: `wrote ${input.path} (${input.contents.split("\n").length} lines)`,
diff: makeDiff(input.path, original, input.contents),
startLine: firstDiffLine(original, input.contents),
};
}
if (!existedBefore) {
return { output: `error: ${input.path} does not exist; pass contents to create it` };
}
if (!existedBefore) {
return { output: `error: ${input.path} does not exist; pass contents to create it` };
}
const oldS = input.old_string ?? "";
const newS = input.new_string ?? "";
const replaceAll = input.replace_all ?? input.allow_multiple_matches;
let matched = original;
const oldS = input.old_string ?? "";
const newS = input.new_string ?? "";
const replaceAll = input.replace_all ?? input.allow_multiple_matches;
let matched = original;
// Strategy 1: exact substring match.
const idx = original.indexOf(oldS);
if (idx !== -1) {
const isUnique = original.indexOf(oldS, idx + 1) === -1;
if (!isUnique && !replaceAll) {
return { output: `error: old_string is not unique in ${input.path}; add more context or set replace_all` };
}
matched = replaceAll
? original.split(oldS).join(newS)
: original.slice(0, idx) + newS + original.slice(idx + oldS.length);
} else {
// Strategy 2: whitespace-insensitive line-window match.
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
const target = norm(oldS);
const lines = original.split("\n");
const windowSize = Math.max(1, oldS.split("\n").length);
const candidates: number[] = [];
for (let i = 0; i <= lines.length - windowSize; i++) {
if (norm(lines.slice(i, i + windowSize).join("\n")) === target) candidates.push(i);
}
if (candidates.length === 0) {
return { output: `error: could not find old_string in ${input.path}` };
}
if (candidates.length > 1 && !replaceAll) {
return { output: `error: old_string matches ${candidates.length} locations in ${input.path}; add more context` };
}
const targets = replaceAll ? candidates.slice().reverse() : [candidates[0]];
for (const found of targets) lines.splice(found, windowSize, ...newS.split("\n"));
matched = lines.join("\n");
}
// Strategy 1: exact substring match.
const idx = original.indexOf(oldS);
if (idx !== -1) {
const isUnique = original.indexOf(oldS, idx + 1) === -1;
if (!isUnique && !replaceAll) {
return { output: `error: old_string is not unique in ${input.path}; add more context or set replace_all` };
}
matched = replaceAll ? original.split(oldS).join(newS) : original.slice(0, idx) + newS + original.slice(idx + oldS.length);
} else {
// Strategy 2: whitespace-insensitive line-window match.
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
const target = norm(oldS);
const lines = original.split("\n");
const windowSize = Math.max(1, oldS.split("\n").length);
const candidates: number[] = [];
for (let i = 0; i <= lines.length - windowSize; i++) {
if (norm(lines.slice(i, i + windowSize).join("\n")) === target) candidates.push(i);
}
if (candidates.length === 0) {
return { output: `error: could not find old_string in ${input.path}` };
}
if (candidates.length > 1 && !replaceAll) {
return { output: `error: old_string matches ${candidates.length} locations in ${input.path}; add more context` };
}
const targets = replaceAll ? candidates.slice().reverse() : [candidates[0]];
for (const found of targets) lines.splice(found, windowSize, ...newS.split("\n"));
matched = lines.join("\n");
}
if (matched === original) {
return { output: `error: edit produced no change in ${input.path}` };
}
await fs.writeFile(p, matched, "utf8");
pendingChanges.record(input.path, original, matched, existedBefore);
return {
output: `edited ${input.path}`,
diff: makeDiff(input.path, original, matched),
startLine: firstDiffLine(original, matched),
};
if (matched === original) {
return { output: `error: edit produced no change in ${input.path}` };
}
await fs.writeFile(p, matched, "utf8");
pendingChanges.record(input.path, original, matched, existedBefore);
return {
output: `edited ${input.path}`,
diff: makeDiff(input.path, original, matched),
startLine: firstDiffLine(original, matched),
};
};
export const strReplaceTool = defineTool("StrReplace", true, editExecute);
@@ -223,26 +442,31 @@ export const writeTool = defineTool("Write", true, editExecute);
// ---- Delete ----
export const deleteFileTool = defineTool("Delete", true, async (input, _signal, _callId, ctx) => {
if (blockedInMultitask(ctx)) return MULTITASK_BLOCK;
if (typeof input.path !== "string" || !input.path) return { output: "error: path is required and must be a string" };
const p = safePath(input.path);
let before = "";
try {
before = await fs.readFile(p, "utf8");
} catch {}
// Schema: fail gracefully if the file doesn't exist / can't be deleted.
try {
await fs.unlink(p);
} catch (e: any) {
if (e?.code === "ENOENT") return { output: `error: ${input.path} does not exist` };
if (e?.code === "EISDIR" || e?.code === "EPERM" || e?.code === "EACCES") {
return { output: `error: cannot delete ${input.path}: ${e.code}` };
}
return { output: `error: cannot delete ${input.path}: ${e instanceof Error ? e.message : String(e)}` };
}
// Track as a change so the user can restore the deleted file.
pendingChanges.record(input.path, before, "", true);
return { output: `deleted ${input.path}` };
if (blockedInMultitask(ctx)) return MULTITASK_BLOCK;
if (typeof input.path !== "string" || !input.path) return { output: "error: path is required and must be a string" };
let p: string;
try {
p = safePath(input.path);
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
let before = "";
try {
before = await fs.readFile(p, "utf8");
} catch {}
// Schema: fail gracefully if the file doesn't exist / can't be deleted.
try {
await fs.unlink(p);
} catch (e: any) {
if (e?.code === "ENOENT") return { output: `error: ${input.path} does not exist` };
if (e?.code === "EISDIR" || e?.code === "EPERM" || e?.code === "EACCES") {
return { output: `error: cannot delete ${input.path}: ${e.code}` };
}
return { output: `error: cannot delete ${input.path}: ${e instanceof Error ? e.message : String(e)}` };
}
// Track as a change so the user can restore the deleted file.
pendingChanges.record(input.path, before, "", true);
return { output: `deleted ${input.path}` };
});
// ---- EditNotebook ----
@@ -251,176 +475,179 @@ const NB_LANGS = new Set(["python", "markdown", "javascript", "typescript", "r",
// Map a cell_language to a Jupyter cell_type. Markdown/raw map directly; every
// programming language is a "code" cell (the language id is kept in metadata).
function nbCellType(lang: string): "code" | "markdown" | "raw" {
const l = (lang || "").toLowerCase();
if (l === "markdown") return "markdown";
if (l === "raw") return "raw";
return "code";
const l = (lang || "").toLowerCase();
if (l === "markdown") return "markdown";
if (l === "raw") return "raw";
return "code";
}
// VS Code language id used in a code cell's metadata so r/sql/shell/etc keep
// their identity (cell_type alone only distinguishes code/markdown/raw).
function nbLanguageId(lang: string): string {
const l = (lang || "").toLowerCase();
const map: Record<string, string> = {
python: "python",
javascript: "javascript",
typescript: "typescript",
r: "r",
sql: "sql",
shell: "shellscript",
other: "plaintext",
};
return map[l] || "python";
const l = (lang || "").toLowerCase();
const map: Record<string, string> = {
python: "python",
javascript: "javascript",
typescript: "typescript",
r: "r",
sql: "sql",
shell: "shellscript",
other: "plaintext",
};
return map[l] || "python";
}
function nbSourceToString(source: unknown): string {
if (Array.isArray(source)) return source.join("");
return typeof source === "string" ? source : "";
if (Array.isArray(source)) return source.join("");
return typeof source === "string" ? source : "";
}
function nbStringToSource(s: string): string[] {
if (s === "") return [];
// Each line keeps its trailing "\n" except the final line (Jupyter convention).
const lines = s.split("\n");
return lines.map((line, i) => (i < lines.length - 1 ? line + "\n" : line));
if (s === "") return [];
// Each line keeps its trailing "\n" except the final line (Jupyter convention).
const lines = s.split("\n");
return lines.map((line, i) => (i < lines.length - 1 ? line + "\n" : line));
}
export const editNotebookTool = defineTool("EditNotebook", true, async (input, _signal, _callId, ctx) => {
if (blockedInMultitask(ctx)) return MULTITASK_BLOCK;
const target = String(input?.target_notebook ?? "");
if (!target) return { output: "error: target_notebook is required" };
if (!target.toLowerCase().endsWith(".ipynb")) {
return { output: "error: EditNotebook only edits .ipynb files" };
}
const cellIdx = Number(input?.cell_idx);
if (!Number.isInteger(cellIdx) || cellIdx < 0) {
return { output: "error: cell_idx must be a non-negative integer" };
}
const isNew = input?.is_new_cell === true;
const language = String(input?.cell_language ?? "");
if (language && !NB_LANGS.has(language.toLowerCase())) {
return { output: `error: cell_language must be one of: ${[...NB_LANGS].join(", ")}` };
}
const oldString = String(input?.old_string ?? "");
const newString = String(input?.new_string ?? "");
if (blockedInMultitask(ctx)) return MULTITASK_BLOCK;
const target = String(input?.target_notebook ?? "");
if (!target) return { output: "error: target_notebook is required" };
if (!target.toLowerCase().endsWith(".ipynb")) {
return { output: "error: EditNotebook only edits .ipynb files" };
}
const cellIdx = Number(input?.cell_idx);
if (!Number.isInteger(cellIdx) || cellIdx < 0) {
return { output: "error: cell_idx must be a non-negative integer" };
}
const isNew = input?.is_new_cell === true;
const language = String(input?.cell_language ?? "");
if (language && !NB_LANGS.has(language.toLowerCase())) {
return { output: `error: cell_language must be one of: ${[...NB_LANGS].join(", ")}` };
}
const oldString = String(input?.old_string ?? "");
const newString = String(input?.new_string ?? "");
const abs = safePath(target);
let abs: string;
try {
abs = safePath(target);
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
// Read (or scaffold) the notebook JSON.
let nb: any;
let before = "";
try {
before = await fs.readFile(abs, "utf8");
nb = JSON.parse(before);
} catch (e: any) {
if (e?.code === "ENOENT" && isNew) {
nb = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 };
} else {
return { output: `error: cannot read notebook: ${e instanceof Error ? e.message : String(e)}` };
}
}
if (!nb || typeof nb !== "object" || !Array.isArray(nb.cells)) {
return { output: "error: not a valid notebook (missing cells array)" };
}
// Read (or scaffold) the notebook JSON.
let nb: any;
let before = "";
try {
before = await fs.readFile(abs, "utf8");
nb = JSON.parse(before);
} catch (e: any) {
if (e?.code === "ENOENT" && isNew) {
nb = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 };
} else {
return { output: `error: cannot read notebook: ${e instanceof Error ? e.message : String(e)}` };
}
}
if (!nb || typeof nb !== "object" || !Array.isArray(nb.cells)) {
return { output: "error: not a valid notebook (missing cells array)" };
}
const cellType = nbCellType(language);
const makeCell = (content: string) => {
const cell: any = { cell_type: cellType, metadata: {}, source: nbStringToSource(content) };
if (cellType === "code") {
cell.execution_count = null;
cell.outputs = [];
cell.metadata.vscode = { languageId: nbLanguageId(language) };
}
return cell;
};
const cellType = nbCellType(language);
const makeCell = (content: string) => {
const cell: any = { cell_type: cellType, metadata: {}, source: nbStringToSource(content) };
if (cellType === "code") {
cell.execution_count = null;
cell.outputs = [];
cell.metadata.vscode = { languageId: nbLanguageId(language) };
}
return cell;
};
if (isNew) {
// Insert a new cell at cell_idx (clamped to the end of the list).
const at = Math.min(cellIdx, nb.cells.length);
nb.cells.splice(at, 0, makeCell(newString));
} else {
const cell = nb.cells[cellIdx];
if (!cell) {
return { output: `error: cell ${cellIdx} does not exist (notebook has ${nb.cells.length} cells)` };
}
const src = nbSourceToString(cell.source);
if (oldString === "") {
return { output: "error: old_string is required when editing an existing cell (set is_new_cell=true to create one)" };
}
// old_string must uniquely identify the target text within the cell.
const first = src.indexOf(oldString);
if (first === -1) {
return { output: `error: old_string not found in cell ${cellIdx}` };
}
if (src.indexOf(oldString, first + 1) !== -1) {
return { output: `error: old_string is not unique in cell ${cellIdx}; add more surrounding context` };
}
const updated = src.slice(0, first) + newString + src.slice(first + oldString.length);
cell.source = nbStringToSource(updated);
// Honor an explicit language change, keeping code/markdown/raw cells valid.
cell.cell_type = cellType;
if (cellType === "code") {
if (cell.execution_count === undefined) cell.execution_count = null;
if (!Array.isArray(cell.outputs)) cell.outputs = [];
cell.metadata = { ...(cell.metadata ?? {}), vscode: { languageId: nbLanguageId(language) } };
} else {
// markdown / raw cells must not carry code-only keys.
delete cell.execution_count;
delete cell.outputs;
if (cell.metadata && cell.metadata.vscode) delete cell.metadata.vscode;
}
}
if (isNew) {
// Insert a new cell at cell_idx (clamped to the end of the list).
const at = Math.min(cellIdx, nb.cells.length);
nb.cells.splice(at, 0, makeCell(newString));
} else {
const cell = nb.cells[cellIdx];
if (!cell) {
return { output: `error: cell ${cellIdx} does not exist (notebook has ${nb.cells.length} cells)` };
}
const src = nbSourceToString(cell.source);
if (oldString === "") {
return { output: "error: old_string is required when editing an existing cell (set is_new_cell=true to create one)" };
}
// old_string must uniquely identify the target text within the cell.
const first = src.indexOf(oldString);
if (first === -1) {
return { output: `error: old_string not found in cell ${cellIdx}` };
}
if (src.indexOf(oldString, first + 1) !== -1) {
return { output: `error: old_string is not unique in cell ${cellIdx}; add more surrounding context` };
}
const updated = src.slice(0, first) + newString + src.slice(first + oldString.length);
cell.source = nbStringToSource(updated);
// Honor an explicit language change, keeping code/markdown/raw cells valid.
cell.cell_type = cellType;
if (cellType === "code") {
if (cell.execution_count === undefined) cell.execution_count = null;
if (!Array.isArray(cell.outputs)) cell.outputs = [];
cell.metadata = { ...(cell.metadata ?? {}), vscode: { languageId: nbLanguageId(language) } };
} else {
// markdown / raw cells must not carry code-only keys.
delete cell.execution_count;
delete cell.outputs;
if (cell.metadata && cell.metadata.vscode) delete cell.metadata.vscode;
}
}
const after = JSON.stringify(nb, null, 1) + "\n";
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, after, "utf8");
const after = JSON.stringify(nb, null, 1) + "\n";
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, after, "utf8");
const action = isNew
? `Created ${cellType} cell at index ${Math.min(cellIdx, nb.cells.length - 1)}`
: `Edited cell ${cellIdx}`;
return { output: `${action} in ${target}`, diff: makeDiff(abs, before, after) };
const action = isNew ? `Created ${cellType} cell at index ${Math.min(cellIdx, nb.cells.length - 1)}` : `Edited cell ${cellIdx}`;
return { output: `${action} in ${target}`, diff: makeDiff(abs, before, after) };
});
// ---- ReadLints ----
export const readLintsTool = defineTool("ReadLints", false, async (input) => {
const root = getWorkspaceRoot();
const all = vscode.languages.getDiagnostics();
const root = getWorkspaceRoot();
const all = vscode.languages.getDiagnostics();
// Normalize each requested path (absolute OR workspace-relative) to a
// workspace-relative, forward-slashed prefix. A path equal to the workspace
// root (or "."/"") means "all files" (empty filter list -> no filtering).
const toRel = (raw: string): string | null => {
const trimmed = String(raw).trim();
if (trimmed === "" || trimmed === ".") return null; // means "all"
const abs = path.resolve(root, trimmed);
let rel = path.relative(root, abs).split(path.sep).join("/");
if (rel === "" ) return null; // path resolves to the root itself -> all
rel = rel.replace(/\/+$/, "");
return rel.startsWith("..") ? `\u0000outside\u0000` : rel; // outside workspace -> never matches
};
// Normalize each requested path (absolute OR workspace-relative) to a
// workspace-relative, forward-slashed prefix. A path equal to the workspace
// root (or "."/"") means "all files" (empty filter list -> no filtering).
const toRel = (raw: string): string | null => {
const trimmed = String(raw).trim();
if (trimmed === "" || trimmed === ".") return null; // means "all"
const abs = path.resolve(root, trimmed);
let rel = path.relative(root, abs).split(path.sep).join("/");
if (rel === "") return null; // path resolves to the root itself -> all
rel = rel.replace(/\/+$/, "");
return rel.startsWith("..") ? `\u0000outside\u0000` : rel; // outside workspace -> never matches
};
let allFiles = false;
const filters: string[] = [];
if (Array.isArray(input.paths)) {
for (const p of input.paths) {
const r = toRel(p);
if (r === null) allFiles = true;
else filters.push(r);
}
}
// Case-insensitive comparison on win32 (drive-letter / path casing).
const ci = process.platform === "win32";
const norm = (s: string) => (ci ? s.toLowerCase() : s);
const filtersN = filters.map(norm);
let allFiles = false;
const filters: string[] = [];
if (Array.isArray(input.paths)) {
for (const p of input.paths) {
const r = toRel(p);
if (r === null) allFiles = true;
else filters.push(r);
}
}
// Case-insensitive comparison on win32 (drive-letter / path casing).
const ci = process.platform === "win32";
const norm = (s: string) => (ci ? s.toLowerCase() : s);
const filtersN = filters.map(norm);
const out: string[] = [];
for (const [uri, diags] of all) {
const relRaw = path.relative(root, uri.fsPath).split(path.sep).join("/");
if (relRaw.startsWith("..")) continue; // outside workspace
const rel = norm(relRaw);
if (!allFiles && filtersN.length && !filtersN.some((f) => rel === f || rel.startsWith(f + "/"))) continue;
for (const d of diags) {
if (d.severity > vscode.DiagnosticSeverity.Warning) continue;
const sev = d.severity === vscode.DiagnosticSeverity.Error ? "error" : "warning";
out.push(`${relRaw}:${d.range.start.line + 1}:${d.range.start.character + 1} ${sev}: ${d.message}`);
}
}
return { output: out.slice(0, 100).join("\n") || "(no diagnostics)" };
const out: string[] = [];
for (const [uri, diags] of all) {
const relRaw = path.relative(root, uri.fsPath).split(path.sep).join("/");
if (relRaw.startsWith("..")) continue; // outside workspace
const rel = norm(relRaw);
if (!allFiles && filtersN.length && !filtersN.some((f) => rel === f || rel.startsWith(f + "/"))) continue;
for (const d of diags) {
if (d.severity > vscode.DiagnosticSeverity.Warning) continue;
const sev = d.severity === vscode.DiagnosticSeverity.Error ? "error" : "warning";
out.push(`${relRaw}:${d.range.start.line + 1}:${d.range.start.character + 1} ${sev}: ${d.message}`);
}
}
return { output: out.slice(0, 100).join("\n") || "(no diagnostics)" };
});
+4
View File
@@ -26,6 +26,10 @@ export {
setSubagentRunner,
setQuestionAsker,
disposeShellSession,
toolTimeoutMs,
withToolTimeout,
setToolTimeoutOverrides,
DEFAULT_TOOL_TIMEOUTS_SEC,
type TodoItem,
} from "./shared";
+112 -25
View File
@@ -13,7 +13,7 @@ import { spawn } from "child_process";
import { safePath, getWorkspaceRoot } from "../../context/workspaceUtils";
import { defineTool } from "./types";
import { STOP, walk, globToRe, rgAvailable } from "./shared";
import { search as semanticIndexSearch, buildIndex, isIndexing } from "../semanticIndex";
import { search as semanticIndexSearch, buildIndex, isIndexing, isIndexingEnabled } from "../semanticIndex";
import { searchDocs, listDocSources } from "../docsIndex";
// Minimal ripgrep --type -> file-extension map for the node fallback.
@@ -39,14 +39,25 @@ const TYPE_EXTS: Record<string, string[]> = {
// ---- Grep (ripgrep with a node fallback) ----
export const grepTool = defineTool("Grep", false, async (input, abortSignal) => {
try {
if (abortSignal?.aborted) return { output: "(grep aborted)" };
const root = getWorkspaceRoot();
const mode: string = input.output_mode || "content";
const target = input.path ? safePath(input.path) : ".";
const cap = Math.max(1, Math.min(Number(input.head_limit) || 200, 5000));
let target = ".";
if (input.path) {
try {
target = safePath(input.path); // spawn arg — spaces OK without shell quoting
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
}
const cap = Math.max(1, Math.min(Number(input.head_limit) || 200, 2000));
const skip = Math.max(0, Number(input.offset) || 0);
const pattern = String(input.pattern ?? "");
if (!pattern) return { output: "error: pattern is required" };
if (await rgAvailable()) {
const args = ["--color=never"];
const args = ["--color=never", "--hidden", "--glob", "!**/.git/**", "--glob", "!**/node_modules/**"];
if (mode === "files_with_matches") {
args.push("--files-with-matches");
} else if (mode === "count") {
@@ -61,31 +72,84 @@ export const grepTool = defineTool("Grep", false, async (input, abortSignal) =>
if (input.multiline) args.push("-U", "--multiline-dotall");
if (input.glob) args.push("--glob", String(input.glob));
if (input.type) args.push("--type", String(input.type));
args.push("--", input.pattern, target);
args.push("--", pattern, target);
const out = await new Promise<string>((res) => {
const c = spawn("rg", args, { cwd: root, signal: abortSignal });
let settled = false;
let o = "";
c.stdout.on("data", (d) => (o += d));
c.on("error", () => res("(grep failed)"));
let c: ReturnType<typeof spawn>;
const finish = (v: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { abortSignal?.removeEventListener("abort", onAbort); } catch { /* ignore */ }
res(v);
};
const onAbort = () => {
try { c.kill("SIGTERM"); } catch { /* ignore */ }
finish(o ? o.slice(0, 50_000) + "\n(grep aborted)" : "(grep aborted)");
};
// Hard kill hung rg even if AbortSignal is missing/ignored.
const timer = setTimeout(() => {
try { c.kill("SIGKILL"); } catch {
try { c.kill("SIGTERM"); } catch { /* ignore */ }
}
finish(o ? o.slice(0, 50_000) + "\n(grep timed out)" : "(grep timed out)");
}, 15_000);
try {
c = spawn("rg", args, { cwd: root, windowsHide: true });
} catch (e) {
finish(`(grep failed: ${e instanceof Error ? e.message : String(e)})`);
return;
}
if (abortSignal?.aborted) {
onAbort();
return;
}
abortSignal?.addEventListener("abort", onAbort, { once: true });
c.stdout?.on("data", (d) => {
o += d;
// Bound memory if rg floods output.
if (o.length > 2_000_000) {
try { c.kill("SIGTERM"); } catch { /* ignore */ }
}
});
c.stderr?.on("data", () => { /* ignore */ });
c.on("error", (e) => finish(`(grep failed: ${e instanceof Error ? e.message : String(e)})`));
c.on("close", () => {
let lines = o.split("\n").filter(Boolean);
if (skip) lines = lines.slice(skip);
res(lines.slice(0, cap).join("\n") || "(no matches)");
finish(lines.slice(0, cap).join("\n") || "(no matches)");
});
});
return { output: out };
}
// Node fallback (no ripgrep available). Honor path/glob/type/-A/-B/-C/multiline.
const scopeRoot = input.path ? safePath(input.path) : root;
let scopeRoot = root;
if (input.path) {
try {
scopeRoot = safePath(input.path);
} catch (e) {
return { output: `error: invalid path: ${e instanceof Error ? e.message : String(e)}` };
}
}
const all: string[] = [];
await walk(scopeRoot, all, 0);
await walk(scopeRoot, all, 0, false, abortSignal, 15_000);
if (abortSignal?.aborted) return { output: "(grep aborted)" };
const flags = input["-i"] ? "i" : "";
const lineRe = new RegExp(input.pattern, flags);
const multiRe = input.multiline ? new RegExp(input.pattern, flags + "s") : null;
const globRe = input.glob ? globToRe(String(input.glob).startsWith("**/") ? String(input.glob) : "**/" + String(input.glob)) : null;
let lineRe: RegExp;
let multiRe: RegExp | null = null;
try {
const flags = input["-i"] ? "i" : "";
lineRe = new RegExp(pattern, flags);
multiRe = input.multiline ? new RegExp(pattern, flags + "s") : null;
} catch (e) {
return { output: `error: invalid pattern: ${e instanceof Error ? e.message : String(e)}` };
}
const globRe = input.glob
? globToRe(String(input.glob).startsWith("**/") ? String(input.glob) : "**/" + String(input.glob))
: null;
const typeExts = input.type ? TYPE_EXTS[String(input.type)] : null;
const aCtx = Math.max(0, Number(input["-A"] ?? input["-C"] ?? 0));
const bCtx = Math.max(0, Number(input["-B"] ?? input["-C"] ?? 0));
@@ -93,12 +157,15 @@ export const grepTool = defineTool("Grep", false, async (input, abortSignal) =>
const hitsByFile: Record<string, string[]> = {};
const countByFile: Record<string, number> = {};
const order: string[] = [];
for (const f of all.slice(0, 5000)) {
for (const f of all.slice(0, 3000)) {
if (abortSignal?.aborted) return { output: "(grep aborted)" };
const rel = path.relative(root, f).split(path.sep).join("/");
if (globRe && !globRe.test(rel)) continue;
if (typeExts && !typeExts.includes(path.extname(f).toLowerCase())) continue;
let txt: string;
try {
const st = await fs.stat(f);
if (st.size > 1_000_000) continue; // skip huge files in fallback
txt = await fs.readFile(f, "utf8");
} catch {
continue; // binary / unreadable
@@ -114,17 +181,18 @@ export const grepTool = defineTool("Grep", false, async (input, abortSignal) =>
if (multiRe) {
if (multiRe.test(txt)) {
countByFile[rel] = (countByFile[rel] ?? 0) + 1;
push(`${rel}:${txt}`);
push(`${rel}:${txt.slice(0, 500)}`);
}
continue;
}
const lines = txt.split("\n");
lines.forEach((l, idx) => {
if (!lineRe.test(l)) return;
for (let idx = 0; idx < lines.length; idx++) {
const l = lines[idx];
if (!lineRe.test(l)) continue;
countByFile[rel] = (countByFile[rel] ?? 0) + 1;
if (mode !== "content") {
push(`${rel}:${idx + 1}:${l}`);
return;
continue;
}
for (let b = bCtx; b >= 1; b--) {
if (idx - b >= 0) push(`${rel}-${idx + 1 - b}-${lines[idx - b]}`);
@@ -133,7 +201,7 @@ export const grepTool = defineTool("Grep", false, async (input, abortSignal) =>
for (let a = 1; a <= aCtx; a++) {
if (idx + a < lines.length) push(`${rel}-${idx + 1 + a}-${lines[idx + a]}`);
}
});
}
}
let result: string[];
@@ -145,6 +213,9 @@ export const grepTool = defineTool("Grep", false, async (input, abortSignal) =>
const shown = result.slice(0, cap);
if (!shown.length) return { output: "(no matches)" };
return { output: shown.join("\n") + (truncated ? `\n... (at least ${result.length} matches, truncated)` : "") };
} catch (e) {
return { output: `error: Grep failed: ${e instanceof Error ? e.message : String(e)}` };
}
});
// ---- SemanticSearch ----
@@ -165,17 +236,26 @@ function keywordFallback(input: any, abortSignal?: AbortSignal, callId?: string,
}
export const semanticSearchTool = defineTool("SemanticSearch", false, async (input, abortSignal, callId, ctx) => {
try {
if (abortSignal?.aborted) return { output: "(search aborted)" };
const query = String(input.query || "").trim();
if (!query) return { output: "(no query)" };
const root = getWorkspaceRoot();
// Build/refresh index on demand (incremental; cheap if already fresh).
if (!isIndexing()) buildIndex(root).catch(() => {});
// Never await a full rebuild here — that hung explore tools for minutes.
if (isIndexingEnabled() && !isIndexing()) void buildIndex(root).catch(() => {});
// Scope by target_directories (prefix match on workspace-relative paths).
const dirs: string[] = Array.isArray(input.target_directories) ? input.target_directories : [];
const prefixes = dirs
.map((d) => path.relative(root, safePath(String(d))).split(path.sep).join("/"))
.map((d) => {
try {
return path.relative(root, safePath(String(d))).split(path.sep).join("/");
} catch {
return "";
}
})
.filter((p) => p && !p.startsWith(".."));
const filter = prefixes.length
? (rel: string) => prefixes.some((p) => rel === p || rel.startsWith(p + "/"))
@@ -193,15 +273,19 @@ export const semanticSearchTool = defineTool("SemanticSearch", false, async (inp
.map((h) => `${h.path}:${h.start}-${h.end} (score ${h.score.toFixed(3)})\n${h.text}`)
.join("\n\n---\n\n");
return { output: out };
} catch (e) {
return { output: `error: SemanticSearch failed: ${e instanceof Error ? e.message : String(e)}` };
}
});
// ---- SearchDocs (user-indexed external documentation) ----
export const searchDocsTool = defineTool("SearchDocs", false, async (input) => {
try {
const query = String(input.query || "").trim();
if (!query) return { output: "(no query)" };
const k = Math.max(1, Math.min(Number(input.num_results) || 6, 12));
const sources = listDocSources().filter((d) => (d.pages ?? 0) > 0);
if (!sources.length) return { output: "(no indexed doc sources — the user can add them in Settings > Indexing & Docs)" };
if (!sources.length) return { output: "(no indexed doc sources - add them in Settings > Indexing & Docs)" };
const want = String(input.doc || "").trim().toLowerCase();
const targets = want
@@ -221,7 +305,10 @@ export const searchDocsTool = defineTool("SearchDocs", false, async (input) => {
if (!top.length) return { output: "(no matching excerpts)" };
return {
output: top
.map((h) => `[${h.doc}] ${h.title} ${h.url} (score ${h.score.toFixed(3)})\n${h.text}`)
.map((h) => `[${h.doc}] ${h.title} - ${h.url} (score ${h.score.toFixed(3)})\n${h.text}`)
.join("\n\n---\n\n"),
};
} catch (e) {
return { output: `error: SearchDocs failed: ${e instanceof Error ? e.message : String(e)}` };
}
});
+279 -28
View File
@@ -13,8 +13,156 @@ import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import type { SubagentRunner, QuestionAsker } from "./types";
// Directories never walked/listed.
export const IGNORE = new Set([".git", "node_modules", "dist", "out"]);
// Directories never walked/listed (tools + indexing).
export const IGNORE = new Set([
".git",
"node_modules",
"dist",
"out",
"build",
".next",
".nuxt",
".output",
".turbo",
".cache",
"coverage",
".venv",
"venv",
"__pycache__",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"target",
"vendor",
"Pods",
".gradle",
".idea",
".vscode",
"bower_components",
"jspm_packages",
".pnpm-store",
".yarn",
"site-packages",
]);
// ---------------------------------------------------------------------------
// Per-tool hard timeouts (ms). Prevents a hung Grep/Glob/Shell/etc. from
// blocking the agent loop forever. Task/AskQuestion are excluded (own budgets).
// ---------------------------------------------------------------------------
export const TOOL_TIMEOUT_MS: Record<string, number> = {
// Outer safety net: slightly above each tool's own cap so the tool can
// clean up (kill process / mark done) before the loop aborts it.
Shell: 45_000,
AwaitShell: 60_000,
Grep: 20_000,
Glob: 20_000,
FileSearch: 15_000,
SemanticSearch: 30_000,
SearchDocs: 25_000,
ListDir: 10_000,
// Inner Read has 3s stat + 12s I/O; outer net must be slightly above.
Read: 15_000,
ReadLints: 15_000,
WebSearch: 20_000,
WebFetch: 25_000,
StrReplace: 20_000,
Write: 20_000,
Delete: 10_000,
EditNotebook: 20_000,
CallMcpTool: 45_000,
FetchMcpResource: 30_000,
ListMcpResources: 15_000,
TodoWrite: 15_000,
TodoRead: 15_000,
WritePlan: 10_000,
SwitchMode: 5_000,
// Foreground Task budget (bg subagents use BG_SUBAGENT_MAX_MS in loop).
Task: 6 * 60_000,
};
/** Default when a tool has no explicit entry. */
export const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
/** Tools that manage their own lifetime (user wait only). Task has a hard budget. */
export const NO_TOOL_TIMEOUT = new Set(["AskQuestion"]);
/** Built-in defaults in seconds (for settings UI). */
export const DEFAULT_TOOL_TIMEOUTS_SEC: Record<string, number> = Object.fromEntries(
Object.entries(TOOL_TIMEOUT_MS).map(([k, v]) => [k, Math.round(v / 1000)]),
);
/** User overrides from settings (tool name → seconds). Empty/missing = built-in default. */
let toolTimeoutOverridesSec: Record<string, number> = {};
/** Apply settings overrides (seconds). Call whenever feature config loads/changes. */
export function setToolTimeoutOverrides(sec: Record<string, number> | undefined): void {
const next: Record<string, number> = {};
if (sec) {
for (const [k, v] of Object.entries(sec)) {
const n = Number(v);
if (Number.isFinite(n) && n > 0) next[k] = Math.floor(n);
}
}
toolTimeoutOverridesSec = next;
}
/**
* Race a tool promise against a hard timeout. On timeout rejects with an Error
* whose message starts with "timeout:" so the loop can surface it cleanly.
* Does not cancel the underlying work by itself — pass a linked AbortSignal
* into the tool when possible (Shell/Grep honor it).
* Always settles (never hangs) even if `p` never resolves.
*/
/**
* Race a tool promise against a hard timeout.
* On timeout: call `onTimeout` first (abort/kill), then reject immediately so
* the loop can settle UI without waiting for the underlying work.
* Late resolve/reject of `p` is ignored (no unhandled rejection).
*/
export function withToolTimeout<T>(
p: Promise<T>,
ms: number,
label: string,
onTimeout?: () => void,
): Promise<T> {
// ms <= 0: no outer race (AskQuestion manages its own lifetime).
if (!ms || ms <= 0) {
return Promise.resolve(p).catch((e) => {
throw e instanceof Error ? e : new Error(String(e));
});
}
const limit = ms;
return new Promise<T>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { onTimeout?.(); } catch { /* ignore */ }
reject(new Error(`timeout: ${label} exceeded ${Math.round(limit / 1000)}s`));
}, limit);
Promise.resolve(p).then(
(v) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(v);
},
(e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
);
});
}
/** Resolve the hard timeout for a tool name (0 = none). Honors settings overrides. */
export function toolTimeoutMs(name: string): number {
if (NO_TOOL_TIMEOUT.has(name)) return 0;
const overrideSec = toolTimeoutOverridesSec[name];
if (overrideSec != null && overrideSec > 0) return overrideSec * 1000;
return TOOL_TIMEOUT_MS[name] ?? DEFAULT_TOOL_TIMEOUT_MS;
}
// Stopwords for the keyword-based SemanticSearch fallback.
export const STOP = new Set([
@@ -99,10 +247,20 @@ export function firstDiffLine(before: string, after: string): number {
/**
* Recursively collect file paths under `dir` (depth-capped). IGNORE dirs
* (.git/node_modules/dist/out) are skipped unless `includeIgnored` is true.
* (node_modules, .git, build caches, …) are skipped unless `includeIgnored` is true.
* Always respects AbortSignal and maxFiles so explore tools cannot hang forever.
*/
export async function walk(dir: string, out: string[], depth: number, includeIgnored = false): Promise<void> {
if (depth > 12) return;
export async function walk(
dir: string,
out: string[],
depth: number,
includeIgnored = false,
signal?: AbortSignal,
/** Soft cap so huge trees cannot hang the tool forever. */
maxFiles = 20_000,
): Promise<void> {
if (depth > 10 || out.length >= maxFiles) return;
if (signal?.aborted) return;
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
@@ -110,12 +268,23 @@ export async function walk(dir: string, out: string[], depth: number, includeIgn
return;
}
for (const e of entries) {
if (signal?.aborted || out.length >= maxFiles) return;
if (!includeIgnored && IGNORE.has(e.name)) continue;
// Always skip the heaviest trees even when includeIgnored (Glob edge cases).
if (e.name === "node_modules" || e.name === ".git") {
if (!includeIgnored) continue;
// Still skip .git internals; allow node_modules only when explicitly requested.
if (e.name === ".git") continue;
}
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(full, out, depth + 1, includeIgnored);
} else {
out.push(full);
try {
if (e.isDirectory()) {
await walk(full, out, depth + 1, includeIgnored, signal, maxFiles);
} else if (e.isFile() || e.isSymbolicLink()) {
out.push(full);
}
} catch {
/* permission / race — skip entry */
}
}
}
@@ -172,12 +341,32 @@ export function slugify(s: string): string {
);
}
/** Whether ripgrep is available on PATH. */
/** Whether ripgrep is available on PATH (cached; 3s probe timeout). */
let rgCached: boolean | null = null;
export function rgAvailable(): Promise<boolean> {
if (rgCached != null) return Promise.resolve(rgCached);
return new Promise((res) => {
const c = spawn("rg", ["--version"]);
c.on("error", () => res(false));
c.on("close", (code) => res(code === 0));
let settled = false;
const finish = (v: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
rgCached = v;
res(v);
};
const timer = setTimeout(() => {
try { c.kill(); } catch { /* ignore */ }
finish(false);
}, 3_000);
let c: ReturnType<typeof spawn>;
try {
c = spawn("rg", ["--version"]);
} catch {
finish(false);
return;
}
c.on("error", () => finish(false));
c.on("close", (code) => finish(code === 0));
});
}
@@ -244,19 +433,47 @@ const shellSessions = new Map<string, ShellSession>();
function spawnSessionShell(cwd: string): ChildProcess {
if (process.platform === "win32") {
return spawn("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"], { cwd });
// -File - reads stdin as a script; NonInteractive avoids prompts that hang.
return spawn(
"powershell.exe",
["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
{ cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] },
);
}
return spawn("bash", ["-i"], { cwd });
// Non-interactive bash (no -i): interactive mode can hang on job control / PS1.
return spawn("bash", ["--noprofile", "--norc"], {
cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "dumb", PS1: "", PS2: "" },
});
}
/** Get (or lazily create) the persistent shell session for a run key. */
export function getShellSession(key: string, cwd: string): ShellSession {
let s = shellSessions.get(key);
if (s && !s.proc.killed && s.proc.exitCode === null) return s;
if (s && !s.proc.killed && s.proc.exitCode === null && s.proc.stdin && !s.proc.stdin.destroyed) {
return s;
}
if (s) {
try { s.proc.kill(); } catch { /* ignore */ }
shellSessions.delete(key);
}
const proc = spawnSessionShell(cwd);
s = { proc, queue: Promise.resolve(), buffer: "" };
proc.stdout?.on("data", (d) => (s!.buffer += d));
proc.stderr?.on("data", (d) => (s!.buffer += d));
proc.stdout?.on("data", (d) => {
try { s!.buffer += d.toString(); } catch { /* ignore */ }
});
proc.stderr?.on("data", (d) => {
try { s!.buffer += d.toString(); } catch { /* ignore */ }
});
proc.on("error", () => {
/* keep buffer; next getShellSession recreates */
});
proc.on("exit", () => {
/* session is dead; next command will respawn */
});
// Prevent unhandled 'error' on stdin from crashing the extension host.
proc.stdin?.on("error", () => { /* ignore broken pipe */ });
shellSessions.set(key, s);
return s;
}
@@ -266,22 +483,56 @@ export function disposeShellSession(key: string): void {
const s = shellSessions.get(key);
if (s) {
try {
s.proc.kill();
} catch {}
if (process.platform === "win32") s.proc.kill();
else s.proc.kill("SIGKILL");
} catch { /* ignore */ }
shellSessions.delete(key);
}
}
/** Wait until the shell finishes, `pattern` matches its output, or `ms` elapses. */
export function waitForShell(sh: BgShell, ms: number, pattern?: RegExp): Promise<void> {
/**
* Wait until the shell finishes, `pattern` matches its output, or `ms` elapses.
* Always resolves (never rejects). `ms <= 0` = one immediate pump + return.
*/
export function waitForShell(sh: BgShell, ms: number, pattern?: RegExp, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const deadline = Date.now() + Math.max(0, ms);
const tick = () => {
sh.pump?.();
if (sh.done || (pattern && pattern.test(sh.output)) || Date.now() >= deadline) return resolve();
setTimeout(tick, 100);
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (interval) clearInterval(interval);
signal?.removeEventListener("abort", onAbort);
resolve();
};
tick();
const onAbort = () => {
if (!sh.done) {
sh.output += "\n(aborted)";
sh.done = true;
}
finish();
};
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
const deadline = Date.now() + Math.max(0, ms);
const check = () => {
try { sh.pump?.(); } catch { /* ignore */ }
if (sh.done) return finish();
if (pattern) {
try {
if (pattern.test(sh.output)) return finish();
} catch { /* bad pattern mid-wait */ }
}
if (ms <= 0 || Date.now() >= deadline) return finish();
};
// Hard wall-clock: never tick forever even if setInterval stalls.
const timer = setTimeout(finish, Math.max(ms, 0) + 250);
const interval = setInterval(check, 50);
check();
});
}
+255 -45
View File
@@ -16,12 +16,19 @@ import {
renderShell,
pushShellOutput,
getShellSession,
disposeShellSession,
type BgShell,
type ShellNotify,
} from "./shared";
const SENTINEL = "__OC_SHELL_DONE__";
const isWin = process.platform === "win32";
/** Default foreground wait for simple commands; hard max keeps the loop responsive. */
const DEFAULT_BLOCK_MS = 15_000;
const MAX_BLOCK_MS = 30_000;
const MAX_AWAIT_MS = 45_000;
/** Absolute hard wall even if block_until_ms is large / tool timeout is higher. */
const SHELL_HARD_WALL_MS = 45_000;
/** Build a notify_on_output config from the tool input, if present. */
function buildNotify(input: any, ctx: any): ShellNotify | undefined {
@@ -42,22 +49,101 @@ function buildNotify(input: any, ctx: any): ShellNotify | undefined {
};
}
/** Quote a filesystem path for the session shell (spaces, quotes, unicode). */
function quotePath(p: string): string {
if (isWin) {
// PowerShell single-quoted literal; escape ' by doubling. Drop trailing
// backslash that would escape the closing quote if we ever used doubles.
return `'${p.replace(/'/g, "''")}'`;
}
// bash: single-quote with '\'' for embedded quotes
return `'${p.replace(/'/g, `'\\''`)}'`;
}
/**
* Frame a command so the session ALWAYS prints a sentinel with exit code.
*
* Critical: do NOT paste the user command raw into the script. Paths with
* spaces, unclosed quotes, or bad syntax leave PowerShell waiting for more
* input forever (no sentinel → tool looks "stuck"). Instead base64-encode the
* command and Invoke-Expression / bash -c it inside try/catch/finally so
* parse errors still emit the sentinel and free the session.
*/
function wrapCommand(command: string, cd: string): string {
const b64 = Buffer.from(command, "utf8").toString("base64");
if (isWin) {
const cdBlock = cd
? `Push-Location -LiteralPath ${quotePath(cd)}; $__oc_pop = $true; `
: `$__oc_pop = $false; `;
// Decode → Invoke-Expression inside try; sentinel always in finally.
// $? / $LASTEXITCODE after IEX covers native cmds and cmdlets.
return (
`${cdBlock}` +
`$__oc_ok = $false; $__oc_code = 1; ` +
`try { ` +
`$__oc_cmd = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')); ` +
`Invoke-Expression -Command $__oc_cmd; ` +
`if ($?) { $__oc_ok = $true; $__oc_code = 0 } ` +
`elseif ($null -ne $LASTEXITCODE -and "$LASTEXITCODE" -ne '') { $__oc_code = [int]$LASTEXITCODE } ` +
`else { $__oc_code = 1 } ` +
`} catch { ` +
`[Console]::Error.WriteLine($_.Exception.Message); $__oc_ok = $false; $__oc_code = 1 ` +
`} finally { ` +
`if ($__oc_pop) { Pop-Location -ErrorAction SilentlyContinue }; ` +
`if ($__oc_ok) { [Console]::Out.WriteLine("${SENTINEL}:0") } ` +
`else { [Console]::Out.WriteLine("${SENTINEL}:$__oc_code") } ` +
`}\n`
);
}
// bash: decode to a temp eval so spaces/quotes never break the outer script.
// Always print sentinel even if eval fails (set +e).
const push = cd ? `pushd ${quotePath(cd)} >/dev/null 2>&1 || true\n` : "";
const pop = cd ? `popd >/dev/null 2>&1 || true\n` : "";
return (
`set +e\n` +
`${push}` +
`__oc_cmd=$(printf '%s' '${b64}' | base64 -d 2>/dev/null || printf '%s' '${b64}' | base64 -D 2>/dev/null)\n` +
`eval "$__oc_cmd"\n` +
`__oc_rc=$?\n` +
`${pop}` +
`printf '%s\\n' "${SENTINEL}:$__oc_rc"\n`
);
}
// ---- Shell (stateful session; backgrounds a command past block_until_ms) ----
// A persistent shell per run keeps cwd/env across calls. Each command is framed
// by a sentinel echo so we can detect completion and capture the exit code.
export const runTerminalTool = defineTool("Shell", true, async (input, abortSignal, _callId, ctx) => {
const root = getWorkspaceRoot();
const blockMs = typeof input.block_until_ms === "number" ? input.block_until_ms : 30_000;
const command = String(input.command ?? "");
const rawBlock = typeof input.block_until_ms === "number" ? input.block_until_ms : DEFAULT_BLOCK_MS;
const blockMs = rawBlock <= 0 ? 0 : Math.min(Math.max(0, rawBlock), MAX_BLOCK_MS);
const command = String(input.command ?? "").trim();
if (!command) return { output: "error: command is required" };
// Prune finished shells older than 10 minutes to bound the registry.
for (const [k, v] of bgShells) {
if (v.done && Date.now() - v.startedAt > 600_000) bgShells.delete(k);
}
// Persistent session keyed per run (falls back to a shared key if absent).
const sessionKey = (ctx as any)?.shellSessionKey ?? "default";
const session = getShellSession(sessionKey, root);
let session = getShellSession(sessionKey, root);
// Serialize commands on this session so sentinels don't interleave.
// Always settle the queue slot even if this command errors/times out.
let releaseQueue!: () => void;
const prev = session.queue.catch(() => {});
session.queue = new Promise<void>((r) => {
releaseQueue = r;
});
try {
// Never block forever on a stuck prior command's queue slot.
await Promise.race([
prev,
new Promise<void>((r) => setTimeout(r, MAX_BLOCK_MS + 5_000)),
]);
} catch {
/* ignore prior failure */
}
// Session may have been replaced while we waited.
session = getShellSession(sessionKey, root);
const sh: BgShell = {
id: nextShellId(),
@@ -71,70 +157,181 @@ export const runTerminalTool = defineTool("Shell", true, async (input, abortSign
};
bgShells.set(sh.id, sh);
// Mark where this command's output begins so we can slice the session buffer.
const startLen = session.buffer.length;
let lastSeen = startLen;
const sentinelRe = new RegExp(SENTINEL + ":(-?\\d+)");
// Drain this command's slice of the session buffer into the BgShell and
// detect the sentinel (printed with the exit code) marking completion.
// Stored on `sh` so AwaitShell can keep draining after we background.
sh.pump = () => {
if (session.buffer.length > lastSeen) {
pushShellOutput(sh, session.buffer.slice(lastSeen));
lastSeen = session.buffer.length;
}
const m = sh.output.match(sentinelRe);
if (m && !sh.done) {
sh.exitCode = Number(m[1]);
sh.done = true;
try {
if (session.buffer.length > lastSeen) {
pushShellOutput(sh, session.buffer.slice(lastSeen));
lastSeen = session.buffer.length;
}
const m = sh.output.match(sentinelRe);
if (m && !sh.done) {
sh.exitCode = Number(m[1]);
sh.done = true;
}
// Dead process with no sentinel → force complete so we never hang.
if (!sh.done && (session.proc.killed || session.proc.exitCode != null)) {
sh.exitCode = session.proc.exitCode ?? 1;
sh.done = true;
sh.output += "\n(shell session exited)";
}
} catch (e) {
if (!sh.done) {
sh.done = true;
sh.exitCode = 1;
sh.output += `\n(pump error: ${e instanceof Error ? e.message : String(e)})`;
}
}
};
const pumpTimer = setInterval(() => sh.pump?.(), 100);
const killSession = () => {
try {
disposeShellSession(sessionKey);
} catch {
/* ignore */
}
};
const onAbort = () => {
sh.output += "\n(aborted)";
sh.done = true;
if (!sh.done) {
sh.output += "\n(aborted)";
sh.done = true;
sh.exitCode = sh.exitCode ?? 130;
}
killSession();
};
abortSignal?.addEventListener("abort", onAbort);
// Optional per-command working directory (a `cd` that does not persist).
const cd = input.working_directory ? safePath(input.working_directory) : "";
const wrapped = isWin
? `${cd ? `Push-Location -LiteralPath '${cd.replace(/'/g, "''")}'; ` : ""}${command}${cd ? "; Pop-Location" : ""}\nWrite-Output "${SENTINEL}:$LASTEXITCODE"\n`
: `${cd ? `pushd '${cd.replace(/'/g, "'\\''")}' && ` : ""}${command}\n__oc_rc=$?\n${cd ? "popd >/dev/null 2>&1\n" : ""}echo "${SENTINEL}:$__oc_rc"\n`;
session.proc.stdin?.write(wrapped);
let cd = "";
if (input.working_directory) {
try {
cd = safePath(String(input.working_directory));
} catch (e) {
abortSignal?.removeEventListener("abort", onAbort);
sh.done = true;
sh.exitCode = 1;
releaseQueue();
return {
output: `error: invalid working_directory: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
const wrapped = wrapCommand(command, cd);
await waitForShell(sh, blockMs);
sh.pump?.();
clearInterval(pumpTimer);
abortSignal?.removeEventListener("abort", onAbort);
try {
const stdin = session.proc.stdin;
if (!stdin || stdin.destroyed) {
killSession();
session = getShellSession(sessionKey, root);
sh.proc = session.proc;
}
const ok = session.proc.stdin?.write(wrapped);
if (ok === false) {
// Backpressure: wait briefly for drain, then continue (pump still works).
await new Promise<void>((r) => {
const t = setTimeout(r, 2_000);
session.proc.stdin?.once("drain", () => {
clearTimeout(t);
r();
});
});
}
} catch (e) {
abortSignal?.removeEventListener("abort", onAbort);
sh.done = true;
sh.exitCode = 1;
releaseQueue();
return {
output: `error: shell write failed: ${e instanceof Error ? e.message : String(e)}`,
};
}
// Strip the sentinel line from the rendered body.
sh.output = sh.output.replace(new RegExp("\\n?" + SENTINEL + ":-?\\d+\\s*"), "");
return { output: renderShell(sh) };
// Cap wait by tool abort + hard wall so Shell never outlives its countdown.
const waitMs = blockMs <= 0 ? 0 : Math.min(blockMs, SHELL_HARD_WALL_MS);
try {
await waitForShell(sh, waitMs, undefined, abortSignal);
try {
sh.pump?.();
} catch {
/* ignore */
}
// Abort/timeout: kill session immediately so the loop is free.
if (abortSignal?.aborted && !sh.done) {
sh.output += "\n(aborted / timed out)";
sh.done = true;
sh.exitCode = sh.exitCode ?? 124;
killSession();
} else if (!sh.done && waitMs > 0) {
// Timed out with no sentinel: do NOT leave a hung command poisoning the
// session — kill and respawn so the next Shell call is clean.
sh.output += `\n(timeout after ${waitMs}ms — session reset; re-run with a shorter command or block_until_ms=0 to background)`;
sh.done = true;
sh.exitCode = sh.exitCode ?? 124;
killSession();
} else if (!sh.done && waitMs === 0) {
// Immediate background: keep pumping via interval until done/timeout later.
const bgPump = setInterval(() => {
try {
sh.pump?.();
} catch {
/* ignore */
}
if (sh.done) clearInterval(bgPump);
}, 100);
// Hard stop background pump after 10 min.
setTimeout(() => clearInterval(bgPump), 600_000).unref?.();
}
// Strip the sentinel line from the rendered body.
sh.output = sh.output.replace(new RegExp("\\n?" + SENTINEL + ":-?\\d+\\s*"), "");
return { output: renderShell(sh) };
} catch (e) {
if (!sh.done) {
sh.done = true;
sh.exitCode = 1;
sh.output += `\n(error: ${e instanceof Error ? e.message : String(e)})`;
}
killSession();
return { output: renderShell(sh) };
} finally {
abortSignal?.removeEventListener("abort", onAbort);
releaseQueue();
}
});
// ---- AwaitShell (poll a backgrounded shell, or just sleep) ----
export const awaitShellTool = defineTool("AwaitShell", false, async (input) => {
const blockMs = typeof input?.block_until_ms === "number" ? input.block_until_ms : 30_000;
export const awaitShellTool = defineTool("AwaitShell", false, async (input, abortSignal) => {
const raw = typeof input?.block_until_ms === "number" ? input.block_until_ms : 15_000;
const blockMs = raw <= 0 ? 0 : Math.min(raw, MAX_AWAIT_MS);
const id = input?.shell_id ? String(input.shell_id) : "";
// No shell id: sleep for the full duration (renders nicely vs. sleeping in the
// shell). shell_id is required for a non-blocking status check (block_until_ms 0).
if (!id) {
if (blockMs <= 0) return { output: "error: shell_id is required when block_until_ms is 0" };
await new Promise((r) => setTimeout(r, blockMs));
await new Promise<void>((r) => {
const t = setTimeout(r, blockMs);
const onAbort = () => {
clearTimeout(t);
r();
};
if (abortSignal?.aborted) {
clearTimeout(t);
r();
return;
}
abortSignal?.addEventListener("abort", onAbort, { once: true });
});
return { output: `Slept for ${blockMs}ms.` };
}
const sh = bgShells.get(id);
if (!sh) {
// A common misuse is passing a Task/subagent call id (e.g. "toolu_…"). Subagents
// are NOT shells; they stream their own events and are awaited automatically
// before the turn ends, so there is nothing to poll here.
if (/^toolu_|^call_/i.test(id)) {
return { output: `error: "${id}" looks like a subagent/Task call id, not a background shell. Subagents are not shells — do not poll them with AwaitShell. They stream results on their own and are awaited automatically before your turn ends; just continue or finish.` };
return {
output: `error: "${id}" looks like a subagent/Task call id, not a background shell. Subagents are not shells — do not poll them with AwaitShell.`,
};
}
return { output: `error: no background shell with id ${id}` };
}
@@ -147,6 +344,19 @@ export const awaitShellTool = defineTool("AwaitShell", false, async (input) => {
return { output: `error: invalid pattern: ${e instanceof Error ? e.message : String(e)}` };
}
}
await waitForShell(sh, blockMs, pattern);
return { output: renderShell(sh) };
try {
await waitForShell(sh, blockMs, pattern, abortSignal);
try {
sh.pump?.();
} catch {
/* ignore */
}
sh.output = sh.output.replace(new RegExp("\\n?" + SENTINEL + ":-?\\d+\\s*"), "");
return { output: renderShell(sh) };
} catch (e) {
return {
output: `error: AwaitShell failed: ${e instanceof Error ? e.message : String(e)}`,
};
}
});
+1 -1
View File
@@ -73,7 +73,7 @@ export type ProviderEvent =
export type AgentEvent =
| { type: "text-delta"; text: string }
| { type: "thinking-delta"; text: string }
| { type: "tool-call-started"; callId: string; name: string; input: unknown }
| { type: "tool-call-started"; callId: string; name: string; input: unknown; timeoutMs?: number; startedAt?: number }
// Live JSON-arg streaming for a started call (UI parses partial input).
| { type: "tool-call-args"; callId: string; argsText: string }
| { type: "tool-call-completed"; callId: string; name: string; status: "completed" | "error"; result: string; diff?: string; startLine?: number; endLine?: number }
+80 -27
View File
@@ -11,37 +11,90 @@ import * as vscode from "vscode";
import * as path from "path";
export function getWorkspaceRoot(): string {
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return folders[0].uri.fsPath;
}
return process.cwd();
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return folders[0].uri.fsPath;
}
return process.cwd();
}
/** Recently viewed files (workspace-relative), most recent first. */
export function getRecentFiles(): string[] {
const root = getWorkspaceRoot();
const out: string[] = [];
for (const tab of vscode.window.tabGroups.all.flatMap((g) => g.tabs)) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
const uri = input?.uri;
if (uri && uri.scheme === "file" && uri.fsPath.startsWith(root)) {
const rel = path.relative(root, uri.fsPath).split(path.sep).join("/");
if (!out.includes(rel)) {
out.push(uri.fsPath);
}
}
}
return out;
const root = getWorkspaceRoot();
const out: string[] = [];
for (const tab of vscode.window.tabGroups.all.flatMap((g) => g.tabs)) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
const uri = input?.uri;
if (uri && uri.scheme === "file" && uri.fsPath.startsWith(root)) {
const rel = path.relative(root, uri.fsPath).split(path.sep).join("/");
if (!out.includes(rel)) {
out.push(uri.fsPath);
}
}
}
return out;
}
export function safePath(rel: string): string {
const root = getWorkspaceRoot();
const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
const norm = path.resolve(abs);
const ws = path.resolve(root);
if (norm !== ws && !norm.startsWith(ws + path.sep)) {
throw new Error(`path outside workspace: ${rel}`);
}
return norm;
/**
* Normalize a model/user path: spaces, quotes, file:// URIs, mixed separators.
* Does not shell-quote callers that inject into a shell must quote the result.
*/
export function normalizePathInput(rel: string): string {
let s = String(rel ?? "").trim();
// file:///C:/foo%20bar or file://localhost/C:/...
if (/^file:\/\//i.test(s)) {
try {
s = decodeURIComponent(vscode.Uri.parse(s).fsPath);
} catch {
s = s.replace(/^file:\/\/\/?/i, "").replace(/\//g, path.sep);
try {
s = decodeURIComponent(s);
} catch {
/* keep */
}
}
}
// Strip surrounding quotes the model wraps around paths with spaces.
// Also handle nested `"path with spaces"` and smart quotes.
for (let i = 0; i < 3; i++) {
const t = s.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) || (t.startsWith("`") && t.endsWith("`")) || (t.startsWith("\u201c") && t.endsWith("\u201d")) || (t.startsWith("\u2018") && t.endsWith("\u2019"))) {
s = t.slice(1, -1).trim();
continue;
}
s = t;
break;
}
// Model sometimes escapes spaces as `\ ` (unix-style).
s = s.replace(/\\ /g, " ");
// Collapse only internal runs of spaces that are clearly accidental? Keep
// real spaces in folder names — do not collapse.
// Normalize separators; path.resolve will also fix mixed ones.
s = s.replace(/\//g, path.sep);
// Drop trailing separators except drive root (C:\).
if (s.length > 3 && (s.endsWith(path.sep) || s.endsWith("/") || s.endsWith("\\"))) {
s = s.replace(/[\\/]+$/, "");
}
return s;
}
/**
* Resolve a workspace path safely. Handles spaces, unicode, and mixed
* separators. Does not shell-quote callers that inject into a shell must
* quote the result (see shell.ts quotePath).
*/
export function safePath(rel: string): string {
const root = getWorkspaceRoot();
const s = normalizePathInput(rel);
if (!s) throw new Error("empty path");
const abs = path.isAbsolute(s) ? s : path.join(root, s);
const norm = path.resolve(abs);
const ws = path.resolve(root);
// Case-insensitive root check on Windows (C:\ vs c:\).
const normKey = process.platform === "win32" ? norm.toLowerCase() : norm;
const wsKey = process.platform === "win32" ? ws.toLowerCase() : ws;
if (normKey !== wsKey && !normKey.startsWith(wsKey + path.sep)) {
throw new Error(`path outside workspace: ${rel}`);
}
return norm;
}
+9 -6
View File
@@ -13,10 +13,11 @@ import { SidebarProvider } from './ui/sidebarProvider';
import { registerInlineReview } from './ui/inlineReview';
import { SettingsPanel } from './ui/settingsPanel';
import { FeatureStore } from './stores/featureStore';
import { setToolTimeoutOverrides } from './agent/tools/shared';
import { mcpManager } from './integrations/mcpClient';
import { setIndexStorageDir, buildIndex } from './agent/semanticIndex';
import { setIndexStorageDir } from './agent/semanticIndex';
import { setDocsStorageDir, setDocSourcesProvider } from './agent/docsIndex';
import { getWorkspaceRoot } from './context/workspaceUtils';
import { initIndexWatch } from './agent/indexWatch';
import { initLlamacpp, checkInstalled, loadModel, disposeLlamacpp } from './agent/llamacpp';
import { initOAuth } from './agent/oauth';
import { initUsage } from './stores/usageStore';
@@ -32,20 +33,22 @@ export function activate(context: vscode.ExtensionContext) {
const settingsManager = new SettingsManager(context);
const featureStore = new FeatureStore(context);
const syncToolTimeouts = () => setToolTimeoutOverrides(featureStore.get().toolTimeoutsSec);
syncToolTimeouts();
context.subscriptions.push(featureStore.onDidChange(syncToolTimeouts));
initOAuth(context);
initUsage(context);
// Prefetch the provider-grouped model list so every UI (settings, pickers)
// renders instantly from the backend cache.
initModelRegistry(featureStore, settingsManager);
// Local semantic index: model + vectors live in extension globalStorage.
// Kick off an initial background build (incremental; no-op if already fresh).
// Local semantic index: vectors in globalStorage; warm disk + incremental sync.
setIndexStorageDir(context.globalStorageUri.fsPath);
setDocsStorageDir(context.globalStorageUri.fsPath);
setDocSourcesProvider(() => featureStore.get().docSources ?? []);
applyEmbedModel(featureStore.get().embedModel || "minilm")
.then(() => buildIndex(getWorkspaceRoot()))
.catch(() => {});
.then(() => initIndexWatch(context, featureStore))
.catch(() => initIndexWatch(context, featureStore));
// Connect any enabled MCP servers in the background.
mcpManager.sync(featureStore.get().mcpServers).catch(() => {});
-30
View File
@@ -295,33 +295,3 @@ export class McpManager {
}
export const mcpManager = new McpManager();
/** A server entry from the official MCP registry (registry.modelcontextprotocol.io). */
export interface RegistryServer {
name: string;
title?: string;
description?: string;
version?: string;
packages?: {
registryType?: string;
identifier?: string;
version?: string;
runtimeHint?: string;
transport?: { type?: string };
runtimeArguments?: { type?: string; name?: string; value?: string }[];
packageArguments?: { type?: string; name?: string; value?: string }[];
environmentVariables?: { name?: string; description?: string; isRequired?: boolean; isSecret?: boolean }[];
}[];
}
/**
* Search the official MCP registry. Runs in the extension host (not the webview)
* so it isn't blocked by the webview CSP. Empty query returns a default listing.
*/
export async function searchMcpRegistry(query: string, limit = 30): Promise<RegistryServer[]> {
const url = `https://registry.modelcontextprotocol.io/v0.1/servers?limit=${limit}${query.trim() ? `&search=${encodeURIComponent(query.trim())}` : ""}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`registry ${r.status}`);
const data = (await r.json()) as { servers?: { server: RegistryServer }[] };
return (data.servers ?? []).map((s) => s.server);
}
+27
View File
@@ -19,6 +19,7 @@ import * as fs from "fs";
import * as path from "path";
import * as zlib from "zlib";
import * as crypto from "crypto";
import { pathToFileURL } from "url";
const REGISTRY = "https://registry.npmjs.org";
@@ -252,6 +253,32 @@ export function ensureRuntimeDeps(): Promise<boolean> {
return readyP;
}
/** Resolve a package's ESM entry point from its package.json. */
function entryOf(pkg: any): string {
const pick = (v: any): string | undefined => {
if (typeof v === "string") return v;
if (v && typeof v === "object") return pick(v.import ?? v.node ?? v.default ?? v.require);
return undefined;
};
return pick(pkg.exports?.["."] ?? pkg.exports) || pkg.module || pkg.main || "index.js";
}
/**
* Import a runtime dep. In production the extension host's ESM resolver can't
* see runtime-deps/node_modules (the CJS require hook doesn't apply to
* import()), so we resolve the entry file ourselves and import it by file URL.
*/
export async function importRuntimeDep<T = any>(name: string): Promise<T> {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
try {
return await import(name); // dev: real node_modules next to us
} catch { /* fall back to runtime-deps dir */ }
if (!rootDir) throw new Error("runtime deps not initialized");
const dir = path.join(rootDir, "node_modules", ...name.split("/"));
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
return await import(pathToFileURL(path.join(dir, entryOf(pkg))).href);
}
// Self-check: OPENCURSOR_SELFCHECK=1 node -e "require('./dist/extension.js')"
if (process.env.OPENCURSOR_SELFCHECK) {
const assert = (c: boolean, m: string) => { if (!c) throw new Error("selfcheck: " + m); };
+92 -5
View File
@@ -16,7 +16,7 @@ export type Mode = "agent" | "ask" | "plan" | "multitask" | "debug";
export type AgentEvent =
| { type: "text-delta"; text: string }
| { type: "thinking-delta"; text: string }
| { type: "tool-call-started"; callId: string; name: string; input: any }
| { type: "tool-call-started"; callId: string; name: string; input: any; timeoutMs?: number; startedAt?: number }
| { type: "tool-call-args"; callId: string; argsText: string }
| {
type: "tool-call-completed";
@@ -58,6 +58,10 @@ export interface ToolBlock {
diff?: string;
startLine?: number;
endLine?: number;
/** Hard timeout budget (ms). UI shows countdown; 0/undefined = none. */
timeoutMs?: number;
/** Wall-clock start for countdown (ms since epoch). */
startedAt?: number;
/** For task (subagent) blocks: the nested read-only sub-chat stream. */
subBlocks?: AssistantBlock[];
subStatus?: "running" | "finished" | "error" | "cancelled";
@@ -196,8 +200,28 @@ export function applyToBlocks(blocksIn: AssistantBlock[], ev: AgentEvent): Assis
} else if (ev.type === "tool-call-started") {
if (last && last.kind === "thinking" && !last.endedAt) blocks[blocks.length - 1] = { ...last, endedAt: Date.now() };
const existing = blocks.findIndex((b) => b.kind === "tool" && b.callId === ev.callId);
if (existing >= 0) blocks[existing] = { ...blocks[existing], name: ev.name, input: ev.input } as AssistantBlock;
else blocks.push({ kind: "tool", callId: ev.callId, name: ev.name, input: ev.input, status: "running" });
const timeoutMs = ev.timeoutMs && ev.timeoutMs > 0 ? ev.timeoutMs : undefined;
const startedAt = ev.startedAt;
if (existing >= 0) {
const prev = blocks[existing] as ToolBlock;
blocks[existing] = {
...prev,
name: ev.name,
input: Object.keys(ev.input || {}).length ? ev.input : prev.input,
timeoutMs: timeoutMs ?? prev.timeoutMs,
startedAt: startedAt ?? prev.startedAt,
} as AssistantBlock;
} else {
blocks.push({
kind: "tool",
callId: ev.callId,
name: ev.name,
input: ev.input,
status: "running",
timeoutMs,
startedAt,
});
}
} else if (ev.type === "tool-call-args") {
return blocks.map((b) =>
b.kind === "tool" && b.callId === ev.callId ? { ...b, input: parsePartialArgs(ev.argsText, b.input) } : b
@@ -277,10 +301,31 @@ export function applyEvent(turns: Turn[], ev: AgentEvent): Turn[] {
const { list, turn } = ensureAssistant(turns);
closeThinking(turn);
const existing = turn.blocks.findIndex((b) => b.kind === "tool" && b.callId === ev.callId);
const timeoutMs = ev.timeoutMs && ev.timeoutMs > 0 ? ev.timeoutMs : undefined;
// startedAt only when provided (execute time). Stream preview may omit it.
const startedAt = ev.startedAt;
if (existing >= 0) {
turn.blocks[existing] = { ...turn.blocks[existing], name: ev.name, input: ev.input } as AssistantBlock;
const prev = turn.blocks[existing] as ToolBlock;
// Never reopen a settled tool (timeout/cancel may race a late start).
if (prev.status !== "running") return list;
turn.blocks[existing] = {
...prev,
name: ev.name,
input: Object.keys(ev.input || {}).length ? ev.input : prev.input,
status: "running",
timeoutMs: timeoutMs ?? prev.timeoutMs,
startedAt: startedAt ?? prev.startedAt,
} as AssistantBlock;
} else {
turn.blocks.push({ kind: "tool", callId: ev.callId, name: ev.name, input: ev.input, status: "running" });
turn.blocks.push({
kind: "tool",
callId: ev.callId,
name: ev.name,
input: ev.input,
status: "running",
timeoutMs,
startedAt,
});
}
return list;
}
@@ -354,6 +399,48 @@ export function applyEvent(turns: Turn[], ev: AgentEvent): Turn[] {
return turns;
}
/** Mark every still-open tool / subagent / thinking block as cancelled or closed. */
export function forceSettleOpenWork(turns: Turn[], reason: "cancelled" | "error" = "cancelled"): Turn[] {
const msg = reason === "error" ? "(error)" : "(cancelled)";
const subSt = reason === "error" ? "error" : "cancelled";
return turns.map((turn) => {
if (turn.role !== "assistant") return turn;
let changed = false;
const blocks = turn.blocks.map((b) => {
if (b.kind === "thinking" && !b.endedAt) {
changed = true;
return { ...b, endedAt: Date.now() };
}
if (b.kind === "tool") {
let next: ToolBlock = b;
if (b.status === "running") {
changed = true;
next = { ...next, status: "error", result: b.result || msg };
}
const isTask = b.name === "Task" || b.name === "task";
if (b.subStatus === "running" || (next.status === "error" && isTask && !b.subStatus)) {
changed = true;
next = { ...next, subStatus: subSt as ToolBlock["subStatus"] };
}
if (next.subBlocks?.length) {
const nested = forceSettleOpenWork([{ role: "assistant", blocks: next.subBlocks }], reason)[0] as AssistantTurn;
if (nested.blocks !== next.subBlocks) {
changed = true;
next = { ...next, subBlocks: nested.blocks };
}
}
return next;
}
if (b.kind === "compaction" && b.status === "running") {
changed = true;
return { ...b, status: "failed" as const };
}
return b;
});
return changed ? { role: "assistant" as const, blocks } : turn;
});
}
/** Close any still-open trailing thinking block (run settled). */
export function closeTrailingThinking(turns: Turn[]): Turn[] {
const lt = turns[turns.length - 1];
+26 -5
View File
@@ -35,14 +35,35 @@ const KEY = "ocursor.conversations";
const ACTIVE_KEY = "ocursor.activeConversation";
export class ConversationStore {
constructor(private readonly context: vscode.ExtensionContext) {}
constructor(private readonly context: vscode.ExtensionContext) {
void this.migrateFromGlobal();
}
/** workspaceState = per-workspace storage; VS Code scopes it for us. */
private get state(): vscode.Memento {
return this.context.workspaceState;
}
/** One-time: move old globalState conversations into this workspace. */
private async migrateFromGlobal(): Promise<void> {
const old = this.context.globalState.get<Conversation[]>(KEY);
if (!old?.length || this.state.get<Conversation[]>(KEY)?.length) {
if (old) await this.context.globalState.update(KEY, undefined);
return;
}
await this.state.update(KEY, old);
const active = this.context.globalState.get<string>(ACTIVE_KEY);
if (active) await this.state.update(ACTIVE_KEY, active);
await this.context.globalState.update(KEY, undefined);
await this.context.globalState.update(ACTIVE_KEY, undefined);
}
private all(): Conversation[] {
return this.context.globalState.get<Conversation[]>(KEY, []);
return this.state.get<Conversation[]>(KEY, []);
}
private async persist(list: Conversation[]): Promise<void> {
await this.context.globalState.update(KEY, list);
await this.state.update(KEY, list);
}
list(): ConversationSummary[] {
@@ -57,11 +78,11 @@ export class ConversationStore {
}
getActiveId(): string | undefined {
return this.context.globalState.get<string>(ACTIVE_KEY);
return this.state.get<string>(ACTIVE_KEY);
}
async setActiveId(id: string | undefined): Promise<void> {
await this.context.globalState.update(ACTIVE_KEY, id);
await this.state.update(ACTIVE_KEY, id);
}
async create(personaId?: string): Promise<Conversation> {
+57 -14
View File
@@ -101,6 +101,29 @@ const effort = (value = "medium", values = ["none", "low", "medium", "high"]): M
const thinking = (value = "adaptive", values = ["disabled", "adaptive", "enabled"]): ModelOption => ({ key: "thinking", label: "Thinking", type: "select", values, value });
const ctx = (values: string[], value: string): ModelOption => ({ key: "max_context", label: "Context", type: "select", values, value });
/** Fallback context sizes for models with no catalog preset (custom / fetched). */
export const DEFAULT_CONTEXT_VALUES = ["32k", "64k", "128k", "200k", "256k", "512k", "1m"];
export const DEFAULT_CONTEXT_VALUE = "128k";
export const defaultContextOption = (): ModelOption =>
ctx([...DEFAULT_CONTEXT_VALUES], DEFAULT_CONTEXT_VALUE);
/** Parse "200k" / "1m" / "128000" → token count. */
export function parseContextLabel(v?: string): number {
if (!v) return 0;
const s = String(v).trim().toLowerCase();
const m = s.match(/^([\d.]+)\s*([kmb])?$/);
if (!m) {
const n = Number(s.replace(/[^\d.]/g, ""));
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
const n = parseFloat(m[1]);
const u = m[2];
if (u === "m") return Math.floor(n * 1_000_000);
if (u === "b") return Math.floor(n * 1_000_000_000);
if (u === "k") return Math.floor(n * 1_000);
return Math.floor(n);
}
/** Built-in catalog of popular coding models. Users can edit options per model. */
export const MODEL_CATALOG: ModelDef[] = [
// OpenAI — gpt-5.5 is the current flagship; effort supports none/low/medium/high (xhigh on top tiers).
@@ -209,6 +232,8 @@ export interface FeatureConfig {
maxTabCount: number;
/** Max agent steps per run before pausing (0 = default 50). */
maxAgentSteps: number;
/** Per-tool hard timeout overrides in seconds (empty = built-in defaults). */
toolTimeoutsSec: Record<string, number>;
/** Automatically continue when the step limit is reached. */
autoContinue: boolean;
/** Play a sound when the agent finishes responding. */
@@ -221,6 +246,8 @@ export interface FeatureConfig {
approvalPolicy: ApprovalPolicy;
/** External documentation sources indexed for @Docs mentions. */
docSources: DocSource[];
/** Master switch for semantic codebase indexing. */
indexingEnabled: boolean;
/** Automatically index newly added workspace folders. */
indexNewFolders: boolean;
/** Index repositories to speed up grep searches (all data local). */
@@ -256,12 +283,14 @@ const DEFAULTS: FeatureConfig = {
submitWithCtrlEnter: false,
maxTabCount: 0,
maxAgentSteps: 50,
toolTimeoutsSec: {},
autoContinue: false,
completionSound: false,
webSearchEnabled: true,
webFetchEnabled: true,
approvalPolicy: DEFAULT_APPROVAL,
docSources: [],
indexingEnabled: true,
indexNewFolders: true,
indexForGrep: true,
};
@@ -321,24 +350,38 @@ export class FeatureStore {
/** Resolved options for a model: stored overrides take precedence over defaults.
* Overrides are kind-scoped ("<kind>:<id>") so the same model id can hold
* different option state per provider (e.g. anthropic vs claude-code);
* a plain-id record is the legacy/shared fallback. */
* a plain-id record is the legacy/shared fallback.
* Models with no catalog context option get a default max_context selector. */
optionsFor(modelId: string, kind?: string): ModelOption[] {
const cfg = this.get();
const def = this.defFor(modelId, kind);
const saved = (kind ? cfg.modelOptions[`${kind}:${modelId}`] : undefined) ?? cfg.modelOptions[modelId];
if (!saved) return def?.options ?? [];
if (!def?.options) return saved;
// Merge: option shape (label/type/values = model capabilities) always comes
// from the current catalog; only the user's selected `value` is persisted.
// This keeps stale saved options from hiding newly-added modes/values.
const savedValue = new Map(saved.map((o) => [o.key, o.value]));
return def.options.map((o) => {
const v = savedValue.get(o.key);
if (v == null) return o;
// Drop a saved value that's no longer a valid choice for this option.
if (o.values && !o.values.includes(v)) return o;
return { ...o, value: v };
});
const base: ModelOption[] = (() => {
if (!saved) return def?.options ? [...def.options] : [];
if (!def?.options) return [...saved];
// Merge: option shape (label/type/values = model capabilities) always comes
// from the current catalog; only the user's selected `value` is persisted.
const savedValue = new Map(saved.map((o) => [o.key, o.value]));
return def.options.map((o) => {
const v = savedValue.get(o.key);
if (v == null) return o;
if (o.values && !o.values.includes(v)) return o;
return { ...o, value: v };
});
})();
// Ensure every model exposes a context window (catalog or fallback dropdown).
if (!base.some((o) => o.key === "max_context")) {
const savedCtx = saved?.find((o) => o.key === "max_context")?.value;
const fallback = defaultContextOption();
if (savedCtx && fallback.values?.includes(savedCtx)) fallback.value = savedCtx;
else if (savedCtx) {
// Keep a custom value the user typed/saved even if not in the list.
fallback.values = [...(fallback.values || []), savedCtx];
fallback.value = savedCtx;
}
base.push(fallback);
}
return base;
}
/** Friendly catalog label for an id, or the id itself if not catalogued. */
+2 -1
View File
@@ -19,7 +19,8 @@ export interface Settings {
}
export const DEFAULT_SETTINGS: Settings = {
model: "auto",
// Auto hidden for now (bring back later): "" resolves to first enabled model.
model: "",
// 0 = don't send max_tokens; the model decides when to stop.
maxResponseLength: 0,
enableWorkspaceContext: true,
+5 -12
View File
@@ -13,9 +13,9 @@ import { listModels } from "../agent/provider";
import { renderWebviewHtml } from "./webviewHtml";
import { FeatureStore, MODEL_CATALOG } from "../stores/featureStore";
import { listRules, listSkills } from "../context/workspaceContext";
import { mcpManager, searchMcpRegistry } from "../integrations/mcpClient";
import { mcpManager } from "../integrations/mcpClient";
import { BUILTIN_PERSONAS } from "../agent/personas";
import { getStatus, onIndexStatus, buildIndex, deleteIndex, EMBED_MODELS } from "../agent/semanticIndex";
import { getStatus, onIndexStatus, buildIndex, deleteIndex, warmIndex, EMBED_MODELS } from "../agent/semanticIndex";
import { indexDocSource, deleteDocIndex, onDocsStatus, getDocsStatus, getDocLogs, type DocSource } from "../agent/docsIndex";
import { getWorkspaceRoot } from "../context/workspaceUtils";
import * as llama from "../agent/llamacpp";
@@ -234,15 +234,6 @@ export class SettingsPanel {
case "resetStorage":
await this._resetStorage();
break;
case "mcpRegistrySearch": {
try {
const servers = await searchMcpRegistry(message.query || "");
this._panel.webview.postMessage({ type: "mcpRegistryResults", servers });
} catch (err: any) {
this._panel.webview.postMessage({ type: "mcpRegistryResults", servers: [], error: String(err?.message || err) });
}
break;
}
case "saveProviderKey":
await this.settingsManager.setProviderKey(message.providerId, message.apiKey ?? "");
this.featureStore.notifyChanged();
@@ -283,6 +274,7 @@ export class SettingsPanel {
await this._sendFeatures();
break;
case "getIndexStatus":
await warmIndex(getWorkspaceRoot());
this._panel.webview.postMessage({ type: "indexStatus", status: getStatus(getWorkspaceRoot()), models: EMBED_MODELS });
this._sendDocs();
break;
@@ -336,6 +328,7 @@ export class SettingsPanel {
break;
}
case "syncIndex":
if (this.featureStore.get().indexingEnabled === false) break;
buildIndex(getWorkspaceRoot()).catch(() => {});
break;
case "deleteIndex":
@@ -346,7 +339,7 @@ export class SettingsPanel {
await this.featureStore.set({ ...f, embedModel: message.modelId });
await applyEmbedModel(message.modelId);
this._panel.webview.postMessage({ type: "indexStatus", status: getStatus(getWorkspaceRoot()), models: EMBED_MODELS });
buildIndex(getWorkspaceRoot()).catch(() => {}); // re-embed with new model
if (f.indexingEnabled !== false) buildIndex(getWorkspaceRoot()).catch(() => {});
break;
}
case "llamacppGet":
+300 -40
View File
@@ -14,7 +14,7 @@ import { AgentEvent, Mode, Attachment } from "../agent/types";
import { listModels, generateTitle, pickModel } from "../agent/provider";
import { renderWebviewHtml } from "./webviewHtml";
import { ConversationStore, titleFromText } from "../stores/conversationStore";
import { FeatureStore, MODEL_CATALOG, kindMatches, optionsToParams, providerEnabled, type ModelDef, type ModelOption, type ProviderConfig } from "../stores/featureStore";
import { FeatureStore, MODEL_CATALOG, kindMatches, optionsToParams, parseContextLabel, providerEnabled, type ModelDef, type ModelOption, type ProviderConfig } from "../stores/featureStore";
import { effectiveContextLength, ensureLoaded, isRunning, serverUrlFor } from "../agent/llamacpp";
import * as ollama from "../agent/ollama";
import * as oauth from "../agent/oauth";
@@ -26,7 +26,7 @@ import { runHooks, runBlockingHooks } from "../integrations/hooksRunner";
import { getWorkspaceRoot } from "../context/workspaceUtils";
import { allPersonas, getPersona } from "../agent/personas";
import { pendingChanges, computeHunks } from "../stores/pendingChanges";
import { applyEvent, closeTrailingThinking, parseMentionTokens, renderMentionTokens, type AgentEvent as SharedAgentEvent, type Turn } from "../shared/turns";
import { applyEvent, closeTrailingThinking, forceSettleOpenWork, parseMentionTokens, renderMentionTokens, type AgentEvent as SharedAgentEvent, type Turn } from "../shared/turns";
import { resolveFileIcon, invalidateFileIconCache } from "./fileIcons";
import {
searchFilesAndFolders, searchCommits, searchDocSources, searchTerminals,
@@ -224,14 +224,81 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
}
case "cancelRun": {
const id = data.convId ?? this._activeId;
if (id) this._sessions.get(id)?.abort.abort();
if (id) this._cancelSession(id);
break;
}
case "cancelSubagent":
// callId is globally unique; find the owning session.
for (const s of this._sessions.values()) {
// callId is globally unique; abort tool/subagent and settle the card now.
for (const [cid, s] of this._sessions) {
const a = s.subagentAborts.get(data.callId);
if (a) { a(); break; }
// Always try abort first so hung Read/Shell stop even if card already settled.
if (a) {
try { a(); } catch { /* ignore */ }
s.subagentAborts.delete(data.callId);
}
// Mark card settled immediately so spinner stops even if the worker
// never emits a terminal event (timeout / hung process / missing path).
let hit = false;
let toolName = "Tool";
const timedOut = data.reason === "timeout";
s.turns = s.turns.map((turn) => {
if (turn.role !== "assistant") return turn;
let changed = false;
const blocks = turn.blocks.map((b) => {
if (b.kind !== "tool" || b.callId !== data.callId) return b;
toolName = b.name;
// Force settle even if already "error" but still showing running UI race.
if (b.status === "running" || b.subStatus === "running" || timedOut) {
hit = true;
changed = true;
const subStatus =
b.name === "Task" || b.name === "task" || b.subStatus
? (timedOut ? ("error" as const) : ("cancelled" as const))
: b.subStatus;
return {
...b,
status: "error" as const,
result:
b.result ||
(timedOut
? `(timeout after ${Math.round((b.timeoutMs || 0) / 1000)}s)`
: "(cancelled)"),
subStatus,
};
}
return b;
});
return changed ? { ...turn, blocks } : turn;
});
if (!hit && !a) continue;
this._persistTurnsNow(cid, s);
const resultMsg = timedOut
? `(timeout after tool budget)`
: "(cancelled)";
// Always push completed so webview spinner dies even if turns map missed.
this._view?.webview.postMessage({
type: "agentEvent",
convId: cid,
event: {
type: "tool-call-completed",
callId: data.callId,
name: toolName,
status: "error",
result: resultMsg,
},
});
if (toolName === "Task" || toolName === "task") {
this._view?.webview.postMessage({
type: "agentEvent",
convId: cid,
event: {
type: "subagent-event",
callId: data.callId,
event: { type: "run-status", status: timedOut ? "error" : "cancelled" },
},
});
}
break;
}
break;
case "resolveApproval":
@@ -510,7 +577,13 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const base = folders && folders.length > 0 ? folders[0].uri : undefined;
const isAbsolute = /^([a-zA-Z]:[\\/]|\/)/.test(relPath);
const uri = isAbsolute ? vscode.Uri.file(relPath) : base ? vscode.Uri.joinPath(base, relPath) : vscode.Uri.file(relPath);
const doc = await vscode.workspace.openTextDocument(uri);
// Race open so a missing/network path cannot hang the extension host forever.
const doc = await Promise.race([
vscode.workspace.openTextDocument(uri),
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error("timed out opening file (path missing or unreachable)")), 5_000),
),
]);
const editor = await vscode.window.showTextDocument(doc, { preview: true });
if (startLine) {
const s = Math.max(0, startLine - 1);
@@ -521,7 +594,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
} catch (err: any) {
vscode.window.showErrorMessage(`OpenCursor: Could not open ${relPath}: ${err.message}`);
vscode.window.showErrorMessage(`OpenCursor: Could not open ${relPath}: ${err?.message || err}`);
}
}
@@ -839,7 +912,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
/** Whether a model id maps to a managed local llama.cpp model. */
private _localModel(modelId: string) {
return this.featureStore.get().llamacppModels.find((m) => m.id === modelId);
const bare = stripModelScope(modelId);
return this.featureStore.get().llamacppModels.find((m) => m.id === modelId || m.id === bare);
}
/**
@@ -911,12 +985,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const m = f.llamacppModels.find((x) => x.id === modelId || x.id === bare);
if (m) return effectiveContextLength(m, f.llamacppContextLength);
const opt = this.featureStore.optionsFor(bare, oauthKind).find((o) => o.key === "max_context")?.value;
const parsed = /^([\d.]+)\s*([km])?$/i.exec((opt || "").trim());
if (parsed) {
const unit = (parsed[2] || "").toLowerCase();
return Math.round(parseFloat(parsed[1]) * (unit === "m" ? 1_000_000 : unit === "k" ? 1_000 : 1));
}
return 200_000; // ponytail: safe default; refine per-provider when model metadata is available
return parseContextLabel(opt) || 128_000;
}
/** Build the picker model list from ALL enabled providers. */
@@ -1021,8 +1090,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const judge = features.autoJudgeModel || candidates[0];
try {
const jprov = await this._resolveProviderForModel(judge);
const picked = await pickModel(jprov.baseUrl, jprov.apiKey, judge, candidates, task, jprov.anthropic);
// Local judge whose server isn't running would need a full model load just
// to route — not worth it; fall back to the first candidate instead.
const judgeIsLocal = !!this._localModel(judge);
if (judgeIsLocal && !isRunning(stripModelScope(judge))) return candidates[0];
if (!jprov.oauthKind && !jprov.baseUrl) return candidates[0]; // unroutable judge
const picked = await pickModel(jprov.baseUrl, jprov.apiKey, jprov.model, candidates, task, jprov.anthropic, jprov.oauthKind);
if (picked && candidates.includes(picked)) return picked;
// Judge may reply with a bare model id (no provider scope) — match it.
const scoped = candidates.find((c) => stripModelScope(c) === stripModelScope(picked));
if (scoped) return scoped;
} catch {
// fall through to default
}
@@ -1079,12 +1156,13 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const allIds = fetched.flatMap((f) => f.ids);
const modelList = this._buildModelList(fetched);
// Selected model vanished (e.g. account disabled) -> fall back to auto.
// Selected model vanished (e.g. account disabled) or is "auto" (hidden for
// now) -> fall back to the first enabled model.
const settings = this.settingsManager.getSettings();
if (settings.model && settings.model !== "auto" && !modelList.some((m) => m.id === settings.model)) {
settings.model = "auto";
if (settings.model === "auto" || (settings.model && !modelList.some((m) => m.id === settings.model))) {
settings.model = modelList[0]?.id || "";
await this.settingsManager.saveSettings(settings);
this._view?.webview.postMessage({ type: "modelSelected", model: "auto" });
this._view?.webview.postMessage({ type: "modelSelected", model: settings.model });
}
this._view?.webview.postMessage({ type: "modelsFetched", models: allIds, modelList });
}
@@ -1165,10 +1243,12 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
if (edit?.mode) this._currentMode = edit.mode as Mode;
const settings = this.settingsManager.getSettings();
// Auto mode: let a judge model pick the best enabled model for this task.
let modelId = settings.model;
if (modelId === "auto") {
modelId = await this._resolveAutoModel(text);
// Auto mode is hidden for now; a lingering "auto" selection (or empty)
// resolves to the first enabled model. (Judge-based routing kept in
// _resolveAutoModel for when Auto returns.)
if (modelId === "auto" || !modelId) {
modelId = this._buildModelList([]).find((m) => m.id !== "auto")?.id || modelId;
}
// Resolve connection details without starting a local server yet — we want
// the chat UI to show a "loading model" state while it boots (below).
@@ -1243,6 +1323,27 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
}
// Only deliver events to the webview when this conversation is on screen.
// High-frequency stream events are coalesced so postMessage + applyEvent
// cannot stall the extension host (looks like stuck tools/subagents).
type PendingUi = { event: AgentEvent; apply: boolean };
const uiPending = new Map<string, PendingUi>();
let uiTimer: ReturnType<typeof setTimeout> | undefined;
const flushUi = () => {
uiTimer = undefined;
if (!uiPending.size) return;
const batch = [...uiPending.values()];
uiPending.clear();
for (const { event, apply } of batch) {
if (apply) {
session.turns = applyEvent(session.turns, event as unknown as SharedAgentEvent);
this._schedulePersistTurns(convId, session);
}
this._view?.webview.postMessage({ type: "agentEvent", convId, event });
}
};
const scheduleUi = () => {
if (!uiTimer) uiTimer = setTimeout(flushUi, 48);
};
const emit = (event: AgentEvent) => {
if (event.type === "error") {
SidebarProvider.log.appendLine(`[${new Date().toISOString()}] [agent] ${event.message}`);
@@ -1252,9 +1353,84 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// Maintain authoritative host turns from the same reducer the UI uses, then
// persist (throttled) so any webview reload restores the live state exactly.
const ev = event as unknown as SharedAgentEvent;
// Coalesce stream deltas before applying / posting.
if (ev.type === "text-delta") {
const prev = uiPending.get("text");
if (prev && (prev.event as SharedAgentEvent).type === "text-delta") {
const p = prev.event as Extract<AgentEvent, { type: "text-delta" }>;
uiPending.set("text", {
event: { type: "text-delta", text: p.text + ev.text },
apply: true,
});
} else {
uiPending.set("text", { event, apply: true });
}
scheduleUi();
return;
}
if (ev.type === "thinking-delta") {
const prev = uiPending.get("think");
if (prev && (prev.event as SharedAgentEvent).type === "thinking-delta") {
const p = prev.event as Extract<AgentEvent, { type: "thinking-delta" }>;
uiPending.set("think", {
event: { type: "thinking-delta", text: p.text + ev.text },
apply: true,
});
} else {
uiPending.set("think", { event, apply: true });
}
scheduleUi();
return;
}
if (ev.type === "tool-call-args") {
uiPending.set(`args:${ev.callId}`, { event, apply: true });
scheduleUi();
return;
}
if (ev.type === "subagent-event") {
const child = ev.event as SharedAgentEvent;
if (child.type === "text-delta" || child.type === "thinking-delta" || child.type === "tool-call-args") {
const key =
child.type === "tool-call-args"
? `sub:${ev.callId}:args:${(child as { callId: string }).callId}`
: `sub:${ev.callId}:${child.type}`;
if (child.type === "text-delta" || child.type === "thinking-delta") {
const prev = uiPending.get(key);
if (prev && (prev.event as SharedAgentEvent).type === "subagent-event") {
const pe = (prev.event as Extract<AgentEvent, { type: "subagent-event" }>).event;
if (pe.type === child.type) {
uiPending.set(key, {
event: {
type: "subagent-event",
callId: ev.callId,
event: { type: child.type, text: (pe as { text: string }).text + child.text },
},
apply: true,
});
scheduleUi();
return;
}
}
}
uiPending.set(key, { event, apply: true });
scheduleUi();
return;
}
}
// Discrete events: flush coalesced stream first (ordering).
if (uiPending.size) {
if (uiTimer) { clearTimeout(uiTimer); uiTimer = undefined; }
flushUi();
}
if (ev.type === "run-status") {
if (ev.status === "finished" || ev.status === "cancelled" || ev.status === "error") {
session.turns = closeTrailingThinking(session.turns);
session.turns = forceSettleOpenWork(
closeTrailingThinking(session.turns),
ev.status === "error" ? "error" : "cancelled",
);
this._persistTurnsNow(convId, session);
}
// OS notification when a run completes while the window is unfocused.
@@ -1286,15 +1462,19 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// Local model not yet running → boot it now, showing a loading state in the
// chat (selecting a model never loads it; only sending a message does).
const local = this._localModel(modelId);
if (local && !isRunning(local.id)) {
emit({ type: "shell-notify", message: `Loading ${local.name}` });
try {
await ensureLoaded(local, features.llamacppConfig);
} catch (e: any) {
emit({ type: "error", message: `Failed to load ${local.name}: ${e?.message || e}` });
emit({ type: "run-status", status: "error" });
return; // `finally` clears the session + persists.
if (local) {
if (!isRunning(local.id)) {
emit({ type: "shell-notify", message: `Loading ${local.name}` });
try {
await ensureLoaded(local, features.llamacppConfig);
} catch (e: any) {
emit({ type: "error", message: `Failed to load ${local.name}: ${e?.message || e}` });
emit({ type: "run-status", status: "error" });
return; // `finally` clears the session + persists.
}
}
// The server binds a random port each load — resolve the URL only now.
prov.baseUrl = serverUrlFor(local, features.llamacppConfig);
}
// Generate a short AI title once the provider/server is ready (local
@@ -1338,7 +1518,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
approve: (toolName, input, callId) => this._approveTool(convId, session, toolName, input, callId),
customSubagents: features.subagents,
subagentModel: features.subagentModel,
registerSubagentAbort: (callId, abort) => session.subagentAborts.set(callId, abort),
registerSubagentAbort: (callId, abort) => {
// Chain aborts (tool kill + nested Task child) so timeout fires both.
const prev = session.subagentAborts.get(callId);
session.subagentAborts.set(callId, () => {
try { prev?.(); } catch { /* ignore */ }
try { abort(); } catch { /* ignore */ }
});
},
askUser: (callId, _header, _questions, sig) => this._askUser(session, callId, sig),
onAfterRun: () => runHooks(features.hooks, "afterRun", { prompt: text }),
onBeforeShell: (command) => runBlockingHooks(features.hooks, "beforeShell", { command }),
@@ -1349,17 +1536,90 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
});
} catch (err: any) {
SidebarProvider.log.appendLine(`[${new Date().toISOString()}] [run] ${err?.stack || err?.message || err}`);
vscode.window.showErrorMessage(`OpenCursor: Connection failed: ${err.message}`);
emit({ type: "error", message: err.message } as AgentEvent);
try {
if (session.abort.signal.aborted) {
emit({ type: "run-status", status: "cancelled" });
} else {
vscode.window.showErrorMessage(`OpenCursor: Connection failed: ${err.message}`);
emit({ type: "error", message: err.message } as AgentEvent);
emit({ type: "run-status", status: "error" });
}
} catch { /* emit must never throw out of run */ }
} finally {
if (session.persistTimer) { clearTimeout(session.persistTimer); session.persistTimer = undefined; }
// Persist final authoritative turns + steps before dropping the session.
await this._store.update(convId, { turns: session.turns, steps: history });
this._sessions.delete(convId);
this._sendConversations();
try {
// Always settle open tools/subagents and clear hangable waiters.
for (const abort of session.subagentAborts.values()) {
try { abort(); } catch { /* ignore */ }
}
session.subagentAborts.clear();
for (const [qid, resolve] of session.pendingQuestions) {
session.pendingQuestions.delete(qid);
try { resolve({}); } catch { /* ignore */ }
}
for (const [rid, p] of session.pendingApprovals) {
session.pendingApprovals.delete(rid);
try { p.resolve(false); } catch { /* ignore */ }
this._view?.webview.postMessage({ type: "approvalResolved", convId, requestId: rid, approved: false });
}
// Only force-close still-open work; leave completed tools alone.
session.turns = forceSettleOpenWork(closeTrailingThinking(session.turns), "cancelled");
if (session.persistTimer) { clearTimeout(session.persistTimer); session.persistTimer = undefined; }
await this._store.update(convId, { turns: session.turns, steps: history });
} catch (e: any) {
SidebarProvider.log.appendLine(`[run] finally: ${e?.message || e}`);
} finally {
this._sessions.delete(convId);
this._sendConversations();
// Guarantee webview leaves "Working" even if run-status was lost (IDE reopen, stuck subagent).
this._view?.webview.postMessage({
type: "agentEvent",
convId,
event: { type: "run-status", status: session.abort.signal.aborted ? "cancelled" : "finished" },
});
}
}
}
/**
* Hard-stop a conversation run: parent abort + every subagent, pending
* approvals/questions, and force-settled UI. Safe to call repeatedly.
*/
private _cancelSession(convId: string): void {
const session = this._sessions.get(convId);
if (!session) {
// Stale UI "Working" with no live session (e.g. after IDE reopen mid-run).
this._view?.webview.postMessage({
type: "agentEvent",
convId,
event: { type: "run-status", status: "cancelled" },
});
return;
}
for (const abort of session.subagentAborts.values()) {
try { abort(); } catch { /* ignore */ }
}
for (const [qid, resolve] of session.pendingQuestions) {
session.pendingQuestions.delete(qid);
try { resolve({}); } catch { /* ignore */ }
}
for (const [rid, p] of session.pendingApprovals) {
session.pendingApprovals.delete(rid);
try { p.resolve(false); } catch { /* ignore */ }
this._view?.webview.postMessage({ type: "approvalResolved", convId, requestId: rid, approved: false });
}
session.turns = forceSettleOpenWork(closeTrailingThinking(session.turns), "cancelled");
this._persistTurnsNow(convId, session);
try {
if (!session.abort.signal.aborted) session.abort.abort();
} catch { /* ignore */ }
// Immediate UI settle so Stop never feels dead while the loop unwinds.
this._view?.webview.postMessage({
type: "agentEvent",
convId,
event: { type: "run-status", status: "cancelled" },
});
}
private _getHtmlForWebview(webview: vscode.Webview) {
return renderWebviewHtml(webview, this.context.extensionUri, "sidebar", "OpenCursor Chat");
}
+178 -14
View File
@@ -41,6 +41,17 @@ interface IndexStatus {
files: number;
chunks: number;
model: string;
backend?: "local" | "remote";
device?: string | null;
accelerator?: "gpu" | "cpu" | "remote" | "pending";
deviceLabel?: string;
modelRepo?: string;
modelDtype?: string;
modelPooling?: string;
modelDim?: number;
remoteBaseUrl?: string;
runtime?: string;
platform?: string;
}
interface EmbedModel {
id: string;
@@ -67,17 +78,45 @@ const NAV: { id: Section; label: string; icon: IconName; sep?: boolean }[] = [
{ id: "about", label: "About", icon: "book" },
];
/** Built-in tool hard timeouts (seconds). Keep in sync with src/agent/tools/shared.ts. */
const TOOL_TIMEOUT_DEFAULTS: { name: string; sec: number }[] = [
{ name: "Shell", sec: 45 },
{ name: "AwaitShell", sec: 60 },
{ name: "Grep", sec: 20 },
{ name: "Glob", sec: 20 },
{ name: "FileSearch", sec: 15 },
{ name: "SemanticSearch", sec: 30 },
{ name: "SearchDocs", sec: 25 },
{ name: "ListDir", sec: 10 },
{ name: "Read", sec: 15 },
{ name: "ReadLints", sec: 15 },
{ name: "WebSearch", sec: 20 },
{ name: "WebFetch", sec: 25 },
{ name: "StrReplace", sec: 20 },
{ name: "Write", sec: 20 },
{ name: "Delete", sec: 10 },
{ name: "EditNotebook", sec: 20 },
{ name: "CallMcpTool", sec: 45 },
{ name: "FetchMcpResource", sec: 30 },
{ name: "ListMcpResources", sec: 15 },
{ name: "TodoWrite", sec: 15 },
{ name: "TodoRead", sec: 15 },
{ name: "WritePlan", sec: 10 },
{ name: "SwitchMode", sec: 5 },
{ name: "Task", sec: 360 },
];
/** Search terms per section so the nav filter finds settings inside pages too. */
const SECTION_KEYWORDS: Partial<Record<Section, string>> = {
general: "editor settings keyboard shortcuts notifications privacy chat titles auto judge model completion sound reset",
usage: "tokens quota limits plan usage oauth account rate limit",
agents: "text size submit ctrl enter max tab count web search fetch context conversation",
agents: "text size submit ctrl enter max tab count web search fetch context conversation tool timeout shell grep",
models: "enable disable model catalog reasoning effort thinking context",
providers: "api key openai anthropic google openrouter oauth custom base url connect",
behavior: "workspace context file reading terminal tools auto edits approval allow deny ask review policy allowlist denylist commands mcp web",
personas: "persona system prompt custom",
rules: "rules skills subagents",
mcp: "mcp tools servers marketplace",
mcp: "mcp tools servers",
hooks: "hooks events commands",
indexing: "codebase index embedding docs semantic sync",
advanced: "system prompt custom instructions",
@@ -120,10 +159,10 @@ function NumInput({
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<label className="switch">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<label className="switch" style={disabled ? { opacity: 0.45, pointerEvents: "none" } : undefined}>
<input type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
<span className="track" />
<span className="thumb" />
</label>
@@ -508,6 +547,11 @@ function IndexingPanel({
}) {
const pct = status.total > 0 ? Math.round((status.done / status.total) * 100) : status.files > 0 ? 100 : 0;
const remoteEmbed = modelList.filter((m) => isEmbeddingModel(m.id));
const accel = status.accelerator || "pending";
const accelClass =
accel === "gpu" ? "gpu" : accel === "cpu" ? "cpu" : accel === "remote" ? "remote" : "pending";
const accelText =
accel === "gpu" ? "GPU" : accel === "cpu" ? "CPU" : accel === "remote" ? "Remote" : "Loading...";
return (
<>
<h1 className="page-title">Indexing &amp; Docs</h1>
@@ -516,30 +560,123 @@ function IndexingPanel({
<div className="index-card-title">Codebase Indexing</div>
<p className="row-desc">
Embed codebase for improved contextual understanding and knowledge. Embeddings and metadata are
stored locally on your machine your code never leaves your computer.
stored locally on your machine - your code never leaves your computer. The index persists across
restarts; only new or changed files are re-embedded.
</p>
<div className="index-progress">
<div className="index-divider" />
<Row title="Enable Indexing" desc="When off, no embedding work runs (existing index is kept on disk and still searchable).">
<Toggle
checked={features.indexingEnabled !== false}
onChange={(v) => setFeatures({ indexingEnabled: v })}
/>
</Row>
<div className="index-progress" style={{ opacity: features.indexingEnabled === false ? 0.5 : 1 }}>
<div className="index-progress-pct">{pct}%</div>
<div className="index-bar">
<div className="index-bar-fill" style={{ width: `${pct}%` }} />
</div>
<div className="index-progress-meta">
{status.indexing ? `Indexing ${status.done} / ${status.total} files…` : `${status.files} files`}
{features.indexingEnabled === false
? `Disabled - ${status.files} files on disk`
: status.indexing
? `Indexing ${status.done} / ${status.total} files...`
: `${status.files} files / ${status.chunks || 0} chunks`}
</div>
</div>
<div className="index-divider" />
<div className="index-runtime">
<div className="index-runtime-head">
<span className={`index-accel-badge ${accelClass}`}>{accelText}</span>
<span className="index-runtime-device">{status.deviceLabel || "-"}</span>
</div>
<dl className="index-tech">
<div>
<dt>Model</dt>
<dd>
<code>{status.model}</code>
</dd>
</div>
{status.modelRepo && (
<div>
<dt>Repo</dt>
<dd>
<code>{status.modelRepo}</code>
</dd>
</div>
)}
{status.modelDtype && (
<div>
<dt>Dtype</dt>
<dd>
<code>{status.modelDtype}</code>
</dd>
</div>
)}
{status.modelPooling && (
<div>
<dt>Pooling</dt>
<dd>
<code>{status.modelPooling}</code>
</dd>
</div>
)}
{status.modelDim != null && (
<div>
<dt>Dimensions</dt>
<dd>
<code>{status.modelDim}</code>
</dd>
</div>
)}
{status.device != null && status.device !== "" && (
<div>
<dt>ONNX EP</dt>
<dd>
<code>{status.device}</code>
</dd>
</div>
)}
<div>
<dt>Backend</dt>
<dd>
<code>{status.backend || "local"}</code>
</dd>
</div>
{status.remoteBaseUrl && (
<div>
<dt>Endpoint</dt>
<dd>
<code>{status.remoteBaseUrl}</code>
</dd>
</div>
)}
<div>
<dt>Runtime</dt>
<dd>
<code>{status.runtime || "—"}</code>
</dd>
</div>
<div>
<dt>Platform</dt>
<dd>
<code>{status.platform || "—"}</code>
</dd>
</div>
</dl>
</div>
<div className="index-divider" />
<div className="index-model-row">
<span className="index-model-label">Embedding model</span>
<ModelSelect
models={remoteEmbed}
value={status.model}
onChange={(id) => !status.indexing && vscode.postMessage({ type: "setEmbedModel", modelId: id })}
customItems={models.map((m) => ({ value: m.id, label: m.name, desc: "local runs on your machine" }))}
onChange={(id) => !status.indexing && features.indexingEnabled !== false && vscode.postMessage({ type: "setEmbedModel", modelId: id })}
customItems={models.map((m) => ({ value: m.id, label: m.name, desc: "local - runs on your machine" }))}
style={{ maxWidth: 260 }}
/>
<div className="index-actions" style={{ marginTop: 0, marginLeft: "auto" }}>
<button className="btn-secondary" disabled={status.indexing} onClick={() => vscode.postMessage({ type: "syncIndex" })}>
<Icon name="reset" /> {status.indexing ? "Syncing" : "Sync"}
<button className="btn-secondary" disabled={status.indexing || features.indexingEnabled === false} onClick={() => vscode.postMessage({ type: "syncIndex" })}>
<Icon name="reset" /> {status.indexing ? "Syncing..." : "Sync"}
</button>
<button className="btn-secondary danger" disabled={status.indexing} onClick={() => vscode.postMessage({ type: "deleteIndex" })}>
<Icon name="trash" /> Delete Index
@@ -549,13 +686,13 @@ function IndexingPanel({
</div>
<div className="index-card rows-card">
<Row title="Index New Folders" desc="Automatically index any new folders added to the workspace">
<Toggle checked={features.indexNewFolders !== false} onChange={(v) => setFeatures({ indexNewFolders: v })} />
<Toggle checked={features.indexNewFolders !== false} onChange={(v) => setFeatures({ indexNewFolders: v })} disabled={features.indexingEnabled === false} />
</Row>
<Row title="Ignore Files in .cursorignore" desc="Files to exclude from indexing in addition to .gitignore">
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openCursorignore" })}>Edit</button>
</Row>
<Row title="Index Repositories for Instant Grep" desc="Automatically index repositories to speed up Grep searches. All data is stored locally.">
<Toggle checked={features.indexForGrep !== false} onChange={(v) => setFeatures({ indexForGrep: v })} />
<Toggle checked={features.indexForGrep !== false} onChange={(v) => setFeatures({ indexForGrep: v })} disabled={features.indexingEnabled === false} />
</Row>
</div>
<DocsSection docs={docs} status={docsStatus} />
@@ -730,6 +867,7 @@ export function App() {
<Row title="Auto-Generate Chat Titles" desc="Generate a short AI title for new conversations after the first message.">
<Toggle checked={features.autoGenerateTitles !== false} onChange={(v) => setFeatures({ autoGenerateTitles: v })} />
</Row>
{/* Auto model (judge routing) hidden for now bring back later.
<Row title="Auto Judge Model" desc="When the chat model is set to Auto, this judge model picks the best enabled model for each task.">
<ModelSelect
models={modelList.length ? modelList : [...modelCatalog, ...(features.customModels || [])].filter((m) => features.enabledModels.includes(m.id))}
@@ -739,6 +877,7 @@ export function App() {
style={{ maxWidth: 240 }}
/>
</Row>
*/}
</Group>
<div className="section-label">Notifications</div>
@@ -810,6 +949,31 @@ export function App() {
</Row>
</Group>
<div className="section-label">Tool Timeouts</div>
<p className="panel-hint">Hard timeout per tool (seconds). Empty = built-in default. Prevents a hung tool from blocking the agent forever.</p>
<Group>
{TOOL_TIMEOUT_DEFAULTS.map(({ name, sec }) => {
const overrides = features.toolTimeoutsSec || {};
const val = overrides[name];
return (
<Row key={name} title={name} desc={`Default ${sec}s`}>
<NumInput
value={val != null && val > 0 ? val : null}
step="1"
min="1"
placeholder={String(sec)}
onChange={(v) => {
const next = { ...(features.toolTimeoutsSec || {}) };
if (v == null || v <= 0) delete next[name];
else next[name] = Math.floor(v);
setFeatures({ toolTimeoutsSec: next });
}}
/>
</Row>
);
})}
</Group>
<div className="section-label">Subagents</div>
<Group>
<Row title="Subagent Model" desc="Default model for subagents launched via the Task tool.">
+5
View File
@@ -260,11 +260,14 @@ export interface FeatureConfig {
submitWithCtrlEnter: boolean;
maxTabCount: number;
maxAgentSteps: number;
/** Per-tool hard timeout overrides in seconds (empty = built-in defaults). */
toolTimeoutsSec: Record<string, number>;
autoContinue: boolean;
completionSound: boolean;
webSearchEnabled: boolean;
webFetchEnabled: boolean;
approvalPolicy: ApprovalPolicy;
indexingEnabled: boolean;
indexNewFolders: boolean;
indexForGrep: boolean;
}
@@ -345,11 +348,13 @@ export const EMPTY_FEATURES: FeatureConfig = {
submitWithCtrlEnter: false,
maxTabCount: 0,
maxAgentSteps: 50,
toolTimeoutsSec: {},
autoContinue: false,
completionSound: false,
webSearchEnabled: true,
webFetchEnabled: true,
approvalPolicy: DEFAULT_APPROVAL,
indexingEnabled: true,
indexNewFolders: true,
indexForGrep: true,
};
+1 -1
View File
@@ -220,7 +220,7 @@ export function LlamacppPanel({
<Icon name="model" size={14} />
<span>{m.name}</span>
{isLoading && <span className="badge-tag loading"><span className="llama-spinner" /> loading</span>}
{isRunning && !isLoading && <span className="badge-tag always">running :{m.port}</span>}
{isRunning && !isLoading && <span className="badge-tag always">running</span>}
</div>
<label className="fc-inline" title="Load this model automatically on startup">
<input
+3 -172
View File
@@ -9,8 +9,7 @@
import * as React from "react";
import { Icon } from "../../shared/icons";
import { vscode } from "../../shared/vscode";
import { FeatureConfig, McpServerConfig, McpStatus, uid } from "../features";
import { FeatureConfig, McpServerConfig, McpStatus } from "../features";
import { Toggle } from "./Toggle";
export function McpPanel({
@@ -26,7 +25,6 @@ export function McpPanel({
}) {
// null = closed; { index: -1 } = adding a new server; otherwise editing that index.
const [editing, setEditing] = React.useState<{ index: number; draft: McpServerConfig } | null>(null);
const [tab, setTab] = React.useState<"installed" | "marketplace">("installed");
const remove = (i: number) => {
setFeatures({ mcpServers: features.mcpServers.filter((_, idx) => idx !== i) });
@@ -51,27 +49,14 @@ export function McpPanel({
const statusFor = (name: string) => status.find((s) => s.name === name);
// One-click install from the marketplace: append the derived config and reconnect.
const install = (cfg: McpServerConfig) => {
const name = features.mcpServers.some((s) => s.name === cfg.name) ? `${cfg.name}-${uid("").slice(0, 4)}` : cfg.name;
setFeatures({ mcpServers: [...features.mcpServers, { ...cfg, name }] });
setTimeout(onSync, 0);
};
return (
<>
<h1 className="page-title">Tools &amp; MCPs</h1>
<div className="sub-tabs">
<button className={"sub-tab" + (tab === "installed" ? " active" : "")} onClick={() => setTab("installed")}>Installed</button>
<button className={"sub-tab" + (tab === "marketplace" ? " active" : "")} onClick={() => setTab("marketplace")}>Marketplace</button>
</div>
{tab === "installed" && (<>
<div className="section-label">MCP Servers</div>
<p className="panel-hint">Connected Model Context Protocol servers and the tools they expose. Browse the <button className="link-btn" onClick={() => setTab("marketplace")}>Marketplace</button> to install with one click.</p>
<p className="panel-hint">Connected Model Context Protocol servers and the tools they expose.</p>
{features.mcpServers.length === 0 && (
<div className="empty-card">No MCP servers yet. Add one or install from the Marketplace.</div>
<div className="empty-card">No MCP servers yet. Add one to get started.</div>
)}
{features.mcpServers.map((srv, i) => {
const st = statusFor(srv.name);
@@ -118,11 +103,6 @@ export function McpPanel({
Reconnect
</button>
</div>
</>)}
{tab === "marketplace" && (
<McpMarketplace installedNames={new Set(features.mcpServers.map((s) => s.name))} onInstall={install} />
)}
{editing && (
<McpModal
@@ -175,152 +155,3 @@ function McpModal({ server, isNew, onClose, onSave }: { server: McpServerConfig;
</div>
);
}
// Marketplace: registry.modelcontextprotocol.io
interface RegistryPackage {
registryType?: string;
identifier?: string;
version?: string;
runtimeHint?: string;
transport?: { type?: string };
runtimeArguments?: { type?: string; name?: string; value?: string }[];
packageArguments?: { type?: string; name?: string; value?: string }[];
environmentVariables?: { name?: string; description?: string; isRequired?: boolean; isSecret?: boolean }[];
}
interface RegistryServer {
name: string;
title?: string;
description?: string;
version?: string;
packages?: RegistryPackage[];
}
/** Default runtime command for a registry package type. */
function runtimeFor(pkg: RegistryPackage): string {
if (pkg.runtimeHint) return pkg.runtimeHint;
switch (pkg.registryType) {
case "npm": return "npx";
case "pypi": return "uvx";
case "oci": return "docker";
case "nuget": return "dnx";
default: return "npx";
}
}
/** Build an args list from registry argument descriptors (positional/named values only). */
function argValues(args?: { type?: string; name?: string; value?: string }[]): string[] {
if (!args) return [];
const out: string[] = [];
for (const a of args) {
if (a.name) out.push(a.name);
if (a.value) out.push(a.value);
}
return out;
}
/**
* Derive a runnable stdio McpServerConfig from a registry server, or null if it
* has no installable stdio package (e.g. remote-only servers).
*/
function configFromRegistry(srv: RegistryServer): McpServerConfig | null {
const pkg = (srv.packages || []).find((p) => (p.transport?.type ?? "stdio") === "stdio" && p.identifier);
if (!pkg) return null;
const runtime = runtimeFor(pkg);
const args: string[] = [...argValues(pkg.runtimeArguments)];
// npx/dnx default to a non-interactive install flag, then the package id.
if (runtime === "npx") args.push("-y");
if (pkg.registryType === "oci") args.push("run", "-i", "--rm");
args.push(pkg.identifier!);
args.push(...argValues(pkg.packageArguments));
const env: Record<string, string> = {};
for (const e of pkg.environmentVariables ?? []) if (e.name) env[e.name] = "";
const shortName = srv.name.split("/").pop() || srv.name;
return {
name: shortName,
transport: "stdio",
command: runtime,
args,
env: Object.keys(env).length ? env : undefined,
enabled: true,
};
}
function McpMarketplace({ installedNames, onInstall }: { installedNames: Set<string>; onInstall: (cfg: McpServerConfig) => void }) {
const [query, setQuery] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [results, setResults] = React.useState<RegistryServer[]>([]);
// The registry fetch runs in the extension host (webview CSP blocks direct fetch).
const search = React.useCallback((q: string) => {
setLoading(true);
setError("");
vscode.postMessage({ type: "mcpRegistrySearch", query: q.trim() });
}, []);
React.useEffect(() => {
const handler = (e: MessageEvent) => {
const m = e.data;
if (m?.type === "mcpRegistryResults") {
setLoading(false);
setResults(m.servers || []);
setError(m.error || "");
}
};
window.addEventListener("message", handler);
search("");
return () => window.removeEventListener("message", handler);
}, [search]);
return (
<>
<div className="section-label">Marketplace</div>
<p className="panel-hint">Search the official <code>registry.modelcontextprotocol.io</code> and install a server with one click. Servers with required environment variables are added with empty values fill them in on the Installed tab.</p>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input
type="search"
value={query}
placeholder="e.g. filesystem, github, playwright"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") search(query); }}
style={{ flex: 1 }}
/>
<button className="btn-primary" onClick={() => search(query)} disabled={loading}>
{loading ? "Searching…" : "Search"}
</button>
</div>
{error && <div className="fc-error">{error}</div>}
{!loading && results.length === 0 && !error && <div className="empty-card">No servers found.</div>}
{results.map((srv) => {
const cfg = configFromRegistry(srv);
const shortName = srv.name.split("/").pop() || srv.name;
const installed = installedNames.has(shortName);
return (
<div className="feature-card" key={srv.name}>
<div className="fc-head">
<div className="fc-title-input" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Icon name="link" size={14} />
<span>{srv.title || shortName}</span>
{srv.version && <span className="badge-tag glob">v{srv.version}</span>}
</div>
{installed ? (
<span className="badge-tag glob">added</span>
) : cfg ? (
<button className="btn-primary sm" onClick={() => onInstall(cfg)}>
<Icon name="plus" size={13} /> Install
</button>
) : (
<span className="badge-tag" title="No stdio package — remote/unsupported">remote</span>
)}
</div>
<div className="fc-body">
{srv.description && <div className="row-desc">{srv.description}</div>}
{cfg && <div className="row-desc" style={{ marginTop: 6, opacity: 0.7, fontFamily: "var(--vscode-editor-font-family, monospace)" }}>{cfg.command} {(cfg.args || []).join(" ")}</div>}
<div className="row-desc" style={{ marginTop: 4, opacity: 0.6 }}>{srv.name}</div>
</div>
</div>
);
})}
</>
);
}
+46
View File
@@ -285,6 +285,52 @@ select:focus { outline: none; border-color: var(--vscode-focusBorder); }
.index-progress-meta { font-size: 11.5px; color: var(--fg-dim); margin-top: 6px; }
.index-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
.index-runtime { margin: 14px 0 4px; }
.index-runtime-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.index-runtime-device { font-size: 12.5px; font-weight: 600; color: var(--fg); }
.index-accel-badge {
display: inline-flex; align-items: center; justify-content: center;
font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
padding: 2px 8px; border-radius: 999px; border: 1px solid transparent;
}
.index-accel-badge.gpu {
color: var(--green);
background: color-mix(in srgb, var(--green) 14%, transparent);
border-color: color-mix(in srgb, var(--green) 35%, transparent);
}
.index-accel-badge.cpu {
color: var(--fg-dim);
background: color-mix(in srgb, var(--fg) 8%, transparent);
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
}
.index-accel-badge.remote {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.index-accel-badge.pending {
color: var(--fg-faint);
background: color-mix(in srgb, var(--fg) 6%, transparent);
border-color: color-mix(in srgb, var(--fg) 14%, transparent);
}
.index-tech {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; margin: 0;
padding: 10px 12px; border-radius: 8px;
background: color-mix(in srgb, var(--fg) 4%, transparent);
border: 1px solid var(--border-soft);
}
.index-tech > div { display: grid; grid-template-columns: 88px 1fr; gap: 6px; align-items: baseline; min-width: 0; }
.index-tech dt { margin: 0; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--fg-dim); }
.index-tech dd { margin: 0; min-width: 0; font-size: 11.5px; color: var(--fg); }
.index-tech code {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 11px; word-break: break-all;
color: var(--fg);
}
@media (max-width: 720px) {
.index-tech { grid-template-columns: 1fr; }
}
/* Indexing & Docs page (Cursor layout) */
.index-divider { height: 1px; background: var(--border-soft); margin: 14px -16px; }
.index-model-row { display: flex; align-items: center; gap: 10px; }
+2
View File
@@ -27,6 +27,7 @@ import {
GitCommitHorizontal,
Globe,
BookOpen,
Copy,
History as HistoryIcon,
Image as ImageIcon,
Infinity as InfinityIcon,
@@ -96,6 +97,7 @@ const MAP = {
book: BookOpen,
more: MoreHorizontal,
download: Download,
copy: Copy,
} satisfies Record<string, LucideIcon>;
export type IconName = keyof typeof MAP;
+78 -39
View File
@@ -12,10 +12,10 @@ import { Icon } from "../shared/icons";
import { renderMarkdown } from "../shared/markdown";
import { vscode } from "../shared/vscode";
import { Composer, KIND_SVG, applyFileIconTo } from "./components/Composer";
import { ToolCard, isReadonlySubagent } from "./components/Tool";
import { ToolCard, isReadonlySubagent, TimeoutBadge, ToolTimeoutWatch, isToolCountdownActive } from "./components/Tool";
import { History } from "./components/History";
import type { AgentEvent, ApprovalMode, ApprovalRequestInfo, AssistantBlock, AssistantTurn, Attachment, ConversationSummary, ErrorBlock, InMessage, MentionItem, Mode, ModelDef, ModelOption, OutMessage, PendingChangeInfo, PersonaInfo, ThinkingBlock, ToolBlock, Turn, UserTurn } from "./types";
import { applyEvent, applyToBlocks, parsePartialArgs, renderMentionTokens } from "./types";
import { applyEvent, applyToBlocks, closeTrailingThinking, forceSettleOpenWork, parsePartialArgs, renderMentionTokens } from "./types";
function post(msg: OutMessage) {
vscode.postMessage(msg);
@@ -213,15 +213,23 @@ function ExploringSection({
const running = tools.some((t) => t.status === "running") || !!live;
const current = [...tools].reverse().find((t) => t.status === "running") ?? tools[tools.length - 1];
const subtitle = running ? capitalize(toolLabel(current.name)) : exploreSummary(tools);
// Keep kill-at-zero active even when the group is collapsed (no ToolCard mount).
const timed = tools.filter((t) => isToolCountdownActive(t) && t.timeoutMs && t.timeoutMs > 0);
const headTimed = timed[0] ?? (current && isToolCountdownActive(current) ? current : null);
return (
<div className={"explore-section" + (open ? " open" : "")}>
{/* Always watch every timed tool so countdown-0 kills even when collapsed. */}
{timed.map((t) => (
<ToolTimeoutWatch key={`watch-${t.callId}`} block={t} />
))}
<div className="explore-head" onClick={() => setOpen((o) => !o)}>
<span className={"tchev" + (open ? " open" : "")}>
<Icon name="chevD" size={12} />
</span>
<Icon name="search" size={12} className="explore-icon" />
<span className="explore-title">{running ? "Exploring" : exploreSummary(tools)}</span>
{headTimed ? <TimeoutBadge block={headTimed} /> : null}
{running ? <span className="spinner" /> : <span className="explore-count">{tools.length}</span>}
</div>
{!open && running && <div className="explore-subtitle">{subtitle}</div>}
@@ -425,8 +433,9 @@ function SubagentChat({ block, onBack }: { block: import("./types").ToolBlock; o
<Icon name="chevD" size={12} /> Back to chat
</button>
<span className="sub-readonly">{isReadonlySubagent(block.input) ? "read-only" : "agent"}</span>
<TimeoutBadge block={block} />
{running && (
<button className="sub-stop" onClick={() => post({ type: "cancelSubagent", callId: block.callId })}>
<button className="sub-stop" onClick={() => post({ type: "cancelSubagent", callId: block.callId, reason: "user" })}>
<Icon name="close" size={12} /> Stop
</button>
)}
@@ -621,10 +630,9 @@ export function App() {
// Pending in-chat approval requests, keyed by conversation id.
const [approvals, setApprovals] = React.useState<Record<string, ApprovalRequestInfo[]>>({});
const [reviewOpen, setReviewOpen] = React.useState(false);
// Editing an earlier user message: index of that turn + its edit-local model/mode.
// Editing an earlier user message: index of that turn. The edit composer
// shares the global model/mode selection (one selection for all composers).
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
const [editModel, setEditModel] = React.useState("");
const [editMode, setEditMode] = React.useState<Mode>("agent");
// Pending edit awaiting the revert-confirm dialog. `restore` = return the
// message to the bottom composer instead of resending it.
const [revertPrompt, setRevertPrompt] = React.useState<{ index: number; text: string; attachments: Attachment[]; restore?: boolean } | null>(null);
@@ -855,25 +863,43 @@ export function App() {
for (const [id, s] of sessionsRef.current) {
if (!id) continue;
const live = set.has(id);
if (live && !s.running) { s.running = true; if (!s.status.text) s.status = { text: "Working" }; }
else if (!live && s.running) { s.running = false; }
if (live && !s.running) {
s.running = true;
if (!s.status.text) s.status = { text: "Working" };
} else if (!live && s.running) {
// Host no longer has this run (stop, crash, IDE reopen) — clear Working.
s.running = false;
s.status = { text: "" };
s.turns = forceSettleOpenWork(closeTrailingThinking(s.turns), "cancelled");
}
}
};
// Seed a session's turns from persisted data without clobbering a live run.
const seedSession = (id: string | undefined, persisted: Turn[], usedTokens?: number) => {
if (!id) return;
// Stale "running" tools left on disk after IDE close → settle them.
const clean = forceSettleOpenWork(closeTrailingThinking(persisted), "cancelled");
const s = sessionsRef.current.get(id);
if (!s) {
sessionsRef.current.set(id, { turns: persisted, running: false, status: { text: "" }, usedTokens });
sessionsRef.current.set(id, { turns: clean, running: false, status: { text: "" }, usedTokens });
} else if (!s.running) {
// Only refresh from disk when not running (live turns are authoritative).
s.turns = persisted;
s.turns = clean;
if (usedTokens !== undefined) s.usedTokens = usedTokens;
}
};
React.useEffect(() => {
// rAF-batch stream-driven re-renders so tool/args/text deltas don't thrash React.
let raf = 0;
const scheduleForce = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
force();
});
};
const handler = (event: MessageEvent<InMessage>) => {
const msg = event.data;
switch (msg.type) {
@@ -891,7 +917,7 @@ export function App() {
force();
break;
case "modelSelected":
setSelectedModel(msg.model || "auto");
setSelectedModel(msg.model || ""); // auto hidden for now
break;
case "configState":
setPersonas(msg.personas || []);
@@ -965,21 +991,20 @@ export function App() {
if (ev.type === "run-status") {
s.status = { text: ev.status === "running" ? "Planning next moves" : ev.status === "finished" ? "" : ev.status };
if (settled) {
const wasRunning = s.running;
s.running = false;
if (ev.status === "finished" && uiPrefsRef.current.completionSound) playCompletionSound();
// Close any still-open trailing thinking block so it stops animating.
const lt = s.turns[s.turns.length - 1];
if (lt && lt.role === "assistant") {
const lb = lt.blocks[lt.blocks.length - 1];
if (lb && lb.kind === "thinking" && !lb.endedAt) {
lt.blocks = [...lt.blocks.slice(0, -1), { ...lb, endedAt: Date.now() }];
}
}
if (wasRunning && ev.status === "finished" && uiPrefsRef.current.completionSound) playCompletionSound();
// Close open thinking + cancel any still-spinning tools/subagents.
s.turns = forceSettleOpenWork(
closeTrailingThinking(s.turns),
ev.status === "error" ? "error" : "cancelled",
);
post({ type: "persistTurns", convId: msg.convId, turns: s.turns });
// Auto-start the next queued message for this conversation (unless
// this settle came from a run replaced by "send now").
if (suppressFlushRef.current.has(msg.convId)) suppressFlushRef.current.delete(msg.convId);
else window.setTimeout(() => flushQueueRef.current(msg.convId), 0);
// Auto-start the next queued message once (duplicate settle from host finally must not double-flush).
if (wasRunning) {
if (suppressFlushRef.current.has(msg.convId)) suppressFlushRef.current.delete(msg.convId);
else window.setTimeout(() => flushQueueRef.current(msg.convId), 0);
}
}
} else {
s.turns = applyEvent(s.turns, ev);
@@ -999,12 +1024,23 @@ export function App() {
// Keep the rendered error block; persist so the chat survives reloads.
s.status = { text: "Error: " + ev.message, error: true };
post({ type: "persistTurns", convId: msg.convId, turns: s.turns });
} else if (ev.type === "mode-changed") {
} else if (ev.type === "mode-changed") {
setMode(ev.mode);
post({ type: "setMode", mode: ev.mode });
}
}
force();
// Immediate paint on settle / tool complete; coalesce stream deltas.
if (
settled ||
ev.type === "tool-call-completed" ||
ev.type === "tool-call-started" ||
ev.type === "error" ||
ev.type === "run-result"
) {
force();
} else {
scheduleForce();
}
break;
}
}
@@ -1022,6 +1058,7 @@ export function App() {
window.addEventListener("beforeunload", flush);
post({ type: "ready" });
return () => {
if (raf) cancelAnimationFrame(raf);
flush();
window.removeEventListener("message", handler);
window.removeEventListener("pagehide", flush);
@@ -1102,16 +1139,15 @@ export function App() {
React.useEffect(() => {
if (editingIndex === null) return;
const h = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest(".msg.user.editing, .modal-overlay")) setEditingIndex(null);
// Portaled dropdowns (model picker / mode menu) live in document.body.
if (!(e.target as HTMLElement).closest(".msg.user.editing, .modal-overlay, .model-picker, .mode-dropdown")) setEditingIndex(null);
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [editingIndex]);
const startEdit = (index: number, turn: UserTurn) => {
const startEdit = (index: number, _turn: UserTurn) => {
setEditingIndex(index);
setEditModel(turn.model || selectedModel);
setEditMode((turn.mode as Mode) || mode);
};
// Resend an edited earlier message. If there are file changes below it, ask the
@@ -1147,15 +1183,12 @@ export function App() {
const commitEdit = (index: number, text: string, attachments: Attachment[], revertFiles: boolean) => {
const s = sessionFor(activeIdRef.current);
// Drop this turn and everything after it, then append the edited message.
s.turns = [...s.turns.slice(0, index), { role: "user", text, attachments: attachments.length ? attachments : undefined, model: editModel, mode: editMode }];
s.turns = [...s.turns.slice(0, index), { role: "user", text, attachments: attachments.length ? attachments : undefined, model: selectedModel, mode }];
setEditingIndex(null);
setRevertPrompt(null);
// Apply the edited model/mode as the active selection too.
if (editModel && editModel !== selectedModel) { setSelectedModel(editModel); post({ type: "selectModel", model: editModel }); }
if (editMode !== mode) { setMode(editMode); post({ type: "setMode", mode: editMode }); }
pinTopRef.current = true;
force();
post({ type: "sendMessage", text, attachments: attachments.length ? attachments : undefined, fromIndex: index, model: editModel, mode: editMode, revertFiles });
post({ type: "sendMessage", text, attachments: attachments.length ? attachments : undefined, fromIndex: index, model: selectedModel, mode, revertFiles });
};
// Switch to agent mode and kick off implementation of a written plan.
@@ -1370,12 +1403,18 @@ export function App() {
initialText={turn.text}
initialAttachments={turn.attachments}
focusKey={`edit-${index}`}
mode={editMode}
onMode={setEditMode}
mode={mode}
onMode={(m) => {
setMode(m);
post({ type: "setMode", mode: m });
}}
models={models}
modelList={modelList}
selectedModel={editModel}
onSelectModel={setEditModel}
selectedModel={selectedModel}
onSelectModel={(m) => {
setSelectedModel(m);
post({ type: "selectModel", model: m });
}}
onSaveModelOptions={(modelId, options) => {
setModelList((prev) => prev.map((m) => (m.id === modelId ? { ...m, options } : m)));
post({ type: "saveModelOptions", modelId, options });
+151 -29
View File
@@ -8,6 +8,7 @@
*/
import * as React from "react";
import { createPortal } from "react-dom";
import { ArrowUp, AtSign, ChevronRight, Square } from "lucide-react";
import { Icon, IconName } from "../../shared/icons";
import { vscode } from "../../shared/vscode";
@@ -261,12 +262,58 @@ function useOutsideClose(open: boolean, close: () => void) {
}, [open, close]);
}
/**
* Anchor a position:fixed dropdown to its trigger, adapting to viewport space:
* opens above when there's room, flips below otherwise; clamps horizontally.
* Returns inline styles (left/top or left/bottom) + max height for the menu.
*/
function useAnchoredMenu(open: boolean, triggerRef: React.RefObject<HTMLElement | null>, menuRef: React.RefObject<HTMLElement | null>, deps: unknown[] = []) {
const [style, setStyle] = React.useState<React.CSSProperties>({});
const [maxH, setMaxH] = React.useState(340);
React.useLayoutEffect(() => {
if (!open) return;
const place = () => {
const t = triggerRef.current?.getBoundingClientRect();
const m = menuRef.current;
if (!t || !m) return;
const margin = 8;
const w = m.offsetWidth;
let left = t.left;
if (left + w > window.innerWidth - margin) left = window.innerWidth - margin - w;
if (left < margin) left = margin;
const spaceAbove = t.top - margin * 2;
const spaceBelow = window.innerHeight - t.bottom - margin * 2;
const needed = Math.min(340, m.scrollHeight || 340);
if (spaceAbove >= needed || spaceAbove >= spaceBelow) {
setStyle({ left, bottom: window.innerHeight - t.top + 6, top: "auto" });
setMaxH(Math.min(340, spaceAbove));
} else {
setStyle({ left, top: t.bottom + 6, bottom: "auto" });
setMaxH(Math.min(340, spaceBelow));
}
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, ...deps]);
return { style, maxH };
}
function ModePicker({ mode, onMode }: { mode: Mode; onMode: (m: Mode) => void }) {
const [open, setOpen] = React.useState(false);
useOutsideClose(open, () => setOpen(false));
const triggerRef = React.useRef<HTMLSpanElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const { style } = useAnchoredMenu(open, triggerRef, menuRef);
const meta = MODES.find((m) => m.id === mode) || MODES[0];
return (
<span
ref={triggerRef}
className="pill mode-pill"
onClick={(e) => {
e.stopPropagation();
@@ -276,8 +323,8 @@ function ModePicker({ mode, onMode }: { mode: Mode; onMode: (m: Mode) => void })
<Icon name={meta.icon} />
<span>{meta.label}</span>
<Icon name="chevD" className="cd" />
{open && (
<div className="mode-dropdown">
{open && createPortal(
<div ref={menuRef} className="mode-dropdown" style={style}>
{MODES.map((o) => (
<div
key={o.id}
@@ -299,12 +346,100 @@ function ModePicker({ mode, onMode }: { mode: Mode; onMode: (m: Mode) => void })
)}
</div>
))}
</div>
</div>,
document.body
)}
</span>
);
}
/** True when max_context was injected as a fallback (no catalog preset). */
function isFallbackContext(m?: ModelDef): boolean {
if (!m) return false;
const o = m.options.find((x) => x.key === "max_context");
if (!o?.values) return false;
return o.values.length >= 5 && o.values.includes("32k") && o.values.includes("1m");
}
/** Context-size dropdown beside model picker for models without catalog presets. */
function ContextPicker({
model,
onSave,
}: {
model?: ModelDef;
onSave: (modelId: string, options: ModelOption[]) => void;
}) {
const [open, setOpen] = React.useState(false);
const btnRef = React.useRef<HTMLButtonElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const [style, setStyle] = React.useState<React.CSSProperties>({});
const opt = model?.options.find((o) => o.key === "max_context");
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (btnRef.current?.contains(t) || menuRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
if (!model || !opt || !isFallbackContext(model)) return null;
const values = opt.values || [];
const openMenu = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) {
setStyle({
position: "fixed",
left: Math.max(8, r.left),
bottom: window.innerHeight - r.top + 6,
zIndex: 200,
});
}
setOpen(true);
};
return (
<>
<button
ref={btnRef}
type="button"
className="pill ctx-pill"
title="Context window size"
onClick={() => (open ? setOpen(false) : openMenu())}
>
<span>{opt.value || "ctx"}</span>
<Icon name="chevD" className="cd" />
</button>
{open && createPortal(
<div ref={menuRef} className="mode-dropdown ctx-dropdown" style={style} onClick={(e) => e.stopPropagation()}>
{values.map((v) => (
<button
key={v}
type="button"
className={"mode-item" + (v === opt.value ? " active" : "")}
onClick={() => {
const next = model.options.map((o) => (o.key === "max_context" ? { ...o, value: v } : o));
onSave(model.id, next);
setOpen(false);
}}
>
<span className="mi-label">{v}</span>
{v === opt.value && (
<span className="mi-check">
<Icon name="check" size={13} />
</span>
)}
</button>
))}
</div>,
document.body,
)}
</>
);
}
/** Short summary of a model's options, e.g. "Low · Thinking". */
function optionSummary(opts: ModelOption[]): string {
const parts: string[] = [];
@@ -519,28 +654,8 @@ function ModelPicker({
const triggerRef = React.useRef<HTMLSpanElement>(null);
const pickerRef = React.useRef<HTMLDivElement>(null);
const [pos, setPos] = React.useState<{ left: number; bottom: number; maxH: number }>({ left: 0, bottom: 0, maxH: 340 });
// Anchor the fixed picker above the trigger, clamped inside the viewport.
React.useLayoutEffect(() => {
if (!open) return;
const place = () => {
const t = triggerRef.current?.getBoundingClientRect();
const p = pickerRef.current;
if (!t || !p) return;
const w = p.offsetWidth;
const margin = 8;
let left = t.left;
if (left + w > window.innerWidth - margin) left = window.innerWidth - margin - w;
if (left < margin) left = margin;
const bottom = window.innerHeight - t.top + 8;
// Never extend past the top of the viewport; keep a margin above.
setPos({ left, bottom, maxH: Math.min(340, window.innerHeight - bottom - margin) });
};
place();
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [open, list.length, !!editing]);
// Anchor the fixed picker to the trigger; flips below when no room above.
const { style: pickerStyle, maxH } = useAnchoredMenu(open, triggerRef, pickerRef, [list.length, !!editing]);
const pick = (id: string) => {
onSelect(id);
@@ -561,8 +676,8 @@ function ModelPicker({
<span className="label">{selLabel}</span>
{summary && <span className="model-summary">{summary}</span>}
<Icon name="chevD" className="cd" />
{open && (
<div ref={pickerRef} className="model-picker" style={{ left: pos.left, bottom: pos.bottom, "--mp-max-h": `${pos.maxH}px` } as React.CSSProperties} onClick={(e) => e.stopPropagation()}>
{open && createPortal(
<div ref={pickerRef} className="model-picker" style={{ ...pickerStyle, "--mp-max-h": `${maxH}px` } as React.CSSProperties} onClick={(e) => e.stopPropagation()}>
{editing ? (
<div className="model-picker-view">
<div className="mp-head">
@@ -589,12 +704,14 @@ function ModelPicker({
/>
</div>
<div className="mp-body">
{/* Auto (judge-picked model) hidden for now bring back later.
<div className={"model-item auto" + (selected === "auto" ? " active" : "")} onClick={() => pick("auto")}>
<Icon name="infinity" className="model-item-ico" />
<span className="model-item-name">Auto</span>
<span className="model-item-sum">picks a model for you</span>
{selected === "auto" && <Icon name="check" className="model-item-check" />}
</div>
*/}
{filtered.length === 0 && <div className="model-item dim">No matches</div>}
{byProvider.map(([provName, list]) => (
<React.Fragment key={provName}>
@@ -625,7 +742,8 @@ function ModelPicker({
</div>
</div>
)}
</div>
</div>,
document.body
)}
</span>
);
@@ -1519,10 +1637,14 @@ export function Composer({
<div className="composer-bar">
<ModePicker mode={mode} onMode={onMode} />
<ModelPicker models={models} modelList={modelList} selected={selectedModel} onSelect={onSelectModel} onSaveOptions={onSaveModelOptions} onResetOptions={onResetModelOptions} />
{(() => {
const sel = modelList.find((m) => m.id === selectedModel);
return <ContextPicker model={sel} onSave={onSaveModelOptions} />;
})()}
<div className="right">
{!editing && (() => {
const sel = modelList.find((m) => m.id === selectedModel);
const total = parseContextSize(sel?.options.find((o) => o.key === "max_context")?.value) || 200_000;
const total = parseContextSize(sel?.options.find((o) => o.key === "max_context")?.value) || 128_000;
return <ContextRing used={usedTokens ?? 0} total={total} />;
})()}
<button
+211 -6
View File
@@ -183,6 +183,7 @@ function TodoList({ block }: { block: ToolBlock }) {
</span>
<span className="label">Todos</span>
<span className="right">
<TimeoutBadge block={block} />
<StatusIcon status={block.status} />
</span>
</div>
@@ -227,6 +228,7 @@ function SubagentCard({ block, onOpen }: { block: ToolBlock; onOpen?: (callId: s
<span className="ticon"><Icon name="task" /></span>
<span className="label">{i.description || "Subagent"}</span>
<span className="sub-spacer" />
<TimeoutBadge block={block} />
<span className="sub-steps">{running ? `${steps} steps…` : `${steps} steps`}</span>
<span className="badge badge-agent">{isReadonlySubagent(i) ? "Explore" : "Agent"}</span>
{running ? <span className="spinner" /> : <StatusIcon status={block.subStatus === "error" ? "error" : "completed"} />}
@@ -290,6 +292,7 @@ function PlanCard({ block, onImplement }: { block: ToolBlock; onImplement?: (pat
<Icon name="todo" />
</span>
<span className="plan-title">{title}</span>
<TimeoutBadge block={block} />
<span className="badge badge-plan">Plan</span>
<StatusIcon status={block.status} />
</div>
@@ -323,6 +326,97 @@ function StatusIcon({ status }: { status: ToolBlock["status"] }) {
return status === "completed" ? <Icon name="check" className="ok-icon" /> : <Icon name="close" className="err-icon" />;
}
/** True while this tool/task should show a live timeout countdown. */
export function isToolCountdownActive(block: ToolBlock): boolean {
if (block.name === "AskQuestion" || block.name === "ask_question") return false;
const isTask = block.name === "Task" || block.name === "task";
if (isTask) {
const subDone = block.subStatus === "finished" || block.subStatus === "cancelled" || block.subStatus === "error";
if (subDone) return false;
// Parent tool may complete early (bg Task); keep counting while nested work open.
return block.status === "running" || block.subStatus === "running" || !!block.subBlocks?.length;
}
return block.status === "running";
}
/** Dedup kill-at-zero across multiple countdown mounts (explore head + card). */
const firedTimeouts = new Set<string>();
/**
* Live countdown for a running tool/task. At 0: cancel/kill via host.
* Uses host `startedAt` when present; otherwise starts the clock on first
* observation so every timed tool always shows a countdown.
*/
export function useToolCountdown(block: ToolBlock): number | null {
const budget = block.timeoutMs && block.timeoutMs > 0 ? block.timeoutMs : 0;
const hostStarted = block.startedAt && block.startedAt > 0 ? block.startedAt : 0;
const running = isToolCountdownActive(block);
const localStart = React.useRef(0);
const [left, setLeft] = React.useState<number | null>(null);
React.useEffect(() => {
if (!running || !budget || !block.callId) {
localStart.current = 0;
setLeft(null);
return;
}
// Prefer host clock; fall back to first UI observation of this run.
if (hostStarted) localStart.current = hostStarted;
else if (!localStart.current) localStart.current = Date.now();
// Allow re-fire only on a brand-new callId (set already cleared on settle).
if (block.status !== "running" && !(block.name === "Task" || block.name === "task")) {
firedTimeouts.delete(block.callId);
}
const tick = () => {
const start = hostStarted || localStart.current;
if (!start) return;
const sec = Math.max(0, Math.ceil((start + budget - Date.now()) / 1000));
setLeft(sec);
if (sec <= 0 && !firedTimeouts.has(block.callId)) {
firedTimeouts.add(block.callId);
post({ type: "cancelSubagent", callId: block.callId, reason: "timeout" });
}
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [budget, hostStarted, running, block.callId, block.status, block.subStatus, block.timeoutMs, block.name]);
if (!running || !budget || left == null) return null;
return left;
}
function formatCountdown(sec: number): string {
if (sec >= 60) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
return `${sec}s`;
}
/** Visible countdown badge; also drives kill-at-zero via useToolCountdown. */
export function TimeoutBadge({ block }: { block: ToolBlock }) {
const left = useToolCountdown(block);
if (left == null) return null;
const urgent = left <= 5;
return (
<span
className={"tool-timeout" + (urgent ? " urgent" : "") + (left === 0 ? " zero" : "")}
title="Timeout remaining — tool is killed at 0"
>
{left === 0 ? "timeout" : formatCountdown(left)}
</span>
);
}
/** Silent countdown (kill at 0) without rendering — for collapsed explore groups. */
export function ToolTimeoutWatch({ block }: { block: ToolBlock }) {
useToolCountdown(block);
return null;
}
function Diff({ diff }: { diff: string }) {
const lines = diff.split("\n");
const needsExpand = lines.length > 6;
@@ -380,6 +474,7 @@ export function ReadLine({ block }: { block: ToolBlock }) {
</span>
<span className="rname">Read {basename(i.path)}</span>
<span className="rlines">{rangeTxt ? "L" + rangeTxt : ""}</span>
<TimeoutBadge block={block} />
<span className="rstatus">
<StatusIcon status={block.status} />
</span>
@@ -535,7 +630,8 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
const i = block.input || {};
const meta = toolMeta(block.name, i);
const isEdit = block.name === "edit_file" || block.name === "StrReplace" || block.name === "Write";
const [open, setOpen] = React.useState(isEdit);
const isShell = block.name === "run_terminal" || block.name === "Shell" || block.name === "AwaitShell";
const [open, setOpen] = React.useState(isEdit || isShell);
const onHeaderClick = () => {
if (isEdit) {
@@ -546,10 +642,12 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
};
const showBody = isEdit ? true : open;
const shellCmd = isShell ? String(i.command || meta.label || "") : "";
const shellParsed = isShell ? parseShellResult(block.result, shellCmd) : null;
return (
<div className={"tool-card " + (isEdit ? "edit-card" : "compact-card")}>
<div className={"tool-card-header " + (isEdit ? "edit-header" : "compact")} onClick={onHeaderClick}>
<div className={"tool-card " + (isEdit ? "edit-card" : "compact-card") + (isShell ? " shell-card" : "")}>
<div className={"tool-card-header " + (isEdit ? "edit-header" : "compact") + (isShell ? " shell-header" : "")} onClick={onHeaderClick}>
<div className="left">
{!isEdit && (
<span className={"tchev" + (open ? " open" : "")}>
@@ -559,7 +657,14 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
<span className="ticon">
{isEdit ? <FileIcon path={i.path || ""} fallback={meta.icon} /> : <Icon name={meta.icon} />}
</span>
<span className="label">{meta.label}</span>
{isShell ? (
<span className="shell-prompt-line" title={shellCmd}>
<span className="shell-prompt">$</span>
<span className="shell-cmd">{shellCmd || "…"}</span>
</span>
) : (
<span className="label">{meta.label}</span>
)}
{isEdit && block.diff && (() => {
const s = diffStats(block.diff);
return (
@@ -571,6 +676,8 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
})()}
</div>
<div className="right">
{isShell && shellCmd ? <CopyCommandButton command={shellCmd} /> : null}
<TimeoutBadge block={block} />
{!isEdit && <span className={"badge " + meta.cls}>{meta.badge}</span>}
<StatusIcon status={block.status} />
</div>
@@ -582,8 +689,18 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
) : isEdit && block.status === "running" ? (
// Stream the code as the model writes it; swapped for the diff on completion.
<pre className="tool-result streaming">{editPreview(block.name, i) || "Writing…"}</pre>
) : block.name === "run_terminal" || block.name === "Shell" ? (
<pre className="terminal-output">{block.result ?? "Running…"}</pre>
) : isShell && shellParsed ? (
<div className="shell-body">
{shellParsed.meta ? <div className="shell-meta">{shellParsed.meta}</div> : null}
<pre className="terminal-output">
{shellParsed.body || (block.status === "running" ? "Running…" : "")}
</pre>
{shellParsed.footer ? (
<div className={"shell-footer" + (shellParsed.ok === false ? " err" : shellParsed.ok ? " ok" : "")}>
{shellParsed.footer}
</div>
) : null}
</div>
) : (
<pre className="tool-result">{block.status === "running" ? "Running…" : (block.result || "").slice(0, 4000)}</pre>
)}
@@ -592,3 +709,91 @@ export function ToolCard({ block, onImplement, onOpenSubagent }: { block: ToolBl
</div>
);
}
function CopyCommandButton({ command }: { command: string }) {
const [copied, setCopied] = React.useState(false);
const onCopy = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const done = () => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
};
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(command).then(done).catch(() => {
// fallback below
try {
const ta = document.createElement("textarea");
ta.value = command;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
done();
} catch { /* ignore */ }
});
} else {
try {
const ta = document.createElement("textarea");
ta.value = command;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
done();
} catch { /* ignore */ }
}
};
return (
<button
type="button"
className={"shell-copy-btn" + (copied ? " copied" : "")}
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? "Copied" : "Copy command"}
onClick={onCopy}
>
<Icon name={copied ? "check" : "copy"} size={12} />
</button>
);
}
/** Strip duplicated `$ command` lines from shell tool output for cleaner card body. */
function parseShellResult(raw: string | undefined, command: string): {
meta: string;
body: string;
footer: string;
ok: boolean | null;
} {
if (!raw) return { meta: "", body: "", footer: "", ok: null };
const lines = raw.replace(/\r\n/g, "\n").split("\n");
let meta = "";
let footer = "";
let ok: boolean | null = null;
const bodyLines: string[] = [];
const cmdNorm = command.trim();
for (const line of lines) {
if (!meta && /^\[shell\s/.test(line)) {
meta = line.replace(/^\[shell\s+/, "").replace(/\]\s*$/, "").trim();
continue;
}
if (/^\(exit_code=/.test(line) || /^\(still running/.test(line)) {
footer = line.replace(/^\(/, "").replace(/\)$/, "");
const m = line.match(/exit_code=(-?\d+)/);
if (m) ok = Number(m[1]) === 0;
continue;
}
// Drop the echo of the command (header already shows it).
const t = line.trim();
if (t === `$ ${cmdNorm}` || t === cmdNorm || (cmdNorm && t === `$ ${cmdNorm}`)) continue;
if (t.startsWith("$ ") && cmdNorm && t.slice(2).trim() === cmdNorm) continue;
bodyLines.push(line);
}
// Trim leading/trailing blank lines from body.
while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift();
while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop();
return { meta, body: bodyLines.join("\n"), footer, ok };
}
+135 -1
View File
@@ -154,6 +154,123 @@ body {
.tool-card-header .left .ticon svg { width: 14px; height: 14px; }
.tool-card-header.compact .left .ticon svg { width: 12px; height: 12px; }
.tool-card-header .left .label { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- Shell / Terminal card ---- */
.tool-card.shell-card {
border-color: color-mix(in srgb, var(--accent, #4a9) 28%, var(--border-soft));
background: var(--bg);
}
.tool-card.shell-card .tool-card-header.shell-header {
height: auto;
min-height: 28px;
padding: 6px 8px;
align-items: flex-start;
gap: 8px;
background: color-mix(in srgb, var(--terminal-bg, var(--bg-dark)) 55%, var(--input-bg));
}
.tool-card.shell-card .tool-card-header .left {
flex: 1 1 auto;
min-width: 0;
align-items: flex-start;
gap: 6px;
}
.tool-card.shell-card .tool-card-header .right {
flex: 0 0 auto;
align-items: center;
padding-top: 1px;
}
.shell-prompt-line {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
font-family: "Cascadia Code", Consolas, monospace;
font-size: 11.5px;
line-height: 1.4;
}
.shell-prompt {
flex: 0 0 auto;
color: var(--accent, #4a9);
font-weight: 600;
user-select: none;
}
.shell-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--fg-faint);
cursor: pointer;
flex-shrink: 0;
}
.shell-copy-btn:hover {
background: color-mix(in srgb, var(--fg) 12%, transparent);
color: var(--fg);
}
.shell-copy-btn.copied {
color: var(--green, #3c9);
}
.shell-copy-btn:focus-visible {
outline: 1px solid var(--accent, #4a9);
outline-offset: 1px;
}
.shell-cmd {
flex: 1 1 auto;
min-width: 0;
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.shell-body {
display: flex;
flex-direction: column;
background: var(--terminal-bg, var(--bg-dark));
}
.shell-meta {
padding: 4px 10px;
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10px;
color: var(--fg-faint);
border-bottom: 1px solid var(--border-soft);
letter-spacing: 0.01em;
}
.shell-footer {
padding: 4px 10px 6px;
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10.5px;
color: var(--fg-faint);
border-top: 1px solid var(--border-soft);
}
.shell-footer.ok { color: var(--green, #3c9); }
.shell-footer.err { color: var(--error, #e55); }
.tool-card.shell-card .terminal-output {
padding: 8px 10px;
max-height: 280px;
border: none;
background: transparent;
}
.tool-card.shell-card .tool-card-body {
border-top: 1px solid var(--border-soft);
background: var(--terminal-bg, var(--bg-dark));
}
.tool-timeout {
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10.5px;
font-variant-numeric: tabular-nums;
color: var(--fg-faint);
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--fg) 8%, transparent);
flex-shrink: 0;
}
.tool-timeout.urgent { color: #e0a040; background: color-mix(in srgb, #e0a040 18%, transparent); }
.tool-timeout.zero { color: #e05555; background: color-mix(in srgb, #e05555 18%, transparent); }
.tool-card-header.edit-header .label { font-family: inherit; font-size: 12px; color: var(--fg); }
.edit-stats { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; flex: 0 0 auto; }
.file-icon-img { width: 14px; height: 14px; display: block; }
@@ -669,6 +786,23 @@ body {
.composer .editor .mention:hover .mention-x { visibility: visible; }
.composer .editor .mention .mention-x:hover { color: var(--vscode-errorForeground, #f14c4c); }
.composer-bar { display: flex; align-items: center; gap: 5px; margin-top: 7px; flex-wrap: nowrap; min-width: 0; }
.ctx-pill { padding: 3px 6px 3px 8px; gap: 2px; }
.ctx-pill .cd, .ctx-pill svg { width: 11px; height: 11px; opacity: .7; }
.ctx-dropdown { min-width: 96px; padding: 4px; }
.ctx-dropdown .mode-item {
width: 100%;
justify-content: space-between;
background: none;
border: none;
color: var(--fg);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.ctx-dropdown .mode-item .mi-label { flex: 1 1 auto; }
.ctx-dropdown .mode-item .mi-check { color: var(--fg-dim); display: inline-flex; }
.ctx-dropdown .mode-item.active { background: var(--hover); }
.pill { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 1px solid var(--border-soft); border-radius: 6px; padding: 3px 8px; font-size: 11.5px; color: var(--fg-dim); cursor: pointer; flex: 0 0 auto; white-space: nowrap; position: relative; transition: background .1s ease, color .1s ease, border-color .1s ease; }
.pill:hover { background: var(--hover); color: var(--fg); border-color: var(--border); }
.mode-pill { color: var(--fg); }
@@ -676,7 +810,7 @@ body {
.pill .cd { width: 11px; height: 11px; }
.pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mode-dropdown { position: absolute; bottom: calc(100% + 6px); left: 0; min-width: 150px; background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 8px; padding: 3px; box-shadow: 0 8px 24px var(--shadow); z-index: 60; animation: pop-up .12s ease; }
.mode-dropdown { position: fixed; min-width: 150px; background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 8px; padding: 3px; box-shadow: 0 8px 24px var(--shadow); z-index: 200; animation: pop-up .12s ease; }
.mode-item { display: flex; align-items: center; gap: 7px; height: 26px; padding: 0 7px; border-radius: 5px; font-size: 12px; color: var(--fg); cursor: pointer; }
.mode-item:hover { background: var(--hover); }
.mode-item .mi-icon { display: inline-flex; flex: 0 0 auto; color: var(--fg-dim); }
+2 -2
View File
@@ -35,7 +35,7 @@ export type {
AssistantTurn,
Turn,
};
export { applyEvent, applyToBlocks, parsePartialArgs, closeTrailingThinking, renderMentionTokens, parseMentionTokens } from "../../src/shared/turns";
export { applyEvent, applyToBlocks, parsePartialArgs, closeTrailingThinking, forceSettleOpenWork, renderMentionTokens, parseMentionTokens } from "../../src/shared/turns";
export interface ConversationSummary {
id: string;
@@ -150,7 +150,7 @@ export type OutMessage =
| { type: "deleteConversation"; id: string }
| { type: "persistTurns"; convId?: string; turns: Turn[] }
| { type: "cancelRun"; convId?: string }
| { type: "cancelSubagent"; callId: string }
| { type: "cancelSubagent"; callId: string; reason?: "timeout" | "user" }
| { type: "answerQuestion"; callId: string; answers: Record<string, string[]> }
| { type: "resolveApproval"; requestId: string; approve?: boolean; pattern?: string; addPattern?: "allow" | "deny"; setMode?: ApprovalMode }
| { type: "setMode"; mode: Mode }