From 7eee0bb08d7744e17052572c4bfad8513e0a772e Mon Sep 17 00:00:00 2001 From: Aaron Luhning Date: Sun, 18 Jan 2026 09:17:07 -0500 Subject: [PATCH 1/3] v0.0.4: Update licensing documentation --- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7669ab..b41eb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.4] - 2026-01-18 + +### Changed +- Updated licensing model: Extension code is now MIT licensed (open source) +- Backend infrastructure (API, licensing system, NEAR contracts) remains proprietary +- Added clear licensing documentation to README and separate LICENSE files for backend components + +## [0.0.3] - 2026-01-18 + +### Changed +- Updated README with correct publisher name (VitalPoint) and extension ID (hopper-velocity) +- Fixed marketplace links and installation commands + +## [0.0.2] - 2026-01-18 + +### Changed +- Updated description to clarify freemium model + ## [0.0.1] - 2026-01-17 ### Added diff --git a/package.json b/package.json index 8337a42..e6662fb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hopper-velocity", "displayName": "Hopper Velocity", "description": "Ship projects faster with AI-powered planning and execution. First phase free, upgrade to continue your project.", - "version": "0.0.3", + "version": "0.0.4", "publisher": "VitalPoint", "license": "MIT", "icon": "resources/icon.png", From 5d43d40f03a60970af844696820c6d131688d332 Mon Sep 17 00:00:00 2001 From: Aaron Luhning Date: Sun, 18 Jan 2026 09:17:51 -0500 Subject: [PATCH 2/3] Add terminal tools and register missing chat participant commands --- src/chat/commands/completeMilestone.ts | 1 + src/chat/commands/considerIssues.ts | 2 +- src/chat/commands/createRoadmap.ts | 2 + src/chat/commands/discoveryPhase.ts | 2 + src/chat/commands/discussPhase.ts | 79 ++++- src/chat/commands/executePlan.ts | 157 +++++++++- src/chat/commands/listPhaseAssumptions.ts | 4 + src/chat/commands/newMilestone.ts | 2 + src/chat/commands/planPhase.ts | 60 +++- src/chat/commands/researchPhase.ts | 237 ++++++++++----- src/extension.ts | 13 +- src/tools/terminalTools.ts | 349 ++++++++++++++++++++++ 12 files changed, 801 insertions(+), 107 deletions(-) create mode 100644 src/tools/terminalTools.ts diff --git a/src/chat/commands/completeMilestone.ts b/src/chat/commands/completeMilestone.ts index 58f685a..977ae32 100644 --- a/src/chat/commands/completeMilestone.ts +++ b/src/chat/commands/completeMilestone.ts @@ -325,6 +325,7 @@ export async function handleCompleteMilestone(ctx: CommandContext): Promise 0) { stream.markdown(`**${urgent.length} urgent issue(s)** may need immediate attention.\n`); - stream.markdown('Consider using `/insert-phase` to address before continuing (coming in Phase 5.1).\n\n'); + stream.markdown('Consider using `/insert-phase` to address before continuing.\n\n'); } if (naturalFit.length > 0) { diff --git a/src/chat/commands/createRoadmap.ts b/src/chat/commands/createRoadmap.ts index d095d03..c56c2a4 100644 --- a/src/chat/commands/createRoadmap.ts +++ b/src/chat/commands/createRoadmap.ts @@ -258,6 +258,7 @@ export async function handleCreateRoadmap(ctx: CommandContext): Promise, workspaceRoot?: string): { start: string; complete: string } { + // Helper to make paths relative and readable + const formatPath = (fullPath: string): string => { + if (workspaceRoot && fullPath.startsWith(workspaceRoot)) { + return fullPath.slice(workspaceRoot.length + 1); // +1 for trailing slash + } + return fullPath; + }; + + // Helper to get file extension description + const getFileType = (filePath: string): string => { + const ext = filePath.split('.').pop()?.toLowerCase(); + const types: Record = { + 'ts': 'TypeScript', + 'tsx': 'TypeScript React', + 'js': 'JavaScript', + 'jsx': 'JavaScript React', + 'json': 'JSON', + 'md': 'Markdown', + 'css': 'CSS', + 'scss': 'SCSS', + 'html': 'HTML', + 'yaml': 'YAML', + 'yml': 'YAML', + 'py': 'Python', + 'go': 'Go', + 'rs': 'Rust', + 'java': 'Java', + 'sql': 'SQL', + 'sh': 'Shell script', + 'bash': 'Bash script' + }; + return types[ext || ''] || 'file'; + }; + + // Helper to estimate content size + const getContentSize = (content: string): string => { + const lines = content.split('\n').length; + if (lines === 1) { + return '1 line'; + } + return `${lines} lines`; + }; + + switch (toolName) { + case 'hopper_createFile': { + const filePath = input.filePath as string || 'unknown'; + const content = input.content as string || ''; + const relativePath = formatPath(filePath); + const fileType = getFileType(filePath); + const size = getContentSize(content); + return { + start: `**Creating ${fileType}:** \`${relativePath}\` (${size})`, + complete: `Created \`${relativePath}\`` + }; + } + + case 'hopper_createDirectory': { + const dirPath = input.dirPath as string || 'unknown'; + const relativePath = formatPath(dirPath); + return { + start: `**Creating directory:** \`${relativePath}/\``, + complete: `Created directory \`${relativePath}/\`` + }; + } + + case 'copilot_editFile': + case 'vscode_editFile': { + const filePath = input.filePath as string || input.path as string || 'unknown'; + const relativePath = formatPath(filePath); + return { + start: `**Editing:** \`${relativePath}\``, + complete: `Edited \`${relativePath}\`` + }; + } + + case 'copilot_readFile': + case 'vscode_readFile': { + const filePath = input.filePath as string || input.path as string || 'unknown'; + const relativePath = formatPath(filePath); + return { + start: `*Reading \`${relativePath}\`...*`, + complete: `*Read \`${relativePath}\`*` + }; + } + + case 'hopper_runInTerminal': { + const command = input.command as string || 'unknown'; + const name = input.name as string; + const displayName = name || command.split(' ')[0]; + return { + start: `**Starting terminal:** \`${displayName}\`\n Command: \`${command}\``, + complete: `Terminal \`${displayName}\` started (running in background)` + }; + } + + case 'hopper_waitForPort': { + const port = input.port as number || 0; + const host = input.host as string || 'localhost'; + return { + start: `**Waiting for port:** ${host}:${port}`, + complete: `Port ${host}:${port} is ready` + }; + } + + case 'hopper_httpHealthCheck': { + const url = input.url as string || 'unknown'; + return { + start: `**Health check:** ${url}`, + complete: `Health check passed: ${url}` + }; + } + + default: + return { + start: `*Executing tool: ${toolName}...*`, + complete: `*Tool ${toolName} completed.*` + }; + } +} + /** * Execute a chat request with tool calling loop. * Handles invoking tools when the model requests them. @@ -27,6 +152,7 @@ import { clearHandoffAfterCompletion } from './resumeWork'; * @param stream The chat response stream to write to * @param token Cancellation token * @param toolInvocationToken Token from chat request for proper UI integration (required for file operations) + * @param workspaceRoot Optional workspace root for relative path formatting */ async function executeWithTools( model: vscode.LanguageModelChat, @@ -34,7 +160,8 @@ async function executeWithTools( tools: vscode.LanguageModelChatTool[], stream: vscode.ChatResponseStream, token: vscode.CancellationToken, - toolInvocationToken?: vscode.ChatParticipantToolToken + toolInvocationToken?: vscode.ChatParticipantToolToken, + workspaceRoot?: string ): Promise { const MAX_ITERATIONS = 10; let iteration = 0; @@ -59,8 +186,12 @@ async function executeWithTools( hasToolCalls = true; toolCallParts.push(part); - // Show tool invocation in stream - stream.markdown(`\n\n*Executing tool: ${part.name}...*\n\n`); + // Format contextual tool message + const toolInput = part.input as Record; + const toolMsg = formatToolMessage(part.name, toolInput, workspaceRoot); + + // Show tool invocation in stream with context + stream.markdown(`\n\n${toolMsg.start}\n\n`); try { // Log tool input for debugging @@ -89,11 +220,11 @@ async function executeWithTools( new vscode.LanguageModelToolResultPart(part.callId, result.content) ); - stream.markdown(`*Tool ${part.name} completed.*\n\n`); + stream.markdown(`${toolMsg.complete}\n\n`); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); console.error(`[Hopper] Tool ${part.name} error:`, err); - stream.markdown(`*Tool ${part.name} failed: ${errorMsg}*\n\n`); + stream.markdown(`**Failed:** ${part.name} - ${errorMsg}\n\n`); toolResults.push( new vscode.LanguageModelToolResultPart( @@ -301,6 +432,20 @@ For file operations, you MUST use the hopper_* tools (NOT copilot_* tools): - Use hopper_createDirectory to create directories (with dirPath) These tools are reliable and work correctly with absolute paths. +**Long-Running Processes (dev servers, watch tasks, etc.)** +For commands that run continuously and don't exit (like dev servers), use these tools: +- Use hopper_runInTerminal to start the process in a separate terminal (returns immediately) + - Parameters: command (required), name (optional terminal name), cwd (optional working directory) +- Use hopper_waitForPort to wait for a port to become available + - Parameters: port (required), host (default: localhost), timeoutMs (default: 30000) +- Use hopper_httpHealthCheck to verify a URL is responding + - Parameters: url (required), expectedStatus (default: 200), timeoutMs (default: 30000) + +Example workflow for starting a dev server: +1. hopper_runInTerminal with command "npm run dev" and name "Dev Server" +2. hopper_waitForPort with port 3000 (or appropriate port) +3. Continue with remaining tasks once the server is ready + **Task:** ${task.name} ${filesLine}**Action to perform:** @@ -925,7 +1070,7 @@ export async function handleExecutePlan(ctx: CommandContext): Promise [additional]`\n\n'); stream.markdown('**Examples:**\n'); stream.markdown('- `/plan-phase 1` - Plan for Phase 1\n'); stream.markdown('- `/plan-phase 2.1` - Plan for inserted Phase 2.1\n'); - stream.markdown('- `/plan-phase` - Auto-detect next unplanned phase\n\n'); + stream.markdown('- `/plan-phase 1 additional` - Create additional plan for Phase 1\n\n'); stream.markdown('**Available phases:**\n'); for (const p of phases) { stream.markdown(`- Phase ${p.number}: ${p.name}\n`); @@ -278,7 +280,7 @@ export async function handlePlanPhase(ctx: CommandContext): Promise`**\n\n'); + stream.markdown('**`/plan-phase [additional]`**\n\n'); stream.markdown('Create a detailed execution plan for a specific phase.\n\n'); stream.markdown('**Examples:**\n'); stream.markdown('- `/plan-phase 1` - Plan for Phase 1\n'); stream.markdown('- `/plan-phase 2` - Plan for Phase 2\n'); - stream.markdown('- `/plan-phase 1.5` - Plan for inserted Phase 1.5\n\n'); + stream.markdown('- `/plan-phase 1.5` - Plan for inserted Phase 1.5\n'); + stream.markdown('- `/plan-phase 1 additional` - Create additional plan for Phase 1\n\n'); stream.markdown('**Available phases:**\n'); for (const p of phases) { stream.markdown(`- Phase ${p.number}: ${p.name}\n`); @@ -370,7 +373,7 @@ export async function handlePlanPhase(ctx: CommandContext): Promise 1) { const existingPlanUri = vscode.Uri.joinPath( workspaceUri, @@ -380,13 +383,37 @@ export async function handlePlanPhase(ctx: CommandContext): Promise`**\n\n'); + stream.markdown('**`/research-phase [topic]`**\n\n'); stream.markdown('Research how to implement a phase before planning.\n\n'); stream.markdown('**Examples:**\n'); stream.markdown('- `/research-phase 3` - Research Phase 3\n'); - stream.markdown('- `/research-phase 2.1` - Research inserted Phase 2.1\n\n'); + stream.markdown('- `/research-phase 2.1` - Research inserted Phase 2.1\n'); + stream.markdown('- `/research-phase 3 authentication patterns` - Add research on specific topic\n\n'); stream.markdown('**When to use:**\n'); stream.markdown('- 3D graphics, game development, audio/music\n'); stream.markdown('- ML/AI integration, real-time systems\n'); @@ -510,14 +548,14 @@ export async function handleResearchPhase(ctx: CommandContext): Promise`\n\n'); + stream.markdown(`"${phaseArg}" is not a valid phase number.\n\n`); + stream.markdown('**Usage:** `/research-phase [topic]`\n\n'); return { metadata: { lastCommand: 'research-phase' } }; } - const phaseNum = parseFloat(promptText); + const phaseNum = parseFloat(phaseArg); if (isNaN(phaseNum) || phaseNum < 1) { stream.markdown('## Invalid Phase Number\n\n'); stream.markdown(`"${promptText}" is not a valid phase number.\n\n`); @@ -558,24 +596,30 @@ export async function handleResearchPhase(ctx: CommandContext): Promise 0; + + if (existingResearchContent && !wantsToAdd) { stream.markdown('## Research Already Exists\n\n'); stream.markdown(`Phase ${phaseNum} (${targetPhase.name}) already has research.\n\n`); stream.markdown('**Existing research:**\n'); stream.reference(researchUri); stream.markdown('\n\n'); - stream.markdown('To update research, delete the existing file and run `/research-phase` again.\n\n'); + stream.markdown('**Options:**\n'); + stream.markdown(`- Add to research: \`/research-phase ${phaseNum} [topic to research]\`\n`); + stream.markdown(`- Delete the file and run \`/research-phase ${phaseNum}\` to regenerate from scratch\n\n`); stream.markdown('**Or proceed to planning:**\n\n'); stream.button({ command: 'hopper.chat-participant.plan-phase', + arguments: [phaseNum], title: `Plan Phase ${phaseNum}` }); return { metadata: { lastCommand: 'research-phase' } }; @@ -592,39 +636,20 @@ export async function handleResearchPhase(ctx: CommandContext): Promise = new Map(); + +/** + * Tool to run a command in a new VSCode terminal. + * Returns immediately after spawning, allowing the agent to continue. + * Use with hopper_waitForPort or hopper_httpHealthCheck to verify readiness. + */ +export class HopperRunInTerminalTool implements vscode.LanguageModelTool { + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + _token: vscode.CancellationToken + ): Promise { + const { command, name, cwd } = options.input; + + try { + if (!command) { + throw new Error('command is required'); + } + + // Generate a terminal name if not provided + const terminalName = name || `Hopper: ${command.split(' ')[0]}`; + + // Check if a terminal with this name already exists + const existingTerminal = vscode.window.terminals.find(t => t.name === terminalName); + if (existingTerminal) { + // Dispose the existing terminal before creating a new one + existingTerminal.dispose(); + managedTerminals.delete(terminalName); + // Brief delay to allow disposal + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Determine working directory + let workingDir = cwd; + if (!workingDir) { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + workingDir = workspaceFolders[0].uri.fsPath; + } + } + + // Create the terminal + const terminal = vscode.window.createTerminal({ + name: terminalName, + cwd: workingDir, + }); + + // Track the terminal + managedTerminals.set(terminalName, terminal); + + // Show the terminal (but don't take focus away from chat) + terminal.show(true); // preserveFocus = true + + // Send the command + terminal.sendText(command); + + console.log(`[Hopper] Started terminal "${terminalName}" with command: ${command}`); + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Terminal "${terminalName}" started with command: ${command}\n\n` + + `The command is running in the background. Use hopper_waitForPort or hopper_httpHealthCheck ` + + `to verify the process is ready before proceeding.` + ) + ]); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`[Hopper] Failed to run in terminal: ${errorMsg}`); + throw new Error(`Failed to run in terminal: ${errorMsg}`); + } + } +} + +/** + * Tool to wait for a port to become available. + * Useful for verifying dev servers and other network services are ready. + */ +export class HopperWaitForPortTool implements vscode.LanguageModelTool { + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken + ): Promise { + const { + port, + host = 'localhost', + timeoutMs = 30000, + intervalMs = 500 + } = options.input; + + try { + if (!port || port < 1 || port > 65535) { + throw new Error('port must be a valid port number (1-65535)'); + } + + console.log(`[Hopper] Waiting for port ${port} on ${host}...`); + + const startTime = Date.now(); + let lastError: Error | null = null; + + while (Date.now() - startTime < timeoutMs) { + if (token.isCancellationRequested) { + throw new Error('Operation cancelled'); + } + + const isOpen = await this.checkPort(host, port); + if (isOpen) { + const elapsed = Date.now() - startTime; + console.log(`[Hopper] Port ${port} is ready after ${elapsed}ms`); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Port ${port} on ${host} is now accepting connections (waited ${elapsed}ms).` + ) + ]); + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + throw new Error( + `Timeout waiting for port ${port} on ${host} after ${timeoutMs}ms` + + (lastError ? `: ${lastError.message}` : '') + ); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`[Hopper] Wait for port failed: ${errorMsg}`); + throw new Error(`Failed waiting for port: ${errorMsg}`); + } + } + + private checkPort(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + + const onError = () => { + socket.destroy(); + resolve(false); + }; + + socket.setTimeout(1000); + socket.once('error', onError); + socket.once('timeout', onError); + + socket.connect(port, host, () => { + socket.end(); + resolve(true); + }); + }); + } +} + +/** + * Tool to perform HTTP health checks with retries. + * Useful for verifying web servers and APIs are ready and responding. + */ +export class HopperHttpHealthCheckTool implements vscode.LanguageModelTool { + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken + ): Promise { + const { + url, + expectedStatus = 200, + timeoutMs = 30000, + intervalMs = 1000, + maxRetries = 30 + } = options.input; + + try { + if (!url) { + throw new Error('url is required'); + } + + // Validate URL + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + console.log(`[Hopper] Starting health check for ${url}...`); + + const startTime = Date.now(); + let attempt = 0; + let lastError: string | null = null; + let lastStatus: number | null = null; + + while (attempt < maxRetries && Date.now() - startTime < timeoutMs) { + if (token.isCancellationRequested) { + throw new Error('Operation cancelled'); + } + + attempt++; + + try { + const result = await this.makeRequest(parsedUrl, 5000); + + if (result.status === expectedStatus) { + const elapsed = Date.now() - startTime; + console.log(`[Hopper] Health check passed after ${attempt} attempts (${elapsed}ms)`); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Health check passed for ${url}\n` + + `- Status: ${result.status}\n` + + `- Attempts: ${attempt}\n` + + `- Time: ${elapsed}ms` + ) + ]); + } + + lastStatus = result.status; + lastError = `Unexpected status ${result.status} (expected ${expectedStatus})`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + throw new Error( + `Health check failed for ${url} after ${attempt} attempts. ` + + (lastStatus !== null ? `Last status: ${lastStatus}. ` : '') + + (lastError ? `Error: ${lastError}` : '') + ); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`[Hopper] Health check failed: ${errorMsg}`); + throw new Error(`Health check failed: ${errorMsg}`); + } + } + + private makeRequest(url: URL, timeoutMs: number): Promise<{ status: number }> { + return new Promise((resolve, reject) => { + const protocol = url.protocol === 'https:' ? https : http; + + const req = protocol.request( + url, + { + method: 'GET', + timeout: timeoutMs, + }, + (res) => { + // Consume response body to free up memory + res.resume(); + resolve({ status: res.statusCode || 0 }); + } + ); + + req.on('error', (err) => { + reject(err); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } +} + +/** + * Register all Hopper terminal tools + */ +export function registerTerminalTools(context: vscode.ExtensionContext): void { + // Register hopper_runInTerminal tool + const runInTerminalTool = vscode.lm.registerTool( + 'hopper_runInTerminal', + new HopperRunInTerminalTool() + ); + context.subscriptions.push(runInTerminalTool); + console.log('[Hopper] Registered hopper_runInTerminal tool'); + + // Register hopper_waitForPort tool + const waitForPortTool = vscode.lm.registerTool( + 'hopper_waitForPort', + new HopperWaitForPortTool() + ); + context.subscriptions.push(waitForPortTool); + console.log('[Hopper] Registered hopper_waitForPort tool'); + + // Register hopper_httpHealthCheck tool + const healthCheckTool = vscode.lm.registerTool( + 'hopper_httpHealthCheck', + new HopperHttpHealthCheckTool() + ); + context.subscriptions.push(healthCheckTool); + console.log('[Hopper] Registered hopper_httpHealthCheck tool'); + + // Clean up managed terminals when extension deactivates + context.subscriptions.push({ + dispose: () => { + for (const terminal of managedTerminals.values()) { + try { + terminal.dispose(); + } catch { + // Ignore errors during cleanup + } + } + managedTerminals.clear(); + } + }); +} + +/** + * Get all managed terminal names (for debugging/status) + */ +export function getManagedTerminals(): string[] { + return Array.from(managedTerminals.keys()); +} From 3ae1ccbebeadc31e7aa51a60f0e7e60e2db55a04 Mon Sep 17 00:00:00 2001 From: Aaron Luhning Date: Sun, 18 Jan 2026 09:26:04 -0500 Subject: [PATCH 3/3] Fix TypeScript lint error in terminalTools --- src/tools/terminalTools.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tools/terminalTools.ts b/src/tools/terminalTools.ts index be662fa..55dfd87 100644 --- a/src/tools/terminalTools.ts +++ b/src/tools/terminalTools.ts @@ -131,7 +131,6 @@ export class HopperWaitForPortTool implements vscode.LanguageModelTool