mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc0b9687d8 | |||
| e9d0b96931 | |||
| 4286a28ce3 | |||
| affe3e1c21 | |||
| 01a0da3bc9 | |||
| b63ac10f8f | |||
| 6b8301a28e | |||
| 5dab80f764 | |||
| f01e337cb6 | |||
| 7f000c254c | |||
| 76993e645b | |||
| d47e978be7 | |||
| a3d917d1b5 | |||
| 205c6cd1b7 | |||
| 3b246ec787 | |||
| 1bfe813edd | |||
| 506a8e14c8 | |||
| 75955a8033 | |||
| 268b0e28de | |||
| 1baa6f8c58 | |||
| 3da6b8675b | |||
| 2f04612e8d | |||
| 72cbd0b120 | |||
| f8ea587099 |
Vendored
+5
@@ -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"]
|
||||
}
|
||||
Vendored
+41
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -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"
|
||||
}
|
||||
Vendored
+79
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)" };
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ export {
|
||||
setSubagentRunner,
|
||||
setQuestionAsker,
|
||||
disposeShellSession,
|
||||
toolTimeoutMs,
|
||||
withToolTimeout,
|
||||
setToolTimeoutOverrides,
|
||||
DEFAULT_TOOL_TIMEOUTS_SEC,
|
||||
type TodoItem,
|
||||
} from "./shared";
|
||||
|
||||
|
||||
+112
-25
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 & 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.">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user