diff --git a/cli/cliOrchestrator.ts b/cli/cliOrchestrator.ts new file mode 100644 index 00000000..2dcff41e --- /dev/null +++ b/cli/cliOrchestrator.ts @@ -0,0 +1,288 @@ +/** + * CLI Orchestrator — Replaces PixelAgentsViewProvider for standalone CLI mode + * + * Creates a webview shim that broadcasts JSON to WebSocket clients. + * Handles incoming messages using the same protocol as the VS Code extension. + * Reuses backend modules directly (assetLoader, layoutPersistence, fileWatcher, etc.). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { WebSocket } from 'ws'; +import type { AgentState } from '../src/types.js'; +import { + loadFurnitureAssets, + loadFloorTiles, + loadWallTiles, + loadCharacterSprites, + loadDefaultLayout, +} from '../src/assetLoader.js'; +import { + readLayoutFromFile, + writeLayoutToFile, + watchLayoutFile, +} from '../src/layoutPersistence.js'; +import type { LayoutWatcher } from '../src/layoutPersistence.js'; +import { readSeats, writeSeats, readSettings, writeSettings } from './persistence.js'; +import { startSessionScanner } from './sessionScanner.js'; + +export interface CliOrchestratorOptions { + /** Path to dist/ directory containing assets/ and webview/ */ + distDir: string; +} + +export class CliOrchestrator { + private clients = new Set(); + private agents = new Map(); + private nextAgentId = { current: 1 }; + private knownJsonlFiles = new Set(); + + // Per-agent timers (same shape as PixelAgentsViewProvider) + private fileWatchers = new Map(); + private pollingTimers = new Map>(); + private waitingTimers = new Map>(); + private permissionTimers = new Map>(); + + private defaultLayout: Record | null = null; + private layoutWatcher: LayoutWatcher | null = null; + private sessionScannerDispose: (() => void) | null = null; + + /** Webview shim — broadcasts to all connected WS clients */ + readonly webview: { postMessage(msg: unknown): void }; + + constructor(private readonly opts: CliOrchestratorOptions) { + this.webview = { + postMessage: (msg: unknown) => { + const json = JSON.stringify(msg); + for (const ws of this.clients) { + if (ws.readyState === 1 /* OPEN */) { + ws.send(json); + } + } + }, + }; + } + + addClient(ws: WebSocket): void { + this.clients.add(ws); + ws.on('close', () => this.clients.delete(ws)); + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + this.handleMessage(msg); + } catch { /* ignore malformed */ } + }); + } + + private async handleMessage(message: Record): Promise { + switch (message.type) { + case 'webviewReady': + await this.onWebviewReady(); + break; + + case 'saveLayout': + this.layoutWatcher?.markOwnWrite(); + writeLayoutToFile(message.layout as Record); + break; + + case 'saveAgentSeats': + writeSeats(message.seats as Record); + break; + + case 'setSoundEnabled': + writeSettings({ soundEnabled: message.enabled as boolean }); + break; + + case 'exportLayout': { + const layout = readLayoutFromFile(); + if (layout) { + this.webview.postMessage({ + type: 'exportLayoutData', + layout: JSON.stringify(layout, null, 2), + }); + } + break; + } + + case 'importLayoutData': { + const imported = message.layout as Record; + if (imported && imported.version === 1 && Array.isArray(imported.tiles)) { + this.layoutWatcher?.markOwnWrite(); + writeLayoutToFile(imported); + this.webview.postMessage({ type: 'layoutLoaded', layout: imported }); + } + break; + } + + // No-ops for CLI mode + case 'openClaude': + case 'focusAgent': + case 'closeAgent': + case 'openSessionsFolder': + case 'importLayout': + break; + } + } + + private async onWebviewReady(): Promise { + const assetsRoot = this.opts.distDir; + const assetsDir = path.join(assetsRoot, 'assets'); + + // Load and send assets in order + try { + // Load bundled default layout + this.defaultLayout = loadDefaultLayout(assetsRoot); + + // Character sprites + if (fs.existsSync(path.join(assetsDir, 'characters'))) { + const charSprites = await loadCharacterSprites(assetsRoot); + if (charSprites) { + this.webview.postMessage({ + type: 'characterSpritesLoaded', + characters: charSprites.characters, + }); + } + } + + // Floor tiles + if (fs.existsSync(path.join(assetsDir, 'floors.png'))) { + const floorTiles = await loadFloorTiles(assetsRoot); + if (floorTiles) { + this.webview.postMessage({ + type: 'floorTilesLoaded', + sprites: floorTiles.sprites, + }); + } + } + + // Wall tiles + if (fs.existsSync(path.join(assetsDir, 'walls.png'))) { + const wallTiles = await loadWallTiles(assetsRoot); + if (wallTiles) { + this.webview.postMessage({ + type: 'wallTilesLoaded', + sprites: wallTiles.sprites, + }); + } + } + + // Furniture + const furnitureAssets = await loadFurnitureAssets(assetsRoot); + if (furnitureAssets) { + const spritesObj: Record = {}; + for (const [id, spriteData] of furnitureAssets.sprites) { + spritesObj[id] = spriteData; + } + this.webview.postMessage({ + type: 'furnitureAssetsLoaded', + catalog: furnitureAssets.catalog, + sprites: spritesObj, + }); + } + } catch (err) { + console.error('[CLI] Error loading assets:', err); + } + + // Send settings + const settings = readSettings(); + this.webview.postMessage({ type: 'settingsLoaded', soundEnabled: settings.soundEnabled }); + + // Send layout + const layout = readLayoutFromFile() || this.defaultLayout; + if (layout) { + this.webview.postMessage({ type: 'layoutLoaded', layout }); + } + + // Start layout watcher + if (!this.layoutWatcher) { + this.layoutWatcher = watchLayoutFile((updatedLayout) => { + this.webview.postMessage({ type: 'layoutLoaded', layout: updatedLayout }); + }); + } + + // Send existing agents + this.sendExistingAgents(); + + // Start session scanner + if (!this.sessionScannerDispose) { + const scanner = startSessionScanner({ + agents: this.agents, + nextAgentId: this.nextAgentId, + knownJsonlFiles: this.knownJsonlFiles, + fileWatchers: this.fileWatchers, + pollingTimers: this.pollingTimers, + waitingTimers: this.waitingTimers, + permissionTimers: this.permissionTimers, + webview: this.webview as never, + onAgentCreated: (agentId, folderName) => { + this.webview.postMessage({ type: 'agentCreated', id: agentId, folderName }); + }, + }); + this.sessionScannerDispose = scanner.dispose; + } + } + + private sendExistingAgents(): void { + const agentIds = [...this.agents.keys()].sort((a, b) => a - b); + const agentMeta = readSeats(); + const folderNames: Record = {}; + for (const [id, agent] of this.agents) { + if (agent.folderName) { + folderNames[id] = agent.folderName; + } + } + this.webview.postMessage({ + type: 'existingAgents', + agents: agentIds, + agentMeta, + folderNames, + }); + + // Re-send current statuses + for (const [agentId, agent] of this.agents) { + for (const [toolId, status] of agent.activeToolStatuses) { + this.webview.postMessage({ + type: 'agentToolStart', + id: agentId, + toolId, + status, + }); + } + if (agent.isWaiting) { + this.webview.postMessage({ + type: 'agentStatus', + id: agentId, + status: 'waiting', + }); + } + } + } + + dispose(): void { + this.layoutWatcher?.dispose(); + this.layoutWatcher = null; + this.sessionScannerDispose?.(); + this.sessionScannerDispose = null; + + // Clean up all agent timers + for (const w of this.fileWatchers.values()) w.close(); + this.fileWatchers.clear(); + for (const t of this.pollingTimers.values()) clearInterval(t); + this.pollingTimers.clear(); + for (const t of this.waitingTimers.values()) clearTimeout(t); + this.waitingTimers.clear(); + for (const t of this.permissionTimers.values()) clearTimeout(t); + this.permissionTimers.clear(); + + // Unwatch all JSONL files + for (const agent of this.agents.values()) { + try { fs.unwatchFile(agent.jsonlFile); } catch { /* ignore */ } + } + + // Close all WebSocket connections + for (const ws of this.clients) { + ws.close(); + } + this.clients.clear(); + } +} diff --git a/cli/main.ts b/cli/main.ts new file mode 100644 index 00000000..c7a56b72 --- /dev/null +++ b/cli/main.ts @@ -0,0 +1,101 @@ +/** + * Pixel Agents CLI — Standalone pixel art office for Claude Code agents + * + * Serves the webview in a browser and auto-detects running Claude Code sessions. + * Usage: pixel-agents [--port ] + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { exec } from 'child_process'; +import { CliOrchestrator } from './cliOrchestrator.js'; +import { createServer } from './server.js'; + +const DEFAULT_PORT = 7842; + +function parseArgs(): { port: number } { + const args = process.argv.slice(2); + let port = DEFAULT_PORT; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--port' && args[i + 1]) { + port = parseInt(args[i + 1], 10); + if (isNaN(port) || port < 1 || port > 65535) { + console.error(`Invalid port: ${args[i + 1]}`); + process.exit(1); + } + i++; + } + } + return { port }; +} + +function resolveDistDir(): string { + // dist/cli.js is at the same level as dist/webview/ and dist/assets/ + // So dist/ is the parent of cli.js + const distDir = path.dirname(__filename); + const webviewDir = path.join(distDir, 'webview'); + const assetsDir = path.join(distDir, 'assets'); + + if (!fs.existsSync(webviewDir)) { + console.error(`Webview directory not found: ${webviewDir}`); + console.error('Run "npm run build:webview" first.'); + process.exit(1); + } + + if (!fs.existsSync(assetsDir)) { + console.warn(`Assets directory not found: ${assetsDir}`); + console.warn('Run "npm run build" to copy assets.'); + } + + return distDir; +} + +function openBrowser(url: string): void { + const platform = process.platform; + let cmd: string; + if (platform === 'darwin') { + cmd = `open "${url}"`; + } else if (platform === 'win32') { + cmd = `start "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + exec(cmd, (err) => { + if (err) { + console.log(`Open ${url} in your browser`); + } + }); +} + +function main(): void { + const { port } = parseArgs(); + const distDir = resolveDistDir(); + const webviewDir = path.join(distDir, 'webview'); + + const orchestrator = new CliOrchestrator({ distDir }); + const server = createServer(webviewDir, orchestrator); + + server.listen(port, () => { + const url = `http://localhost:${port}`; + console.log(`Pixel Agents running at ${url}`); + console.log('Watching for Claude Code sessions...'); + console.log('Press Ctrl+C to stop.\n'); + openBrowser(url); + }); + + // Graceful shutdown + const shutdown = () => { + console.log('\nShutting down...'); + orchestrator.dispose(); + server.close(() => { + process.exit(0); + }); + // Force exit after 3s if server doesn't close + setTimeout(() => process.exit(0), 3000); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main(); diff --git a/cli/persistence.ts b/cli/persistence.ts new file mode 100644 index 00000000..8e29a44d --- /dev/null +++ b/cli/persistence.ts @@ -0,0 +1,61 @@ +/** + * CLI Persistence — Simple JSON file storage for ~/.pixel-agents/ + * + * Handles seats.json and settings.json with atomic write (same pattern as layoutPersistence.ts). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const PERSISTENCE_DIR = path.join(os.homedir(), '.pixel-agents'); +const SEATS_FILE = path.join(PERSISTENCE_DIR, 'seats.json'); +const SETTINGS_FILE = path.join(PERSISTENCE_DIR, 'settings.json'); + +function ensureDir(): void { + if (!fs.existsSync(PERSISTENCE_DIR)) { + fs.mkdirSync(PERSISTENCE_DIR, { recursive: true }); + } +} + +function atomicWrite(filePath: string, data: unknown): void { + ensureDir(); + const json = JSON.stringify(data, null, 2); + const tmpPath = filePath + '.tmp'; + fs.writeFileSync(tmpPath, json, 'utf-8'); + fs.renameSync(tmpPath, filePath); +} + +function readJson(filePath: string, fallback: T): T { + try { + if (!fs.existsSync(filePath)) return fallback; + const raw = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +// ── Seats ──────────────────────────────────────────────────── + +export function readSeats(): Record { + return readJson>(SEATS_FILE, {}); +} + +export function writeSeats(seats: Record): void { + atomicWrite(SEATS_FILE, seats); +} + +// ── Settings ───────────────────────────────────────────────── + +interface CliSettings { + soundEnabled: boolean; +} + +export function readSettings(): CliSettings { + return readJson(SETTINGS_FILE, { soundEnabled: true }); +} + +export function writeSettings(settings: CliSettings): void { + atomicWrite(SETTINGS_FILE, settings); +} diff --git a/cli/server.ts b/cli/server.ts new file mode 100644 index 00000000..bc49a06b --- /dev/null +++ b/cli/server.ts @@ -0,0 +1,73 @@ +/** + * HTTP + WebSocket Server for Pixel Agents CLI + * + * Serves static files from dist/webview/ and handles WebSocket connections on /ws. + * No Express needed — plain Node.js http.createServer (~60 lines). + */ + +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import { WebSocketServer } from 'ws'; +import type { CliOrchestrator } from './cliOrchestrator.js'; + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ico': 'image/x-icon', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', +}; + +export function createServer( + webviewDir: string, + orchestrator: CliOrchestrator, +): http.Server { + const server = http.createServer((req, res) => { + let urlPath = req.url?.split('?')[0] || '/'; + if (urlPath === '/') urlPath = '/index.html'; + + const filePath = path.join(webviewDir, urlPath); + + // Prevent directory traversal + if (!filePath.startsWith(webviewDir)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + }); + + // WebSocket server on /ws path + const wss = new WebSocketServer({ server, path: '/ws' }); + wss.on('connection', (ws) => { + console.log('[CLI] WebSocket client connected'); + orchestrator.addClient(ws); + ws.on('close', () => { + console.log('[CLI] WebSocket client disconnected'); + }); + }); + + return server; +} diff --git a/cli/sessionScanner.ts b/cli/sessionScanner.ts new file mode 100644 index 00000000..7a5402a7 --- /dev/null +++ b/cli/sessionScanner.ts @@ -0,0 +1,147 @@ +/** + * Session Scanner — Detects Claude Code CLI sessions by watching ~/.claude/projects/ + * + * On startup, seeds all existing JSONL files as "known" (no agents for old sessions). + * Polls every 2s for new JSONL files across all project directories. + * When a new JSONL appears → creates an agent and starts file watching. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { AgentState } from '../src/types.js'; +import { startFileWatching, readNewLines } from '../src/fileWatcher.js'; + +const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); +const SCAN_INTERVAL_MS = 2000; + +export interface SessionScannerOptions { + agents: Map; + nextAgentId: { current: number }; + knownJsonlFiles: Set; + fileWatchers: Map; + pollingTimers: Map>; + waitingTimers: Map>; + permissionTimers: Map>; + webview: { postMessage(msg: unknown): void } | undefined; + onAgentCreated: (agentId: number, folderName: string) => void; +} + +/** Reverse the path encoding used by Claude Code: convert `-` back to `/` */ +function decodeFolderName(dirName: string): string { + // Claude Code hashes: path.replace(/[^a-zA-Z0-9-]/g, '-') + // We can't perfectly reverse this, but we can derive a short name from the last segment + const parts = dirName.split('-').filter(Boolean); + // Return last non-empty segment as a readable name + return parts[parts.length - 1] || dirName; +} + +export function startSessionScanner(opts: SessionScannerOptions): { dispose(): void } { + const { + agents, nextAgentId, knownJsonlFiles, + fileWatchers, pollingTimers, waitingTimers, permissionTimers, + onAgentCreated, + } = opts; + + // Seed all existing JSONL files on startup + seedKnownFiles(knownJsonlFiles); + + const timer = setInterval(() => { + scanForNewSessions(opts); + }, SCAN_INTERVAL_MS); + + return { + dispose() { + clearInterval(timer); + }, + }; +} + +function seedKnownFiles(knownJsonlFiles: Set): void { + try { + if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return; + const projectDirs = fs.readdirSync(CLAUDE_PROJECTS_DIR); + for (const dirName of projectDirs) { + const dirPath = path.join(CLAUDE_PROJECTS_DIR, dirName); + try { + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) continue; + const files = fs.readdirSync(dirPath) + .filter(f => f.endsWith('.jsonl')) + .map(f => path.join(dirPath, f)); + for (const f of files) { + knownJsonlFiles.add(f); + } + } catch { /* skip inaccessible dirs */ } + } + } catch { /* projects dir may not exist */ } +} + +function scanForNewSessions(opts: SessionScannerOptions): void { + const { + agents, nextAgentId, knownJsonlFiles, + fileWatchers, pollingTimers, waitingTimers, permissionTimers, + onAgentCreated, + } = opts; + + try { + if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return; + const projectDirs = fs.readdirSync(CLAUDE_PROJECTS_DIR); + + for (const dirName of projectDirs) { + const dirPath = path.join(CLAUDE_PROJECTS_DIR, dirName); + try { + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) continue; + + const files = fs.readdirSync(dirPath) + .filter(f => f.endsWith('.jsonl')) + .map(f => path.join(dirPath, f)); + + for (const file of files) { + if (knownJsonlFiles.has(file)) continue; + knownJsonlFiles.add(file); + + // Check if this JSONL is actively being written to (recent mtime) + try { + const fstat = fs.statSync(file); + const ageMs = Date.now() - fstat.mtimeMs; + // Only create agents for files modified in the last 30 seconds + if (ageMs > 30000) continue; + } catch { continue; } + + // New active session found — create agent + const id = nextAgentId.current++; + const folderName = decodeFolderName(dirName); + const agent: AgentState = { + id, + projectDir: dirPath, + jsonlFile: file, + fileOffset: 0, + lineBuffer: '', + activeToolIds: new Set(), + activeToolStatuses: new Map(), + activeToolNames: new Map(), + activeSubagentToolIds: new Map(), + activeSubagentToolNames: new Map(), + isWaiting: false, + permissionSent: false, + hadToolsInTurn: false, + folderName, + }; + + agents.set(id, agent); + console.log(`[CLI] New session detected: ${path.basename(file)} in ${dirName} → agent ${id}`); + onAgentCreated(id, folderName); + + startFileWatching( + id, file, agents, + fileWatchers, pollingTimers, waitingTimers, permissionTimers, + opts.webview as never, + ); + readNewLines(id, agents, waitingTimers, permissionTimers, opts.webview as never); + } + } catch { /* skip inaccessible dirs */ } + } + } catch { /* ignore scan errors */ } +} diff --git a/cli/vscode-stub.ts b/cli/vscode-stub.ts new file mode 100644 index 00000000..5e946e71 --- /dev/null +++ b/cli/vscode-stub.ts @@ -0,0 +1,38 @@ +/** + * Minimal vscode stub for CLI builds. + * + * The CLI reuses backend modules (fileWatcher, assetLoader, etc.) that import from 'vscode'. + * Only type-level and a few runtime references exist in the code paths the CLI actually uses. + * This stub satisfies the require('vscode') calls at bundle time without pulling in the real API. + */ + +// The CLI code paths never actually call vscode.window or vscode.workspace functions, +// but the modules that are bundled contain references to them. +export const window = { + activeTerminal: undefined, + terminals: [], + createTerminal: () => ({}), + showWarningMessage: () => {}, + showInformationMessage: () => {}, + showErrorMessage: () => {}, + showSaveDialog: async () => undefined, + showOpenDialog: async () => undefined, + onDidChangeActiveTerminal: () => ({ dispose: () => {} }), + onDidCloseTerminal: () => ({ dispose: () => {} }), +}; + +export const workspace = { + workspaceFolders: undefined, +}; + +export const env = { + openExternal: () => {}, +}; + +export class Uri { + static file(_path: string) { return { fsPath: _path }; } + static joinPath(base: { fsPath: string }, ...segments: string[]) { + const path = require('path'); + return { fsPath: path.join(base.fsPath, ...segments) }; + } +} diff --git a/esbuild.js b/esbuild.js index efcc3254..27fa659c 100644 --- a/esbuild.js +++ b/esbuild.js @@ -4,6 +4,7 @@ const path = require("path"); const production = process.argv.includes('--production'); const watch = process.argv.includes('--watch'); +const cliBuild = process.argv.includes('--cli'); /** * Copy assets folder to dist/assets @@ -46,7 +47,7 @@ const esbuildProblemMatcherPlugin = { }, }; -async function main() { +async function buildExtension() { const ctx = await esbuild.context({ entryPoints: [ 'src/extension.ts' @@ -75,6 +76,49 @@ async function main() { } } +async function buildCli() { + console.log('Building CLI...'); + const ctx = await esbuild.context({ + entryPoints: ['cli/main.ts'], + bundle: true, + format: 'cjs', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'node', + outfile: 'dist/cli.js', + alias: { + 'vscode': './cli/vscode-stub.ts', + }, + banner: { + js: '#!/usr/bin/env node', + }, + logLevel: 'silent', + plugins: [esbuildProblemMatcherPlugin], + }); + if (watch) { + await ctx.watch(); + } else { + await ctx.rebuild(); + await ctx.dispose(); + // Copy assets for CLI too + copyAssets(); + // Make CLI executable + try { + fs.chmodSync(path.join(__dirname, 'dist', 'cli.js'), '755'); + } catch { /* ignore on Windows */ } + console.log('✓ CLI built → dist/cli.js'); + } +} + +async function main() { + if (cliBuild) { + await buildCli(); + } else { + await buildExtension(); + } +} + main().catch(e => { console.error(e); process.exit(1); diff --git a/package-lock.json b/package-lock.json index 68c701a2..cb2db3e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,22 @@ { "name": "pixel-agents", - "version": "0.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixel-agents", - "version": "0.0.1", + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "ws": "^8.19.0" + }, "devDependencies": { "@anthropic-ai/sdk": "^0.74.0", "@types/node": "22.x", "@types/pngjs": "^6.0.5", - "@types/vscode": "^1.109.0", + "@types/vscode": "^1.100.0", + "@types/ws": "^8.18.1", "esbuild": "^0.27.2", "eslint": "^9.39.2", "npm-run-all": "^4.1.5", @@ -21,7 +26,7 @@ "typescript-eslint": "^8.54.0" }, "engines": { - "vscode": "^1.109.0" + "vscode": "^1.100.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -782,6 +787,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -827,7 +842,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1019,7 +1033,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1628,7 +1641,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3825,7 +3837,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3970,7 +3981,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4172,6 +4182,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 93fde8ee..d4b89f74 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "icon": "icon.png", "license": "MIT", "engines": { - "vscode": "^1.107.0" + "vscode": "^1.100.0" }, "categories": [ "Other" @@ -48,11 +48,16 @@ ] } }, + "bin": { + "pixel-agents": "./dist/cli.js" + }, "scripts": { "vscode:prepublish": "npm run package", "build:webview": "cd webview-ui && npm run build", + "build:cli": "node esbuild.js --cli", "compile": "npm run check-types && npm run lint && node esbuild.js && npm run build:webview", "build": "npm run compile", + "start": "node dist/cli.js", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", @@ -65,7 +70,8 @@ "@anthropic-ai/sdk": "^0.74.0", "@types/node": "22.x", "@types/pngjs": "^6.0.5", - "@types/vscode": "^1.107.0", + "@types/vscode": "^1.100.0", + "@types/ws": "^8.18.1", "esbuild": "^0.27.2", "eslint": "^9.39.2", "npm-run-all": "^4.1.5", @@ -73,5 +79,8 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0" + }, + "dependencies": { + "ws": "^8.19.0" } } diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index fe78bd46..055ba7b1 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -74,12 +74,12 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { } else if (message.type === 'focusAgent') { const agent = this.agents.get(message.id); if (agent) { - agent.terminalRef.show(); + agent.terminalRef?.show(); } } else if (message.type === 'closeAgent') { const agent = this.agents.get(message.id); if (agent) { - agent.terminalRef.dispose(); + agent.terminalRef?.dispose(); } } else if (message.type === 'saveAgentSeats') { // Store seat assignments in a separate key (never touched by persistAgents) diff --git a/src/agentManager.ts b/src/agentManager.ts index 4c53af84..99d4b336 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -147,7 +147,7 @@ export function persistAgents( for (const agent of agents.values()) { persisted.push({ id: agent.id, - terminalName: agent.terminalRef.name, + terminalName: agent.terminalRef?.name || '', jsonlFile: agent.jsonlFile, projectDir: agent.projectDir, folderName: agent.folderName, diff --git a/src/types.ts b/src/types.ts index 973afa3b..be69e96c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import type * as vscode from 'vscode'; export interface AgentState { id: number; - terminalRef: vscode.Terminal; + terminalRef?: vscode.Terminal; projectDir: string; jsonlFile: string; fileOffset: number; diff --git a/tsconfig.json b/tsconfig.json index 6c8aafd1..17b02da3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "webview-ui", "dist", "out", - "scripts" + "scripts", + "cli" ] } diff --git a/webview-ui/src/components/BottomToolbar.tsx b/webview-ui/src/components/BottomToolbar.tsx index 48d17419..db42c118 100644 --- a/webview-ui/src/components/BottomToolbar.tsx +++ b/webview-ui/src/components/BottomToolbar.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { SettingsModal } from './SettingsModal.js' import type { WorkspaceFolder } from '../hooks/useExtensionMessages.js' -import { vscode } from '../vscodeApi.js' +import { vscode, isCliMode } from '../vscodeApi.js' interface BottomToolbarProps { isEditMode: boolean @@ -87,65 +87,78 @@ export function BottomToolbar({ return (
-
- - {isFolderPickerOpen && ( -
+ ) : ( +
+ - ))} -
- )} -
+ + Agent + + {isFolderPickerOpen && ( +
+ {workspaceFolders.map((folder, i) => ( + + ))} +
+ )} +
+ )}
{/* Menu items */} + {!isCliMode && ( + + )} -