diff --git a/cli/cliOrchestrator.ts b/cli/cliOrchestrator.ts new file mode 100644 index 00000000..91aa81e7 --- /dev/null +++ b/cli/cliOrchestrator.ts @@ -0,0 +1,289 @@ +/** + * 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 + 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; + private initialized = false; + + /** 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 + try { + this.defaultLayout = loadDefaultLayout(assetsRoot); + + if (fs.existsSync(path.join(assetsDir, 'characters'))) { + const charSprites = await loadCharacterSprites(assetsRoot); + if (charSprites) { + this.webview.postMessage({ + type: 'characterSpritesLoaded', + characters: charSprites.characters, + }); + } + } + + if (fs.existsSync(path.join(assetsDir, 'floors.png'))) { + const floorTiles = await loadFloorTiles(assetsRoot); + if (floorTiles) { + this.webview.postMessage({ + type: 'floorTilesLoaded', + sprites: floorTiles.sprites, + }); + } + } + + if (fs.existsSync(path.join(assetsDir, 'walls.png'))) { + const wallTiles = await loadWallTiles(assetsRoot); + if (wallTiles) { + this.webview.postMessage({ + type: 'wallTilesLoaded', + sprites: wallTiles.sprites, + }); + } + } + + 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 (once) + if (!this.layoutWatcher) { + this.layoutWatcher = watchLayoutFile((updatedLayout) => { + this.webview.postMessage({ type: 'layoutLoaded', layout: updatedLayout }); + }); + } + + // Send existing agents + this.sendExistingAgents(); + + // Start session scanner (once) + 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, + onAgentCreated: (agentId, folderName) => { + this.webview.postMessage({ type: 'agentCreated', id: agentId, folderName }); + }, + }); + this.sessionScannerDispose = scanner.dispose; + } + + this.initialized = true; + } + + 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 as Record).folderName) { + folderNames[id] = (agent as Record).folderName as string; + } + } + 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; + + 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(); + + for (const agent of this.agents.values()) { + try { + fs.unwatchFile(agent.jsonlFile); + } catch { + /* ignore */ + } + } + + 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..31959912 --- /dev/null +++ b/cli/main.ts @@ -0,0 +1,99 @@ +/** + * Pixel Agents CLI — Standalone pixel art office in the browser + * + * Serves the webview and auto-detects running Claude Code sessions. + * Usage: pixel-agents [--port ] + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +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 { + // In bundled mode: dist/cli.js → dist/ is the parent + // Use import.meta.url for ESM compatibility + const thisFile = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url); + const distDir = path.dirname(thisFile); + const webviewDir = path.join(distDir, 'webview'); + + if (!fs.existsSync(webviewDir)) { + console.error(`Webview directory not found: ${webviewDir}`); + console.error('Run "npm run build:webview" first.'); + process.exit(1); + } + + const assetsDir = path.join(distDir, 'assets'); + 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(`\nšŸŽ® Pixel Agents running at ${url}`); + console.log(' Watching for Claude Code sessions...'); + console.log(' Press Ctrl+C to stop.\n'); + openBrowser(url); + }); + + const shutdown = () => { + console.log('\nShutting down...'); + orchestrator.dispose(); + server.close(() => process.exit(0)); + 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..8c5cfcf1 --- /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. + */ + +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..46ad6c1f --- /dev/null +++ b/cli/server.ts @@ -0,0 +1,71 @@ +/** + * HTTP + WebSocket Server for Pixel Agents CLI + * + * Serves static files from dist/webview/ and handles WebSocket connections on /ws. + * Plain Node.js http.createServer — no Express needed. + */ + +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 + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(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..f5132852 --- /dev/null +++ b/cli/sessionScanner.ts @@ -0,0 +1,135 @@ +/** + * 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; +/** Only create agents for JSONL files modified within this window */ +const MAX_AGE_MS = 30_000; + +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; +} + +/** Derive a short readable name from the encoded directory name */ +function decodeFolderName(dirName: string): string { + const parts = dirName.split('-').filter(Boolean); + return parts[parts.length - 1] || dirName; +} + +/** Collect all JSONL file paths under ~/.claude/projects/ */ +function collectJsonlFiles(): string[] { + const files: string[] = []; + try { + if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return files; + const projectDirs = fs.readdirSync(CLAUDE_PROJECTS_DIR); + for (const dirName of projectDirs) { + const dirPath = path.join(CLAUDE_PROJECTS_DIR, dirName); + try { + if (!fs.statSync(dirPath).isDirectory()) continue; + for (const f of fs.readdirSync(dirPath)) { + if (f.endsWith('.jsonl')) { + files.push(path.join(dirPath, f)); + } + } + } catch { + /* skip inaccessible dirs */ + } + } + } catch { + /* projects dir may not exist */ + } + return files; +} + +export function startSessionScanner(opts: SessionScannerOptions): { dispose(): void } { + const { knownJsonlFiles } = opts; + + // Seed all existing JSONL files on startup (don't create agents for old sessions) + for (const f of collectJsonlFiles()) { + knownJsonlFiles.add(f); + } + + const timer = setInterval(() => scanForNewSessions(opts), SCAN_INTERVAL_MS); + return { dispose: () => clearInterval(timer) }; +} + +function scanForNewSessions(opts: SessionScannerOptions): void { + const { + agents, + nextAgentId, + knownJsonlFiles, + fileWatchers, + pollingTimers, + waitingTimers, + permissionTimers, + onAgentCreated, + } = opts; + + for (const file of collectJsonlFiles()) { + if (knownJsonlFiles.has(file)) continue; + knownJsonlFiles.add(file); + + // Only create agents for recently modified files + try { + const fstat = fs.statSync(file); + if (Date.now() - fstat.mtimeMs > MAX_AGE_MS) continue; + } catch { + continue; + } + + const dirName = path.basename(path.dirname(file)); + const id = nextAgentId.current++; + const folderName = decodeFolderName(dirName); + const agent: AgentState = { + id, + projectDir: path.dirname(file), + 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: ${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); + } +} diff --git a/cli/vscode-stub.ts b/cli/vscode-stub.ts new file mode 100644 index 00000000..cc706853 --- /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. + */ + +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 db17a127..34adb412 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'], bundle: true, @@ -73,6 +74,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(); + copyAssets(); + 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 2d81a91c..0a55ca6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,18 @@ "name": "pixel-agents", "version": "1.0.2", "license": "MIT", + "dependencies": { + "ws": "^8.19.0" + }, + "bin": { + "pixel-agents": "dist/cli.js" + }, "devDependencies": { "@anthropic-ai/sdk": "^0.74.0", "@types/node": "22.x", "@types/pngjs": "^6.0.5", "@types/vscode": "^1.107.0", + "@types/ws": "^8.18.1", "esbuild": "^0.27.2", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -788,6 +795,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", @@ -4732,6 +4749,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/package.json b/package.json index 5381e1a1..395fd6d4 100644 --- a/package.json +++ b/package.json @@ -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 && npm run build:webview", "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", @@ -72,6 +77,7 @@ "@types/node": "22.x", "@types/pngjs": "^6.0.5", "@types/vscode": "^1.107.0", + "@types/ws": "^8.18.1", "esbuild": "^0.27.2", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -85,6 +91,9 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.54.0" }, + "dependencies": { + "ws": "^8.19.0" + }, "lint-staged": { "src/**/*.ts": "eslint --fix", "webview-ui/src/**/*.{ts,tsx}": "eslint --fix", diff --git a/src/PixelAgentsViewProvider.ts b/src/PixelAgentsViewProvider.ts index 3cc0c459..d0979852 100644 --- a/src/PixelAgentsViewProvider.ts +++ b/src/PixelAgentsViewProvider.ts @@ -92,12 +92,12 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { ); } else if (message.type === 'focusAgent') { const agent = this.agents.get(message.id); - if (agent) { + if (agent?.terminalRef) { agent.terminalRef.show(); } } else if (message.type === 'closeAgent') { const agent = this.agents.get(message.id); - if (agent) { + if (agent?.terminalRef) { agent.terminalRef.dispose(); } } else if (message.type === 'saveAgentSeats') { @@ -307,7 +307,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { this.activeAgentId.current = null; if (!terminal) return; for (const [id, agent] of this.agents) { - if (agent.terminalRef === terminal) { + if (agent.terminalRef && agent.terminalRef === terminal) { this.activeAgentId.current = id; webviewView.webview.postMessage({ type: 'agentSelected', id }); break; @@ -317,7 +317,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider { vscode.window.onDidCloseTerminal((closed) => { for (const [id, agent] of this.agents) { - if (agent.terminalRef === closed) { + if (agent.terminalRef && agent.terminalRef === closed) { if (this.activeAgentId.current === id) { this.activeAgentId.current = null; } diff --git a/src/agentManager.ts b/src/agentManager.ts index 5d012711..9acf5f1d 100644 --- a/src/agentManager.ts +++ b/src/agentManager.ts @@ -181,6 +181,10 @@ export function persistAgents( ): void { const persisted: PersistedAgent[] = []; for (const agent of agents.values()) { + // CLI-mode agents have no terminal — skip them + if (!agent.terminalRef) { + continue; + } persisted.push({ id: agent.id, terminalName: agent.terminalRef.name, diff --git a/src/types.ts b/src/types.ts index feeec137..1c031190 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,8 @@ import type * as vscode from 'vscode'; export interface AgentState { id: number; - terminalRef: vscode.Terminal; + /** VS Code terminal that owns this agent. Undefined in standalone CLI mode. */ + 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 92744c74..7f0b10ec 100644 --- a/webview-ui/src/components/BottomToolbar.tsx +++ b/webview-ui/src/components/BottomToolbar.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { WorkspaceFolder } from '../hooks/useExtensionMessages.js'; -import { vscode } from '../vscodeApi.js'; +import { isCliMode, vscode } from '../vscodeApi.js'; import { SettingsModal } from './SettingsModal.js'; interface BottomToolbarProps { @@ -87,65 +87,78 @@ export function BottomToolbar({ return (
-
- - {isFolderPickerOpen && ( -
+ ) : ( +
+ - ))} -
- )} -
+ + Agent + + {isFolderPickerOpen && ( +
+ {workspaceFolders.map((folder, i) => ( + + ))} +
+ )} +
+ )}
{/* Menu items */} + {!isCliMode && ( + + )} -