@bytespell/shella 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
  2. package/bundled-plugins/agent/README.md +7 -0
  3. package/bundled-plugins/agent/components.json +24 -0
  4. package/bundled-plugins/agent/eslint.config.js +23 -0
  5. package/bundled-plugins/agent/index.html +13 -0
  6. package/bundled-plugins/agent/package-lock.json +12140 -0
  7. package/bundled-plugins/agent/package.json +62 -0
  8. package/bundled-plugins/agent/public/vite.svg +1 -0
  9. package/bundled-plugins/agent/server.js +631 -0
  10. package/bundled-plugins/agent/src/App.tsx +755 -0
  11. package/bundled-plugins/agent/src/assets/react.svg +1 -0
  12. package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
  13. package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
  14. package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
  15. package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
  16. package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
  17. package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
  18. package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
  19. package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
  20. package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
  21. package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
  22. package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
  23. package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
  24. package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
  25. package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
  26. package/bundled-plugins/agent/src/index.css +131 -0
  27. package/bundled-plugins/agent/src/lib/utils.ts +6 -0
  28. package/bundled-plugins/agent/src/main.tsx +11 -0
  29. package/bundled-plugins/agent/src/reducer.test.ts +359 -0
  30. package/bundled-plugins/agent/src/reducer.ts +255 -0
  31. package/bundled-plugins/agent/src/store.ts +379 -0
  32. package/bundled-plugins/agent/src/types.ts +98 -0
  33. package/bundled-plugins/agent/src/utils.test.ts +393 -0
  34. package/bundled-plugins/agent/src/utils.ts +158 -0
  35. package/bundled-plugins/agent/tsconfig.app.json +32 -0
  36. package/bundled-plugins/agent/tsconfig.json +13 -0
  37. package/bundled-plugins/agent/tsconfig.node.json +26 -0
  38. package/bundled-plugins/agent/vite.config.ts +14 -0
  39. package/bundled-plugins/agent/vitest.config.ts +17 -0
  40. package/bundled-plugins/terminal/README.md +7 -0
  41. package/bundled-plugins/terminal/index.html +24 -0
  42. package/bundled-plugins/terminal/package-lock.json +3346 -0
  43. package/bundled-plugins/terminal/package.json +38 -0
  44. package/bundled-plugins/terminal/server.ts +265 -0
  45. package/bundled-plugins/terminal/src/App.tsx +153 -0
  46. package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
  47. package/bundled-plugins/terminal/src/main.tsx +9 -0
  48. package/bundled-plugins/terminal/src/store.ts +114 -0
  49. package/bundled-plugins/terminal/tsconfig.json +22 -0
  50. package/bundled-plugins/terminal/vite.config.ts +10 -0
  51. package/dist/src/plugin-manager.js +1 -1
  52. package/dist/src/plugin-manager.js.map +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "agent",
3
+ "private": true,
4
+ "version": "0.2.6",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "tsc -b && vite build",
10
+ "lint": "eslint .",
11
+ "preview": "vite preview",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
14
+ },
15
+ "dependencies": {
16
+ "@base-ui/react": "^1.1.0",
17
+ "@fontsource-variable/inter": "^5.2.8",
18
+ "@pierre/diffs": "^1.0.7",
19
+ "@streamdown/code": "^1.0.1",
20
+ "@tailwindcss/typography": "^0.5.19",
21
+ "@tailwindcss/vite": "^4.1.17",
22
+ "class-variance-authority": "^0.7.1",
23
+ "clsx": "^2.1.1",
24
+ "express": "^5.0.0",
25
+ "lucide-react": "^0.562.0",
26
+ "radix-ui": "^1.4.3",
27
+ "react": "^19.2.0",
28
+ "react-dom": "^19.2.0",
29
+ "shadcn": "^3.7.0",
30
+ "streamdown": "^2.1.0",
31
+ "tailwind-merge": "^3.4.0",
32
+ "tailwindcss": "^4.1.17",
33
+ "tw-animate-css": "^1.4.0",
34
+ "use-stick-to-bottom": "^1.1.1",
35
+ "ws": "^8.19.0",
36
+ "zustand": "^5.0.10"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^9.39.1",
40
+ "@testing-library/dom": "^10.4.1",
41
+ "@testing-library/react": "^16.3.2",
42
+ "@types/jsdom": "^27.0.0",
43
+ "@types/node": "^24.10.1",
44
+ "@types/react": "^19.2.5",
45
+ "@types/react-dom": "^19.2.3",
46
+ "@vitejs/plugin-react": "^5.1.1",
47
+ "eslint": "^9.39.1",
48
+ "eslint-plugin-react-hooks": "^7.0.1",
49
+ "eslint-plugin-react-refresh": "^0.4.24",
50
+ "globals": "^16.5.0",
51
+ "happy-dom": "^20.3.4",
52
+ "jsdom": "^27.4.0",
53
+ "typescript": "~5.9.3",
54
+ "typescript-eslint": "^8.46.4",
55
+ "vite": "^7.2.4",
56
+ "vitest": "^4.0.17"
57
+ },
58
+ "shella": {
59
+ "displayName": "Agent",
60
+ "devCommand": "node server.js"
61
+ }
62
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,631 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { spawn } from 'child_process';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import { fileURLToPath } from 'url';
9
+ import readline from 'readline';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const app = express();
13
+ const PORT = process.env.PORT;
14
+ const INSTANCE_ID = process.env.INSTANCE_ID;
15
+ const isDev = !fs.existsSync(path.join(__dirname, 'dist', 'index.html'));
16
+ const PI_AUTH_PATH = path.join(os.homedir(), '.pi', 'agent', 'auth.json');
17
+ const PI_SESSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'sessions');
18
+ const AGENT_STATE_DIR = path.join(os.homedir(), '.local', 'state', 'shella', 'agent');
19
+ const AGENT_STATE_FILE = path.join(AGENT_STATE_DIR, `instance-${INSTANCE_ID}.json`);
20
+
21
+ // Convert cwd to session directory name (matches pi's encoding)
22
+ function cwdToSessionDirName(cwd) {
23
+ return `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
24
+ }
25
+
26
+ // Convert session directory name back to cwd path
27
+ function sessionDirNameToCwd(dirName) {
28
+ // Format: --path-to-dir-- -> /path/to/dir
29
+ const inner = dirName.replace(/^--/, '').replace(/--$/, '');
30
+ return '/' + inner.replace(/-/g, '/');
31
+ }
32
+
33
+ // Extract cwd from a session file path
34
+ function cwdFromSessionPath(sessionPath) {
35
+ // Session path format: ~/.pi/agent/sessions/--path-encoded--/session.jsonl
36
+ const sessionsDir = PI_SESSIONS_DIR;
37
+ if (!sessionPath.startsWith(sessionsDir)) {
38
+ return null;
39
+ }
40
+ const relativePath = sessionPath.slice(sessionsDir.length + 1); // +1 for trailing /
41
+ const dirName = relativePath.split('/')[0]; // Get the --path-encoded-- part
42
+ if (dirName.startsWith('--') && dirName.endsWith('--')) {
43
+ return sessionDirNameToCwd(dirName);
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // Load persisted agent state
49
+ function loadAgentState() {
50
+ console.log('[agent] Loading state for instance:', INSTANCE_ID);
51
+ console.log('[agent] State file path:', AGENT_STATE_FILE);
52
+ try {
53
+ if (fs.existsSync(AGENT_STATE_FILE)) {
54
+ const data = JSON.parse(fs.readFileSync(AGENT_STATE_FILE, 'utf-8'));
55
+ console.log('[agent] Loaded persisted state:', JSON.stringify(data));
56
+ return data;
57
+ } else {
58
+ console.log('[agent] No state file found, starting fresh');
59
+ }
60
+ } catch (err) {
61
+ console.log('[agent] Failed to load persisted state:', err.message);
62
+ }
63
+ return {};
64
+ }
65
+
66
+ // Save agent state to disk
67
+ function saveAgentState(state) {
68
+ try {
69
+ fs.mkdirSync(AGENT_STATE_DIR, { recursive: true });
70
+ fs.writeFileSync(AGENT_STATE_FILE, JSON.stringify(state, null, 2));
71
+ console.log('[agent] Saved state:', state);
72
+ } catch (err) {
73
+ console.log('[agent] Failed to save state:', err.message);
74
+ }
75
+ }
76
+
77
+ // List sessions for a given cwd
78
+ async function listSessions(cwd, currentSessionFile) {
79
+ const sessionDirName = cwdToSessionDirName(cwd);
80
+ const sessionDir = path.join(PI_SESSIONS_DIR, sessionDirName);
81
+
82
+ if (!fs.existsSync(sessionDir)) {
83
+ return [];
84
+ }
85
+
86
+ const files = fs.readdirSync(sessionDir)
87
+ .filter(f => f.endsWith('.jsonl'))
88
+ .sort()
89
+ .reverse(); // Most recent first
90
+
91
+ const sessions = [];
92
+ for (const file of files) {
93
+ const filePath = path.join(sessionDir, file);
94
+ try {
95
+ const content = fs.readFileSync(filePath, 'utf-8');
96
+ const lines = content.split('\n').filter(l => l.trim());
97
+
98
+ let header = null;
99
+ let name = null;
100
+ let lastUserMessage = null;
101
+ let messageCount = 0;
102
+ let created = null;
103
+
104
+ for (const line of lines) {
105
+ try {
106
+ const entry = JSON.parse(line);
107
+
108
+ // Get header
109
+ if (entry.type === 'session') {
110
+ header = entry;
111
+ created = entry.timestamp;
112
+ }
113
+
114
+ // Get name from session_info entry (use latest)
115
+ if (entry.type === 'session_info' && entry.name) {
116
+ name = entry.name.trim();
117
+ }
118
+
119
+ // Count messages and track last user message as fallback name
120
+ if (entry.type === 'message' && entry.message) {
121
+ messageCount++;
122
+ if (entry.message.role === 'user') {
123
+ // Extract text from content
124
+ const content = entry.message.content;
125
+ if (typeof content === 'string') {
126
+ lastUserMessage = content.slice(0, 100);
127
+ } else if (Array.isArray(content)) {
128
+ const textBlock = content.find(b => b.type === 'text');
129
+ if (textBlock?.text) {
130
+ lastUserMessage = textBlock.text.slice(0, 100);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ } catch { /* skip malformed lines */ }
136
+ }
137
+
138
+ if (header) {
139
+ sessions.push({
140
+ id: header.id,
141
+ name: name || lastUserMessage || null,
142
+ file: filePath,
143
+ messageCount,
144
+ created: created ? new Date(created).getTime() : null,
145
+ lastModified: fs.statSync(filePath).mtimeMs,
146
+ isCurrent: filePath === currentSessionFile,
147
+ });
148
+ }
149
+ } catch (err) {
150
+ console.log('[agent] Error reading session file:', file, err.message);
151
+ }
152
+ }
153
+
154
+ return sessions;
155
+ }
156
+
157
+ // Load persisted state
158
+ const persistedState = loadAgentState();
159
+
160
+ // Current working directory for pi process (restore from persisted state)
161
+ let currentCwd = persistedState.cwd || os.homedir();
162
+ // Current session file path (tracked from get_state responses)
163
+ let currentSessionFile = persistedState.sessionFile || null;
164
+
165
+ // Allow embedding in iframes
166
+ app.use((req, res, next) => {
167
+ res.removeHeader('X-Frame-Options');
168
+ res.setHeader('Content-Security-Policy', 'frame-ancestors *');
169
+ next();
170
+ });
171
+
172
+ app.use(express.json());
173
+
174
+ // Health check
175
+ app.get('/api/health', (req, res) => {
176
+ res.json({ status: 'ok' });
177
+ });
178
+
179
+ // Check auth status
180
+ app.get('/api/auth/status', (req, res) => {
181
+ try {
182
+ if (fs.existsSync(PI_AUTH_PATH)) {
183
+ const authData = JSON.parse(fs.readFileSync(PI_AUTH_PATH, 'utf-8'));
184
+ const providers = Object.keys(authData);
185
+ res.json({ authenticated: providers.length > 0, providers });
186
+ } else {
187
+ res.json({ authenticated: false, providers: [] });
188
+ }
189
+ } catch {
190
+ res.json({ authenticated: false, providers: [] });
191
+ }
192
+ });
193
+
194
+ // List directories for picker
195
+ app.get('/api/directories', (req, res) => {
196
+ const dirPath = req.query.path || os.homedir();
197
+ try {
198
+ const stats = fs.statSync(dirPath);
199
+ if (!stats.isDirectory()) {
200
+ return res.status(400).json({ error: 'Not a directory' });
201
+ }
202
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
203
+ const directories = entries
204
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
205
+ .map(e => e.name)
206
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
207
+ res.json({
208
+ path: dirPath,
209
+ parent: path.dirname(dirPath),
210
+ directories,
211
+ isRoot: dirPath === '/' || dirPath === path.dirname(dirPath),
212
+ });
213
+ } catch (err) {
214
+ res.status(400).json({ error: err.message });
215
+ }
216
+ });
217
+
218
+ async function startServer() {
219
+ if (isDev) {
220
+ const { createServer: createViteServer } = await import('vite');
221
+ const vite = await createViteServer({
222
+ server: { middlewareMode: true, hmr: { port: Number(PORT) + 1000 } },
223
+ appType: 'spa',
224
+ });
225
+ app.use(vite.middlewares);
226
+ } else {
227
+ app.use(express.static(path.join(__dirname, 'dist')));
228
+ app.get('/{*splat}', (req, res) => {
229
+ res.sendFile(path.join(__dirname, 'dist', 'index.html'));
230
+ });
231
+ }
232
+
233
+ const server = createServer(app);
234
+
235
+ // Single pi process shared across all clients
236
+ let piProcess = null;
237
+ let rl = null;
238
+ const clients = new Set();
239
+
240
+ function broadcast(message) {
241
+ for (const client of clients) {
242
+ if (client.readyState === WebSocket.OPEN) {
243
+ client.send(message);
244
+ }
245
+ }
246
+ }
247
+
248
+ function sendCommand(cmd) {
249
+ if (piProcess?.stdin?.writable) {
250
+ console.log('[agent] Sending command:', JSON.stringify(cmd));
251
+ piProcess.stdin.write(JSON.stringify(cmd) + '\n');
252
+ } else {
253
+ console.log('[agent] WARNING: Cannot send command, stdin not writable. piProcess:', !!piProcess, 'stdin:', !!piProcess?.stdin, 'writable:', piProcess?.stdin?.writable);
254
+ console.log('[agent] Dropped command:', JSON.stringify(cmd));
255
+ }
256
+ }
257
+
258
+ let piReady = false;
259
+ let onPiReady = null;
260
+
261
+ // Current session file to load (null = new session)
262
+ // Initialize from persisted state if available
263
+ let currentSessionPath = currentSessionFile;
264
+
265
+ function spawnPi() {
266
+ if (piProcess) return;
267
+
268
+ const args = ['--mode', 'rpc'];
269
+ if (currentSessionPath) {
270
+ args.push('--session', currentSessionPath);
271
+ }
272
+ console.log('[agent] Spawning pi', args.join(' '));
273
+ piReady = false;
274
+
275
+ piProcess = spawn('pi', args, {
276
+ stdio: ['pipe', 'pipe', 'pipe'],
277
+ env: { ...process.env },
278
+ cwd: currentCwd,
279
+ });
280
+
281
+ piProcess.on('spawn', () => {
282
+ console.log('[agent] pi process spawned and ready');
283
+ piReady = true;
284
+ if (onPiReady) {
285
+ onPiReady();
286
+ onPiReady = null;
287
+ }
288
+ });
289
+
290
+ rl = readline.createInterface({
291
+ input: piProcess.stdout,
292
+ crlfDelay: Infinity
293
+ });
294
+
295
+ rl.on('line', (line) => {
296
+ try {
297
+ const event = JSON.parse(line);
298
+ // Log different event types
299
+ switch (event.type) {
300
+ case 'response':
301
+ console.log('[agent] Response:', event.command, event.success ? 'success' : 'failed',
302
+ event.data ? `data keys: ${Object.keys(event.data)}` : 'no data');
303
+ // Track current session file for session listing and persist it
304
+ if (event.command === 'get_state' && event.success && event.data?.sessionFile) {
305
+ if (currentSessionFile !== event.data.sessionFile) {
306
+ currentSessionFile = event.data.sessionFile;
307
+ saveAgentState({ cwd: currentCwd, sessionFile: currentSessionFile });
308
+ }
309
+ }
310
+ break;
311
+ case 'agent_start':
312
+ console.log('[agent] Agent started');
313
+ break;
314
+ case 'agent_end':
315
+ console.log('[agent] Agent ended:', event.stopReason || 'unknown reason');
316
+ break;
317
+ case 'tool_execution_start':
318
+ console.log('[agent] Tool start:', event.toolName, 'id:', event.toolCallId);
319
+ if (event.args) {
320
+ const argsStr = JSON.stringify(event.args);
321
+ console.log('[agent] args:', argsStr.length > 200 ? argsStr.slice(0, 200) + '...' : argsStr);
322
+ }
323
+ break;
324
+ case 'tool_execution_update':
325
+ // Don't log updates to avoid spam, but could enable for debugging
326
+ break;
327
+ case 'tool_execution_end':
328
+ console.log('[agent] Tool end:', event.toolCallId, event.isError ? 'ERROR' : 'success');
329
+ break;
330
+ case 'message_start':
331
+ console.log('[agent] Message start:', event.message?.role || 'unknown role');
332
+ break;
333
+ case 'message_update':
334
+ // Don't log message updates to avoid spam
335
+ break;
336
+ case 'message_end':
337
+ console.log('[agent] Message end');
338
+ break;
339
+ default:
340
+ console.log('[agent] Event:', event.type);
341
+ }
342
+ broadcast(JSON.stringify({ type: 'rpc_event', event }));
343
+ } catch {
344
+ console.log('[agent] Non-JSON output:', line);
345
+ }
346
+ });
347
+
348
+ piProcess.stderr?.on('data', (data) => {
349
+ console.error('[agent] stderr:', data.toString());
350
+ });
351
+
352
+ const thisProcess = piProcess;
353
+ piProcess.on('exit', (code, signal) => {
354
+ console.log(`[agent] pi exited: code=${code}, signal=${signal}`);
355
+ // Only clear/broadcast if this is still the current process (not if we've already respawned)
356
+ if (piProcess === thisProcess) {
357
+ piProcess = null;
358
+ rl?.close();
359
+ rl = null;
360
+ broadcast(JSON.stringify({ type: 'process_exit', code, signal }));
361
+ }
362
+ });
363
+
364
+ piProcess.on('error', (err) => {
365
+ console.error('[agent] error:', err);
366
+ broadcast(JSON.stringify({
367
+ type: 'error',
368
+ message: `Failed to start pi: ${err.message}`
369
+ }));
370
+ });
371
+
372
+ sendCommand({ type: 'get_state' });
373
+ sendCommand({ type: 'get_messages' });
374
+ }
375
+
376
+ spawnPi();
377
+
378
+ const wss = new WebSocketServer({ server, path: '/ws' });
379
+
380
+ wss.on('connection', (ws) => {
381
+ console.log('[agent] Client connected, currentCwd:', currentCwd);
382
+ clients.add(ws);
383
+
384
+ if (piProcess) {
385
+ console.log('[agent] Sending ready with cwd:', currentCwd);
386
+ ws.send(JSON.stringify({ type: 'ready', cwd: currentCwd }));
387
+ sendCommand({ type: 'get_state' });
388
+ sendCommand({ type: 'get_messages' });
389
+ sendCommand({ type: 'get_session_stats' });
390
+ } else {
391
+ ws.send(JSON.stringify({ type: 'error', message: 'Pi process not running' }));
392
+ }
393
+
394
+ ws.on('message', (message) => {
395
+ try {
396
+ const msg = JSON.parse(message.toString());
397
+
398
+ switch (msg.type) {
399
+ case 'start':
400
+ if (!piProcess) spawnPi();
401
+ ws.send(JSON.stringify({ type: 'ready' }));
402
+ break;
403
+ case 'command':
404
+ if (msg.command) {
405
+ // Handle get_sessions locally (pi doesn't have this command)
406
+ if (msg.command.type === 'get_sessions') {
407
+ // Get current session file from pi state if we have it
408
+ // For now, we'll mark current based on sessionFile from get_state response
409
+ listSessions(currentCwd, currentSessionFile).then(sessions => {
410
+ ws.send(JSON.stringify({
411
+ type: 'rpc_event',
412
+ event: {
413
+ type: 'response',
414
+ command: 'get_sessions',
415
+ success: true,
416
+ data: { sessions }
417
+ }
418
+ }));
419
+ }).catch(err => {
420
+ ws.send(JSON.stringify({
421
+ type: 'rpc_event',
422
+ event: {
423
+ type: 'response',
424
+ command: 'get_sessions',
425
+ success: false,
426
+ error: err.message
427
+ }
428
+ }));
429
+ });
430
+ } else if (msg.command.type === 'switch_session') {
431
+ // Switch session by respawning pi with --session flag
432
+ const sessionPath = msg.command.sessionPath;
433
+ if (sessionPath && fs.existsSync(sessionPath)) {
434
+ console.log('[agent] Switching session to:', sessionPath);
435
+ currentSessionPath = sessionPath;
436
+ currentSessionFile = sessionPath;
437
+
438
+ // Extract cwd from session path and update if different
439
+ const sessionCwd = cwdFromSessionPath(sessionPath);
440
+ const cwdChanged = sessionCwd && sessionCwd !== currentCwd;
441
+ if (cwdChanged) {
442
+ console.log('[agent] Session cwd differs, updating from', currentCwd, 'to', sessionCwd);
443
+ currentCwd = sessionCwd;
444
+ }
445
+
446
+ // Persist state
447
+ saveAgentState({ cwd: currentCwd, sessionFile: currentSessionFile });
448
+
449
+ // Kill current process and wait for exit before respawning
450
+ const oldProcess = piProcess;
451
+ const oldRl = rl;
452
+ piProcess = null;
453
+ rl = null;
454
+
455
+ const doSpawn = () => {
456
+ console.log('[agent] Spawning new pi for session switch');
457
+ // Respawn with new session, notify clients when ready
458
+ // Also fetch messages automatically after switching
459
+ onPiReady = () => {
460
+ console.log('[agent] Pi ready after session switch, sending response');
461
+
462
+ // If cwd changed, notify clients first
463
+ if (cwdChanged) {
464
+ broadcast(JSON.stringify({ type: 'cwd_changed', cwd: currentCwd }));
465
+ }
466
+
467
+ // Send success response
468
+ broadcast(JSON.stringify({
469
+ type: 'rpc_event',
470
+ event: {
471
+ type: 'response',
472
+ command: 'switch_session',
473
+ success: true,
474
+ data: { sessionPath, cwd: currentCwd }
475
+ }
476
+ }));
477
+ // Also request messages and state so client gets them
478
+ console.log('[agent] Requesting messages after session switch');
479
+ sendCommand({ type: 'get_messages' });
480
+ sendCommand({ type: 'get_state' });
481
+ sendCommand({ type: 'get_session_stats' });
482
+ };
483
+ spawnPi();
484
+ };
485
+
486
+ if (oldProcess) {
487
+ console.log('[agent] Killing old pi process, waiting for exit');
488
+ oldRl?.close();
489
+ oldProcess.once('exit', () => {
490
+ console.log('[agent] Old pi process exited');
491
+ doSpawn();
492
+ });
493
+ oldProcess.kill();
494
+ } else {
495
+ doSpawn();
496
+ }
497
+ } else {
498
+ ws.send(JSON.stringify({
499
+ type: 'rpc_event',
500
+ event: {
501
+ type: 'response',
502
+ command: 'switch_session',
503
+ success: false,
504
+ error: 'Session file not found'
505
+ }
506
+ }));
507
+ }
508
+ } else if (msg.command.type === 'new_session') {
509
+ // New session by respawning pi without --session flag
510
+ console.log('[agent] Starting new session');
511
+ currentSessionPath = null;
512
+ currentSessionFile = null;
513
+
514
+ // Kill current process and wait for exit before respawning
515
+ const oldProcess = piProcess;
516
+ const oldRl = rl;
517
+ piProcess = null;
518
+ rl = null;
519
+
520
+ const doSpawn = () => {
521
+ console.log('[agent] Spawning new pi for new session');
522
+ onPiReady = () => {
523
+ broadcast(JSON.stringify({
524
+ type: 'rpc_event',
525
+ event: {
526
+ type: 'response',
527
+ command: 'new_session',
528
+ success: true,
529
+ data: {}
530
+ }
531
+ }));
532
+ };
533
+ spawnPi();
534
+ };
535
+
536
+ if (oldProcess) {
537
+ console.log('[agent] Killing old pi process, waiting for exit');
538
+ oldRl?.close();
539
+ oldProcess.once('exit', () => {
540
+ console.log('[agent] Old pi process exited');
541
+ doSpawn();
542
+ });
543
+ oldProcess.kill();
544
+ } else {
545
+ doSpawn();
546
+ }
547
+ } else {
548
+ sendCommand(msg.command);
549
+ }
550
+ }
551
+ break;
552
+ case 'prompt':
553
+ sendCommand({ type: 'prompt', message: msg.message, images: msg.images });
554
+ break;
555
+ case 'abort':
556
+ sendCommand({ type: 'abort' });
557
+ break;
558
+ case 'steer':
559
+ sendCommand({ type: 'steer', message: msg.message });
560
+ break;
561
+ case 'extension_ui_response':
562
+ sendCommand(msg);
563
+ break;
564
+ case 'change_cwd':
565
+ if (msg.path) {
566
+ try {
567
+ const stats = fs.statSync(msg.path);
568
+ if (stats.isDirectory()) {
569
+ console.log('[agent] Changing cwd to:', msg.path);
570
+ currentCwd = msg.path;
571
+ currentSessionPath = null; // New cwd means new session
572
+ currentSessionFile = null;
573
+ // Persist state
574
+ saveAgentState({ cwd: currentCwd, sessionFile: null });
575
+ // Kill current process - it will respawn with new cwd
576
+ if (piProcess) {
577
+ piProcess.kill();
578
+ piProcess = null;
579
+ rl?.close();
580
+ rl = null;
581
+ }
582
+ // Respawn with new cwd, wait for ready before notifying clients
583
+ onPiReady = () => {
584
+ broadcast(JSON.stringify({ type: 'cwd_changed', cwd: currentCwd }));
585
+ };
586
+ spawnPi();
587
+ // If already ready (shouldn't happen but just in case)
588
+ if (piReady && onPiReady) {
589
+ onPiReady();
590
+ onPiReady = null;
591
+ }
592
+ } else {
593
+ ws.send(JSON.stringify({ type: 'error', message: 'Path is not a directory' }));
594
+ }
595
+ } catch (err) {
596
+ ws.send(JSON.stringify({ type: 'error', message: `Invalid path: ${err.message}` }));
597
+ }
598
+ }
599
+ break;
600
+ }
601
+ } catch (err) {
602
+ console.error('[agent] Invalid message:', err);
603
+ }
604
+ });
605
+
606
+ ws.on('close', () => {
607
+ console.log('[agent] Client disconnected');
608
+ clients.delete(ws);
609
+ });
610
+
611
+ ws.on('error', (err) => {
612
+ console.error('[agent] WebSocket error:', err);
613
+ clients.delete(ws);
614
+ });
615
+ });
616
+
617
+ server.listen(PORT, () => {
618
+ console.log(`[agent] Running on port ${PORT}${isDev ? ' (dev mode)' : ''}`);
619
+ });
620
+
621
+ process.on('SIGTERM', () => {
622
+ console.log('[agent] Shutting down');
623
+ piProcess?.kill();
624
+ rl?.close();
625
+ for (const ws of clients) ws.close();
626
+ wss.close();
627
+ server.close(() => process.exit(0));
628
+ });
629
+ }
630
+
631
+ startServer();