@bytespell/shella 0.2.3 → 0.2.5
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.
- package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
- package/bundled-plugins/agent/README.md +7 -0
- package/bundled-plugins/agent/components.json +24 -0
- package/bundled-plugins/agent/eslint.config.js +23 -0
- package/bundled-plugins/agent/index.html +13 -0
- package/bundled-plugins/agent/package-lock.json +12140 -0
- package/bundled-plugins/agent/package.json +62 -0
- package/bundled-plugins/agent/public/vite.svg +1 -0
- package/bundled-plugins/agent/server.js +631 -0
- package/bundled-plugins/agent/src/App.tsx +755 -0
- package/bundled-plugins/agent/src/assets/react.svg +1 -0
- package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
- package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
- package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
- package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
- package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
- package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
- package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
- package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
- package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
- package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
- package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
- package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
- package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
- package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
- package/bundled-plugins/agent/src/index.css +131 -0
- package/bundled-plugins/agent/src/lib/utils.ts +6 -0
- package/bundled-plugins/agent/src/main.tsx +11 -0
- package/bundled-plugins/agent/src/reducer.test.ts +359 -0
- package/bundled-plugins/agent/src/reducer.ts +255 -0
- package/bundled-plugins/agent/src/store.ts +379 -0
- package/bundled-plugins/agent/src/types.ts +98 -0
- package/bundled-plugins/agent/src/utils.test.ts +393 -0
- package/bundled-plugins/agent/src/utils.ts +158 -0
- package/bundled-plugins/agent/tsconfig.app.json +32 -0
- package/bundled-plugins/agent/tsconfig.json +13 -0
- package/bundled-plugins/agent/tsconfig.node.json +26 -0
- package/bundled-plugins/agent/vite.config.ts +14 -0
- package/bundled-plugins/agent/vitest.config.ts +17 -0
- package/bundled-plugins/terminal/README.md +7 -0
- package/bundled-plugins/terminal/index.html +24 -0
- package/bundled-plugins/terminal/package-lock.json +3346 -0
- package/bundled-plugins/terminal/package.json +38 -0
- package/bundled-plugins/terminal/server.ts +265 -0
- package/bundled-plugins/terminal/src/App.tsx +153 -0
- package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
- package/bundled-plugins/terminal/src/main.tsx +9 -0
- package/bundled-plugins/terminal/src/store.ts +114 -0
- package/bundled-plugins/terminal/tsconfig.json +22 -0
- package/bundled-plugins/terminal/vite.config.ts +10 -0
- package/package.json +1 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.2.5",
|
|
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();
|