chore(merge): resolve conflicts with upstream/main for PR #3138

- auto-worktree.ts: take upstream's MERGE_HEAD cleanup wording/order
- state.ts: take upstream's inline disk→DB reconciliation (#2631)
  over the simpler "always call deriveStateFromDb" approach
This commit is contained in:
Jeremy 2026-04-01 14:04:16 -05:00
commit d929e9ceed
45 changed files with 5164 additions and 415 deletions

View file

@ -0,0 +1,241 @@
# Ollama Extension — First-Class Local LLM Support
## Status: DRAFT — Awaiting approval
## Problem
Ollama support in GSD2 currently requires manual `models.json` configuration. Users must:
1. Know the OpenAI-compatibility endpoint (`localhost:11434/v1`)
2. Manually list every model they want to use
3. Set compat flags (`supportsDeveloperRole: false`, etc.)
4. Use a dummy API key
There's an `ollama-cloud` provider for hosted Ollama, and a discovery adapter that can list models, but no first-class **local Ollama** extension that "just works."
## Goal
Make Ollama the easiest way to use GSD2 — zero config when Ollama is running locally. All Ollama functionality lives in a single extension: `src/resources/extensions/ollama/`.
## Architecture
Everything is a self-contained extension under `src/resources/extensions/ollama/`. The extension:
- Auto-detects Ollama on startup via health check
- Discovers and registers local models with the model registry
- Provides native Ollama API streaming (not OpenAI shim)
- Exposes `/ollama` slash commands for model management
- Registers an LLM-callable tool for model pull/status
Minimal core changes — only `KnownProvider` and `KnownApi` type additions in `pi-ai`, and `env-api-keys.ts` for key resolution. Everything else is in the extension.
## File Structure
```
src/resources/extensions/ollama/
├── index.ts # Extension entry — wires everything on session_start
├── ollama-client.ts # HTTP client for Ollama REST API (/api/*)
├── ollama-discovery.ts # Model discovery + capability detection
├── ollama-provider.ts # Native /api/chat streaming provider (registers with pi-ai)
├── ollama-commands.ts # /ollama slash commands (status, pull, list, remove, ps)
├── ollama-tool.ts # LLM-callable tool for model management
├── model-capabilities.ts # Known model capability table (context window, vision, reasoning)
└── types.ts # Shared types for Ollama API responses
```
## Scope
### Phase 1: Auto-Discovery + OpenAI-Compat Routing
**What:** Extension that auto-detects Ollama, discovers models, registers them using the existing `openai-completions` API provider. Zero config needed.
**Extension files:**
- `ollama/index.ts` — Main entry. On `session_start`:
1. Probe `localhost:11434` (or `OLLAMA_HOST`) with 1.5s timeout
2. If reachable, discover models via `/api/tags`
3. Register discovered models with `ctx.modelRegistry` using correct defaults
4. Show status widget if Ollama is detected
- `ollama/ollama-client.ts` — Low-level HTTP client:
- `isRunning()``GET /` health check
- `getVersion()``GET /api/version`
- `listModels()``GET /api/tags`
- `showModel(name)``POST /api/show` (details, template, parameters, size)
- `getRunningModels()``GET /api/ps` (loaded models, VRAM usage)
- `pullModel(name, onProgress)``POST /api/pull` (streaming progress)
- `deleteModel(name)``DELETE /api/delete`
- `copyModel(source, dest)``POST /api/copy`
- Respects `OLLAMA_HOST` env var for non-default endpoints
- `ollama/ollama-discovery.ts` — Enhanced model discovery:
- Calls `/api/tags` to get model list
- Calls `/api/show` per model (batch, cached) to get:
- `details.parameter_size` → estimate context window
- `details.families` → detect vision (clip), reasoning (deepseek-r1)
- `modelfile` → extract default parameters
- Returns enriched `DiscoveredModel[]` with proper capabilities
- `ollama/model-capabilities.ts` — Known model lookup table:
- Maps well-known model families to capabilities
- e.g., `llama3.1``{ contextWindow: 131072, input: ["text"] }`
- e.g., `llava``{ contextWindow: 4096, input: ["text", "image"] }`
- e.g., `deepseek-r1``{ reasoning: true, contextWindow: 131072 }`
- e.g., `qwen2.5-coder``{ contextWindow: 131072, input: ["text"] }`
- Fallback: estimate from parameter count if not in table
- `ollama/types.ts` — Ollama API response types
**Core changes (minimal):**
- `packages/pi-ai/src/types.ts` — Add `"ollama"` to `KnownProvider`
- `packages/pi-ai/src/env-api-keys.ts` — Add `"ollama"` key resolution (returns `"ollama"` placeholder — no real key needed)
- `src/onboarding.ts` — Add `"ollama"` to provider selection list
- `src/wizard.ts` — Add `ollama` entry (no key required)
**Model registration details:**
Each discovered model registers as:
```typescript
{
id: "llama3.1:8b", // from /api/tags
name: "Llama 3.1 8B", // humanized
api: "openai-completions", // uses existing provider
provider: "ollama",
baseUrl: "http://localhost:11434/v1",
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false, // from capabilities table
input: ["text"], // from capabilities table
contextWindow: 131072, // from capabilities table or /api/show
maxTokens: 16384, // conservative default
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
},
}
```
**Behavior:**
- `gsd --list-models` shows all locally-pulled Ollama models automatically
- `/model ollama/llama3.1:8b` works without any config file
- If Ollama isn't running, extension is silent — no errors, no models listed
- `models.json` overrides still work (user config wins over auto-discovery)
### Phase 2: Native Ollama API Provider (`/api/chat`)
**What:** A dedicated streaming provider that talks Ollama's native protocol instead of the OpenAI compatibility shim.
**Extension files:**
- `ollama/ollama-provider.ts` — Native `/api/chat` streaming:
- Registers `"ollama-chat"` API with `registerApiProvider()`
- Implements `stream()` and `streamSimple()`:
- Maps GSD `Context` → Ollama messages format
- Maps GSD `Tool[]` → Ollama tool format
- Streams NDJSON responses, maps back to `AssistantMessage` events
- Extracts `<think>` blocks for reasoning models (deepseek-r1, qwq)
- Ollama-specific options:
- `keep_alive` — control model memory retention (default: "5m")
- `num_ctx` — pass through model's context window
- `num_predict` — max output tokens
- Temperature, top_p, top_k
- Response metadata:
- `eval_count` / `eval_duration` → tokens/sec in usage stats
- `total_duration`, `load_duration` → performance visibility
- Vision support: converts image content to base64 for multimodal models
**Core changes:**
- `packages/pi-ai/src/types.ts` — Add `"ollama-chat"` to `KnownApi`
**Phase 1 models switch to `api: "ollama-chat"` by default.** Users can force OpenAI-compat via `models.json` override if needed.
**Why native over OpenAI-compat:**
- Full `keep_alive` / `num_ctx` control
- Better error messages (Ollama-native vs generic OpenAI)
- More reliable tool calling on Ollama's native format
- Performance metrics in response (tokens/sec)
- Foundation for model management commands
### Phase 3: Local LLM Management UX
**What:** `/ollama` slash commands and an LLM tool for model management.
**Extension files:**
- `ollama/ollama-commands.ts` — Slash commands registered via `pi.registerCommand()`:
- `/ollama` — Status overview:
```
Ollama v0.5.7 — running (localhost:11434)
Loaded:
llama3.1:8b 4.7 GB VRAM idle 3m
Available:
llama3.1:8b (4.7 GB)
qwen2.5-coder:7b (4.4 GB)
deepseek-r1:8b (4.9 GB)
```
- `/ollama pull <model>` — Pull with streaming progress via `ctx.ui.setWidget()`
- `/ollama list` — List all local models with sizes and families
- `/ollama remove <model>` — Delete a model (with confirmation)
- `/ollama ps` — Running models + VRAM usage
- `ollama/ollama-tool.ts` — LLM-callable tool registered via `pi.registerTool()`:
- `ollama_manage` tool — lets the agent pull/list/check models
- Parameters: `{ action: "list" | "pull" | "status" | "ps", model?: string }`
- Use case: agent detects it needs a model, pulls it automatically
**UX Flow:**
```
$ gsd
> /ollama
Ollama v0.5.7 — running (localhost:11434)
Loaded:
llama3.1:8b — 4.7 GB VRAM, idle 3m
Available:
llama3.1:8b (4.7 GB)
qwen2.5-coder:7b (4.4 GB)
deepseek-r1:8b (4.9 GB)
> /ollama pull codestral:22b
Pulling codestral:22b...
████████████████████████████░░░░ 78% (14.2 GB / 18.1 GB)
✓ codestral:22b ready
> /model ollama/codestral:22b
Switched to codestral:22b (local, Ollama)
```
## Implementation Order
1. **Phase 1** — Auto-discovery with OpenAI-compat routing. Biggest user impact, smallest risk.
2. **Phase 3** — Management UX (`/ollama` commands). Valuable even before native API.
3. **Phase 2** — Native `/api/chat` provider. Optimization over OpenAI-compat; do last.
## Core Changes Summary (minimal)
| File | Change |
|------|--------|
| `packages/pi-ai/src/types.ts` | Add `"ollama"` to `KnownProvider`, `"ollama-chat"` to `KnownApi` (Phase 2) |
| `packages/pi-ai/src/env-api-keys.ts` | Add `"ollama"` → always returns `"ollama"` placeholder |
| `src/onboarding.ts` | Add `"ollama"` to provider picker |
| `src/wizard.ts` | Add `"ollama"` key mapping (no key required) |
Everything else lives in `src/resources/extensions/ollama/`.
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Ollama not running — startup probe latency | 1.5s timeout; cache result; probe async so it doesn't block TUI paint |
| Model capabilities unknown | Known-model table + `/api/show` fallback + parameter_size estimation |
| Tool calling unreliable on small models | Detect param count; warn on <7B models |
| Ollama API changes between versions | Version detect via `/api/version`; stable endpoints only |
| Conflicts with `models.json` Ollama config | User config always wins; auto-discovered models merge beneath manual config |
| Extension disabled — no impact on core | Extension is additive; disabling removes all Ollama features cleanly |
## Testing Strategy
- Unit tests: `ollama-client.ts` with mocked fetch responses
- Unit tests: `ollama-discovery.ts` model capability parsing
- Unit tests: `ollama-provider.ts` message format mapping + NDJSON stream parsing
- Unit tests: `model-capabilities.ts` known model lookups
- Integration test: mock HTTP server simulating Ollama `/api/tags`, `/api/chat`, `/api/pull`
- Manual test: real Ollama instance with llama3.1, qwen2.5-coder, deepseek-r1
## Open Questions
1. **Startup probe** — Probe Ollama on `session_start` (adds ~1.5s if not running) or lazy on first `/model`? **Recommendation: async probe on session_start (non-blocking), eager if `OLLAMA_HOST` is set.**
2. **Auto-start** — Try to launch Ollama if installed but not running? **Recommendation: no — too invasive. Show helpful message in `/ollama` status.**
3. **Vision support** — Support multimodal models (llava, etc.) in Phase 2 native API? **Recommendation: yes, detected via capabilities table.**
4. **Model refresh** — How often to re-probe Ollama for new models? **Recommendation: on `/ollama list`, on `/model` command, and every 5 min (existing TTL).**

View file

@ -137,6 +137,7 @@ export function getEnvApiKey(provider: any): string | undefined {
"opencode-go": "OPENCODE_API_KEY",
"kimi-coding": "KIMI_API_KEY",
"alibaba-coding-plan": "ALIBABA_API_KEY",
ollama: "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
"custom-openai": "CUSTOM_OPENAI_API_KEY",
};

View file

@ -43,6 +43,7 @@ export type KnownProvider =
| "opencode-go"
| "kimi-coding"
| "alibaba-coding-plan"
| "ollama"
| "ollama-cloud";
export type Provider = KnownProvider | string;

View file

@ -37,6 +37,7 @@ const defaultModelPerProvider: Record<KnownProvider, string> = {
"opencode-go": "kimi-k2.5",
"kimi-coding": "kimi-k2-thinking",
"alibaba-coding-plan": "qwen3.5-plus",
ollama: "llama3.1:8b",
"ollama-cloud": "qwen3:32b",
};

View file

@ -74,6 +74,7 @@ const LLM_PROVIDER_IDS = [
'xai',
'openrouter',
'mistral',
'ollama',
'ollama-cloud',
'custom-openai',
]
@ -90,6 +91,7 @@ const OTHER_PROVIDERS = [
{ value: 'xai', label: 'xAI (Grok)' },
{ value: 'openrouter', label: 'OpenRouter' },
{ value: 'mistral', label: 'Mistral' },
{ value: 'ollama', label: 'Ollama (Local)' },
{ value: 'ollama-cloud', label: 'Ollama Cloud' },
{ value: 'custom-openai', label: 'Custom (OpenAI-compatible)' },
]
@ -335,6 +337,9 @@ async function runLlmStep(p: ClackModule, pc: PicoModule, authStorage: AuthStora
if (provider === 'custom-openai') {
return await runCustomOpenAIFlow(p, pc, authStorage)
}
if (provider === 'ollama') {
return await runOllamaLocalFlow(p, pc, authStorage)
}
const label = provider === 'anthropic' ? 'Anthropic'
: provider === 'openai' ? 'OpenAI'
: OTHER_PROVIDERS.find(op => op.value === provider)?.label ?? String(provider)
@ -444,6 +449,54 @@ async function runApiKeyFlow(
return true
}
// ─── Ollama Local Flow ───────────────────────────────────────────────────────
async function runOllamaLocalFlow(
p: ClackModule,
pc: PicoModule,
authStorage: AuthStorage,
): Promise<boolean> {
const host = process.env.OLLAMA_HOST || 'http://localhost:11434'
const s = p.spinner()
s.start(`Checking Ollama at ${host}...`)
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 3000)
const response = await fetch(host, { signal: controller.signal })
clearTimeout(timeout)
if (response.ok) {
s.stop(`Ollama is running at ${pc.green(host)}`)
// Store a placeholder so the provider is recognized as authenticated
authStorage.set('ollama', { type: 'api_key', key: 'ollama' })
p.log.success(`${pc.green('Ollama (Local)')} configured — no API key needed`)
p.log.info(pc.dim('Models are discovered automatically from your local Ollama instance.'))
return true
} else {
s.stop('Ollama check failed')
p.log.warn(`Ollama responded with status ${response.status} at ${host}`)
}
} catch {
s.stop('Ollama not detected')
p.log.warn(`Could not reach Ollama at ${host}`)
p.log.info(pc.dim('Install Ollama from https://ollama.com and run "ollama serve"'))
p.log.info(pc.dim('Set OLLAMA_HOST if using a non-default address.'))
}
// Even if not reachable now, save the config — the extension will detect it at runtime
const proceed = await p.confirm({
message: 'Save Ollama as your provider anyway? (it will auto-detect when running)',
})
if (p.isCancel(proceed) || !proceed) return false
authStorage.set('ollama', { type: 'api_key', key: 'ollama' })
p.log.success(`${pc.green('Ollama (Local)')} saved — models will appear when Ollama is running`)
return true
}
// ─── Custom OpenAI-compatible Flow ────────────────────────────────────────────
async function runCustomOpenAIFlow(

View file

@ -1566,17 +1566,18 @@ export function mergeMilestoneToMain(
// Non-fatal — proceed with merge; untracked files may block it
}
// 7b. Clean up stale merge state files before starting the squash merge (#2912).
// A previous failed merge, libgit2 native path, or external tooling may leave
// MERGE_HEAD on disk. git refuses to start a new merge when MERGE_HEAD exists,
// causing `git merge --squash` to fail with "You have not concluded your merge".
// 7b. Clean up stale merge state before attempting squash merge (#2912).
// A leftover MERGE_HEAD (from a previous failed merge, libgit2 native path,
// or interrupted operation) causes `git merge --squash` to refuse with
// "fatal: You have not concluded your merge (MERGE_HEAD exists)".
// Defensively remove merge artifacts before starting.
try {
const gitDir_ = resolveGitDir(originalBasePath_);
for (const f of ["MERGE_HEAD", "MERGE_MSG", "SQUASH_MSG"]) {
for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
const p = join(gitDir_, f);
if (existsSync(p)) unlinkSync(p);
}
} catch { /* best-effort — proceed and let the merge report the error if it fails */ }
} catch { /* best-effort */ }
// 8. Squash merge — auto-resolve .gsd/ state file conflicts (#530)
const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch);

View file

@ -95,6 +95,27 @@ export async function buildBeforeAgentStartResult(
}
}
let codebaseBlock = "";
const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE");
if (existsSync(codebasePath)) {
try {
const rawContent = readFileSync(codebasePath, "utf-8").trim();
if (rawContent) {
// Cap injection size to ~2 000 tokens to avoid bloating every request.
// Full map is always available at .gsd/CODEBASE.md.
const MAX_CODEBASE_CHARS = 8_000;
const generatedMatch = rawContent.match(/Generated: (\S+)/);
const generatedAt = generatedMatch?.[1] ?? "unknown";
const content = rawContent.length > MAX_CODEBASE_CHARS
? rawContent.slice(0, MAX_CODEBASE_CHARS) + "\n\n*(truncated — see .gsd/CODEBASE.md for full map)*"
: rawContent;
codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, may be stale — run /gsd codebase update to refresh)]\n\n${content}`;
}
} catch {
// skip
}
}
warnDeprecatedAgentInstructions();
const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd());
@ -103,7 +124,7 @@ export async function buildBeforeAgentStartResult(
const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd()) : null;
const worktreeBlock = buildWorktreeContextBlock();
const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
stopContextTimer({
systemPromptSize: fullSystem.length,

View file

@ -0,0 +1,351 @@
/**
* GSD Codebase Map Generator
*
* Produces .gsd/CODEBASE.md a structural table of contents for the project.
* Gives fresh agent contexts instant orientation without filesystem exploration.
*
* Generation: walk `git ls-files`, group by directory, output with descriptions.
* Maintenance: agent updates descriptions as it works; incremental update preserves them.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname, extname } from "node:path";
import { execSync } from "node:child_process";
import { gsdRoot } from "./paths.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface CodebaseMapOptions {
excludePatterns?: string[];
maxFiles?: number;
collapseThreshold?: number;
}
interface FileEntry {
path: string;
description: string;
}
interface DirectoryGroup {
path: string;
files: FileEntry[];
collapsed: boolean;
}
// ─── Defaults ────────────────────────────────────────────────────────────────
const DEFAULT_EXCLUDES = [
".gsd/",
".planning/",
".git/",
"node_modules/",
"dist/",
"build/",
".next/",
"coverage/",
"__pycache__/",
".venv/",
"vendor/",
];
const DEFAULT_MAX_FILES = 500;
const DEFAULT_COLLAPSE_THRESHOLD = 20;
// ─── Parsing ─────────────────────────────────────────────────────────────────
/**
* Parse an existing CODEBASE.md to extract file description mappings.
* Also scans <!-- gsd:collapsed-descriptions --> comment blocks to preserve
* descriptions for files in collapsed directories across incremental updates.
*/
export function parseCodebaseMap(content: string): Map<string, string> {
const descriptions = new Map<string, string>();
let inCollapsedBlock = false;
for (const line of content.split("\n")) {
// Track collapsed-description comment blocks
if (line.trimStart().startsWith("<!-- gsd:collapsed-descriptions")) {
inCollapsedBlock = true;
continue;
}
if (inCollapsedBlock && line.trimStart().startsWith("-->")) {
inCollapsedBlock = false;
continue;
}
// Match: - `path/to/file.ts` — Description here
const match = line.match(/^- `(.+?)` — (.+)$/);
if (match) {
descriptions.set(match[1], match[2]);
continue;
}
// Match: - `path/to/file.ts` (no description) — only outside collapsed blocks
if (!inCollapsedBlock) {
const bareMatch = line.match(/^- `(.+?)`\s*$/);
if (bareMatch) {
descriptions.set(bareMatch[1], "");
}
}
}
return descriptions;
}
// ─── File Enumeration ────────────────────────────────────────────────────────
function shouldExclude(filePath: string, excludes: string[]): boolean {
for (const pattern of excludes) {
if (pattern.endsWith("/")) {
if (filePath.startsWith(pattern) || filePath.includes(`/${pattern}`)) return true;
} else if (filePath === pattern || filePath.endsWith(`/${pattern}`)) {
return true;
}
}
// Skip binary/lock files
const ext = extname(filePath).toLowerCase();
if ([".lock", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".svg"].includes(ext)) {
return true;
}
return false;
}
function lsFiles(basePath: string): string[] {
try {
const result = execSync("git ls-files", { cwd: basePath, encoding: "utf-8", timeout: 10000 });
return result.split("\n").filter(Boolean);
} catch {
return [];
}
}
/**
* Enumerate tracked files, applying exclusions and the maxFiles cap.
* Returns both the file list and whether truncation occurred.
*/
function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): { files: string[]; truncated: boolean } {
const allFiles = lsFiles(basePath);
const filtered = allFiles.filter((f) => !shouldExclude(f, excludes));
const truncated = filtered.length > maxFiles;
return { files: truncated ? filtered.slice(0, maxFiles) : filtered, truncated };
}
// ─── Grouping ────────────────────────────────────────────────────────────────
function groupByDirectory(
files: string[],
descriptions: Map<string, string>,
collapseThreshold: number,
): DirectoryGroup[] {
const dirMap = new Map<string, FileEntry[]>();
for (const file of files) {
const dir = dirname(file);
const dirKey = dir === "." ? "" : dir;
if (!dirMap.has(dirKey)) {
dirMap.set(dirKey, []);
}
dirMap.get(dirKey)!.push({
path: file,
description: descriptions.get(file) ?? "",
});
}
const groups: DirectoryGroup[] = [];
const sortedDirs = [...dirMap.keys()].sort();
for (const dir of sortedDirs) {
const dirFiles = dirMap.get(dir)!;
dirFiles.sort((a, b) => a.path.localeCompare(b.path));
groups.push({
path: dir,
files: dirFiles,
collapsed: dirFiles.length > collapseThreshold,
});
}
return groups;
}
// ─── Rendering ───────────────────────────────────────────────────────────────
function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string {
const lines: string[] = [];
const now = new Date().toISOString().split(".")[0] + "Z";
const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0);
lines.push("# Codebase Map");
lines.push("");
lines.push(`Generated: ${now} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`);
if (truncated) {
lines.push(`Note: Truncated to first ${totalFiles} files. Run with higher --max-files to include all.`);
}
lines.push("");
for (const group of groups) {
const heading = group.path || "(root)";
lines.push(`### ${heading}/`);
if (group.collapsed) {
// Summarize collapsed directories
const extensions = new Map<string, number>();
for (const f of group.files) {
const ext = extname(f.path) || "(no ext)";
extensions.set(ext, (extensions.get(ext) ?? 0) + 1);
}
const extSummary = [...extensions.entries()]
.sort((a, b) => b[1] - a[1])
.map(([ext, count]) => `${count} ${ext}`)
.join(", ");
lines.push(`- *(${group.files.length} files: ${extSummary})*`);
// Preserve any existing descriptions in a hidden comment block so
// incremental updates can recover them via parseCodebaseMap.
const descLines = group.files
.filter((f) => f.description)
.map((f) => `- \`${f.path}\`${f.description}`);
if (descLines.length > 0) {
lines.push("<!-- gsd:collapsed-descriptions");
lines.push(...descLines);
lines.push("-->");
}
} else {
for (const file of group.files) {
if (file.description) {
lines.push(`- \`${file.path}\`${file.description}`);
} else {
lines.push(`- \`${file.path}\``);
}
}
}
lines.push("");
}
return lines.join("\n");
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Generate a fresh CODEBASE.md from scratch.
* Preserves existing descriptions if `existingDescriptions` is provided.
*/
export function generateCodebaseMap(
basePath: string,
options?: CodebaseMapOptions,
existingDescriptions?: Map<string, string>,
): { content: string; fileCount: number; truncated: boolean; files: string[] } {
const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])];
const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES;
const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD;
const { files, truncated } = enumerateFiles(basePath, excludes, maxFiles);
const descriptions = existingDescriptions ?? new Map<string, string>();
const groups = groupByDirectory(files, descriptions, collapseThreshold);
const content = renderCodebaseMap(groups, files.length, truncated);
return { content, fileCount: files.length, truncated, files };
}
/**
* Incremental update: re-scan files, preserve existing descriptions,
* add new files, remove deleted files.
*/
export function updateCodebaseMap(
basePath: string,
options?: CodebaseMapOptions,
): { content: string; added: number; removed: number; unchanged: number; fileCount: number; truncated: boolean } {
const codebasePath = join(gsdRoot(basePath), "CODEBASE.md");
// Load existing descriptions
let existingDescriptions = new Map<string, string>();
if (existsSync(codebasePath)) {
const existing = readFileSync(codebasePath, "utf-8");
existingDescriptions = parseCodebaseMap(existing);
}
const existingFiles = new Set(existingDescriptions.keys());
// Generate new map preserving descriptions — reuse the returned file list
// to avoid a second enumeration (prevents race between content and stats).
const result = generateCodebaseMap(basePath, options, existingDescriptions);
const currentSet = new Set(result.files);
// Count changes
let added = 0;
let removed = 0;
for (const f of result.files) {
if (!existingFiles.has(f)) added++;
}
for (const f of existingFiles) {
if (!currentSet.has(f)) removed++;
}
return {
content: result.content,
added,
removed,
unchanged: result.files.length - added,
fileCount: result.fileCount,
truncated: result.truncated,
};
}
/**
* Write CODEBASE.md to .gsd/ directory.
*/
export function writeCodebaseMap(basePath: string, content: string): string {
const root = gsdRoot(basePath);
mkdirSync(root, { recursive: true });
const outPath = join(root, "CODEBASE.md");
writeFileSync(outPath, content, "utf-8");
return outPath;
}
/**
* Read existing CODEBASE.md, or return null if it doesn't exist.
*/
export function readCodebaseMap(basePath: string): string | null {
const codebasePath = join(gsdRoot(basePath), "CODEBASE.md");
if (!existsSync(codebasePath)) return null;
try {
return readFileSync(codebasePath, "utf-8");
} catch {
return null;
}
}
/**
* Get stats about the codebase map.
*/
export function getCodebaseMapStats(basePath: string): {
exists: boolean;
fileCount: number;
describedCount: number;
undescribedCount: number;
generatedAt: string | null;
} {
const content = readCodebaseMap(basePath);
if (!content) {
return { exists: false, fileCount: 0, describedCount: 0, undescribedCount: 0, generatedAt: null };
}
// Parse total file count from the header line (accurate even for collapsed dirs)
const fileCountMatch = content.match(/Files:\s*(\d+)/);
const totalFiles = fileCountMatch ? parseInt(fileCountMatch[1], 10) : 0;
// Use parseCodebaseMap to count described files (includes collapsed-description blocks)
const descriptions = parseCodebaseMap(content);
const described = [...descriptions.values()].filter((d) => d.length > 0).length;
const dateMatch = content.match(/Generated: (\S+)/);
return {
exists: true,
fileCount: totalFiles,
describedCount: described,
undescribedCount: totalFiles - described,
generatedAt: dateMatch?.[1] ?? null,
};
}

View file

@ -0,0 +1,164 @@
/**
* GSD Command /gsd codebase
*
* Generate and manage the codebase map (.gsd/CODEBASE.md).
* Subcommands: generate, update, stats, help
*/
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import {
generateCodebaseMap,
updateCodebaseMap,
writeCodebaseMap,
getCodebaseMapStats,
readCodebaseMap,
} from "./codebase-generator.js";
const USAGE =
"Usage: /gsd codebase [generate|update|stats]\n\n" +
" generate [--max-files N] — Generate or regenerate CODEBASE.md\n" +
" update — Incremental update (preserves descriptions)\n" +
" stats — Show file count, coverage, and generation time\n" +
" help — Show this help\n\n" +
"With no subcommand, shows stats if a map exists or help if not.";
export async function handleCodebase(
args: string,
ctx: ExtensionCommandContext,
_pi: ExtensionAPI,
): Promise<void> {
const basePath = process.cwd();
const parts = args.trim().split(/\s+/);
const sub = parts[0] ?? "";
switch (sub) {
case "generate": {
const maxFiles = parseMaxFiles(args, ctx);
if (maxFiles === false) return; // validation failed, message already shown
const existing = readCodebaseMap(basePath);
const existingDescriptions = existing
? (await import("./codebase-generator.js")).parseCodebaseMap(existing)
: undefined;
const result = generateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }, existingDescriptions);
if (result.fileCount === 0) {
ctx.ui.notify(
"Codebase map generated with 0 files.\n" +
"Is this a git repository? Run 'git ls-files' to verify.",
"warning",
);
return;
}
const outPath = writeCodebaseMap(basePath, result.content);
ctx.ui.notify(
`Codebase map generated: ${result.fileCount} files\n` +
`Written to: ${outPath}` +
(result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""),
"success",
);
return;
}
case "update": {
const existing = readCodebaseMap(basePath);
if (!existing) {
ctx.ui.notify(
"No codebase map found. Run /gsd codebase generate to create one.",
"warning",
);
return;
}
const maxFiles = parseMaxFiles(args, ctx);
if (maxFiles === false) return;
const result = updateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined });
writeCodebaseMap(basePath, result.content);
ctx.ui.notify(
`Codebase map updated: ${result.fileCount} files\n` +
` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` +
(result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""),
"success",
);
return;
}
case "stats": {
showStats(basePath, ctx);
return;
}
case "help":
ctx.ui.notify(USAGE, "info");
return;
case "": {
// Safe default: show stats if map exists, help if not
const existing = readCodebaseMap(basePath);
if (existing) {
showStats(basePath, ctx);
} else {
ctx.ui.notify(USAGE, "info");
}
return;
}
default:
ctx.ui.notify(
`Unknown subcommand "${sub}".\n\n${USAGE}`,
"warning",
);
}
}
function showStats(basePath: string, ctx: ExtensionCommandContext): void {
const stats = getCodebaseMapStats(basePath);
if (!stats.exists) {
ctx.ui.notify("No codebase map found. Run /gsd codebase generate to create one.", "info");
return;
}
const coverage = stats.fileCount > 0
? Math.round((stats.describedCount / stats.fileCount) * 100)
: 0;
ctx.ui.notify(
`Codebase Map Stats:\n` +
` Files: ${stats.fileCount}\n` +
` Described: ${stats.describedCount} (${coverage}%)\n` +
` Undescribed: ${stats.undescribedCount}\n` +
` Generated: ${stats.generatedAt ?? "unknown"}\n\n` +
(stats.undescribedCount > 0
? `Tip: Run /gsd codebase update to refresh after file changes.`
: `Coverage is complete.`),
"info",
);
}
/**
* Parse and validate --max-files flag.
* Returns the parsed number, undefined if flag not present, or false if invalid.
*/
function parseMaxFiles(args: string, ctx: ExtensionCommandContext): number | undefined | false {
const maxFilesStr = extractFlag(args, "--max-files");
if (!maxFilesStr) return undefined;
const maxFiles = parseInt(maxFilesStr, 10);
if (isNaN(maxFiles) || maxFiles < 1) {
ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning");
return false;
}
return maxFiles;
}
function extractFlag(args: string, flag: string): string | undefined {
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`);
const match = args.match(regex);
return match?.[1];
}

View file

@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
export const GSD_COMMAND_DESCRIPTION =
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink";
"GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase";
export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "help", desc: "Categorized command reference with descriptions" },
@ -71,6 +71,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "mcp", desc: "MCP server status and connectivity check (status, check <server>)" },
{ cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" },
{ cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" },
{ cmd: "codebase", desc: "Generate and manage codebase map (.gsd/CODEBASE.md)" },
];
const NESTED_COMPLETIONS: CompletionMap = {
@ -225,6 +226,14 @@ const NESTED_COMPLETIONS: CompletionMap = {
{ cmd: "pause", desc: "Pause custom workflow auto-mode" },
{ cmd: "resume", desc: "Resume paused custom workflow auto-mode" },
],
codebase: [
{ cmd: "generate", desc: "Generate or regenerate CODEBASE.md" },
{ cmd: "generate --max-files", desc: "Generate with custom file limit (default: 500)" },
{ cmd: "update", desc: "Incremental update (preserves descriptions)" },
{ cmd: "update --max-files", desc: "Update with custom file limit" },
{ cmd: "stats", desc: "Show file count, description coverage, and generation time" },
{ cmd: "help", desc: "Show usage and available subcommands" },
],
};
function filterOptions(

View file

@ -206,5 +206,10 @@ Examples:
await handleRethink(trimmed, ctx, pi);
return true;
}
if (trimmed === "codebase" || trimmed.startsWith("codebase ")) {
const { handleCodebase } = await import("../../commands-codebase.js");
await handleCodebase(trimmed.replace(/^codebase\s*/, "").trim(), ctx, pi);
return true;
}
return false;
}

View file

@ -35,15 +35,17 @@ const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = {
"complete-slice": "light",
"run-uat": "light",
// Tier 2 — Standard: research, routine planning, discussion
// Tier 2 — Standard: research, routine discussion
"discuss-milestone": "standard",
"discuss-slice": "standard",
"research-milestone": "standard",
"research-slice": "standard",
"plan-milestone": "standard",
"plan-slice": "standard",
// Tier 3 — Heavy: execution, replanning (requires deep reasoning)
// Tier 3 — Heavy: planning, execution, replanning (requires deep reasoning)
// Planning is heavy so it uses the best configured model (e.g. Opus) and is
// not downgraded by dynamic routing when a capable model is configured.
"plan-milestone": "heavy",
"plan-slice": "heavy",
"execute-task": "standard", // default standard, upgraded by metadata
"replan-slice": "heavy",
"reassess-roadmap": "heavy",
@ -185,8 +187,8 @@ function analyzePlanComplexity(
// Check if this is a milestone-level plan (more complex) vs single slice
const { milestone: mid, slice: sid } = parseUnitId(unitId);
if (!sid) {
// Milestone-level planning is always at least standard
return { tier: "standard", reason: "milestone-level planning" };
// Milestone-level planning is always heavy — requires full context and best model
return { tier: "heavy", reason: "milestone-level planning" };
}
// For slice planning, try to read the context/research to gauge complexity

View file

@ -264,6 +264,7 @@ export const GSD_ROOT_FILES = {
REQUIREMENTS: "REQUIREMENTS.md",
OVERRIDES: "OVERRIDES.md",
KNOWLEDGE: "KNOWLEDGE.md",
CODEBASE: "CODEBASE.md",
} as const;
export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES;
@ -276,6 +277,7 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
REQUIREMENTS: "requirements.md",
OVERRIDES: "overrides.md",
KNOWLEDGE: "knowledge.md",
CODEBASE: "codebase.md",
};
// ─── GSD Root Discovery ───────────────────────────────────────────────────────

View file

@ -228,15 +228,36 @@ export async function deriveState(basePath: string): Promise<GSDState> {
const stopTimer = debugTime("derive-state-impl");
let result: GSDState;
// Dual-path: try DB-backed derivation when DB is available.
// Always go through deriveStateFromDb when DB is open — even if hierarchy
// tables are empty — because it contains disk→DB reconciliation logic that
// discovers milestones created outside the DB write path (#2631).
// Dual-path: try DB-backed derivation first when hierarchy tables are populated
if (isDbAvailable()) {
const stopDbTimer = debugTime("derive-state-db");
result = await deriveStateFromDb(basePath);
stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
_telemetry.dbDeriveCount++;
let dbMilestones = getAllMilestones();
// Disk→DB reconciliation when DB is empty but disk has milestones (#2631).
// deriveStateFromDb() does its own reconciliation, but deriveState() skips
// it entirely when the DB is empty. Sync here so the DB path is used when
// disk milestones exist but haven't been migrated yet.
if (dbMilestones.length === 0) {
const diskIds = findMilestoneIds(basePath);
let synced = false;
for (const diskId of diskIds) {
if (!isGhostMilestone(basePath, diskId)) {
insertMilestone({ id: diskId, status: 'active' });
synced = true;
}
}
if (synced) dbMilestones = getAllMilestones();
}
if (dbMilestones.length > 0) {
const stopDbTimer = debugTime("derive-state-db");
result = await deriveStateFromDb(basePath);
stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
_telemetry.dbDeriveCount++;
} else {
// DB open but no milestones on disk either — use filesystem path
result = await _deriveStateImpl(basePath);
_telemetry.markdownDeriveCount++;
}
} else {
result = await _deriveStateImpl(basePath);
_telemetry.markdownDeriveCount++;

View file

@ -0,0 +1,488 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { execSync } from "node:child_process";
import {
parseCodebaseMap,
generateCodebaseMap,
updateCodebaseMap,
writeCodebaseMap,
readCodebaseMap,
getCodebaseMapStats,
} from "../codebase-generator.ts";
// ─── Helpers ──────────────────────────────────────────────────────────────
function makeTmpRepo(): string {
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
mkdirSync(join(base, ".gsd"), { recursive: true });
execSync("git init", { cwd: base, stdio: "ignore" });
return base;
}
function addFile(base: string, path: string, content = ""): void {
const fullPath = join(base, path);
mkdirSync(join(fullPath, ".."), { recursive: true });
writeFileSync(fullPath, content || `// ${path}\n`, "utf-8");
execSync(`git add "${path}"`, { cwd: base, stdio: "ignore" });
}
function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
// ─── parseCodebaseMap ────────────────────────────────────────────────────
test("parseCodebaseMap: parses file with description", () => {
const content = `# Codebase Map
### src/
- \`main.ts\` — Application entry point
- \`utils.ts\` — Shared utilities
`;
const map = parseCodebaseMap(content);
assert.equal(map.size, 2);
assert.equal(map.get("main.ts"), "Application entry point");
assert.equal(map.get("utils.ts"), "Shared utilities");
});
test("parseCodebaseMap: parses file without description", () => {
const content = `- \`config.ts\`\n- \`index.ts\` — Entry\n`;
const map = parseCodebaseMap(content);
assert.equal(map.size, 2);
assert.equal(map.get("config.ts"), "");
assert.equal(map.get("index.ts"), "Entry");
});
test("parseCodebaseMap: empty content returns empty map", () => {
const map = parseCodebaseMap("");
assert.equal(map.size, 0);
});
test("parseCodebaseMap: ignores non-matching lines", () => {
const content = `# Codebase Map\n\nGenerated: 2026-03-23\n\n### src/\n- \`file.ts\` — desc\n`;
const map = parseCodebaseMap(content);
assert.equal(map.size, 1);
});
test("parseCodebaseMap: recovers descriptions from collapsed-description comments", () => {
const content = `# Codebase Map
### src/components/
- *(25 files: 25 .ts)*
<!-- gsd:collapsed-descriptions
- \`src/components/Foo.ts\` — The Foo component
- \`src/components/Bar.ts\` — The Bar component
-->
`;
const map = parseCodebaseMap(content);
assert.equal(map.get("src/components/Foo.ts"), "The Foo component");
assert.equal(map.get("src/components/Bar.ts"), "The Bar component");
// The collapsed summary line itself should not be parsed as a file
assert.ok(!map.has("*(25 files: 25 .ts)*"));
});
test("parseCodebaseMap: handles corrupted/malformed input gracefully", () => {
const content = [
"- `unclosed backtick",
"- `` — empty filename",
"- `valid.ts` — ok",
"random garbage line",
"- `a.ts` — desc with other text",
].join("\n");
const map = parseCodebaseMap(content);
assert.ok(map.has("valid.ts"));
assert.ok(map.has("a.ts"));
// Malformed lines should be silently skipped
assert.equal(map.size, 2);
});
// ─── generateCodebaseMap ─────────────────────────────────────────────────
test("generateCodebaseMap: generates from git ls-files", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, "src/utils.ts");
addFile(base, "README.md");
const result = generateCodebaseMap(base);
assert.ok(result.content.includes("# Codebase Map"));
assert.ok(result.content.includes("`src/main.ts`"));
assert.ok(result.content.includes("`src/utils.ts`"));
assert.ok(result.content.includes("README.md"));
assert.equal(result.fileCount, 3);
assert.equal(result.truncated, false);
assert.equal(result.files.length, 3);
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: excludes .gsd/ files", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, ".gsd/PROJECT.md");
const result = generateCodebaseMap(base);
assert.ok(result.content.includes("`src/main.ts`"));
assert.ok(!result.content.includes("PROJECT.md"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: excludes binary and lock files", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, "package-lock.json"); // .json not excluded
addFile(base, "yarn.lock"); // .lock excluded
addFile(base, "assets/logo.png"); // .png excluded
const result = generateCodebaseMap(base);
assert.ok(result.content.includes("`src/main.ts`"));
assert.ok(result.content.includes("package-lock.json"));
assert.ok(!result.content.includes("yarn.lock"));
assert.ok(!result.content.includes("logo.png"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: respects custom excludePatterns", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, "docs/guide.md");
addFile(base, "docs/api.md");
const result = generateCodebaseMap(base, { excludePatterns: ["docs/"] });
assert.ok(result.content.includes("`src/main.ts`"));
assert.ok(!result.content.includes("guide.md"));
assert.ok(!result.content.includes("api.md"));
assert.equal(result.fileCount, 1);
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: preserves existing descriptions", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, "src/utils.ts");
const descriptions = new Map<string, string>();
descriptions.set("src/main.ts", "App entry point");
const result = generateCodebaseMap(base, undefined, descriptions);
assert.ok(result.content.includes("`src/main.ts` — App entry point"));
assert.ok(result.content.includes("`src/utils.ts`"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: collapses large directories", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 25; i++) {
addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`);
}
const result = generateCodebaseMap(base);
// Collapsed summary should appear
assert.ok(result.content.includes("*(25 files: 25 .ts)*"));
// Individual file entries should NOT appear in main body
assert.ok(!result.content.includes("`src/components/comp00.ts`\n"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: respects custom collapseThreshold", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 5; i++) addFile(base, `src/comp${i}.ts`);
// Low threshold: 5 files should collapse
const collapsed = generateCodebaseMap(base, { collapseThreshold: 3 });
assert.ok(collapsed.content.includes("5 files"));
// High threshold: 5 files should expand
const expanded = generateCodebaseMap(base, { collapseThreshold: 10 });
assert.ok(expanded.content.includes("`src/comp0.ts`"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: truncated=false when file count is below maxFiles", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 4; i++) addFile(base, `file${i}.ts`);
const result = generateCodebaseMap(base, { maxFiles: 5 });
assert.equal(result.fileCount, 4);
assert.equal(result.truncated, false);
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: truncated=false when file count equals maxFiles exactly", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 5; i++) addFile(base, `file${i}.ts`);
const result = generateCodebaseMap(base, { maxFiles: 5 });
assert.equal(result.fileCount, 5);
assert.equal(result.truncated, false); // exactly at limit — nothing was truncated
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: truncated=true when file count exceeds maxFiles", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`);
const result = generateCodebaseMap(base, { maxFiles: 5 });
assert.equal(result.fileCount, 5);
assert.equal(result.truncated, true);
assert.ok(result.content.includes("Truncated"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: returns empty map for non-git directory", () => {
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
mkdirSync(join(base, ".gsd"), { recursive: true });
// No git init
try {
const result = generateCodebaseMap(base);
assert.equal(result.fileCount, 0);
assert.equal(result.truncated, false);
assert.ok(result.content.includes("# Codebase Map"));
assert.equal(result.files.length, 0);
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: handles empty repository (no committed files)", () => {
const base = makeTmpRepo();
try {
const result = generateCodebaseMap(base);
assert.equal(result.fileCount, 0);
assert.equal(result.truncated, false);
assert.ok(result.content.includes("Files: 0"));
} finally {
cleanup(base);
}
});
test("generateCodebaseMap: collapsed directories preserve descriptions in hidden comment", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 25; i++) {
addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`);
}
// Generate with a description for one file in the collapsed dir
const descriptions = new Map([["src/components/comp00.ts", "The first component"]]);
const result = generateCodebaseMap(base, undefined, descriptions);
// The description should be in the hidden comment block
assert.ok(result.content.includes("<!-- gsd:collapsed-descriptions"));
assert.ok(result.content.includes("`src/components/comp00.ts` — The first component"));
// Re-parsing should recover the description
const recovered = parseCodebaseMap(result.content);
assert.equal(recovered.get("src/components/comp00.ts"), "The first component");
} finally {
cleanup(base);
}
});
// ─── updateCodebaseMap ───────────────────────────────────────────────────
test("updateCodebaseMap: preserves descriptions on update", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, "src/utils.ts");
const initial = generateCodebaseMap(base, undefined, new Map([["src/main.ts", "Entry point"]]));
writeCodebaseMap(base, initial.content);
addFile(base, "src/new.ts");
const result = updateCodebaseMap(base);
assert.ok(result.content.includes("`src/main.ts` — Entry point"));
assert.equal(result.added, 1);
assert.equal(result.fileCount, 3);
} finally {
cleanup(base);
}
});
test("updateCodebaseMap: tracks removed files", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/keep.ts");
addFile(base, "src/remove.ts");
// Commit so git rm can operate
execSync("git -c user.email=t@t.com -c user.name=T commit -m init", { cwd: base, stdio: "ignore" });
const initial = generateCodebaseMap(base);
writeCodebaseMap(base, initial.content);
execSync("git rm src/remove.ts", { cwd: base, stdio: "ignore" });
const result = updateCodebaseMap(base);
assert.equal(result.removed, 1);
assert.equal(result.unchanged, 1);
assert.equal(result.fileCount, 1);
assert.ok(!result.content.includes("remove.ts"));
} finally {
cleanup(base);
}
});
test("updateCodebaseMap: propagates truncated flag", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`);
const initial = generateCodebaseMap(base, { maxFiles: 5 });
writeCodebaseMap(base, initial.content);
const result = updateCodebaseMap(base, { maxFiles: 5 });
assert.equal(result.truncated, true);
} finally {
cleanup(base);
}
});
test("updateCodebaseMap: preserves descriptions from collapsed directories", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 25; i++) {
addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`);
}
// Generate with a description in the (collapsed) components dir
const descriptions = new Map([["src/components/comp00.ts", "The first component"]]);
const initial = generateCodebaseMap(base, undefined, descriptions);
writeCodebaseMap(base, initial.content);
// Update should recover description from the hidden comment
const result = updateCodebaseMap(base);
const recovered = parseCodebaseMap(result.content);
assert.equal(recovered.get("src/components/comp00.ts"), "The first component");
} finally {
cleanup(base);
}
});
// ─── writeCodebaseMap / readCodebaseMap ──────────────────────────────────
test("writeCodebaseMap + readCodebaseMap roundtrip", () => {
const base = makeTmpRepo();
try {
const content = "# Codebase Map\n\n- `test.ts` — A test file\n";
const outPath = writeCodebaseMap(base, content);
assert.ok(existsSync(outPath));
const read = readCodebaseMap(base);
assert.equal(read, content);
} finally {
cleanup(base);
}
});
test("readCodebaseMap: returns null when file missing", () => {
const base = makeTmpRepo();
try {
const result = readCodebaseMap(base);
assert.equal(result, null);
} finally {
cleanup(base);
}
});
test("writeCodebaseMap: creates .gsd/ directory if missing", () => {
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
mkdirSync(base, { recursive: true });
// Intentionally do NOT pre-create .gsd/
try {
const outPath = writeCodebaseMap(base, "# Codebase Map\n");
assert.ok(existsSync(outPath));
} finally {
cleanup(base);
}
});
// ─── getCodebaseMapStats ─────────────────────────────────────────────────
test("getCodebaseMapStats: no map returns exists=false", () => {
const base = makeTmpRepo();
try {
const stats = getCodebaseMapStats(base);
assert.equal(stats.exists, false);
assert.equal(stats.fileCount, 0);
} finally {
cleanup(base);
}
});
test("getCodebaseMapStats: reports coverage", () => {
const base = makeTmpRepo();
try {
const content = `# Codebase Map\n\nGenerated: 2026-03-23T14:00:00Z | Files: 3 | Described: 2/3\n\n- \`a.ts\` — Has desc\n- \`b.ts\`\n- \`c.ts\` — Also has\n`;
writeCodebaseMap(base, content);
const stats = getCodebaseMapStats(base);
assert.equal(stats.exists, true);
assert.equal(stats.fileCount, 3); // from header, not parse count
assert.equal(stats.describedCount, 2);
assert.equal(stats.undescribedCount, 1);
assert.equal(stats.generatedAt, "2026-03-23T14:00:00Z");
} finally {
cleanup(base);
}
});
test("getCodebaseMapStats: reads total file count from header for accuracy with collapsed dirs", () => {
const base = makeTmpRepo();
try {
// Simulate a map with a collapsed dir: header says 30 files but parser only sees 2
const content = [
"# Codebase Map",
"",
"Generated: 2026-03-23T14:00:00Z | Files: 30 | Described: 2/30",
"",
"### src/components/",
"- *(28 files: 28 .ts)*",
"",
"### src/",
"- `main.ts` — Entry point",
"- `utils.ts` — Utilities",
].join("\n");
writeCodebaseMap(base, content);
const stats = getCodebaseMapStats(base);
assert.equal(stats.fileCount, 30); // from header, not from parseCodebaseMap
assert.equal(stats.describedCount, 2);
assert.equal(stats.undescribedCount, 28);
} finally {
cleanup(base);
}
});

View file

@ -41,14 +41,14 @@ test("research-slice classifies as standard", () => {
assert.equal(result.tier, "standard");
});
test("plan-milestone classifies as standard", () => {
test("plan-milestone classifies as heavy", () => {
const result = classifyUnitComplexity("plan-milestone", "M001", "/tmp/fake");
assert.equal(result.tier, "standard");
assert.equal(result.tier, "heavy");
});
test("plan-slice classifies as standard", () => {
test("plan-slice classifies as heavy", () => {
const result = classifyUnitComplexity("plan-slice", "M001/S01", "/tmp/fake");
assert.equal(result.tier, "standard");
assert.equal(result.tier, "heavy");
});
test("replan-slice classifies as heavy", () => {

View file

@ -739,6 +739,39 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
);
});
test("#2912: stale SQUASH_MSG and MERGE_MSG are cleaned before squash merge", () => {
// Verifies that the pre-merge cleanup (step 7b) removes all three merge
// artifacts — not just MERGE_HEAD — so that `git merge --squash` never
// encounters leftover state from a prior interrupted operation.
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M294");
addSliceToMilestone(repo, wtPath, "M294", "S01", "Feature C", [
{ file: "feature-c.ts", content: "export const c = true;\n", message: "add feature c" },
]);
const roadmap = makeRoadmap("M294", "Stale merge artifacts", [
{ id: "S01", title: "Feature C" },
]);
// Plant stale merge artifacts in the git dir to simulate a prior
// interrupted merge. The pre-merge cleanup must remove all of them.
const gitDir = join(repo, ".git");
writeFileSync(join(gitDir, "SQUASH_MSG"), "stale squash message\n");
writeFileSync(join(gitDir, "MERGE_MSG"), "stale merge message\n");
mergeMilestoneToMain(repo, "M294", roadmap);
assert.ok(
!existsSync(join(gitDir, "SQUASH_MSG")),
"#2912: stale SQUASH_MSG must be removed by pre-merge cleanup",
);
assert.ok(
!existsSync(join(gitDir, "MERGE_MSG")),
"#2912: stale MERGE_MSG must be removed by pre-merge cleanup",
);
});
test("#1906: codeFilesChanged=true when real code is merged", () => {
const repo = freshRepo();
const wtPath = createAutoWorktree(repo, "M190");

View file

@ -0,0 +1,130 @@
// GSD2 — Ollama Extension: First-class local LLM support
/**
* Ollama Extension
*
* Auto-detects a running Ollama instance, discovers locally pulled models,
* and registers them as a first-class provider. No configuration required
* if Ollama is running, models appear automatically.
*
* Features:
* - Auto-discovery of local models via /api/tags
* - Capability detection (vision, reasoning, context window)
* - /ollama slash commands for model management
* - ollama_manage tool for LLM-driven model operations
* - Zero-cost model registration (local inference)
*
* Respects OLLAMA_HOST env var for non-default endpoints.
*/
import { importExtensionModule, type ExtensionAPI } from "@gsd/pi-coding-agent";
import type { OpenAICompletionsCompat } from "@gsd/pi-ai";
import * as client from "./ollama-client.js";
import { discoverModels, getOllamaOpenAIBaseUrl } from "./ollama-discovery.js";
import { registerOllamaCommands } from "./ollama-commands.js";
/** Default compat settings for Ollama models via OpenAI-compat endpoint */
const OLLAMA_COMPAT: OpenAICompletionsCompat = {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
supportsStore: false,
};
let toolsPromise: Promise<void> | null = null;
async function registerOllamaTools(pi: ExtensionAPI): Promise<void> {
if (!toolsPromise) {
toolsPromise = (async () => {
const { registerOllamaTool } = await importExtensionModule<
typeof import("./ollama-tool.js")
>(import.meta.url, "./ollama-tool.js");
registerOllamaTool(pi);
})().catch((error) => {
toolsPromise = null;
throw error;
});
}
return toolsPromise;
}
/** Track whether we've registered models so we can clean up on shutdown */
let providerRegistered = false;
/**
* Probe Ollama and register discovered models.
* Safe to call multiple times re-discovers and re-registers.
*/
async function probeAndRegister(pi: ExtensionAPI): Promise<boolean> {
const running = await client.isRunning();
if (!running) {
if (providerRegistered) {
pi.unregisterProvider("ollama");
providerRegistered = false;
}
return false;
}
const models = await discoverModels();
if (models.length === 0) return true; // Running but no models pulled
const baseUrl = getOllamaOpenAIBaseUrl();
pi.registerProvider("ollama", {
authMode: "none",
baseUrl,
api: "openai-completions",
isReady: () => true,
models: models.map((m) => ({
id: m.id,
name: m.name,
reasoning: m.reasoning,
input: m.input,
cost: m.cost,
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
compat: OLLAMA_COMPAT,
})),
});
providerRegistered = true;
return true;
}
export default function ollama(pi: ExtensionAPI) {
// Register slash commands immediately (they check Ollama availability themselves)
registerOllamaCommands(pi);
pi.on("session_start", async (_event, ctx) => {
// Register tool (deferred to avoid blocking startup)
if (ctx.hasUI) {
void registerOllamaTools(pi).catch((error) => {
ctx.ui.notify(
`Ollama tool failed to load: ${error instanceof Error ? error.message : String(error)}`,
"warning",
);
});
} else {
await registerOllamaTools(pi);
}
// Async probe — don't block startup
probeAndRegister(pi)
.then((found) => {
if (found && ctx.hasUI) {
ctx.ui.setStatus("ollama", "Ollama");
}
})
.catch(() => {
// Silently ignore probe failures
});
});
pi.on("session_shutdown", async () => {
if (providerRegistered) {
pi.unregisterProvider("ollama");
providerRegistered = false;
}
toolsPromise = null;
});
}

View file

@ -0,0 +1,145 @@
// GSD2 — Known model capability table for Ollama models
/**
* Maps well-known Ollama model families to their capabilities.
* Used to enrich auto-discovered models with accurate context windows,
* vision support, and reasoning detection.
*
* Fallback: estimate from parameter count if model isn't in the table.
*/
export interface ModelCapability {
contextWindow?: number;
maxTokens?: number;
input?: ("text" | "image")[];
reasoning?: boolean;
}
/**
* Known model family capabilities.
* Keys are matched as prefixes against the model name (before the colon/tag).
* More specific entries should appear first.
*/
const KNOWN_MODELS: Array<[pattern: string, caps: ModelCapability]> = [
// ─── Reasoning models ───────────────────────────────────────────────
["deepseek-r1", { contextWindow: 131072, reasoning: true }],
["qwq", { contextWindow: 131072, reasoning: true }],
// ─── Vision models ──────────────────────────────────────────────────
["llava", { contextWindow: 4096, input: ["text", "image"] }],
["bakllava", { contextWindow: 4096, input: ["text", "image"] }],
["moondream", { contextWindow: 8192, input: ["text", "image"] }],
["llama3.2-vision", { contextWindow: 131072, input: ["text", "image"] }],
["minicpm-v", { contextWindow: 4096, input: ["text", "image"] }],
// ─── Code models ────────────────────────────────────────────────────
["codestral", { contextWindow: 262144, maxTokens: 32768 }],
["qwen2.5-coder", { contextWindow: 131072, maxTokens: 32768 }],
["deepseek-coder-v2", { contextWindow: 131072, maxTokens: 16384 }],
["starcoder2", { contextWindow: 16384, maxTokens: 8192 }],
["codegemma", { contextWindow: 8192, maxTokens: 8192 }],
["codellama", { contextWindow: 16384, maxTokens: 8192 }],
["devstral", { contextWindow: 131072, maxTokens: 32768 }],
// ─── Llama family ───────────────────────────────────────────────────
["llama3.3", { contextWindow: 131072, maxTokens: 16384 }],
["llama3.2", { contextWindow: 131072, maxTokens: 16384 }],
["llama3.1", { contextWindow: 131072, maxTokens: 16384 }],
["llama3", { contextWindow: 8192, maxTokens: 8192 }],
["llama2", { contextWindow: 4096, maxTokens: 4096 }],
// ─── Qwen family ────────────────────────────────────────────────────
["qwen3", { contextWindow: 131072, maxTokens: 32768 }],
["qwen2.5", { contextWindow: 131072, maxTokens: 32768 }],
["qwen2", { contextWindow: 131072, maxTokens: 32768 }],
// ─── Gemma family ───────────────────────────────────────────────────
["gemma3", { contextWindow: 131072, maxTokens: 16384 }],
["gemma2", { contextWindow: 8192, maxTokens: 8192 }],
// ─── Mistral family ─────────────────────────────────────────────────
["mistral-large", { contextWindow: 131072, maxTokens: 16384 }],
["mistral-small", { contextWindow: 131072, maxTokens: 16384 }],
["mistral-nemo", { contextWindow: 131072, maxTokens: 16384 }],
["mistral", { contextWindow: 32768, maxTokens: 8192 }],
["mixtral", { contextWindow: 32768, maxTokens: 8192 }],
// ─── Phi family ─────────────────────────────────────────────────────
["phi4", { contextWindow: 16384, maxTokens: 16384 }],
["phi3.5", { contextWindow: 131072, maxTokens: 16384 }],
["phi3", { contextWindow: 131072, maxTokens: 4096 }],
// ─── Command R ──────────────────────────────────────────────────────
["command-r-plus", { contextWindow: 131072, maxTokens: 16384 }],
["command-r", { contextWindow: 131072, maxTokens: 16384 }],
];
/**
* Look up capabilities for a model by name.
* Matches the longest prefix from the known models table.
*/
export function getModelCapabilities(modelName: string): ModelCapability {
// Strip tag (everything after the colon) for matching
const baseName = modelName.split(":")[0].toLowerCase();
for (const [pattern, caps] of KNOWN_MODELS) {
if (baseName === pattern || baseName.startsWith(pattern)) {
return caps;
}
}
return {};
}
/**
* Estimate context window from parameter size string (e.g. "7B", "70B", "1.5B").
* Used as fallback when model isn't in the known table.
*/
export function estimateContextFromParams(parameterSize: string): number {
const match = parameterSize.match(/([\d.]+)\s*([BbMm])/);
if (!match) return 8192;
const size = parseFloat(match[1]);
const unit = match[2].toUpperCase();
// Convert to billions
const billions = unit === "M" ? size / 1000 : size;
// Rough heuristics: larger models tend to support larger contexts
if (billions >= 70) return 131072;
if (billions >= 30) return 65536;
if (billions >= 13) return 32768;
if (billions >= 7) return 16384;
return 8192;
}
/**
* Humanize a model name for display (e.g. "llama3.1:8b" "Llama 3.1 8B").
*/
export function humanizeModelName(modelName: string): string {
const [base, tag] = modelName.split(":");
// Capitalize first letter, add spaces around version numbers
let name = base
.replace(/([a-z])(\d)/g, "$1 $2")
.replace(/(\d)([a-z])/g, "$1 $2")
.replace(/^./, (c) => c.toUpperCase());
// Clean up common patterns
name = name.replace(/\s*-\s*/g, " ");
if (tag && tag !== "latest") {
name += ` ${tag.toUpperCase()}`;
}
return name;
}
/**
* Format byte size for display (e.g. 4700000000 "4.7 GB").
*/
export function formatModelSize(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
return `${(bytes / 1e3).toFixed(0)} KB`;
}

View file

@ -0,0 +1,196 @@
// GSD2 — HTTP client for Ollama REST API
/**
* Low-level HTTP client for the Ollama REST API.
* Respects the OLLAMA_HOST environment variable for non-default endpoints.
*
* Reference: https://github.com/ollama/ollama/blob/main/docs/api.md
*/
import type {
OllamaPsResponse,
OllamaPullProgress,
OllamaShowResponse,
OllamaTagsResponse,
OllamaVersionResponse,
} from "./types.js";
const DEFAULT_HOST = "http://localhost:11434";
const PROBE_TIMEOUT_MS = 1500;
const REQUEST_TIMEOUT_MS = 10000;
/**
* Get the Ollama host URL from OLLAMA_HOST or default.
*/
export function getOllamaHost(): string {
const host = process.env.OLLAMA_HOST;
if (!host) return DEFAULT_HOST;
// OLLAMA_HOST can be just a host:port without scheme
if (host.startsWith("http://") || host.startsWith("https://")) return host;
return `http://${host}`;
}
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = REQUEST_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
/**
* Check if Ollama is running and reachable.
*/
export async function isRunning(): Promise<boolean> {
try {
const response = await fetchWithTimeout(`${getOllamaHost()}/`, {}, PROBE_TIMEOUT_MS);
return response.ok;
} catch {
return false;
}
}
/**
* Get Ollama version.
*/
export async function getVersion(): Promise<string | null> {
try {
const response = await fetchWithTimeout(`${getOllamaHost()}/api/version`);
if (!response.ok) return null;
const data = (await response.json()) as OllamaVersionResponse;
return data.version;
} catch {
return null;
}
}
/**
* List all locally available models.
*/
export async function listModels(): Promise<OllamaTagsResponse> {
const response = await fetchWithTimeout(`${getOllamaHost()}/api/tags`);
if (!response.ok) {
throw new Error(`Ollama /api/tags returned ${response.status}: ${response.statusText}`);
}
return (await response.json()) as OllamaTagsResponse;
}
/**
* Get detailed information about a specific model.
*/
export async function showModel(name: string): Promise<OllamaShowResponse> {
const response = await fetchWithTimeout(`${getOllamaHost()}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error(`Ollama /api/show returned ${response.status}: ${response.statusText}`);
}
return (await response.json()) as OllamaShowResponse;
}
/**
* List currently loaded/running models.
*/
export async function getRunningModels(): Promise<OllamaPsResponse> {
const response = await fetchWithTimeout(`${getOllamaHost()}/api/ps`);
if (!response.ok) {
throw new Error(`Ollama /api/ps returned ${response.status}: ${response.statusText}`);
}
return (await response.json()) as OllamaPsResponse;
}
/**
* Pull a model with streaming progress.
* Calls onProgress for each progress update.
* Returns when the pull is complete.
*/
export async function pullModel(
name: string,
onProgress?: (progress: OllamaPullProgress) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await fetch(`${getOllamaHost()}/api/pull`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, stream: true }),
signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Ollama /api/pull returned ${response.status}: ${text}`);
}
if (!response.body) {
throw new Error("Ollama /api/pull returned no body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const progress = JSON.parse(trimmed) as OllamaPullProgress;
onProgress?.(progress);
} catch {
// Skip malformed lines
}
}
}
// Process remaining buffer
if (buffer.trim()) {
try {
const progress = JSON.parse(buffer.trim()) as OllamaPullProgress;
onProgress?.(progress);
} catch {
// Ignore
}
}
}
/**
* Delete a local model.
*/
export async function deleteModel(name: string): Promise<void> {
const response = await fetchWithTimeout(`${getOllamaHost()}/api/delete`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Ollama /api/delete returned ${response.status}: ${text}`);
}
}
/**
* Copy a model to a new name.
*/
export async function copyModel(source: string, destination: string): Promise<void> {
const response = await fetchWithTimeout(`${getOllamaHost()}/api/copy`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, destination }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Ollama /api/copy returned ${response.status}: ${text}`);
}
}

View file

@ -0,0 +1,248 @@
// GSD2 — Ollama slash commands
/**
* Registers /ollama slash commands for managing local Ollama models.
*
* Commands:
* /ollama Show status (running?, version, loaded models)
* /ollama list List all available local models with sizes
* /ollama pull Pull a model with progress
* /ollama remove Delete a local model
* /ollama ps Show running models and resource usage
*/
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import { Text } from "@gsd/pi-tui";
import * as client from "./ollama-client.js";
import { discoverModels, formatModelForDisplay } from "./ollama-discovery.js";
import { formatModelSize } from "./model-capabilities.js";
export function registerOllamaCommands(pi: ExtensionAPI): void {
pi.registerCommand("ollama", {
description: "Manage local Ollama models — list | pull | remove | ps",
async handler(args, ctx) {
const parts = (args ?? "").trim().split(/\s+/);
const subcommand = parts[0] || "status";
const modelArg = parts.slice(1).join(" ");
switch (subcommand) {
case "status":
return await handleStatus(ctx);
case "list":
case "ls":
return await handleList(ctx);
case "pull":
return await handlePull(modelArg, ctx);
case "remove":
case "rm":
case "delete":
return await handleRemove(modelArg, ctx);
case "ps":
return await handlePs(ctx);
default:
ctx.ui.notify(
`Unknown subcommand: ${subcommand}. Use: status, list, pull, remove, ps`,
"warning",
);
}
},
});
}
async function handleStatus(ctx: any): Promise<void> {
const running = await client.isRunning();
if (!running) {
ctx.ui.notify(
"Ollama is not running. Install from https://ollama.com and run 'ollama serve'",
"warning",
);
return;
}
const version = await client.getVersion();
const lines: string[] = [];
lines.push(`Ollama${version ? ` v${version}` : ""} — running (${client.getOllamaHost()})`);
// Show loaded models
try {
const ps = await client.getRunningModels();
if (ps.models && ps.models.length > 0) {
lines.push("");
lines.push("Loaded:");
for (const m of ps.models) {
const vram = m.size_vram > 0 ? formatModelSize(m.size_vram) + " VRAM" : "CPU";
const expiresAt = new Date(m.expires_at);
const idleMs = expiresAt.getTime() - Date.now();
const idleMin = Math.max(0, Math.floor(idleMs / 60000));
lines.push(` ${m.name} ${vram} expires in ${idleMin}m`);
}
}
} catch {
// ps endpoint may not be available on older versions
}
// Show available models
try {
const models = await discoverModels();
if (models.length > 0) {
lines.push("");
lines.push("Available:");
for (const m of models) {
lines.push(` ${formatModelForDisplay(m)}`);
}
} else {
lines.push("");
lines.push("No models pulled. Use /ollama pull <model> to get started.");
}
} catch (err) {
lines.push("");
lines.push(`Error listing models: ${err instanceof Error ? err.message : String(err)}`);
}
await ctx.ui.custom(
(tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
const text = new Text(lines.map((l) => theme.fg("fg", l)).join("\n"), 0, 0);
setTimeout(() => done(undefined), 0);
return text;
},
);
}
async function handleList(ctx: any): Promise<void> {
const running = await client.isRunning();
if (!running) {
ctx.ui.notify("Ollama is not running", "warning");
return;
}
const models = await discoverModels();
if (models.length === 0) {
ctx.ui.notify("No models available. Use /ollama pull <model> to download one.", "info");
return;
}
const lines = ["Local Ollama models:", ""];
for (const m of models) {
lines.push(` ${formatModelForDisplay(m)}`);
}
await ctx.ui.custom(
(tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
const text = new Text(lines.map((l) => theme.fg("fg", l)).join("\n"), 0, 0);
setTimeout(() => done(undefined), 0);
return text;
},
);
}
async function handlePull(modelName: string, ctx: any): Promise<void> {
if (!modelName) {
ctx.ui.notify("Usage: /ollama pull <model> (e.g. /ollama pull llama3.1:8b)", "warning");
return;
}
const running = await client.isRunning();
if (!running) {
ctx.ui.notify("Ollama is not running", "warning");
return;
}
ctx.ui.setWidget("ollama-pull", [`Pulling ${modelName}...`]);
try {
let lastPercent = -1;
await client.pullModel(modelName, (progress) => {
if (progress.total && progress.completed) {
const percent = Math.floor((progress.completed / progress.total) * 100);
if (percent !== lastPercent) {
lastPercent = percent;
const completed = formatModelSize(progress.completed);
const total = formatModelSize(progress.total);
ctx.ui.setWidget("ollama-pull", [
`Pulling ${modelName}... ${percent}% (${completed} / ${total})`,
]);
}
} else if (progress.status) {
ctx.ui.setWidget("ollama-pull", [`${modelName}: ${progress.status}`]);
}
});
ctx.ui.setWidget("ollama-pull", undefined);
ctx.ui.notify(`${modelName} pulled successfully`, "success");
} catch (err) {
ctx.ui.setWidget("ollama-pull", undefined);
ctx.ui.notify(
`Failed to pull ${modelName}: ${err instanceof Error ? err.message : String(err)}`,
"error",
);
}
}
async function handleRemove(modelName: string, ctx: any): Promise<void> {
if (!modelName) {
ctx.ui.notify("Usage: /ollama remove <model>", "warning");
return;
}
const running = await client.isRunning();
if (!running) {
ctx.ui.notify("Ollama is not running", "warning");
return;
}
const confirmed = await ctx.ui.confirm(
"Delete model",
`Are you sure you want to delete ${modelName}?`,
);
if (!confirmed) return;
try {
await client.deleteModel(modelName);
ctx.ui.notify(`${modelName} deleted`, "success");
} catch (err) {
ctx.ui.notify(
`Failed to delete ${modelName}: ${err instanceof Error ? err.message : String(err)}`,
"error",
);
}
}
async function handlePs(ctx: any): Promise<void> {
const running = await client.isRunning();
if (!running) {
ctx.ui.notify("Ollama is not running", "warning");
return;
}
try {
const ps = await client.getRunningModels();
if (!ps.models || ps.models.length === 0) {
ctx.ui.notify("No models currently loaded in memory", "info");
return;
}
const lines = ["Running models:", ""];
for (const m of ps.models) {
const vram = m.size_vram > 0 ? formatModelSize(m.size_vram) + " VRAM" : "CPU only";
const totalSize = formatModelSize(m.size);
const expiresAt = new Date(m.expires_at);
const idleMs = expiresAt.getTime() - Date.now();
const idleMin = Math.max(0, Math.floor(idleMs / 60000));
lines.push(` ${m.name} ${totalSize} ${vram} expires in ${idleMin}m`);
}
await ctx.ui.custom(
(tui: any, theme: any, _kb: any, done: (r: undefined) => void) => {
const text = new Text(lines.map((l) => theme.fg("fg", l)).join("\n"), 0, 0);
setTimeout(() => done(undefined), 0);
return text;
},
);
} catch (err) {
ctx.ui.notify(
`Failed to get running models: ${err instanceof Error ? err.message : String(err)}`,
"error",
);
}
}

View file

@ -0,0 +1,106 @@
// GSD2 — Ollama model discovery and capability detection
/**
* Discovers locally available Ollama models and enriches them with
* capability metadata (context window, vision, reasoning) from the
* known model table and /api/show responses.
*
* Returns models in the format expected by pi.registerProvider().
*/
import { listModels, getOllamaHost } from "./ollama-client.js";
import {
estimateContextFromParams,
formatModelSize,
getModelCapabilities,
humanizeModelName,
} from "./model-capabilities.js";
import type { OllamaModelInfo } from "./types.js";
export interface DiscoveredOllamaModel {
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
/** Raw size in bytes for display purposes */
sizeBytes: number;
/** Parameter size string from Ollama (e.g. "7B") */
parameterSize: string;
}
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
function enrichModel(info: OllamaModelInfo): DiscoveredOllamaModel {
const caps = getModelCapabilities(info.name);
const parameterSize = info.details?.parameter_size ?? "";
// Determine context window: known table > estimate from param size > default
const contextWindow =
caps.contextWindow ??
(parameterSize ? estimateContextFromParams(parameterSize) : 8192);
// Determine max tokens: known table > fraction of context > default
const maxTokens =
caps.maxTokens ?? Math.min(Math.floor(contextWindow / 4), 16384);
// Detect vision from families or known table
const hasVision =
caps.input?.includes("image") ??
(info.details?.families?.some((f) => f === "clip" || f === "mllama") ?? false);
// Detect reasoning from known table
const reasoning = caps.reasoning ?? false;
return {
id: info.name,
name: humanizeModelName(info.name),
reasoning,
input: hasVision ? ["text", "image"] : ["text"],
cost: ZERO_COST,
contextWindow,
maxTokens,
sizeBytes: info.size,
parameterSize,
};
}
/**
* Discover all locally available Ollama models with enriched capabilities.
*/
export async function discoverModels(): Promise<DiscoveredOllamaModel[]> {
const tags = await listModels();
if (!tags.models || tags.models.length === 0) return [];
return tags.models.map(enrichModel);
}
/**
* Format a discovered model for display in model list.
*/
export function formatModelForDisplay(model: DiscoveredOllamaModel): string {
const parts = [model.id];
if (model.sizeBytes > 0) {
parts.push(`(${formatModelSize(model.sizeBytes)})`);
}
const flags: string[] = [];
if (model.reasoning) flags.push("reasoning");
if (model.input.includes("image")) flags.push("vision");
if (flags.length > 0) {
parts.push(`[${flags.join(", ")}]`);
}
return parts.join(" ");
}
/**
* Build the OpenAI-compat base URL for Ollama.
*/
export function getOllamaOpenAIBaseUrl(): string {
return `${getOllamaHost()}/v1`;
}

View file

@ -0,0 +1,218 @@
// GSD2 — LLM-callable Ollama management tool
/**
* Registers an ollama_manage tool that the LLM can call to interact
* with the local Ollama instance list models, pull new ones, check status.
*/
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import { Text } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import * as client from "./ollama-client.js";
import { discoverModels, formatModelForDisplay } from "./ollama-discovery.js";
import { formatModelSize } from "./model-capabilities.js";
interface OllamaToolDetails {
action: string;
model?: string;
modelCount?: number;
durationMs: number;
error?: string;
}
export function registerOllamaTool(pi: ExtensionAPI): void {
pi.registerTool({
name: "ollama_manage",
label: "Ollama",
description:
"Manage local Ollama models. List available models, pull new ones, " +
"check Ollama status, or see running models and resource usage. " +
"Use this when you need a specific local model that isn't available yet.",
promptSnippet: "Manage local Ollama models (list, pull, status, ps)",
promptGuidelines: [
"Use 'list' to see what models are available locally before trying to use one.",
"Use 'pull' to download a model that isn't available yet.",
"Use 'status' to check if Ollama is running.",
"Use 'ps' to see which models are loaded in memory and VRAM usage.",
"Common models: llama3.1:8b, qwen2.5-coder:7b, deepseek-r1:8b, codestral:22b",
],
parameters: Type.Object({
action: Type.Union(
[
Type.Literal("list"),
Type.Literal("pull"),
Type.Literal("status"),
Type.Literal("ps"),
],
{ description: "Action to perform" },
),
model: Type.Optional(
Type.String({ description: "Model name (required for pull)" }),
),
}),
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
const startTime = Date.now();
const { action, model } = params;
try {
switch (action) {
case "status": {
const running = await client.isRunning();
if (!running) {
return {
content: [{ type: "text", text: "Ollama is not running. It needs to be started with 'ollama serve'." }],
details: { action, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
const version = await client.getVersion();
return {
content: [{ type: "text", text: `Ollama${version ? ` v${version}` : ""} is running at ${client.getOllamaHost()}` }],
details: { action, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
case "list": {
const running = await client.isRunning();
if (!running) {
return {
content: [{ type: "text", text: "Ollama is not running." }],
isError: true,
details: { action, durationMs: Date.now() - startTime, error: "not_running" } as OllamaToolDetails,
};
}
const models = await discoverModels();
if (models.length === 0) {
return {
content: [{ type: "text", text: "No models available. Pull one with action='pull'." }],
details: { action, modelCount: 0, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
const lines = models.map((m) => formatModelForDisplay(m));
return {
content: [{ type: "text", text: `Available models:\n${lines.join("\n")}` }],
details: { action, modelCount: models.length, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
case "pull": {
if (!model) {
return {
content: [{ type: "text", text: "Error: 'model' parameter is required for pull action." }],
isError: true,
details: { action, durationMs: Date.now() - startTime, error: "missing_model" } as OllamaToolDetails,
};
}
const running = await client.isRunning();
if (!running) {
return {
content: [{ type: "text", text: "Ollama is not running." }],
isError: true,
details: { action, model, durationMs: Date.now() - startTime, error: "not_running" } as OllamaToolDetails,
};
}
let lastStatus = "";
await client.pullModel(model, (progress) => {
if (progress.total && progress.completed) {
const pct = Math.floor((progress.completed / progress.total) * 100);
const status = `Pulling ${model}... ${pct}%`;
if (status !== lastStatus) {
lastStatus = status;
onUpdate?.({ content: [{ type: "text", text: status }], details: { action, model, durationMs: Date.now() - startTime } as OllamaToolDetails });
}
} else if (progress.status && progress.status !== lastStatus) {
lastStatus = progress.status;
onUpdate?.({ content: [{ type: "text", text: `${model}: ${progress.status}` }], details: { action, model, durationMs: Date.now() - startTime } as OllamaToolDetails });
}
}, signal);
return {
content: [{ type: "text", text: `Successfully pulled ${model}` }],
details: { action, model, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
case "ps": {
const running = await client.isRunning();
if (!running) {
return {
content: [{ type: "text", text: "Ollama is not running." }],
isError: true,
details: { action, durationMs: Date.now() - startTime, error: "not_running" } as OllamaToolDetails,
};
}
const ps = await client.getRunningModels();
if (!ps.models || ps.models.length === 0) {
return {
content: [{ type: "text", text: "No models currently loaded in memory." }],
details: { action, modelCount: 0, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
const lines = ps.models.map((m) => {
const vram = m.size_vram > 0 ? `${formatModelSize(m.size_vram)} VRAM` : "CPU";
return `${m.name}${formatModelSize(m.size)} total, ${vram}`;
});
return {
content: [{ type: "text", text: `Loaded models:\n${lines.join("\n")}` }],
details: { action, modelCount: ps.models.length, durationMs: Date.now() - startTime } as OllamaToolDetails,
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${action}` }],
isError: true,
details: { action, durationMs: Date.now() - startTime, error: "unknown_action" } as OllamaToolDetails,
};
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Ollama error: ${msg}` }],
isError: true,
details: { action, model, durationMs: Date.now() - startTime, error: msg } as OllamaToolDetails,
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("ollama "));
text += theme.fg("accent", args.action);
if (args.model) {
text += theme.fg("dim", ` ${args.model}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial, expanded }, theme) {
const d = result.details as OllamaToolDetails | undefined;
if (isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
if ((result as any).isError || d?.error) {
return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0);
}
let text = theme.fg("success", d?.action ?? "done");
if (d?.modelCount !== undefined) {
text += theme.fg("dim", ` (${d.modelCount} models)`);
}
text += theme.fg("dim", ` ${d?.durationMs ?? 0}ms`);
if (expanded) {
const content = result.content[0];
if (content?.type === "text") {
const preview = content.text.split("\n").slice(0, 10).join("\n");
text += "\n\n" + theme.fg("dim", preview);
}
}
return new Text(text, 0, 0);
},
});
}

View file

@ -0,0 +1,162 @@
// GSD2 — Tests for Ollama model capability detection
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
getModelCapabilities,
estimateContextFromParams,
humanizeModelName,
formatModelSize,
} from "../model-capabilities.js";
// ─── getModelCapabilities ────────────────────────────────────────────────────
describe("getModelCapabilities", () => {
it("returns reasoning for deepseek-r1 models", () => {
const caps = getModelCapabilities("deepseek-r1:8b");
assert.equal(caps.reasoning, true);
assert.equal(caps.contextWindow, 131072);
});
it("returns reasoning for qwq models", () => {
const caps = getModelCapabilities("qwq:32b");
assert.equal(caps.reasoning, true);
});
it("returns vision for llava models", () => {
const caps = getModelCapabilities("llava:7b");
assert.deepEqual(caps.input, ["text", "image"]);
});
it("returns vision for llama3.2-vision models", () => {
const caps = getModelCapabilities("llama3.2-vision:11b");
assert.deepEqual(caps.input, ["text", "image"]);
});
it("returns correct context for llama3.1", () => {
const caps = getModelCapabilities("llama3.1:8b");
assert.equal(caps.contextWindow, 131072);
});
it("returns correct context for llama3 (no .1)", () => {
const caps = getModelCapabilities("llama3:8b");
assert.equal(caps.contextWindow, 8192);
});
it("returns correct context for llama2", () => {
const caps = getModelCapabilities("llama2:7b");
assert.equal(caps.contextWindow, 4096);
});
it("returns correct context for qwen2.5-coder", () => {
const caps = getModelCapabilities("qwen2.5-coder:7b");
assert.equal(caps.contextWindow, 131072);
assert.equal(caps.maxTokens, 32768);
});
it("returns correct context for codestral", () => {
const caps = getModelCapabilities("codestral:22b");
assert.equal(caps.contextWindow, 262144);
});
it("returns correct context for mistral-nemo", () => {
const caps = getModelCapabilities("mistral-nemo:12b");
assert.equal(caps.contextWindow, 131072);
});
it("returns correct context for gemma3", () => {
const caps = getModelCapabilities("gemma3:9b");
assert.equal(caps.contextWindow, 131072);
});
it("returns empty object for unknown models", () => {
const caps = getModelCapabilities("totally-unknown-model:3b");
assert.deepEqual(caps, {});
});
it("strips tag before matching", () => {
const caps = getModelCapabilities("llama3.1:70b-instruct-q4_0");
assert.equal(caps.contextWindow, 131072);
});
it("matches case-insensitively", () => {
const caps = getModelCapabilities("Llama3.1:8B");
assert.equal(caps.contextWindow, 131072);
});
});
// ─── estimateContextFromParams ───────────────────────────────────────────────
describe("estimateContextFromParams", () => {
it("estimates 8192 for small models", () => {
assert.equal(estimateContextFromParams("1.5B"), 8192);
});
it("estimates 16384 for 7B models", () => {
assert.equal(estimateContextFromParams("7B"), 16384);
});
it("estimates 32768 for 13B models", () => {
assert.equal(estimateContextFromParams("13B"), 32768);
});
it("estimates 65536 for 34B models", () => {
assert.equal(estimateContextFromParams("34B"), 65536);
});
it("estimates 131072 for 70B+ models", () => {
assert.equal(estimateContextFromParams("70B"), 131072);
});
it("handles decimal sizes", () => {
assert.equal(estimateContextFromParams("7.5B"), 16384);
});
it("handles M (millions)", () => {
assert.equal(estimateContextFromParams("500M"), 8192);
});
it("returns 8192 for unparseable input", () => {
assert.equal(estimateContextFromParams("unknown"), 8192);
});
it("returns 8192 for empty string", () => {
assert.equal(estimateContextFromParams(""), 8192);
});
});
// ─── humanizeModelName ───────────────────────────────────────────────────────
describe("humanizeModelName", () => {
it("capitalizes and adds tag", () => {
assert.equal(humanizeModelName("llama3.1:8b"), "Llama 3.1 8B");
});
it("handles latest tag", () => {
assert.equal(humanizeModelName("llama3.1:latest"), "Llama 3.1");
});
it("handles no tag", () => {
assert.equal(humanizeModelName("llama3.1"), "Llama 3.1");
});
it("handles hyphenated names", () => {
const result = humanizeModelName("deepseek-r1:8b");
assert.ok(result.includes("8B"));
});
});
// ─── formatModelSize ─────────────────────────────────────────────────────────
describe("formatModelSize", () => {
it("formats GB", () => {
assert.equal(formatModelSize(4_700_000_000), "4.7 GB");
});
it("formats MB", () => {
assert.equal(formatModelSize(500_000_000), "500.0 MB");
});
it("formats KB", () => {
assert.equal(formatModelSize(500_000), "500 KB");
});
});

View file

@ -0,0 +1,38 @@
// GSD2 — Tests for Ollama HTTP client
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { getOllamaHost } from "../ollama-client.js";
// ─── getOllamaHost ──────────────────────────────────────────────────────────
describe("getOllamaHost", () => {
const originalHost = process.env.OLLAMA_HOST;
afterEach(() => {
if (originalHost === undefined) {
delete process.env.OLLAMA_HOST;
} else {
process.env.OLLAMA_HOST = originalHost;
}
});
it("returns default when OLLAMA_HOST is not set", () => {
delete process.env.OLLAMA_HOST;
assert.equal(getOllamaHost(), "http://localhost:11434");
});
it("returns OLLAMA_HOST when set with scheme", () => {
process.env.OLLAMA_HOST = "http://myhost:12345";
assert.equal(getOllamaHost(), "http://myhost:12345");
});
it("adds http:// when OLLAMA_HOST has no scheme", () => {
process.env.OLLAMA_HOST = "myhost:12345";
assert.equal(getOllamaHost(), "http://myhost:12345");
});
it("preserves https:// scheme", () => {
process.env.OLLAMA_HOST = "https://secure-ollama.example.com";
assert.equal(getOllamaHost(), "https://secure-ollama.example.com");
});
});

View file

@ -0,0 +1,28 @@
// GSD2 — Tests for Ollama model discovery and enrichment
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import { getOllamaOpenAIBaseUrl } from "../ollama-discovery.js";
// ─── getOllamaOpenAIBaseUrl ─────────────────────────────────────────────────
describe("getOllamaOpenAIBaseUrl", () => {
const originalHost = process.env.OLLAMA_HOST;
afterEach(() => {
if (originalHost === undefined) {
delete process.env.OLLAMA_HOST;
} else {
process.env.OLLAMA_HOST = originalHost;
}
});
it("returns default OpenAI-compat URL", () => {
delete process.env.OLLAMA_HOST;
assert.equal(getOllamaOpenAIBaseUrl(), "http://localhost:11434/v1");
});
it("appends /v1 to custom OLLAMA_HOST", () => {
process.env.OLLAMA_HOST = "http://remote:9999";
assert.equal(getOllamaOpenAIBaseUrl(), "http://remote:9999/v1");
});
});

View file

@ -0,0 +1,130 @@
// GSD2 — Ollama API response types
/**
* Type definitions for the Ollama REST API.
* Reference: https://github.com/ollama/ollama/blob/main/docs/api.md
*/
// ─── /api/tags ──────────────────────────────────────────────────────────────
export interface OllamaModelDetails {
parent_model: string;
format: string;
family: string;
families: string[] | null;
parameter_size: string;
quantization_level: string;
}
export interface OllamaModelInfo {
name: string;
model: string;
modified_at: string;
size: number;
digest: string;
details: OllamaModelDetails;
}
export interface OllamaTagsResponse {
models: OllamaModelInfo[];
}
// ─── /api/show ──────────────────────────────────────────────────────────────
export interface OllamaShowResponse {
modelfile: string;
parameters: string;
template: string;
details: OllamaModelDetails;
model_info: Record<string, unknown>;
}
// ─── /api/ps ────────────────────────────────────────────────────────────────
export interface OllamaRunningModel {
name: string;
model: string;
size: number;
digest: string;
details: OllamaModelDetails;
expires_at: string;
size_vram: number;
}
export interface OllamaPsResponse {
models: OllamaRunningModel[];
}
// ─── /api/pull ──────────────────────────────────────────────────────────────
export interface OllamaPullProgress {
status: string;
digest?: string;
total?: number;
completed?: number;
}
// ─── /api/version ───────────────────────────────────────────────────────────
export interface OllamaVersionResponse {
version: string;
}
// ─── /api/chat ──────────────────────────────────────────────────────────────
export interface OllamaChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
images?: string[];
tool_calls?: OllamaToolCall[];
}
export interface OllamaToolCall {
function: {
name: string;
arguments: Record<string, unknown>;
};
}
export interface OllamaTool {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
required?: string[];
properties: Record<string, unknown>;
};
};
}
export interface OllamaChatRequest {
model: string;
messages: OllamaChatMessage[];
stream?: boolean;
tools?: OllamaTool[];
options?: {
num_ctx?: number;
num_predict?: number;
temperature?: number;
top_p?: number;
top_k?: number;
stop?: string[];
};
keep_alive?: string;
}
export interface OllamaChatResponse {
model: string;
created_at: string;
message: OllamaChatMessage;
done: boolean;
done_reason?: string;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
eval_count?: number;
eval_duration?: number;
}

View file

@ -1,24 +1,45 @@
# Changelog
## [0.3.0]
### Added
- **SCM provider** — "GSD Agent" appears in Source Control panel with accept/discard per-file diffs
- **Change tracker** — captures original file content before agent modifications for diff and rollback
- **Checkpoints** — automatic snapshots on each agent turn with restore capability
- **Diagnostic bridge** — "Fix Problems in File" and "Fix All Problems" commands read VS Code diagnostics and send to agent
- **Line-level decorations** — green/yellow highlights on agent-modified lines with gutter indicators
- **Chat context injection** — auto-includes editor selection and file diagnostics when relevant
- **Git integration** — commit agent changes, create branches, show diffs
- **Approval modes** — auto-approve, ask (prompts before writes), plan-only (read-only)
- **UI request handling** — agent questions, confirmations, and selections now show as VS Code dialogs instead of hanging
- **Fix Errors button** — quick access to diagnostic fixing in sidebar Actions
- **5 new settings**`showProgressNotifications`, `activityFeedMaxItems`, `showContextWarning`, `contextWarningThreshold`, `approvalMode`
### Changed
- **Sidebar redesign** — compact card-based layout with collapsible sections, pill toggles, hidden empty data
- **Workflow buttons** now route through Chat panel so responses are visible
- **Slash completion** filtered to `/gsd` commands only
- **Checkpoint labels** show timestamp + first action (e.g., "10:32 — Edit sidebar.ts")
- **Session tree** supports ISO timestamp filenames (GSD's actual format)
- **Session persistence** enabled (removed `--no-session` flag)
- **Progress notifications** disabled by default (Chat panel provides inline progress)
- **Sidebar reduced** from 6 panels to 3 (GSD Agent, Sessions, Activity)
- **Settings section** starts collapsed by default
## [0.2.0]
### Added
- **Activity feed** — real-time TreeView showing tool executions (Read, Write, Edit, Bash, Grep, Glob) with status icons, duration, and click-to-open
- **Workflow controls** — sidebar buttons for Auto, Next, Quick Task, Capture, Status, and Fork that send `/gsd` slash commands
- **Progress notifications** — VS Code notification with cancel button while the agent is working
- **Context window indicator** — color-coded usage bar (green/yellow/red) in sidebar with configurable threshold warnings
- **Session forking** — fork from any message via QuickPick using `get_fork_messages` and `fork` RPC commands
- **Queue mode controls** — toggle steering and follow-up modes (all vs one-at-a-time) from the sidebar
- **Enhanced conversation history** — tool call rendering, collapsible thinking blocks, search/filter, fork-from-here buttons
- **Enhanced code lens** — Refactor, Find Bugs, and Generate Tests actions alongside Ask GSD
- **4 new settings**`showProgressNotifications`, `activityFeedMaxItems`, `showContextWarning`, `contextWarningThreshold`
- **8 new commands** (33 total) — `clearActivity`, `forkSession`, `toggleSteeringMode`, `toggleFollowUpMode`, `refactorSymbol`, `findBugsSymbol`, `generateTestsSymbol`
### Changed
- Sidebar session table now shows steering and follow-up queue mode with clickable toggle badges
- Token usage section includes context window usage bar when model context window is known
- **Activity feed** — real-time TreeView showing tool executions with status icons, duration, and click-to-open
- **Workflow controls** — sidebar buttons for Auto, Next, Quick Task, Capture
- **Context window indicator** — color-coded usage bar in sidebar with threshold warnings
- **Session forking** — fork from any message via QuickPick
- **Queue mode controls** — toggle steering and follow-up modes from the sidebar
- **Enhanced conversation history** — tool call rendering, collapsible thinking blocks, search/filter, fork-from-here
- **Enhanced code lens** — Refactor, Find Bugs, and Generate Tests alongside Ask GSD
- **8 new commands** (33 total)
## [0.1.0]
@ -31,7 +52,7 @@ Initial release.
- Bash terminal — pseudoterminal routing agent Bash tool output
- Session tree — browse and switch between session files
- Conversation history — webview panel with full chat log
- Slash command completion — auto-complete for `/gsd` commands in editors
- Slash command completion — auto-complete for `/gsd` commands
- Code lens — "Ask GSD" above functions and classes in TS/JS/Python/Go/Rust
- 25 commands with 6 keyboard shortcuts
- Auto-start, auto-compaction, and code lens configuration

View file

@ -1,88 +1,193 @@
# GSD-2 — VS Code Extension
Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd` in VS Code Chat, and monitor your agent from a sidebar dashboard — all without leaving the editor.
Control the [GSD-2 coding agent](https://github.com/gsd-build/gsd-2) directly from VS Code. Run autonomous coding sessions, chat with `@gsd`, monitor agent activity in real-time, review and accept/reject changes, and manage your workflow — all without leaving the editor.
![GSD Extension Overview](docs/images/overview.png)
## Requirements
GSD must be installed before activating this extension:
```bash
npm install -g gsd-pi
```
Node.js ≥ 22.0.0 and Git are required.
## Features
### Sidebar Dashboard
Click the GSD icon in the Activity Bar to open the agent dashboard. It shows:
- Connection status (connected / disconnected)
- Active model and provider
- Thinking level
- Token usage and session cost
- Quick action buttons: Start, Stop, New Session, Compact, Abort
### Chat Integration (`@gsd`)
Use `@gsd` in VS Code Chat (`Ctrl+Shift+I`) to send messages to the agent:
```
@gsd refactor the auth module to use JWT
@gsd /gsd auto
@gsd what's the current milestone status?
```
### Commands
All commands are accessible via `Ctrl+Shift+P`:
| Command | Description |
|---------|-------------|
| **GSD: Start Agent** | Connect to the GSD agent |
| **GSD: Stop Agent** | Disconnect the agent |
| **GSD: New Session** | Start a fresh conversation |
| **GSD: Send Message** | Send a message to the agent |
| **GSD: Abort Current Operation** | Interrupt the current operation |
| **GSD: Steer Agent** | Send a steering message mid-operation |
| **GSD: Switch Model** | Pick a model from QuickPick |
| **GSD: Cycle Model** | Rotate to the next configured model |
| **GSD: Set Thinking Level** | Choose off / low / medium / high |
| **GSD: Cycle Thinking Level** | Rotate through thinking levels |
| **GSD: Compact Context** | Manually trigger context compaction |
| **GSD: Export Conversation as HTML** | Save the session as HTML |
| **GSD: Show Session Stats** | Display token usage and cost |
| **GSD: Run Bash Command** | Execute a shell command via the agent |
| **GSD: List Available Commands** | Browse and run GSD slash commands |
### Keyboard Shortcuts
| Shortcut | Command |
|----------|---------|
| `Ctrl+Shift+G Ctrl+Shift+N` | New Session |
| `Ctrl+Shift+G Ctrl+Shift+M` | Cycle Model |
| `Ctrl+Shift+G Ctrl+Shift+T` | Cycle Thinking Level |
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `gsd.binaryPath` | `"gsd"` | Path to the GSD binary if not on PATH |
| `gsd.autoStart` | `false` | Start the agent automatically when the extension activates |
| `gsd.autoCompaction` | `true` | Enable automatic context compaction |
- **GSD-2** installed globally: `npm install -g gsd-pi`
- **Node.js** >= 22.0.0
- **Git** installed and on PATH
- **VS Code** >= 1.95.0
## Quick Start
1. Install GSD: `npm install -g gsd-pi`
2. Install this extension
3. Open a project folder in VS Code
4. `Ctrl+Shift+P` → **GSD: Start Agent**
5. Use `@gsd` in Chat or the sidebar to interact with the agent
4. Click the **GSD icon** in the Activity Bar (left sidebar)
5. Click **Start Agent** or run `Ctrl+Shift+P` > **GSD: Start Agent**
6. Start chatting with `@gsd` in Chat or click **Auto** in the sidebar
---
## Features
### Sidebar Dashboard
Click the **GSD icon** in the Activity Bar. The compact header shows connection status, model, session, message count, thinking level, context usage bar, and cost — all in two lines. Sections (Workflow, Stats, Actions, Settings) are collapsible and remember their state.
### Workflow Controls
One-click buttons for GSD's core commands. All route through the Chat panel so you see the full response:
| Button | What it does |
|--------|-------------|
| **Auto** | Start autonomous mode — research, plan, execute |
| **Next** | Execute one unit of work, then pause |
| **Quick** | Quick task without planning (opens input) |
| **Capture** | Capture a thought for later triage |
### Chat Integration (`@gsd`)
Use `@gsd` in VS Code Chat (`Cmd+Shift+I`) to talk to the agent:
```
@gsd refactor the auth module to use JWT
@gsd /gsd auto
@gsd fix the errors in this file
```
- **Auto-starts** the agent if not running
- **File context** via `#file` references
- **Selection context** — automatically includes selected code
- **Diagnostic context** — auto-includes errors/warnings when you mention "fix" or "error"
- **Streaming** progress, file anchors, token usage footer
### Source Control Integration
Agent-modified files appear in a dedicated **"GSD Agent"** section of the Source Control panel:
- **Click any file** to see a before/after diff in VS Code's native diff editor
- **Accept** or **Discard** changes per-file via inline buttons
- **Accept All** / **Discard All** via the SCM title bar
- Gutter diff indicators (green/red bars) show exactly what changed
### Line-Level Decorations
When the agent modifies a file, you'll see:
- **Green background** on newly added lines
- **Yellow background** on modified lines
- **Left border gutter indicator** on all agent-touched lines
- **Hover** any decorated line to see "Modified by GSD Agent"
### Checkpoints & Rollback
Automatic checkpoints are created at the start of each agent turn. Use **Discard All** in the SCM panel to revert all agent changes to their original state, or discard individual files.
### Activity Feed
The **Activity** panel shows a real-time log of every tool the agent executes — Read, Write, Edit, Bash, Grep, Glob — with status icons (running/success/error), duration, and click-to-open for file operations.
### Sessions
The **Sessions** panel lists all past sessions for the current workspace. Click any session to switch to it. The current session is highlighted green. Sessions persist to disk automatically.
### Diagnostic Integration
- **Fix Errors** button in the sidebar reads the active file's diagnostics from the Problems panel and sends them to the agent
- **Fix All Problems** (`Cmd+Shift+P` > GSD: Fix All Problems) collects errors/warnings across the workspace
- Works automatically in chat — mention "fix" or "error" and diagnostics are included
### Code Lens
Four inline actions above every function and class (TS/JS/Python/Go/Rust):
| Action | What it does |
|--------|-------------|
| **Ask GSD** | Explain the function/class |
| **Refactor** | Improve clarity, performance, or structure |
| **Find Bugs** | Review for bugs and edge cases |
| **Tests** | Generate test coverage |
### Git Integration
- **Commit Agent Changes** — stages and commits modified files with your message
- **Create Branch** — create a new branch for agent work
- **Show Diff** — view git diff of agent changes
### Approval Modes
Control how much autonomy the agent has:
| Mode | Behavior |
|------|----------|
| **Auto-approve** | Agent runs freely (default) |
| **Ask** | Prompts before file writes and commands |
| **Plan-only** | Read-only — agent can analyze but not modify |
Change via Settings section or `Cmd+Shift+P` > **GSD: Select Approval Mode**.
### Agent UI Requests
When the agent needs input (questions, confirmations, selections), VS Code dialogs appear automatically — no more hanging on `ask_user_questions`.
### Additional Features
- **Conversation History** — full message viewer with tool calls, thinking blocks, search, and fork-from-here
- **Slash Command Completion** — type `/` for auto-complete of `/gsd` commands
- **File Decorations** — "G" badge on agent-modified files in the Explorer
- **Bash Terminal** — dedicated terminal for agent shell output
- **Context Window Warning** — notification when context exceeds threshold
- **Progress Notifications** — optional notification with cancel button (off by default)
---
## All Commands
| Command | Shortcut | Description |
|---------|----------|-------------|
| **GSD: Start Agent** | | Connect to the GSD agent |
| **GSD: Stop Agent** | | Disconnect the agent |
| **GSD: New Session** | `Cmd+Shift+G` `Cmd+Shift+N` | Start a fresh conversation |
| **GSD: Send Message** | `Cmd+Shift+G` `Cmd+Shift+P` | Send a message to the agent |
| **GSD: Abort** | `Cmd+Shift+G` `Cmd+Shift+A` | Interrupt the current operation |
| **GSD: Steer Agent** | `Cmd+Shift+G` `Cmd+Shift+I` | Steering message mid-operation |
| **GSD: Switch Model** | | Pick a model from QuickPick |
| **GSD: Cycle Model** | `Cmd+Shift+G` `Cmd+Shift+M` | Rotate to the next model |
| **GSD: Set Thinking Level** | | Choose off / low / medium / high |
| **GSD: Cycle Thinking** | `Cmd+Shift+G` `Cmd+Shift+T` | Rotate through thinking levels |
| **GSD: Compact Context** | | Trigger context compaction |
| **GSD: Export HTML** | | Save session as HTML |
| **GSD: Session Stats** | | Display token usage and cost |
| **GSD: Run Bash** | | Execute a shell command |
| **GSD: List Commands** | | Browse slash commands |
| **GSD: Set Session Name** | | Rename current session |
| **GSD: Copy Last Response** | | Copy to clipboard |
| **GSD: Switch Session** | | Load a different session |
| **GSD: Show History** | | Open conversation viewer |
| **GSD: Fork Session** | | Fork from a previous message |
| **GSD: Fix Problems in File** | | Send file diagnostics to agent |
| **GSD: Fix All Problems** | | Send workspace errors to agent |
| **GSD: Commit Agent Changes** | | Git commit modified files |
| **GSD: Create Branch** | | Create branch for agent work |
| **GSD: Show Agent Diff** | | View git diff |
| **GSD: Accept All Changes** | | Accept all SCM changes |
| **GSD: Discard All Changes** | | Revert all agent modifications |
| **GSD: Select Approval Mode** | | Choose auto-approve/ask/plan-only |
| **GSD: Cycle Approval Mode** | | Rotate through approval modes |
| **GSD: Code Lens** actions | | Ask, Refactor, Find Bugs, Tests |
> On Windows/Linux, replace `Cmd` with `Ctrl`.
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `gsd.binaryPath` | `"gsd"` | Path to the GSD binary |
| `gsd.autoStart` | `false` | Start agent on extension activation |
| `gsd.autoCompaction` | `true` | Automatic context compaction |
| `gsd.codeLens` | `true` | Code lens above functions/classes |
| `gsd.showProgressNotifications` | `false` | Progress notification (off — Chat shows progress) |
| `gsd.activityFeedMaxItems` | `100` | Max items in Activity feed |
| `gsd.showContextWarning` | `true` | Warn when context exceeds threshold |
| `gsd.contextWarningThreshold` | `80` | Context % that triggers warning |
| `gsd.approvalMode` | `"auto-approve"` | Agent permission mode |
## How It Works
The extension spawns `gsd --mode rpc` in the background and communicates over JSON-RPC via stdin/stdout. All RPC commands are supported, including streaming events for real-time sidebar updates.
The extension spawns `gsd --mode rpc` and communicates over JSON-RPC via stdin/stdout. Agent events stream in real-time. The change tracker captures file state before modifications for SCM diffs and rollback. UI requests from the agent (questions, confirmations) are handled via VS Code dialogs.
## Links

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

View file

@ -3,7 +3,7 @@
"displayName": "GSD-2",
"description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, activity feed, conversation history, code lens, session forking, slash command completion, workflow controls, and 33 commands",
"publisher": "FluxLabs",
"version": "0.2.0",
"version": "0.3.0",
"icon": "logo.jpg",
"license": "MIT",
"repository": {
@ -168,6 +168,67 @@
{
"command": "gsd.generateTestsSymbol",
"title": "GSD: Generate Tests for Symbol"
},
{
"command": "gsd.acceptAllChanges",
"title": "GSD: Accept All Agent Changes",
"icon": "$(check-all)"
},
{
"command": "gsd.discardAllChanges",
"title": "GSD: Discard All Agent Changes",
"icon": "$(discard)"
},
{
"command": "gsd.acceptFileChanges",
"title": "Accept Changes",
"icon": "$(check)"
},
{
"command": "gsd.discardFileChanges",
"title": "Discard Changes",
"icon": "$(discard)"
},
{
"command": "gsd.restoreCheckpoint",
"title": "GSD: Restore Checkpoint"
},
{
"command": "gsd.fixProblemsInFile",
"title": "GSD: Fix Problems in File"
},
{
"command": "gsd.fixAllProblems",
"title": "GSD: Fix All Problems"
},
{
"command": "gsd.clearDiagnostics",
"title": "GSD: Clear Agent Diagnostics"
},
{
"command": "gsd.commitAgentChanges",
"title": "GSD: Commit Agent Changes"
},
{
"command": "gsd.createAgentBranch",
"title": "GSD: Create Branch for Agent Work"
},
{
"command": "gsd.showAgentDiff",
"title": "GSD: Show Agent Diff"
},
{
"command": "gsd.clearPlan",
"title": "GSD: Clear Plan View",
"icon": "$(clear-all)"
},
{
"command": "gsd.cycleApprovalMode",
"title": "GSD: Cycle Approval Mode"
},
{
"command": "gsd.selectApprovalMode",
"title": "GSD: Select Approval Mode"
}
],
"keybindings": [
@ -240,6 +301,30 @@
"when": "view == gsd-activity",
"group": "navigation"
}
],
"scm/title": [
{
"command": "gsd.acceptAllChanges",
"group": "navigation",
"when": "scmProvider == gsd"
},
{
"command": "gsd.discardAllChanges",
"group": "navigation",
"when": "scmProvider == gsd"
}
],
"scm/resourceState/context": [
{
"command": "gsd.acceptFileChanges",
"group": "inline",
"when": "scmProvider == gsd"
},
{
"command": "gsd.discardFileChanges",
"group": "inline",
"when": "scmProvider == gsd"
}
]
},
"chatParticipants": [
@ -276,7 +361,7 @@
},
"gsd.showProgressNotifications": {
"type": "boolean",
"default": true,
"default": false,
"description": "Show progress notification while the agent is working"
},
"gsd.activityFeedMaxItems": {
@ -297,6 +382,17 @@
"minimum": 50,
"maximum": 95,
"description": "Context window usage percentage that triggers a warning"
},
"gsd.approvalMode": {
"type": "string",
"default": "auto-approve",
"enum": ["auto-approve", "ask", "plan-only"],
"enumDescriptions": [
"Agent runs freely without prompts",
"Prompt before file changes and commands",
"Read-only mode — agent can analyze but not modify"
],
"description": "Approval mode for agent actions"
}
}
}

View file

@ -0,0 +1,295 @@
import * as vscode from "vscode";
import * as fs from "node:fs";
import type { GsdClient, AgentEvent } from "./gsd-client.js";
export interface FileSnapshot {
uri: vscode.Uri;
originalContent: string;
timestamp: number;
}
export interface Checkpoint {
id: number;
label: string;
timestamp: number;
/** Map of file path → original content at checkpoint creation time */
snapshots: Map<string, string>;
}
/**
* Tracks file changes made by the GSD agent. Stores original file content
* before the agent modifies it, enabling diff views, SCM integration,
* and checkpoint/rollback functionality.
*/
export class GsdChangeTracker implements vscode.Disposable {
/** file path → original content (before first agent modification this session) */
private originals = new Map<string, string>();
/** Set of file paths modified in the current agent turn */
private currentTurnFiles = new Set<string>();
/** Ordered list of checkpoints */
private _checkpoints: Checkpoint[] = [];
private nextCheckpointId = 1;
/** toolUseId → file path for in-flight tool executions */
private pendingTools = new Map<string, string>();
/** Whether the current turn has been described in the checkpoint label */
private turnDescribed = false;
private readonly _onDidChange = new vscode.EventEmitter<string[]>();
/** Fires when the set of tracked files changes. Payload is array of changed file paths. */
readonly onDidChange = this._onDidChange.event;
private readonly _onCheckpointChange = new vscode.EventEmitter<void>();
readonly onCheckpointChange = this._onCheckpointChange.event;
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: GsdClient) {
this.disposables.push(this._onDidChange, this._onCheckpointChange);
this.disposables.push(
client.onEvent((evt) => this.handleEvent(evt)),
client.onConnectionChange((connected) => {
if (!connected) {
this.reset();
}
}),
);
}
/** All file paths that have been modified by the agent */
get modifiedFiles(): string[] {
return [...this.originals.keys()];
}
/** Get the original content of a file (before agent first modified it) */
getOriginal(filePath: string): string | undefined {
return this.originals.get(filePath);
}
/** Whether the tracker has any modifications */
get hasChanges(): boolean {
return this.originals.size > 0;
}
/** Current checkpoints (newest first) */
get checkpoints(): readonly Checkpoint[] {
return this._checkpoints;
}
/**
* Discard agent changes to a single file restore original content.
* Returns true if the file was restored.
*/
async discardFile(filePath: string): Promise<boolean> {
const original = this.originals.get(filePath);
if (original === undefined) return false;
try {
await fs.promises.writeFile(filePath, original, "utf8");
this.originals.delete(filePath);
this._onDidChange.fire([filePath]);
return true;
} catch {
return false;
}
}
/**
* Discard all agent changes restore all files to their original state.
*/
async discardAll(): Promise<number> {
let count = 0;
const paths = [...this.originals.keys()];
for (const filePath of paths) {
if (await this.discardFile(filePath)) {
count++;
}
}
return count;
}
/**
* Accept changes to a file remove from tracking (keep the current content).
*/
acceptFile(filePath: string): void {
if (this.originals.delete(filePath)) {
this._onDidChange.fire([filePath]);
}
}
/**
* Accept all changes clear all tracking.
*/
acceptAll(): void {
const paths = [...this.originals.keys()];
this.originals.clear();
if (paths.length > 0) {
this._onDidChange.fire(paths);
}
}
/**
* Restore all files to a checkpoint state.
*/
async restoreCheckpoint(checkpointId: number): Promise<number> {
const idx = this._checkpoints.findIndex((c) => c.id === checkpointId);
if (idx === -1) return 0;
const checkpoint = this._checkpoints[idx];
let count = 0;
for (const [filePath, content] of checkpoint.snapshots) {
try {
await fs.promises.writeFile(filePath, content, "utf8");
count++;
} catch {
// skip files that can't be restored
}
}
// Reset originals to the checkpoint state
this.originals = new Map(checkpoint.snapshots);
// Remove all checkpoints after this one
this._checkpoints = this._checkpoints.slice(0, idx);
this._onDidChange.fire([...checkpoint.snapshots.keys()]);
this._onCheckpointChange.fire();
return count;
}
/** Clear all tracking state */
reset(): void {
const paths = [...this.originals.keys()];
this.originals.clear();
this.currentTurnFiles.clear();
this.pendingTools.clear();
this._checkpoints = [];
this.nextCheckpointId = 1;
if (paths.length > 0) {
this._onDidChange.fire(paths);
}
this._onCheckpointChange.fire();
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private handleEvent(evt: AgentEvent): void {
switch (evt.type) {
case "agent_start":
this.createCheckpoint();
this.currentTurnFiles.clear();
this.turnDescribed = false;
break;
case "tool_execution_start": {
const toolName = String(evt.toolName ?? "");
const toolInput = (evt.toolInput ?? {}) as Record<string, unknown>;
const toolUseId = String(evt.toolUseId ?? "");
// Update checkpoint label with first action description
if (!this.turnDescribed) {
this.turnDescribed = true;
this.updateLatestCheckpointLabel(describeAction(toolName, toolInput));
}
if (toolName !== "Write" && toolName !== "Edit") break;
const filePath = String(toolInput.file_path ?? toolInput.path ?? "");
if (!filePath) break;
// Store the original content before the agent modifies it
// Only capture on FIRST modification (don't overwrite)
if (!this.originals.has(filePath)) {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, "utf8");
this.originals.set(filePath, content);
} else {
// File doesn't exist yet — original is "empty" (new file)
this.originals.set(filePath, "");
}
} catch {
// Can't read file, skip tracking
}
}
if (toolUseId) {
this.pendingTools.set(toolUseId, filePath);
}
break;
}
case "tool_execution_end": {
const toolUseId = String(evt.toolUseId ?? "");
const filePath = this.pendingTools.get(toolUseId);
if (filePath) {
this.pendingTools.delete(toolUseId);
this.currentTurnFiles.add(filePath);
this._onDidChange.fire([filePath]);
}
break;
}
}
}
private createCheckpoint(): void {
const now = Date.now();
const time = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const fileCount = this.originals.size;
const label = fileCount > 0
? `${time} (${fileCount} file${fileCount !== 1 ? "s" : ""} tracked)`
: `${time} (start)`;
const checkpoint: Checkpoint = {
id: this.nextCheckpointId++,
label,
timestamp: now,
snapshots: new Map(this.originals),
};
this._checkpoints.push(checkpoint);
this._onCheckpointChange.fire();
}
/**
* Update the label of the latest checkpoint with a description
* of the first action taken (called after first tool execution in a turn).
*/
private updateLatestCheckpointLabel(description: string): void {
if (this._checkpoints.length === 0) return;
const latest = this._checkpoints[this._checkpoints.length - 1];
const time = new Date(latest.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
latest.label = `${time}${description}`;
this._onCheckpointChange.fire();
}
}
function describeAction(toolName: string, input: Record<string, unknown>): string {
switch (toolName) {
case "Read": {
const p = String(input.file_path ?? input.path ?? "");
return `Read ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Write": {
const p = String(input.file_path ?? "");
return `Write ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Edit": {
const p = String(input.file_path ?? "");
return `Edit ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Bash":
return `$ ${String(input.command ?? "").slice(0, 40)}`;
case "Grep":
return `Grep: ${String(input.pattern ?? "").slice(0, 30)}`;
case "Glob":
return `Glob: ${String(input.pattern ?? "").slice(0, 30)}`;
default:
return toolName;
}
}

View file

@ -39,6 +39,21 @@ export function registerChatParticipant(
message = `${fileContext}\n\n${message}`;
}
// Auto-include editor selection if present and not already referenced
const selectionContext = getSelectionContext();
if (selectionContext) {
message = `${selectionContext}\n\n${message}`;
}
// Auto-include diagnostics for the active file if the prompt mentions "fix", "error", "problem", "warning"
const fixKeywords = /\b(fix|error|problem|warning|issue|bug|lint|diagnos)/i;
if (fixKeywords.test(message)) {
const diagContext = getActiveDiagnosticsContext();
if (diagContext) {
message = `${message}\n\n${diagContext}`;
}
}
// Track streaming state
let agentDone = false;
let totalInputTokens = 0;
@ -281,3 +296,42 @@ function resolveFileUri(fp: string): vscode.Uri | null {
return null;
}
}
/**
* Get the current editor selection as context, if any text is selected.
*/
function getSelectionContext(): string | null {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty) return null;
const selection = editor.document.getText(editor.selection);
if (!selection.trim()) return null;
const relativePath = vscode.workspace.asRelativePath(editor.document.uri);
const { start, end } = editor.selection;
return `Selected code in \`${relativePath}\` (lines ${start.line + 1}-${end.line + 1}):\n\`\`\`\n${selection}\n\`\`\``;
}
/**
* Get diagnostics (errors/warnings) for the active editor file.
*/
function getActiveDiagnosticsContext(): string | null {
const editor = vscode.window.activeTextEditor;
if (!editor) return null;
const diagnostics = vscode.languages.getDiagnostics(editor.document.uri);
const significant = diagnostics.filter(
(d) => d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning,
);
if (significant.length === 0) return null;
const relativePath = vscode.workspace.asRelativePath(editor.document.uri);
const lines = [`Current diagnostics in \`${relativePath}\`:`];
for (const d of significant) {
const sev = d.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning";
const line = d.range.start.line + 1;
const source = d.source ? ` [${d.source}]` : "";
lines.push(`- ${sev} (line ${line}): ${d.message}${source}`);
}
return lines.join("\n");
}

View file

@ -0,0 +1,55 @@
import * as vscode from "vscode";
import type { GsdChangeTracker, Checkpoint } from "./change-tracker.js";
/**
* TreeDataProvider that shows agent checkpoints (one per agent turn).
* Each checkpoint can be restored to revert all file changes since that point.
*/
export class GsdCheckpointProvider implements vscode.TreeDataProvider<Checkpoint>, vscode.Disposable {
public static readonly viewId = "gsd-checkpoints";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private disposables: vscode.Disposable[] = [];
constructor(private readonly tracker: GsdChangeTracker) {
this.disposables.push(
this._onDidChangeTreeData,
tracker.onCheckpointChange(() => this._onDidChangeTreeData.fire()),
);
}
getTreeItem(checkpoint: Checkpoint): vscode.TreeItem {
const fileCount = checkpoint.snapshots.size;
const time = new Date(checkpoint.timestamp);
const timeStr = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const item = new vscode.TreeItem(
checkpoint.label,
vscode.TreeItemCollapsibleState.None,
);
item.description = `${timeStr} (${fileCount} file${fileCount !== 1 ? "s" : ""})`;
item.iconPath = new vscode.ThemeIcon("history");
item.tooltip = `Checkpoint: ${checkpoint.label}\nTime: ${time.toLocaleString()}\nFiles tracked: ${fileCount}\n\nClick to restore to this point`;
item.contextValue = "checkpoint";
item.command = {
command: "gsd.restoreCheckpoint",
title: "Restore Checkpoint",
arguments: [checkpoint.id],
};
return item;
}
getChildren(): Checkpoint[] {
// Show newest first
return [...this.tracker.checkpoints].reverse();
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}

View file

@ -0,0 +1,142 @@
import * as vscode from "vscode";
import type { GsdClient } from "./gsd-client.js";
/**
* Integrates with VS Code's diagnostic system:
* - Reads diagnostics (errors/warnings) from the Problems panel and sends them to the agent
* - Provides a DiagnosticCollection for the agent to surface its own findings
*/
export class GsdDiagnosticBridge implements vscode.Disposable {
private readonly collection: vscode.DiagnosticCollection;
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: GsdClient) {
this.collection = vscode.languages.createDiagnosticCollection("gsd");
this.disposables.push(this.collection);
}
/**
* Read all diagnostics for the active file and send them to the agent
* as a "fix these problems" prompt.
*/
async fixProblemsInFile(): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("No active file to fix.");
return;
}
const uri = editor.document.uri;
const diagnostics = vscode.languages.getDiagnostics(uri);
if (diagnostics.length === 0) {
vscode.window.showInformationMessage("No problems found in this file.");
return;
}
const fileName = vscode.workspace.asRelativePath(uri);
const problemText = formatDiagnostics(fileName, diagnostics);
const prompt = [
`Fix the following problems in \`${fileName}\`:`,
"",
problemText,
"",
"Fix all of these issues. Show me the changes.",
].join("\n");
await this.client.sendPrompt(prompt);
}
/**
* Read all diagnostics across the workspace (errors only) and send
* them to the agent as a "fix all errors" prompt.
*/
async fixAllProblems(): Promise<void> {
const allDiagnostics = vscode.languages.getDiagnostics();
const errorFiles: { fileName: string; diagnostics: vscode.Diagnostic[] }[] = [];
for (const [uri, diagnostics] of allDiagnostics) {
// Only include errors and warnings, skip hints/info
const significant = diagnostics.filter(
(d) => d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning,
);
if (significant.length > 0) {
errorFiles.push({
fileName: vscode.workspace.asRelativePath(uri),
diagnostics: significant,
});
}
}
if (errorFiles.length === 0) {
vscode.window.showInformationMessage("No errors or warnings found in the workspace.");
return;
}
// Cap at 20 files to avoid overwhelming the agent
const capped = errorFiles.slice(0, 20);
const totalProblems = capped.reduce((sum, f) => sum + f.diagnostics.length, 0);
const sections = capped.map((f) => formatDiagnostics(f.fileName, f.diagnostics));
const prompt = [
`Fix the following ${totalProblems} problems across ${capped.length} file${capped.length > 1 ? "s" : ""}:`,
"",
...sections,
"",
"Fix all of these issues.",
].join("\n");
await this.client.sendPrompt(prompt);
}
/**
* Add a GSD diagnostic (agent finding) to a file.
* Can be used to surface agent review findings in the Problems panel.
*/
addFinding(
uri: vscode.Uri,
range: vscode.Range,
message: string,
severity: vscode.DiagnosticSeverity = vscode.DiagnosticSeverity.Warning,
): void {
const existing = this.collection.get(uri) ?? [];
const diagnostic = new vscode.Diagnostic(range, message, severity);
diagnostic.source = "GSD Agent";
this.collection.set(uri, [...existing, diagnostic]);
}
/** Clear all GSD diagnostics */
clearFindings(): void {
this.collection.clear();
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}
function formatDiagnostics(fileName: string, diagnostics: vscode.Diagnostic[]): string {
const lines = [`**${fileName}**`];
for (const d of diagnostics) {
const severity = severityLabel(d.severity);
const line = d.range.start.line + 1;
const col = d.range.start.character + 1;
const source = d.source ? ` [${d.source}]` : "";
lines.push(` - ${severity} (line ${line}:${col}): ${d.message}${source}`);
}
return lines.join("\n");
}
function severityLabel(severity: vscode.DiagnosticSeverity): string {
switch (severity) {
case vscode.DiagnosticSeverity.Error: return "Error";
case vscode.DiagnosticSeverity.Warning: return "Warning";
case vscode.DiagnosticSeverity.Information: return "Info";
case vscode.DiagnosticSeverity.Hint: return "Hint";
default: return "Unknown";
}
}

View file

@ -9,12 +9,24 @@ import { GsdConversationHistoryPanel } from "./conversation-history.js";
import { GsdSlashCompletionProvider } from "./slash-completion.js";
import { GsdCodeLensProvider } from "./code-lens.js";
import { GsdActivityFeedProvider } from "./activity-feed.js";
import { GsdChangeTracker } from "./change-tracker.js";
import { GsdScmProvider } from "./scm-provider.js";
import { GsdDiagnosticBridge } from "./diagnostics.js";
import { GsdLineDecorationManager } from "./line-decorations.js";
import { GsdGitIntegration } from "./git-integration.js";
import { GsdPermissionManager } from "./permissions.js";
let client: GsdClient | undefined;
let sidebarProvider: GsdSidebarProvider | undefined;
let fileDecorations: GsdFileDecorationProvider | undefined;
let sessionTreeProvider: GsdSessionTreeProvider | undefined;
let activityFeedProvider: GsdActivityFeedProvider | undefined;
let changeTracker: GsdChangeTracker | undefined;
let scmProvider: GsdScmProvider | undefined;
let diagnosticBridge: GsdDiagnosticBridge | undefined;
let lineDecorations: GsdLineDecorationManager | undefined;
let gitIntegration: GsdGitIntegration | undefined;
let permissionManager: GsdPermissionManager | undefined;
function requireConnected(): boolean {
if (!client?.isConnected) {
@ -128,6 +140,34 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.window.registerTreeDataProvider(GsdActivityFeedProvider.viewId, activityFeedProvider),
);
// -- Change tracker & SCM provider -------------------------------------
changeTracker = new GsdChangeTracker(client);
context.subscriptions.push(changeTracker);
scmProvider = new GsdScmProvider(changeTracker, cwd);
context.subscriptions.push(scmProvider);
// -- Diagnostics -------------------------------------------------------
diagnosticBridge = new GsdDiagnosticBridge(client);
context.subscriptions.push(diagnosticBridge);
// -- Line-level decorations --------------------------------------------
lineDecorations = new GsdLineDecorationManager(changeTracker!);
context.subscriptions.push(lineDecorations);
// -- Git integration ---------------------------------------------------
gitIntegration = new GsdGitIntegration(changeTracker!, cwd);
context.subscriptions.push(gitIntegration);
// -- Permissions -------------------------------------------------------
permissionManager = new GsdPermissionManager(client);
context.subscriptions.push(permissionManager);
// -- Progress notifications --------------------------------------------
let currentProgress: { resolve: () => void } | undefined;
@ -789,6 +829,135 @@ export function activate(context: vscode.ExtensionContext): void {
}),
);
// -- SCM commands -------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("gsd.acceptAllChanges", () => {
changeTracker?.acceptAll();
vscode.window.showInformationMessage("All agent changes accepted.");
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.discardAllChanges", async () => {
if (!changeTracker?.hasChanges) {
vscode.window.showInformationMessage("No agent changes to discard.");
return;
}
const confirm = await vscode.window.showWarningMessage(
`Discard all agent changes (${changeTracker.modifiedFiles.length} files)?`,
{ modal: true },
"Discard",
);
if (confirm === "Discard") {
const count = await changeTracker.discardAll();
vscode.window.showInformationMessage(`Reverted ${count} file${count !== 1 ? "s" : ""}.`);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.discardFileChanges", async (resourceState: vscode.SourceControlResourceState) => {
if (!changeTracker || !resourceState?.resourceUri) return;
const filePath = resourceState.resourceUri.fsPath;
const success = await changeTracker.discardFile(filePath);
if (success) {
vscode.window.showInformationMessage(`Reverted ${vscode.workspace.asRelativePath(filePath)}`);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.acceptFileChanges", (resourceState: vscode.SourceControlResourceState) => {
if (!changeTracker || !resourceState?.resourceUri) return;
changeTracker.acceptFile(resourceState.resourceUri.fsPath);
}),
);
// -- Checkpoint commands ------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("gsd.restoreCheckpoint", async (checkpointId: number) => {
if (!changeTracker) return;
const checkpoint = changeTracker.checkpoints.find((c) => c.id === checkpointId);
if (!checkpoint) return;
const confirm = await vscode.window.showWarningMessage(
`Restore to "${checkpoint.label}"? This will revert files to their state at ${new Date(checkpoint.timestamp).toLocaleTimeString()}.`,
{ modal: true },
"Restore",
);
if (confirm === "Restore") {
const count = await changeTracker.restoreCheckpoint(checkpointId);
vscode.window.showInformationMessage(`Restored ${count} file${count !== 1 ? "s" : ""} to checkpoint.`);
}
}),
);
// -- Diagnostic commands ------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("gsd.fixProblemsInFile", async () => {
if (!requireConnected()) return;
try {
await diagnosticBridge!.fixProblemsInFile();
} catch (err) {
handleError(err, "Failed to fix problems");
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.fixAllProblems", async () => {
if (!requireConnected()) return;
try {
await diagnosticBridge!.fixAllProblems();
} catch (err) {
handleError(err, "Failed to fix problems");
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.clearDiagnostics", () => {
diagnosticBridge?.clearFindings();
}),
);
// -- Permission commands ------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("gsd.cycleApprovalMode", () => {
permissionManager?.cycleMode();
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.selectApprovalMode", () => {
permissionManager?.selectMode();
}),
);
// -- Git commands -------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("gsd.commitAgentChanges", () => {
gitIntegration?.commitAgentChanges();
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.createAgentBranch", () => {
gitIntegration?.createAgentBranch();
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.showAgentDiff", () => {
gitIntegration?.showAgentDiff();
}),
);
// -- Auto-start ---------------------------------------------------------
if (config.get<boolean>("autoStart", false)) {
@ -802,9 +971,21 @@ export function deactivate(): void {
fileDecorations?.dispose();
sessionTreeProvider?.dispose();
activityFeedProvider?.dispose();
changeTracker?.dispose();
scmProvider?.dispose();
diagnosticBridge?.dispose();
lineDecorations?.dispose();
gitIntegration?.dispose();
permissionManager?.dispose();
client = undefined;
sidebarProvider = undefined;
fileDecorations = undefined;
sessionTreeProvider = undefined;
activityFeedProvider = undefined;
changeTracker = undefined;
scmProvider = undefined;
diagnosticBridge = undefined;
lineDecorations = undefined;
gitIntegration = undefined;
permissionManager = undefined;
}

View file

@ -0,0 +1,122 @@
import * as vscode from "vscode";
import { exec } from "node:child_process";
import type { GsdChangeTracker } from "./change-tracker.js";
/**
* Provides git integration for agent changes commit, branch, and diff.
*/
export class GsdGitIntegration implements vscode.Disposable {
private disposables: vscode.Disposable[] = [];
constructor(
private readonly tracker: GsdChangeTracker,
private readonly cwd: string,
) {}
/**
* Commit all files modified by the agent with a user-provided message.
*/
async commitAgentChanges(): Promise<void> {
const files = this.tracker.modifiedFiles;
if (files.length === 0) {
vscode.window.showInformationMessage("No agent changes to commit.");
return;
}
const defaultMsg = `feat: agent changes (${files.length} file${files.length !== 1 ? "s" : ""})`;
const message = await vscode.window.showInputBox({
prompt: "Commit message for agent changes",
value: defaultMsg,
placeHolder: "feat: describe the changes",
});
if (!message) return;
try {
// Stage the modified files
await this.git(`add ${files.map((f) => `"${f}"`).join(" ")}`);
// Commit
await this.git(`commit -m "${message.replace(/"/g, '\\"')}"`);
// Accept all changes (clear tracking since they're committed)
this.tracker.acceptAll();
vscode.window.showInformationMessage(`Committed ${files.length} file${files.length !== 1 ? "s" : ""}.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Git commit failed: ${msg}`);
}
}
/**
* Create a new branch for agent work and switch to it.
*/
async createAgentBranch(): Promise<void> {
const branchName = await vscode.window.showInputBox({
prompt: "Branch name for agent work",
placeHolder: "feat/agent-changes",
validateInput: (value) => {
if (!value.trim()) return "Branch name is required";
if (/\s/.test(value)) return "Branch name cannot contain spaces";
return null;
},
});
if (!branchName) return;
try {
await this.git(`checkout -b "${branchName}"`);
vscode.window.showInformationMessage(`Created and switched to branch: ${branchName}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Failed to create branch: ${msg}`);
}
}
/**
* Show a git diff of all agent-modified files.
*/
async showAgentDiff(): Promise<void> {
const files = this.tracker.modifiedFiles;
if (files.length === 0) {
vscode.window.showInformationMessage("No agent changes to diff.");
return;
}
try {
const diff = await this.git("diff");
if (!diff.trim()) {
// Files may be untracked — show status instead
const status = await this.git("status --short");
const channel = vscode.window.createOutputChannel("GSD Git Diff");
channel.appendLine("# Agent-modified files (unstaged):");
channel.appendLine(status);
channel.show();
} else {
const channel = vscode.window.createOutputChannel("GSD Git Diff");
channel.clear();
channel.appendLine(diff);
channel.show();
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Git diff failed: ${msg}`);
}
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private git(args: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(`git ${args}`, { cwd: this.cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
reject(new Error(stderr.trim() || err.message));
} else {
resolve(stdout);
}
});
});
}
}

View file

@ -123,11 +123,10 @@ export class GsdClient implements vscode.Disposable {
return;
}
const proc = spawn(this.binaryPath, ["--mode", "rpc", "--no-session"], {
const proc = spawn(this.binaryPath, ["--mode", "rpc"], {
cwd: this.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
shell: process.platform === "win32",
});
this.process = proc;
@ -580,10 +579,104 @@ export class GsdClient implements vscode.Disposable {
return;
}
// Extension UI request — agent needs user input
if (data.type === "extension_ui_request" && typeof data.id === "string") {
void this.handleUIRequest(data);
return;
}
// Streaming event
this._onEvent.fire(data as AgentEvent);
}
private async handleUIRequest(request: Record<string, unknown>): Promise<void> {
const id = request.id as string;
const method = request.method as string;
try {
switch (method) {
case "select": {
const options = (request.options as string[]) ?? [];
const title = String(request.title ?? "Select");
const allowMultiple = request.allowMultiple === true;
if (allowMultiple) {
const picked = await vscode.window.showQuickPick(options, {
title,
canPickMany: true,
});
if (picked) {
this.sendRaw({ type: "extension_ui_response", id, values: picked });
} else {
this.sendRaw({ type: "extension_ui_response", id, cancelled: true });
}
} else {
const picked = await vscode.window.showQuickPick(options, { title });
if (picked) {
this.sendRaw({ type: "extension_ui_response", id, value: picked });
} else {
this.sendRaw({ type: "extension_ui_response", id, cancelled: true });
}
}
break;
}
case "confirm": {
const title = String(request.title ?? "Confirm");
const message = String(request.message ?? "");
const result = await vscode.window.showInformationMessage(
`${title}: ${message}`,
{ modal: true },
"Yes",
"No",
);
this.sendRaw({ type: "extension_ui_response", id, confirmed: result === "Yes" });
break;
}
case "input": {
const title = String(request.title ?? "Input");
const placeholder = String(request.placeholder ?? "");
const value = await vscode.window.showInputBox({ title, placeHolder: placeholder });
if (value !== undefined) {
this.sendRaw({ type: "extension_ui_response", id, value });
} else {
this.sendRaw({ type: "extension_ui_response", id, cancelled: true });
}
break;
}
case "notify": {
const message = String(request.message ?? "");
const notifyType = String(request.notifyType ?? "info");
if (notifyType === "error") {
vscode.window.showErrorMessage(`GSD: ${message}`);
} else if (notifyType === "warning") {
vscode.window.showWarningMessage(`GSD: ${message}`);
} else {
vscode.window.showInformationMessage(`GSD: ${message}`);
}
// Notify doesn't need a response
break;
}
default:
// Unknown method — cancel to unblock the agent
this.sendRaw({ type: "extension_ui_response", id, cancelled: true });
break;
}
} catch {
// On error, cancel to unblock
this.sendRaw({ type: "extension_ui_response", id, cancelled: true });
}
}
private sendRaw(data: Record<string, unknown>): void {
if (this.process?.stdin) {
this.process.stdin.write(JSON.stringify(data) + "\n");
}
}
private send(command: Record<string, unknown>): Promise<RpcResponse> {
if (!this.process?.stdin) {
return Promise.reject(new Error("GSD client not started"));

View file

@ -0,0 +1,130 @@
import * as vscode from "vscode";
import type { GsdChangeTracker } from "./change-tracker.js";
/**
* Provides line-level editor decorations for files modified by the GSD agent.
* Shows subtle background highlights on changed lines and gutter icons.
*/
export class GsdLineDecorationManager implements vscode.Disposable {
private readonly addedDecoration: vscode.TextEditorDecorationType;
private readonly modifiedDecoration: vscode.TextEditorDecorationType;
private readonly gutterDecoration: vscode.TextEditorDecorationType;
private disposables: vscode.Disposable[] = [];
constructor(private readonly tracker: GsdChangeTracker) {
this.addedDecoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
backgroundColor: "rgba(78, 201, 176, 0.07)",
overviewRulerColor: "rgba(78, 201, 176, 0.5)",
overviewRulerLane: vscode.OverviewRulerLane.Left,
});
this.modifiedDecoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
backgroundColor: "rgba(204, 167, 0, 0.07)",
overviewRulerColor: "rgba(204, 167, 0, 0.5)",
overviewRulerLane: vscode.OverviewRulerLane.Left,
});
this.gutterDecoration = vscode.window.createTextEditorDecorationType({
gutterIconPath: new vscode.ThemeIcon("hubot").id, // fallback
gutterIconSize: "contain",
// Use a colored left border as a gutter indicator (more reliable than icons)
borderWidth: "0 0 0 3px",
borderStyle: "solid",
borderColor: "rgba(78, 201, 176, 0.4)",
});
this.disposables.push(
this.addedDecoration,
this.modifiedDecoration,
this.gutterDecoration,
);
// Refresh decorations when tracked files change
this.disposables.push(
tracker.onDidChange(() => this.refreshAll()),
vscode.window.onDidChangeActiveTextEditor(() => this.refreshAll()),
vscode.workspace.onDidChangeTextDocument((e) => {
const editor = vscode.window.activeTextEditor;
if (editor && e.document === editor.document) {
this.refreshEditor(editor);
}
}),
);
}
private refreshAll(): void {
for (const editor of vscode.window.visibleTextEditors) {
this.refreshEditor(editor);
}
}
private refreshEditor(editor: vscode.TextEditor): void {
const filePath = editor.document.uri.fsPath;
const original = this.tracker.getOriginal(filePath);
if (original === undefined) {
// No tracked changes for this file — clear decorations
editor.setDecorations(this.addedDecoration, []);
editor.setDecorations(this.modifiedDecoration, []);
editor.setDecorations(this.gutterDecoration, []);
return;
}
const currentLines = editor.document.getText().split("\n");
const originalLines = original.split("\n");
const { added, modified } = diffLines(originalLines, currentLines);
const addedRanges = added.map((line) => {
const range = new vscode.Range(line, 0, line, currentLines[line]?.length ?? 0);
return { range, hoverMessage: new vscode.MarkdownString("$(hubot) *Added by GSD Agent*") };
});
const modifiedRanges = modified.map((line) => {
const range = new vscode.Range(line, 0, line, currentLines[line]?.length ?? 0);
return { range, hoverMessage: new vscode.MarkdownString("$(hubot) *Modified by GSD Agent*") };
});
const gutterRanges = [...added, ...modified].map((line) => ({
range: new vscode.Range(line, 0, line, 0),
}));
editor.setDecorations(this.addedDecoration, addedRanges);
editor.setDecorations(this.modifiedDecoration, modifiedRanges);
editor.setDecorations(this.gutterDecoration, gutterRanges);
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}
/**
* Simple line-level diff: compare original vs current line-by-line.
* Returns arrays of line numbers that were added or modified.
*/
function diffLines(
originalLines: string[],
currentLines: string[],
): { added: number[]; modified: number[] } {
const added: number[] = [];
const modified: number[] = [];
const maxShared = Math.min(originalLines.length, currentLines.length);
for (let i = 0; i < maxShared; i++) {
if (originalLines[i] !== currentLines[i]) {
modified.push(i);
}
}
// Lines beyond original length are "added"
for (let i = originalLines.length; i < currentLines.length; i++) {
added.push(i);
}
return { added, modified };
}

View file

@ -0,0 +1,143 @@
import * as vscode from "vscode";
import type { GsdClient, AgentEvent } from "./gsd-client.js";
type ApprovalMode = "ask" | "auto-approve" | "plan-only";
/**
* Permission/approval system for agent actions.
* Can be configured to prompt before file writes, command execution, etc.
*/
export class GsdPermissionManager implements vscode.Disposable {
private _mode: ApprovalMode = "auto-approve";
private disposables: vscode.Disposable[] = [];
private readonly _onModeChange = new vscode.EventEmitter<ApprovalMode>();
readonly onModeChange = this._onModeChange.event;
constructor(private readonly client: GsdClient) {
// Load saved mode from configuration
this._mode = vscode.workspace.getConfiguration("gsd").get<ApprovalMode>("approvalMode", "auto-approve");
this.disposables.push(
this._onModeChange,
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("gsd.approvalMode")) {
this._mode = vscode.workspace.getConfiguration("gsd").get<ApprovalMode>("approvalMode", "auto-approve");
this._onModeChange.fire(this._mode);
}
}),
);
// If mode is "ask", intercept tool executions for write operations
if (this._mode === "ask") {
this.disposables.push(
client.onEvent((evt) => this.handleEvent(evt)),
);
}
}
get mode(): ApprovalMode {
return this._mode;
}
/**
* Cycle through approval modes: auto-approve -> ask -> plan-only -> auto-approve
*/
async cycleMode(): Promise<void> {
const modes: ApprovalMode[] = ["auto-approve", "ask", "plan-only"];
const currentIdx = modes.indexOf(this._mode);
this._mode = modes[(currentIdx + 1) % modes.length];
await vscode.workspace.getConfiguration("gsd").update("approvalMode", this._mode, vscode.ConfigurationTarget.Workspace);
this._onModeChange.fire(this._mode);
const labels: Record<ApprovalMode, string> = {
"auto-approve": "Auto-Approve (agent runs freely)",
"ask": "Ask (prompt before file changes)",
"plan-only": "Plan Only (read-only, no writes)",
};
vscode.window.showInformationMessage(`Approval mode: ${labels[this._mode]}`);
}
/**
* Show a QuickPick to select approval mode.
*/
async selectMode(): Promise<void> {
const items: (vscode.QuickPickItem & { mode: ApprovalMode })[] = [
{
label: "$(check) Auto-Approve",
description: "Agent runs freely without prompts",
detail: "Best for trusted workflows. The agent can read, write, and execute without asking.",
mode: "auto-approve",
},
{
label: "$(shield) Ask",
description: "Prompt before file changes",
detail: "The agent will ask for approval before writing or editing files.",
mode: "ask",
},
{
label: "$(eye) Plan Only",
description: "Read-only mode, no writes allowed",
detail: "The agent can read and analyze but cannot modify files or run commands.",
mode: "plan-only",
},
];
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Current mode: ${this._mode}`,
});
if (selected) {
this._mode = selected.mode;
await vscode.workspace.getConfiguration("gsd").update("approvalMode", this._mode, vscode.ConfigurationTarget.Workspace);
this._onModeChange.fire(this._mode);
}
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private async handleEvent(evt: AgentEvent): Promise<void> {
if (this._mode !== "ask") return;
if (evt.type !== "tool_execution_start") return;
const toolName = String(evt.toolName ?? "");
if (toolName !== "Write" && toolName !== "Edit" && toolName !== "Bash") return;
const toolInput = (evt.toolInput ?? {}) as Record<string, unknown>;
let description = "";
switch (toolName) {
case "Write":
case "Edit": {
const filePath = String(toolInput.file_path ?? "");
const shortPath = filePath.split(/[\\/]/).slice(-3).join("/");
description = `${toolName}: ${shortPath}`;
break;
}
case "Bash": {
const cmd = String(toolInput.command ?? "").slice(0, 80);
description = `Execute: ${cmd}`;
break;
}
}
// Note: In practice, the RPC protocol doesn't support blocking tool execution
// for approval. This notification serves as awareness — the user sees what's
// happening and can abort if needed. True blocking approval would require
// protocol changes in the RPC server.
vscode.window.showInformationMessage(
`Agent: ${description}`,
"OK",
"Abort",
).then((choice) => {
if (choice === "Abort") {
this.client.abort().catch(() => {});
}
});
}
}

View file

@ -0,0 +1,190 @@
import * as vscode from "vscode";
import type { GsdClient, AgentEvent } from "./gsd-client.js";
interface PlanStep {
id: number;
tool: string;
description: string;
status: "pending" | "running" | "done" | "error";
timestamp: number;
duration?: number;
}
/**
* TreeDataProvider that shows a plan-like view of agent tool executions.
* Displays steps as they happen, showing what the agent is doing and
* what it has completed a live execution plan.
*/
export class GsdPlanViewerProvider implements vscode.TreeDataProvider<PlanStep>, vscode.Disposable {
public static readonly viewId = "gsd-plan";
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private steps: PlanStep[] = [];
private nextId = 0;
private runningTools = new Map<string, number>(); // toolUseId -> step id
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: GsdClient) {
this.disposables.push(
this._onDidChangeTreeData,
client.onEvent((evt) => this.handleEvent(evt)),
client.onConnectionChange((connected) => {
if (!connected) {
this.steps = [];
this.runningTools.clear();
this._onDidChangeTreeData.fire();
}
}),
);
}
getTreeItem(step: PlanStep): vscode.TreeItem {
const icon = stepIcon(step.status);
const item = new vscode.TreeItem(step.description, vscode.TreeItemCollapsibleState.None);
item.iconPath = icon;
item.description = step.duration !== undefined ? `${step.duration}ms` : step.status === "running" ? "running..." : "";
const time = new Date(step.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
item.tooltip = `${step.tool}: ${step.description}\nStatus: ${step.status}\nTime: ${time}`;
return item;
}
getChildren(): PlanStep[] {
return this.steps;
}
clear(): void {
this.steps = [];
this.runningTools.clear();
this._onDidChangeTreeData.fire();
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private handleEvent(evt: AgentEvent): void {
switch (evt.type) {
case "agent_start": {
// Don't clear — keep history visible. Add a separator.
if (this.steps.length > 0) {
this.steps.push({
id: this.nextId++,
tool: "separator",
description: "--- New Turn ---",
status: "done",
timestamp: Date.now(),
});
}
this.steps.push({
id: this.nextId++,
tool: "agent",
description: "Agent started",
status: "running",
timestamp: Date.now(),
});
this._onDidChangeTreeData.fire();
break;
}
case "agent_end": {
// Mark the agent step as done
const agentStep = [...this.steps].reverse().find((s) => s.tool === "agent" && s.status === "running");
if (agentStep) {
agentStep.status = "done";
agentStep.duration = Date.now() - agentStep.timestamp;
agentStep.description = "Agent finished";
}
this._onDidChangeTreeData.fire();
break;
}
case "tool_execution_start": {
const toolName = String(evt.toolName ?? "");
const toolInput = (evt.toolInput ?? {}) as Record<string, unknown>;
const toolUseId = String(evt.toolUseId ?? "");
const description = describeStep(toolName, toolInput);
const id = this.nextId++;
this.steps.push({
id,
tool: toolName,
description,
status: "running",
timestamp: Date.now(),
});
if (toolUseId) {
this.runningTools.set(toolUseId, id);
}
// Cap at 200 steps
while (this.steps.length > 200) {
this.steps.shift();
}
this._onDidChangeTreeData.fire();
break;
}
case "tool_execution_end": {
const toolUseId = String(evt.toolUseId ?? "");
const stepId = this.runningTools.get(toolUseId);
if (stepId !== undefined) {
this.runningTools.delete(toolUseId);
const step = this.steps.find((s) => s.id === stepId);
if (step) {
const isError = evt.error === true || evt.isError === true;
step.status = isError ? "error" : "done";
step.duration = Date.now() - step.timestamp;
this._onDidChangeTreeData.fire();
}
}
break;
}
}
}
}
function stepIcon(status: string): vscode.ThemeIcon {
switch (status) {
case "running":
return new vscode.ThemeIcon("sync~spin", new vscode.ThemeColor("charts.yellow"));
case "done":
return new vscode.ThemeIcon("pass", new vscode.ThemeColor("testing.iconPassed"));
case "error":
return new vscode.ThemeIcon("error", new vscode.ThemeColor("testing.iconFailed"));
default:
return new vscode.ThemeIcon("circle-outline");
}
}
function describeStep(toolName: string, input: Record<string, unknown>): string {
switch (toolName) {
case "Read": {
const p = String(input.file_path ?? input.path ?? "");
return `Read ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Write": {
const p = String(input.file_path ?? "");
return `Write ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Edit": {
const p = String(input.file_path ?? "");
return `Edit ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Bash":
return `$ ${String(input.command ?? "").slice(0, 50)}`;
case "Grep":
return `Grep: ${String(input.pattern ?? "").slice(0, 40)}`;
case "Glob":
return `Glob: ${String(input.pattern ?? "").slice(0, 40)}`;
default:
return toolName;
}
}

View file

@ -0,0 +1,124 @@
import * as vscode from "vscode";
import * as path from "node:path";
import type { GsdChangeTracker } from "./change-tracker.js";
const GSD_ORIGINAL_SCHEME = "gsd-original";
/**
* Source Control provider that shows files modified by the GSD agent
* in a dedicated "GSD Agent" section of the Source Control panel.
* Supports QuickDiff to show before/after diffs, and accept/discard per-file.
*/
export class GsdScmProvider implements vscode.Disposable {
private readonly scm: vscode.SourceControl;
private readonly changesGroup: vscode.SourceControlResourceGroup;
private readonly contentProvider: GsdOriginalContentProvider;
private disposables: vscode.Disposable[] = [];
constructor(
private readonly tracker: GsdChangeTracker,
private readonly workspaceRoot: string,
) {
// Register content provider for original file contents
this.contentProvider = new GsdOriginalContentProvider(tracker);
this.disposables.push(
vscode.workspace.registerTextDocumentContentProvider(
GSD_ORIGINAL_SCHEME,
this.contentProvider,
),
);
// Create source control instance
this.scm = vscode.scm.createSourceControl(
"gsd",
"GSD Agent",
vscode.Uri.file(workspaceRoot),
);
this.scm.quickDiffProvider = {
provideOriginalResource: (uri: vscode.Uri): vscode.Uri | undefined => {
const filePath = uri.fsPath;
if (this.tracker.getOriginal(filePath) !== undefined) {
return uri.with({ scheme: GSD_ORIGINAL_SCHEME });
}
return undefined;
},
};
this.scm.inputBox.placeholder = "Describe changes to accept...";
this.scm.acceptInputCommand = {
command: "gsd.acceptAllChanges",
title: "Accept All",
};
this.scm.count = 0;
this.disposables.push(this.scm);
// Create resource group
this.changesGroup = this.scm.createResourceGroup("changes", "Agent Changes");
this.changesGroup.hideWhenEmpty = true;
this.disposables.push(this.changesGroup);
// Listen for change tracker updates
this.disposables.push(
tracker.onDidChange(() => this.refresh()),
);
this.refresh();
}
private refresh(): void {
const files = this.tracker.modifiedFiles;
this.changesGroup.resourceStates = files.map((filePath) => {
const uri = vscode.Uri.file(filePath);
const fileName = path.basename(filePath);
const relativePath = path.relative(this.workspaceRoot, filePath);
const state: vscode.SourceControlResourceState = {
resourceUri: uri,
decorations: {
strikeThrough: false,
tooltip: `Modified by GSD Agent`,
light: { iconPath: new vscode.ThemeIcon("edit") },
dark: { iconPath: new vscode.ThemeIcon("edit") },
},
command: {
command: "vscode.diff",
title: "Show Changes",
arguments: [
uri.with({ scheme: GSD_ORIGINAL_SCHEME }),
uri,
`${fileName} (GSD Agent Changes)`,
],
},
};
return state;
});
this.scm.count = files.length;
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
}
/**
* TextDocumentContentProvider that serves the original (pre-agent) content
* of files via the `gsd-original:` URI scheme.
*/
class GsdOriginalContentProvider implements vscode.TextDocumentContentProvider {
private readonly _onDidChange = new vscode.EventEmitter<vscode.Uri>();
readonly onDidChange = this._onDidChange.event;
constructor(private readonly tracker: GsdChangeTracker) {
tracker.onDidChange((paths) => {
for (const p of paths) {
this._onDidChange.fire(vscode.Uri.file(p).with({ scheme: GSD_ORIGINAL_SCHEME }));
}
});
}
provideTextDocumentContent(uri: vscode.Uri): string {
const filePath = uri.with({ scheme: "file" }).fsPath;
return this.tracker.getOriginal(filePath) ?? "";
}
}

View file

@ -56,18 +56,35 @@ export class GsdSessionTreeProvider implements vscode.TreeDataProvider<SessionIt
const items: SessionItem[] = [];
for (const file of files) {
// Filename format: <unixTimestampMs>_<sessionId>.jsonl
const match = file.match(/^(\d+)_(.+)\.jsonl$/);
if (!match) {
const sessionFile = path.join(sessionDir, file);
// Try two filename formats:
// 1. ISO timestamp: 2026-03-23T17-49-05-784Z_<sessionId>.jsonl
// 2. Unix timestamp: <unixTimestampMs>_<sessionId>.jsonl
const isoMatch = file.match(/^(\d{4}-\d{2}-\d{2}T[\d-]+Z)_(.+)\.jsonl$/);
const unixMatch = file.match(/^(\d{10,})_(.+)\.jsonl$/);
let timestamp: Date;
let sessionId: string;
if (isoMatch) {
// Convert ISO-like format (dashes instead of colons) back to parseable ISO
const isoStr = isoMatch[1].replace(/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d+)Z/, "$1:$2:$3.$4Z");
timestamp = new Date(isoStr);
sessionId = isoMatch[2];
} else if (unixMatch) {
timestamp = new Date(parseInt(unixMatch[1], 10));
sessionId = unixMatch[2];
} else {
continue;
}
const ts = parseInt(match[1], 10);
const sessionId = match[2];
const sessionFile = path.join(sessionDir, file);
if (isNaN(timestamp.getTime())) continue;
items.push({
label: formatDate(new Date(ts)),
label: formatDate(timestamp),
sessionFile,
timestamp: new Date(ts),
timestamp,
sessionId,
isCurrent: sessionFile === state.sessionFile,
});

View file

@ -2,8 +2,17 @@ import * as vscode from "vscode";
import type { GsdClient, SessionStats, ThinkingLevel } from "./gsd-client.js";
/**
* WebviewViewProvider that renders a sidebar panel showing connection status,
* model info, thinking level, token usage, cost, and quick action controls.
* Send a message through VS Code's Chat panel so the user sees the response.
* Opens the Chat panel and pre-fills the @gsd participant with the message.
*/
async function sendViaChat(message: string): Promise<void> {
await vscode.commands.executeCommand("workbench.action.chat.open", { query: message });
}
/**
* WebviewViewProvider that renders a compact, card-based sidebar panel.
* Designed for information density without clutter collapsible sections,
* hidden empty data, and consolidated action buttons.
*/
export class GsdSidebarProvider implements vscode.WebviewViewProvider {
public static readonly viewId = "gsd-sidebar";
@ -106,22 +115,18 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
await vscode.commands.executeCommand("gsd.copyLastResponse");
break;
case "autoMode":
if (this.client.isConnected) {
await this.client.sendPrompt("/gsd auto").catch(() => {});
}
await sendViaChat("@gsd /gsd auto");
break;
case "nextUnit":
if (this.client.isConnected) {
await this.client.sendPrompt("/gsd next").catch(() => {});
}
await sendViaChat("@gsd /gsd next");
break;
case "quickTask": {
const quickInput = await vscode.window.showInputBox({
prompt: "Describe the quick task",
placeHolder: "e.g. fix the typo in README",
});
if (quickInput && this.client.isConnected) {
await this.client.sendPrompt(`/gsd quick ${quickInput}`).catch(() => {});
if (quickInput) {
await sendViaChat(`@gsd /gsd quick ${quickInput}`);
}
break;
}
@ -130,15 +135,13 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
prompt: "Capture a thought",
placeHolder: "e.g. we should also handle the edge case for...",
});
if (thought && this.client.isConnected) {
await this.client.sendPrompt(`/gsd capture ${thought}`).catch(() => {});
if (thought) {
await sendViaChat(`@gsd /gsd capture ${thought}`);
}
break;
}
case "status":
if (this.client.isConnected) {
await this.client.sendPrompt("/gsd status").catch(() => {});
}
await sendViaChat("@gsd /gsd status");
break;
case "forkSession":
await vscode.commands.executeCommand("gsd.forkSession");
@ -149,6 +152,9 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
case "toggleFollowUpMode":
await vscode.commands.executeCommand("gsd.toggleFollowUpMode");
break;
case "showHistory":
await vscode.commands.executeCommand("gsd.showHistory");
break;
}
});
@ -168,6 +174,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
}
let modelName = "N/A";
let modelShort = "";
let sessionId = "N/A";
let sessionName = "";
let messageCount = 0;
@ -189,6 +196,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
modelName = state.model
? `${state.model.provider}/${state.model.id}`
: "Not set";
modelShort = state.model?.id ?? "";
sessionId = state.sessionId;
sessionName = state.sessionName ?? "";
messageCount = state.messageCount;
@ -216,6 +224,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
this.view.webview.html = this.getHtml({
connected,
modelName,
modelShort,
sessionId,
sessionName,
messageCount,
@ -244,6 +253,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
private getHtml(info: {
connected: boolean;
modelName: string;
modelShort: string;
sessionId: string;
sessionName: string;
messageCount: number;
@ -259,57 +269,49 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
followUpMode: "all" | "one-at-a-time";
}): string {
const statusColor = info.connected ? "#4ec9b0" : "#f44747";
const statusText = info.connected
? info.isStreaming
? "Processing..."
: info.isCompacting
? "Compacting..."
: "Connected"
: "Disconnected";
const statusLabel = info.isStreaming ? "Working" : info.isCompacting ? "Compacting" : info.connected ? "Connected" : "Disconnected";
const inputTokens = info.stats?.inputTokens?.toLocaleString() ?? "-";
const outputTokens = info.stats?.outputTokens?.toLocaleString() ?? "-";
const cacheRead = info.stats?.cacheReadTokens?.toLocaleString() ?? "-";
const cacheWrite = info.stats?.cacheWriteTokens?.toLocaleString() ?? "-";
const turnCount = info.stats?.turnCount?.toString() ?? "-";
const duration = info.stats?.duration !== undefined
? `${Math.round(info.stats.duration / 1000)}s`
: "-";
const cost = info.stats?.totalCost !== undefined ? `$${info.stats.totalCost.toFixed(4)}` : "-";
// Model short name for header
const modelDisplay = info.modelShort || "N/A";
const thinkingBadge = info.thinkingLevel !== "off"
? `<span class="badge">${info.thinkingLevel}</span>`
: `<span class="badge muted">off</span>`;
// Session display — name or truncated ID
const sessionDisplay = info.sessionName || (info.sessionId !== "N/A" ? info.sessionId.slice(0, 8) : "N/A");
const autoCompBadge = info.autoCompaction
? `<span class="badge">on</span>`
: `<span class="badge muted">off</span>`;
const autoRetryBadge = info.autoRetry
? `<span class="badge">on</span>`
: `<span class="badge muted">off</span>`;
const streamingIndicator = info.isStreaming
? `<div class="streaming-indicator"><span class="spinner"></span> Agent is working...</div>`
// Cost for header
const costDisplay = info.stats?.totalCost !== undefined && info.stats.totalCost > 0
? `$${info.stats.totalCost.toFixed(4)}`
: "";
// Context window usage
// Context window
const totalTokens = (info.stats?.inputTokens ?? 0) + (info.stats?.outputTokens ?? 0);
const contextPct = info.contextWindow > 0 ? Math.min(100, Math.round((totalTokens / info.contextWindow) * 100)) : 0;
const contextColor = contextPct > 80 ? "#f44747" : contextPct > 50 ? "#cca700" : "#4ec9b0";
const contextLabel = info.contextWindow > 0
? `${contextPct}% (${Math.round(totalTokens / 1000)}k / ${Math.round(info.contextWindow / 1000)}k)`
: "N/A";
const steeringBadge = info.steeringMode === "one-at-a-time"
? `<span class="badge">1-at-a-time</span>`
: `<span class="badge muted">all</span>`;
const followUpBadge = info.followUpMode === "one-at-a-time"
? `<span class="badge">1-at-a-time</span>`
: `<span class="badge muted">all</span>`;
// Only show stats that have real data
const hasStats = info.stats && (
(info.stats.inputTokens !== undefined && info.stats.inputTokens > 0) ||
(info.stats.outputTokens !== undefined && info.stats.outputTokens > 0)
);
const nonce = getNonce();
// Build stat rows only for non-zero values
let statRows = "";
if (hasStats && info.stats) {
const pairs: [string, string][] = [];
if (info.stats.inputTokens) pairs.push(["In", formatNum(info.stats.inputTokens)]);
if (info.stats.outputTokens) pairs.push(["Out", formatNum(info.stats.outputTokens)]);
if (info.stats.cacheReadTokens) pairs.push(["Cache R", formatNum(info.stats.cacheReadTokens)]);
if (info.stats.cacheWriteTokens) pairs.push(["Cache W", formatNum(info.stats.cacheWriteTokens)]);
if (info.stats.turnCount) pairs.push(["Turns", String(info.stats.turnCount)]);
if (info.stats.duration) pairs.push(["Time", `${Math.round(info.stats.duration / 1000)}s`]);
if (info.stats.totalCost !== undefined && info.stats.totalCost > 0) pairs.push(["Cost", `$${info.stats.totalCost.toFixed(4)}`]);
statRows = pairs.map(([k, v]) =>
`<span class="stat-label">${k}</span><span class="stat-value">${v}</span>`
).join("");
}
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
@ -317,291 +319,329 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
padding: 12px;
margin: 0;
padding: 8px;
}
.status-row {
/* ---- Header card ---- */
.header {
padding: 10px 12px;
border-radius: 6px;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
margin-bottom: 8px;
}
.header-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.status-dot {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: ${statusColor};
flex-shrink: 0;
}
.streaming-indicator {
.status-label {
font-size: 11px;
opacity: 0.7;
flex-shrink: 0;
}
.header-model {
margin-left: auto;
font-size: 11px;
font-weight: 600;
opacity: 0.85;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-model:hover { opacity: 1; }
.header-cost {
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.6;
flex-shrink: 0;
}
.header-sub {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 11px;
opacity: 0.6;
}
.header-sub .sep { opacity: 0.3; }
.session-name {
cursor: pointer;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-name:hover { opacity: 1; text-decoration: underline; }
/* ---- Streaming banner ---- */
.streaming {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
margin-bottom: 12px;
background: var(--vscode-editor-background);
border-radius: 4px;
margin-bottom: 8px;
background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent);
border: 1px solid var(--vscode-focusBorder);
border-radius: 6px;
font-size: 12px;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid var(--vscode-foreground);
width: 10px; height: 10px;
border: 2px solid var(--vscode-focusBorder);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.section {
margin-bottom: 14px;
}
.section-title {
font-size: 11px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.info-table {
width: 100%;
}
.info-table td {
padding: 3px 0;
vertical-align: middle;
}
.info-table td:first-child {
opacity: 0.7;
padding-right: 12px;
white-space: nowrap;
}
.info-table td:last-child {
word-break: break-all;
}
.badge {
display: inline-block;
padding: 1px 6px;
@keyframes spin { to { transform: rotate(360deg); } }
.streaming-abort {
margin-left: auto;
font-size: 10px;
padding: 2px 8px;
border: 1px solid var(--vscode-foreground);
background: transparent;
color: var(--vscode-foreground);
border-radius: 3px;
font-size: 11px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.badge.muted {
opacity: 0.5;
}
.badge.clickable {
cursor: pointer;
opacity: 0.6;
}
.badge.clickable:hover {
opacity: 0.8;
.streaming-abort:hover { opacity: 1; }
/* ---- Context bar (inline in header) ---- */
.context-bar {
margin-top: 8px;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-row {
display: flex;
gap: 6px;
}
.btn-row button {
flex: 1;
}
button {
display: block;
.context-track {
width: 100%;
padding: 6px 14px;
border: none;
height: 3px;
background: var(--vscode-panel-border);
border-radius: 2px;
overflow: hidden;
}
.context-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.context-text {
font-size: 10px;
opacity: 0.5;
margin-top: 2px;
}
/* ---- Collapsible section ---- */
.section {
margin-bottom: 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
button.secondary {
color: var(--vscode-button-secondaryForeground);
background: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.token-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
font-size: 12px;
}
.token-stats .label {
user-select: none;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
background: var(--vscode-editor-background);
}
.token-stats .value {
.section-header:hover { opacity: 1; }
.chevron {
font-size: 10px;
transition: transform 0.15s;
}
.section.collapsed .section-body { display: none; }
.section.collapsed .chevron { transform: rotate(-90deg); }
.section-body {
padding: 6px 10px 8px;
}
/* ---- Stats grid ---- */
.stats-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 10px;
font-size: 11px;
}
.stat-label { opacity: 0.6; }
.stat-value {
text-align: right;
font-variant-numeric: tabular-nums;
}
.context-bar-outer {
width: 100%;
height: 6px;
background: var(--vscode-editor-background);
border-radius: 3px;
overflow: hidden;
margin: 4px 0 2px;
}
.context-bar-inner {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.context-label {
/* ---- Toggle row ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
font-size: 11px;
opacity: 0.7;
}
.toggle-label { opacity: 0.7; }
.toggle-pill {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.toggle-pill.on {
background: color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
border-color: var(--vscode-focusBorder);
color: var(--vscode-foreground);
}
.toggle-pill.off {
background: transparent;
border-color: var(--vscode-panel-border);
opacity: 0.5;
}
.toggle-pill:hover { opacity: 1; }
/* ---- Buttons ---- */
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.actions.three-col {
grid-template-columns: 1fr 1fr 1fr;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 5px 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
width: auto;
}
.action-btn:hover {
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-focusBorder);
}
.action-btn.primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border-color: var(--vscode-button-background);
font-weight: 600;
}
.action-btn.primary:hover {
background: var(--vscode-button-hoverBackground);
}
.action-btn.danger {
border-color: #f44747;
color: #f44747;
}
.action-btn.danger:hover {
background: color-mix(in srgb, #f44747 15%, transparent);
}
.action-btn.full {
grid-column: 1 / -1;
}
/* ---- Disconnected state ---- */
.disconnected {
text-align: center;
padding: 20px 12px;
}
.disconnected p {
opacity: 0.5;
font-size: 12px;
margin-bottom: 12px;
}
.start-btn {
padding: 8px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: var(--vscode-font-size);
font-weight: 600;
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
width: auto;
display: inline-block;
}
.start-btn:hover {
background: var(--vscode-button-hoverBackground);
}
</style>
</head>
<body>
<div class="status-row">
<div class="status-dot"></div>
<strong>${statusText}</strong>
</div>
${streamingIndicator}
<div class="section">
<div class="section-title">Session</div>
<table class="info-table">
<tr><td>Model</td><td>${escapeHtml(info.modelName)}</td></tr>
<tr>
<td>Session</td>
<td>
${escapeHtml(info.sessionName || info.sessionId)}
${info.connected ? `<span class="badge clickable" data-command="setSessionName" title="Rename session" style="margin-left:4px">✎</span>` : ""}
</td>
</tr>
<tr><td>Messages</td><td>${info.messageCount}${info.pendingMessageCount > 0 ? ` <span class="badge muted">+${info.pendingMessageCount} pending</span>` : ""}</td></tr>
<tr>
<td>Thinking</td>
<td>${thinkingBadge}</td>
</tr>
<tr>
<td>Auto-compact</td>
<td>${autoCompBadge}</td>
</tr>
<tr>
<td>Auto-retry</td>
<td>${autoRetryBadge}</td>
</tr>
<tr>
<td>Steering</td>
<td><span class="badge clickable" data-command="toggleSteeringMode">${info.steeringMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span></td>
</tr>
<tr>
<td>Follow-up</td>
<td><span class="badge clickable" data-command="toggleFollowUpMode">${info.followUpMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span></td>
</tr>
</table>
</div>
${info.connected && info.stats ? `
<div class="section">
<div class="section-title">Token Usage</div>
<div class="token-stats">
<span class="label">Input</span>
<span class="value">${inputTokens}</span>
<span class="label">Output</span>
<span class="value">${outputTokens}</span>
<span class="label">Cache read</span>
<span class="value">${cacheRead}</span>
<span class="label">Cache write</span>
<span class="value">${cacheWrite}</span>
<span class="label">Turns</span>
<span class="value">${turnCount}</span>
<span class="label">Duration</span>
<span class="value">${duration}</span>
<span class="label">Cost</span>
<span class="value">${cost}</span>
${info.connected ? this.getConnectedHtml(info, {
statusLabel,
modelDisplay,
sessionDisplay,
costDisplay,
contextPct,
contextColor,
hasStats: !!hasStats,
statRows,
nonce,
}) : `
<div class="header">
<div class="header-top">
<div class="status-dot"></div>
<span class="status-label">Disconnected</span>
</div>
</div>
${info.contextWindow > 0 ? `
<div class="section">
<div class="section-title">Context Window</div>
<div class="context-bar-outer">
<div class="context-bar-inner" style="width: ${contextPct}%; background: ${contextColor};"></div>
</div>
<div class="context-label">${contextLabel}</div>
<div class="disconnected">
<p>Agent is not running</p>
<button class="start-btn" data-command="start">Start Agent</button>
</div>
` : ""}
` : ""}
${info.connected ? `
<div class="section">
<div class="section-title">Workflow</div>
<div class="btn-group">
<div class="btn-row">
<button data-command="autoMode">Auto</button>
<button class="secondary" data-command="nextUnit">Next</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="quickTask">Quick</button>
<button class="secondary" data-command="capture">Capture</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="status">Status</button>
<button class="secondary" data-command="forkSession">Fork</button>
</div>
</div>
</div>
` : ""}
<div class="section">
<div class="section-title">Controls</div>
<div class="btn-group">
${info.connected
? `<button data-command="stop">Stop Agent</button>
<div class="btn-row">
<button class="secondary" data-command="newSession">New Session</button>
<button class="secondary" data-command="switchModel">Model</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="cycleThinking">Thinking</button>
<button class="secondary" data-command="toggleAutoCompaction">Auto-Compact</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="toggleAutoRetry">Auto-Retry</button>
<button class="secondary" data-command="copyLastResponse">Copy Response</button>
</div>`
: `<button data-command="start">Start Agent</button>`
}
</div>
</div>
${info.connected ? `
<div class="section">
<div class="section-title">Actions</div>
<div class="btn-group">
<div class="btn-row">
<button class="secondary" data-command="compact">Compact</button>
<button class="secondary" data-command="exportHtml">Export</button>
</div>
<div class="btn-row">
<button class="secondary" data-command="abort">Abort</button>
<button class="secondary" data-command="listCommands">Commands</button>
</div>
</div>
</div>
` : ""}
`}
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const stored = vscode.getState() || {};
// Restore collapsed state
document.querySelectorAll('.section').forEach(s => {
const id = s.dataset.section;
if (id && stored[id] === 'collapsed') s.classList.add('collapsed');
});
document.addEventListener('click', (e) => {
// Section toggle
const header = e.target.closest('.section-header');
if (header) {
const section = header.parentElement;
section.classList.toggle('collapsed');
const id = section.dataset.section;
if (id) {
const state = vscode.getState() || {};
state[id] = section.classList.contains('collapsed') ? 'collapsed' : 'open';
vscode.setState(state);
}
return;
}
// Button/command click
const btn = e.target.closest('[data-command]');
if (btn) {
vscode.postMessage({ command: btn.dataset.command });
@ -611,6 +651,144 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
</body>
</html>`;
}
private getConnectedHtml(
info: {
connected: boolean;
modelName: string;
modelShort: string;
sessionId: string;
sessionName: string;
messageCount: number;
pendingMessageCount: number;
thinkingLevel: ThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
autoCompaction: boolean;
autoRetry: boolean;
stats: SessionStats | null;
contextWindow: number;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
},
ui: {
statusLabel: string;
modelDisplay: string;
sessionDisplay: string;
costDisplay: string;
contextPct: number;
contextColor: string;
hasStats: boolean;
statRows: string;
nonce: string;
},
): string {
const pendingBadge = info.pendingMessageCount > 0
? ` <span style="opacity:0.5">+${info.pendingMessageCount}</span>`
: "";
return `
<!-- Header card -->
<div class="header">
<div class="header-top">
<div class="status-dot"></div>
<span class="status-label">${ui.statusLabel}</span>
<span class="header-model" data-command="switchModel" title="${escapeHtml(info.modelName)}">${escapeHtml(ui.modelDisplay)}</span>
${ui.costDisplay ? `<span class="header-cost">${ui.costDisplay}</span>` : ""}
</div>
<div class="header-sub">
<span class="session-name" data-command="setSessionName" title="${escapeHtml(info.sessionId)}">${escapeHtml(ui.sessionDisplay)}</span>
<span class="sep">/</span>
<span>${info.messageCount} msg${pendingBadge}</span>
<span class="sep">/</span>
<span data-command="cycleThinking" style="cursor:pointer" title="Click to cycle thinking level">${info.thinkingLevel === "off" ? "no think" : info.thinkingLevel}</span>
</div>
${info.contextWindow > 0 ? `
<div class="context-bar">
<div class="context-track">
<div class="context-fill" style="width:${ui.contextPct}%;background:${ui.contextColor}"></div>
</div>
<div class="context-text">${ui.contextPct}% context (${formatNum((info.stats?.inputTokens ?? 0) + (info.stats?.outputTokens ?? 0))} / ${formatNum(info.contextWindow)})</div>
</div>
` : ""}
</div>
${info.isStreaming ? `
<div class="streaming">
<span class="spinner"></span>
<span>Agent is working...</span>
<button class="streaming-abort" data-command="abort">Stop</button>
</div>
` : ""}
<!-- Workflow -->
<div class="section" data-section="workflow">
<div class="section-header"><span class="chevron">&#9660;</span> Workflow</div>
<div class="section-body">
<div class="actions">
<button class="action-btn primary" data-command="autoMode">Auto</button>
<button class="action-btn" data-command="nextUnit">Next</button>
<button class="action-btn" data-command="quickTask">Quick</button>
<button class="action-btn" data-command="capture">Capture</button>
</div>
</div>
</div>
${ui.hasStats ? `
<!-- Stats -->
<div class="section" data-section="stats">
<div class="section-header"><span class="chevron">&#9660;</span> Stats</div>
<div class="section-body">
<div class="stats-grid">${ui.statRows}</div>
</div>
</div>
` : ""}
<!-- Actions -->
<div class="section" data-section="actions">
<div class="section-header"><span class="chevron">&#9660;</span> Actions</div>
<div class="section-body">
<div class="actions three-col">
<button class="action-btn" data-command="newSession">New</button>
<button class="action-btn" data-command="compact">Compact</button>
<button class="action-btn" data-command="copyLastResponse">Copy</button>
<button class="action-btn" data-command="status">Status</button>
<button class="action-btn" data-command="fixProblemsInFile">Fix Errs</button>
<button class="action-btn" data-command="showHistory">History</button>
</div>
<div style="margin-top:6px">
<button class="action-btn danger full" data-command="stop">Stop Agent</button>
</div>
</div>
</div>
<!-- Settings (collapsed by default) -->
<div class="section collapsed" data-section="settings">
<div class="section-header"><span class="chevron">&#9660;</span> Settings</div>
<div class="section-body">
<div class="toggle-row">
<span class="toggle-label">Auto-compact</span>
<span class="toggle-pill ${info.autoCompaction ? "on" : "off"}" data-command="toggleAutoCompaction">${info.autoCompaction ? "on" : "off"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Auto-retry</span>
<span class="toggle-pill ${info.autoRetry ? "on" : "off"}" data-command="toggleAutoRetry">${info.autoRetry ? "on" : "off"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Steering</span>
<span class="toggle-pill ${info.steeringMode === "one-at-a-time" ? "on" : "off"}" data-command="toggleSteeringMode">${info.steeringMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Follow-up</span>
<span class="toggle-pill ${info.followUpMode === "one-at-a-time" ? "on" : "off"}" data-command="toggleFollowUpMode">${info.followUpMode === "one-at-a-time" ? "1-at-a-time" : "all"}</span>
</div>
<div class="toggle-row">
<span class="toggle-label">Approval</span>
<span class="toggle-pill on" data-command="selectApprovalMode">change</span>
</div>
</div>
</div>`;
}
}
function escapeHtml(text: string): string {
@ -621,6 +799,12 @@ function escapeHtml(text: string): string {
.replace(/"/g, "&quot;");
}
function formatNum(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
function getNonce(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let nonce = "";

View file

@ -77,7 +77,9 @@ export class GsdSlashCompletionProvider
private async refreshCache(): Promise<void> {
try {
this.cachedCommands = await this.client.getCommands();
const all = await this.client.getCommands();
// Only show /gsd commands — filter out unrelated extension/skill commands
this.cachedCommands = all.filter((cmd) => cmd.name.startsWith("gsd"));
} catch {
// Silently ignore — agent may not be ready yet.
}