pi2web 0.2.0
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/ANDROID_APP_FEATURES.md +256 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/VOICE_SERVER_SETUP.md +393 -0
- package/dist/agents.js +123 -0
- package/dist/auth.js +31 -0
- package/dist/mail-notifier.js +132 -0
- package/dist/main.js +90 -0
- package/dist/metadata-store.js +139 -0
- package/dist/sdk-backend.js +85 -0
- package/dist/server.js +419 -0
- package/dist/terminals.js +70 -0
- package/dist/voice.js +322 -0
- package/dist/workspaces.js +60 -0
- package/package.json +79 -0
- package/packages/edge-tts-server/README.md +68 -0
- package/packages/edge-tts-server/pyproject.toml +19 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__init__.py +3 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__main__.py +77 -0
- package/packages/protocol/openapi.yaml +38 -0
- package/packages/protocol/websocket.schema.json +17 -0
- package/web/app.js +544 -0
- package/web/icon.svg +19 -0
- package/web/index.html +84 -0
- package/web/styles.css +29 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
8
|
+
import { AuthStore } from './auth.js';
|
|
9
|
+
import { WorkspaceStore } from './workspaces.js';
|
|
10
|
+
import { AgentManager } from './agents.js';
|
|
11
|
+
import { TerminalManager } from './terminals.js';
|
|
12
|
+
import { MetadataStore } from './metadata-store.js';
|
|
13
|
+
import { SessionManager, VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
|
|
14
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
15
|
+
const json = (res, status, data) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(data)); };
|
|
16
|
+
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web'), require = createRequire(import.meta.url);
|
|
17
|
+
const markedBrowser = path.join(path.dirname(require.resolve('marked/package.json')), 'lib', 'marked.umd.js');
|
|
18
|
+
const webFiles = { '/': { file: 'index.html', type: 'text/html; charset=utf-8' }, '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' }, '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' }, '/styles.css': { file: 'styles.css', type: 'text/css; charset=utf-8' }, '/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' }, '/favicon.ico': { file: 'icon.svg', type: 'image/svg+xml' }, '/vendor/xterm.js': { file: require.resolve('@xterm/xterm'), type: 'text/javascript; charset=utf-8' }, '/vendor/xterm.css': { file: require.resolve('@xterm/xterm/css/xterm.css'), type: 'text/css; charset=utf-8' }, '/vendor/addon-fit.js': { file: require.resolve('@xterm/addon-fit'), type: 'text/javascript; charset=utf-8' }, '/vendor/marked.js': { file: markedBrowser, type: 'text/javascript; charset=utf-8' }, '/vendor/dompurify.js': { file: require.resolve('dompurify/purify.min.js'), type: 'text/javascript; charset=utf-8' } };
|
|
19
|
+
export class RemotePiServer {
|
|
20
|
+
server;
|
|
21
|
+
wss;
|
|
22
|
+
auth;
|
|
23
|
+
workspaces;
|
|
24
|
+
agents;
|
|
25
|
+
terminals;
|
|
26
|
+
metadata;
|
|
27
|
+
opts;
|
|
28
|
+
dataDir;
|
|
29
|
+
clients = new Set();
|
|
30
|
+
unsubscribeMetadata;
|
|
31
|
+
sessionIndexTimes = new Map();
|
|
32
|
+
sessionIndexJobs = new Map();
|
|
33
|
+
agentIndexJobs = new Map();
|
|
34
|
+
voice;
|
|
35
|
+
mailNotifier;
|
|
36
|
+
loginFailures = new Map();
|
|
37
|
+
loginWindowMs = 60_000;
|
|
38
|
+
loginMaxFailures = 10;
|
|
39
|
+
loginLockMs = 60_000;
|
|
40
|
+
loginMaxLockMs = 15 * 60_000;
|
|
41
|
+
constructor(options = {}) { this.opts = { host: options.host ?? '127.0.0.1', port: options.port ?? 11318 }; this.dataDir = options.dataDir ?? path.join(process.env.HOME ?? '.', '.pi', 'remote-pi'); this.auth = new AuthStore(this.dataDir); this.metadata = new MetadataStore(this.dataDir); this.workspaces = options.workspaces ?? new WorkspaceStore(); this.agents = options.agents ?? new AgentManager(this.workspaces); this.terminals = options.terminals ?? new TerminalManager(this.workspaces); this.voice = options.voice; this.mailNotifier = options.mailNotifier; this.server = http.createServer((req, res) => void this.handle(req, res)); this.wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 }); this.server.on('upgrade', (req, socket, head) => void this.upgrade(req, socket, head)); this.wss.on('connection', (ws, req) => { const match = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname.match(/^\/api\/v1\/terminals\/([^/]+)\/ws$/); match ? this.terminalConnection(ws, decodeURIComponent(match[1])) : this.connection(ws, req); }); }
|
|
42
|
+
async start() { const first = await this.auth.init(); this.metadata.init(); if (this.mailNotifier) {
|
|
43
|
+
const settings = this.metadata.getSetting('mailNotifications');
|
|
44
|
+
if (settings)
|
|
45
|
+
try {
|
|
46
|
+
this.mailNotifier.updateSettings(settings);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(`Ignoring invalid stored mail notification settings: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
} await this.loadMetadata(); this.unsubscribeMetadata = this.agents.subscribeAll((agentId, event) => { const record = this.agents.get(agentId).record; this.voice?.handleAgentEvent(agentId, event.event, { sessionName: record.sessionName, setSessionName: async (name) => { if (this.agents.get(agentId).record.sessionName?.trim())
|
|
52
|
+
return; await this.agents.setSessionName(agentId, name); } }); this.mailNotifier?.handleAgentEvent(agentId, event.event, { sessionName: record.sessionName, workspaceLabel: this.workspaces.get(record.workspaceId)?.label, cwd: record.cwd }); if (['message_end', 'agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type))) {
|
|
53
|
+
const record = this.agents.get(agentId).record;
|
|
54
|
+
this.metadata.scheduleAgent(record);
|
|
55
|
+
if (['agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type)))
|
|
56
|
+
void this.indexAgentSession(agentId);
|
|
57
|
+
} }); if (first.token)
|
|
58
|
+
console.log(`Remote Pi pairing code (shown once; save it securely): ${first.token}`); await new Promise(r => this.server.listen(this.opts.port, this.opts.host, r)); return this.address(); }
|
|
59
|
+
address() { const a = this.server.address(); return typeof a === 'object' && a ? { host: this.opts.host, port: a.port } : undefined; }
|
|
60
|
+
async stop() { this.unsubscribeMetadata?.(); this.unsubscribeMetadata = undefined; this.voice?.close(); await this.mailNotifier?.close(); await Promise.allSettled([...this.sessionIndexJobs.values(), ...this.agentIndexJobs.values()]); this.metadata.flush(); for (const c of this.clients)
|
|
61
|
+
c.close(); for (const a of this.agents.list())
|
|
62
|
+
await this.agents.dispose(a.agentId).catch(() => { }); this.terminals.closeAll(); this.wss.close(); await new Promise(r => this.server.close(() => r())); this.metadata.close(); }
|
|
63
|
+
async upgrade(req, socket, head) { const protocol = req.headers['sec-websocket-protocol']; const wsToken = Array.isArray(protocol) ? protocol.find(x => x.startsWith('access-token.'))?.slice(13) : protocol?.split(',').map(x => x.trim()).find(x => x.startsWith('access-token.'))?.slice(13); const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname, isSocket = pathname === '/api/v1/ws' || /^\/api\/v1\/terminals\/[^/]+\/ws$/.test(pathname); if (!isSocket) {
|
|
64
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
65
|
+
socket.destroy();
|
|
66
|
+
return;
|
|
67
|
+
} const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key); if (retryAfter > 0) {
|
|
68
|
+
socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${Math.ceil(retryAfter / 1000)}\r\n\r\n`);
|
|
69
|
+
socket.destroy();
|
|
70
|
+
return;
|
|
71
|
+
} if (!(await this.authorized(req.headers.authorization)) && !(wsToken && await this.auth.verify(wsToken))) {
|
|
72
|
+
this.recordLoginFailure(key);
|
|
73
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
74
|
+
socket.destroy();
|
|
75
|
+
return;
|
|
76
|
+
} this.loginFailures.delete(key); this.wss.handleUpgrade(req, socket, head, ws => this.wss.emit('connection', ws, req)); }
|
|
77
|
+
clientKey(req) { return req.socket.remoteAddress ?? 'unknown'; }
|
|
78
|
+
/** Rate limits pairing attempts per source address. Reverse proxies and tunnels share one bucket by design. */
|
|
79
|
+
loginRetryAfterMs(key) { const entry = this.loginFailures.get(key); if (!entry)
|
|
80
|
+
return 0; const now = Date.now(); if (entry.lockedUntil > now)
|
|
81
|
+
return entry.lockedUntil - now; if (entry.expiresAt <= now)
|
|
82
|
+
this.loginFailures.delete(key); return 0; }
|
|
83
|
+
recordLoginFailure(key) { const now = Date.now(); if (this.loginFailures.size > 1000)
|
|
84
|
+
for (const [id, value] of this.loginFailures)
|
|
85
|
+
if (value.expiresAt <= now && value.lockedUntil <= now)
|
|
86
|
+
this.loginFailures.delete(id); const entry = this.loginFailures.get(key); if (!entry || (entry.expiresAt <= now && entry.lockedUntil <= now)) {
|
|
87
|
+
this.loginFailures.set(key, { count: 1, expiresAt: now + this.loginWindowMs, lockedUntil: 0, lockLevel: 0 });
|
|
88
|
+
return 0;
|
|
89
|
+
} entry.count += 1; entry.expiresAt = now + this.loginWindowMs; if (entry.count < this.loginMaxFailures)
|
|
90
|
+
return 0; entry.count = 0; entry.lockLevel = Math.min(entry.lockLevel + 1, 5); entry.lockedUntil = now + Math.min(this.loginLockMs * 2 ** (entry.lockLevel - 1), this.loginMaxLockMs); entry.expiresAt = entry.lockedUntil + this.loginWindowMs; return entry.lockedUntil - now; }
|
|
91
|
+
connection(ws, _req) {
|
|
92
|
+
this.clients.add(ws);
|
|
93
|
+
const subscriptions = new Map();
|
|
94
|
+
const send = (message) => { if (ws.readyState === WebSocket.OPEN)
|
|
95
|
+
ws.send(JSON.stringify(message)); };
|
|
96
|
+
const unsubscribeVoice = this.voice?.subscribe((agentId, event) => { if (subscriptions.has('*') || subscriptions.has(agentId))
|
|
97
|
+
send({ type: 'voice_event', agentId, event }); });
|
|
98
|
+
ws.on('close', () => { this.clients.delete(ws); unsubscribeVoice?.(); for (const unsubscribe of subscriptions.values())
|
|
99
|
+
unsubscribe(); });
|
|
100
|
+
ws.on('message', raw => {
|
|
101
|
+
void (async () => {
|
|
102
|
+
let m;
|
|
103
|
+
try {
|
|
104
|
+
m = JSON.parse(raw.toString());
|
|
105
|
+
if (m.type === 'subscribe_all') {
|
|
106
|
+
subscriptions.get('*')?.();
|
|
107
|
+
const forward = (agentId, e) => !subscriptions.has(agentId) && send({ type: 'agent_event', agentId, eventId: e.id, sequence: e.sequence, timestamp: e.timestamp, event: e.event });
|
|
108
|
+
subscriptions.set('*', this.agents.subscribeAll(forward));
|
|
109
|
+
send({ type: 'subscribed_all', protocolVersion: 1 });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (m.type === 'subscribe') {
|
|
113
|
+
subscriptions.get(m.agentId)?.();
|
|
114
|
+
const forward = (e) => send({ type: 'agent_event', agentId: m.agentId, eventId: e.id, sequence: e.sequence, timestamp: e.timestamp, event: e.event });
|
|
115
|
+
subscriptions.set(m.agentId, this.agents.subscribe(m.agentId, forward));
|
|
116
|
+
send({ type: 'subscribed', agentId: m.agentId, protocolVersion: 1, currentSequence: this.agents.currentSequence(m.agentId) });
|
|
117
|
+
if (m.fromNow === true)
|
|
118
|
+
return;
|
|
119
|
+
const last = Number(m.lastSequence ?? 0);
|
|
120
|
+
if (this.agents.hasReplayGap(m.agentId, last)) {
|
|
121
|
+
const messageLimit = m.messageLimit === undefined ? undefined : Number(m.messageLimit);
|
|
122
|
+
send({ type: 'agent_snapshot', agentId: m.agentId, ...await this.agents.snapshot(m.agentId, Number.isFinite(messageLimit) ? messageLimit : undefined) });
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
for (const e of this.agents.events(m.agentId, last))
|
|
126
|
+
forward(e);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (['prompt', 'steer', 'follow-up', 'abort'].includes(m.type)) {
|
|
130
|
+
if (m.type !== 'abort')
|
|
131
|
+
this.voice?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
132
|
+
await this.agents.command(m.agentId, m.type, m.message);
|
|
133
|
+
send({ type: 'command_result', requestId: m.requestId, success: true });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw new Error('Unknown command');
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
send({ type: 'command_result', requestId: m?.requestId, success: false, error: { code: e.code ?? 'BAD_REQUEST', message: e instanceof SyntaxError ? 'Invalid JSON' : e.message } });
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
terminalConnection(ws, terminalId) { this.clients.add(ws); let unsubscribe = () => { }; ws.on('close', () => { this.clients.delete(ws); unsubscribe(); }); try {
|
|
145
|
+
const send = (message) => { if (ws.readyState !== WebSocket.OPEN)
|
|
146
|
+
return; if (ws.bufferedAmount > 1024 * 1024) {
|
|
147
|
+
ws.close(1013, 'Terminal client is too slow');
|
|
148
|
+
return;
|
|
149
|
+
} ws.send(JSON.stringify(message)); };
|
|
150
|
+
unsubscribe = this.terminals.subscribe(terminalId, event => { send(event); if (event.type === 'exit')
|
|
151
|
+
setTimeout(() => ws.close(), 50); });
|
|
152
|
+
const snapshot = this.terminals.snapshot(terminalId);
|
|
153
|
+
send({ type: 'snapshot', data: snapshot.data, record: snapshot.record });
|
|
154
|
+
ws.on('message', raw => { try {
|
|
155
|
+
const message = JSON.parse(raw.toString());
|
|
156
|
+
if (message.type === 'input')
|
|
157
|
+
this.terminals.write(terminalId, message.data);
|
|
158
|
+
else if (message.type === 'resize')
|
|
159
|
+
this.terminals.resize(terminalId, Number(message.cols), Number(message.rows));
|
|
160
|
+
else
|
|
161
|
+
throw new Error('Unknown terminal command');
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
send({ type: 'error', message: error.message });
|
|
165
|
+
} });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
ws.send(JSON.stringify({ type: 'error', message: error.message }));
|
|
169
|
+
ws.close();
|
|
170
|
+
} }
|
|
171
|
+
async loadMetadata() { let workspaces = this.metadata.listWorkspaces(); if (!workspaces.length) {
|
|
172
|
+
for (const workspace of this.workspaces.list())
|
|
173
|
+
this.metadata.saveWorkspace(workspace);
|
|
174
|
+
workspaces = this.metadata.listWorkspaces();
|
|
175
|
+
} this.workspaces.replace(workspaces); let agents = this.metadata.listAgents(); if (!agents.length) {
|
|
176
|
+
for (const agent of this.agents.list())
|
|
177
|
+
this.metadata.saveAgent(agent);
|
|
178
|
+
agents = this.metadata.listAgents();
|
|
179
|
+
} await this.agents.restore(agents); }
|
|
180
|
+
async ensureSessionIndex(workspace, cwd, force = false) { const key = `${workspace.id}:${cwd}`, last = this.sessionIndexTimes.get(key) ?? 0; if (!force && Date.now() - last < 30000)
|
|
181
|
+
return; const existing = this.sessionIndexJobs.get(key); if (existing)
|
|
182
|
+
return existing; const job = (async () => { const sessions = await SessionManager.list(cwd); this.metadata.syncSessions(workspace.id, cwd, sessions); this.sessionIndexTimes.set(key, Date.now()); })().finally(() => this.sessionIndexJobs.delete(key)); this.sessionIndexJobs.set(key, job); return job; }
|
|
183
|
+
async indexAgentSession(agentId) { const existing = this.agentIndexJobs.get(agentId); if (existing)
|
|
184
|
+
return existing; const job = (async () => { try {
|
|
185
|
+
const record = this.agents.get(agentId).record, summary = await this.agents.sessionInfo(agentId);
|
|
186
|
+
this.metadata.upsertSessionFromAgent(record, summary);
|
|
187
|
+
this.metadata.scheduleAgent(record);
|
|
188
|
+
}
|
|
189
|
+
catch { /* Session files may not exist until the first assistant response is persisted. */ } })().finally(() => this.agentIndexJobs.delete(agentId)); this.agentIndexJobs.set(agentId, job); return job; }
|
|
190
|
+
async authorized(value) { return !!value && value.startsWith('Bearer ') && this.auth.verify(value.slice(7)); }
|
|
191
|
+
async serveWeb(pathname, res) { const asset = webFiles[pathname]; if (!asset)
|
|
192
|
+
return false; try {
|
|
193
|
+
const content = await readFile(path.isAbsolute(asset.file) ? asset.file : path.join(webRoot, asset.file));
|
|
194
|
+
res.statusCode = 200;
|
|
195
|
+
res.setHeader('content-type', asset.type);
|
|
196
|
+
res.setHeader('cache-control', 'no-cache');
|
|
197
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
198
|
+
res.end(content);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
json(res, 500, { error: { code: 'WEB_UI_UNAVAILABLE', message: 'Web UI files are missing. Reinstall the pi2web package.' } });
|
|
202
|
+
} return true; }
|
|
203
|
+
/** Reads a JSON body as bytes so the size limit applies while streaming and multi-byte characters are never split across chunks. */
|
|
204
|
+
async body(req, maxBytes = 2 * 1024 * 1024) { const raw = await this.binaryBody(req, maxBytes); return raw.length ? JSON.parse(raw.toString('utf8')) : {}; }
|
|
205
|
+
async binaryBody(req, maxBytes = 25 * 1024 * 1024) { const chunks = []; let size = 0; for await (const value of req) {
|
|
206
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
207
|
+
size += chunk.length;
|
|
208
|
+
if (size > maxBytes)
|
|
209
|
+
throw Object.assign(new Error('Request too large'), { code: 'REQUEST_TOO_LARGE' });
|
|
210
|
+
chunks.push(chunk);
|
|
211
|
+
} return Buffer.concat(chunks); }
|
|
212
|
+
async handle(req, res) { const requestId = randomUUID(); res.setHeader('x-request-id', requestId); res.setHeader('access-control-allow-origin', '*'); res.setHeader('access-control-allow-headers', 'Authorization,Content-Type,X-Audio-Filename'); res.setHeader('access-control-allow-methods', 'GET,POST,DELETE,OPTIONS'); try {
|
|
213
|
+
if (req.method === 'OPTIONS') {
|
|
214
|
+
res.statusCode = 204;
|
|
215
|
+
return res.end();
|
|
216
|
+
}
|
|
217
|
+
const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
218
|
+
if (req.method === 'GET' && await this.serveWeb(u.pathname, res))
|
|
219
|
+
return;
|
|
220
|
+
if (req.method === 'GET' && u.pathname === '/health')
|
|
221
|
+
return json(res, 200, { data: { status: 'ok' } });
|
|
222
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
223
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/auth/login') {
|
|
224
|
+
const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key);
|
|
225
|
+
if (retryAfter > 0) {
|
|
226
|
+
res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
|
|
227
|
+
return json(res, 429, { error: { code: 'TOO_MANY_ATTEMPTS', message: `Too many failed pairing attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`, requestId } });
|
|
228
|
+
}
|
|
229
|
+
const b = await this.body(req);
|
|
230
|
+
if (await this.auth.verify(String(b.token ?? ''))) {
|
|
231
|
+
this.loginFailures.delete(key);
|
|
232
|
+
return json(res, 200, { data: { authenticated: true } });
|
|
233
|
+
}
|
|
234
|
+
const lockedFor = this.recordLoginFailure(key);
|
|
235
|
+
if (lockedFor > 0)
|
|
236
|
+
res.setHeader('retry-after', String(Math.ceil(lockedFor / 1000)));
|
|
237
|
+
return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Invalid token', requestId } });
|
|
238
|
+
}
|
|
239
|
+
if (!(await this.authorized(req.headers.authorization)))
|
|
240
|
+
return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Authentication required', requestId } });
|
|
241
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/system/status')
|
|
242
|
+
return json(res, 200, { data: { version: pkg.version, protocolVersion: 1, piVersion: PI_VERSION, nodeVersion: process.version, uptimeSeconds: Math.floor(process.uptime()), agentCount: this.agents.list().length, terminalCount: this.terminals.list().length, webSocketConnections: this.clients.size, voiceEnabled: !!this.voice, voiceCapabilities: this.voice?.capabilities() ?? { tts: false, stt: false }, mailNotificationsConfigured: !!this.mailNotifier, mailNotificationsEnabled: this.mailNotifier?.getSettings().enabled ?? false } });
|
|
243
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/mail-notifications')
|
|
244
|
+
return json(res, 200, { data: { available: !!this.mailNotifier, settings: this.mailNotifier?.getSettings() ?? null } });
|
|
245
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/mail-notifications') {
|
|
246
|
+
if (!this.mailNotifier)
|
|
247
|
+
throw Object.assign(new Error('MailDispatch is not configured at server startup'), { code: 'MAIL_NOTIFICATIONS_DISABLED' });
|
|
248
|
+
const b = await this.body(req), patch = {};
|
|
249
|
+
if ('enabled' in b) {
|
|
250
|
+
if (typeof b.enabled !== 'boolean')
|
|
251
|
+
throw Object.assign(new Error('enabled must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
|
|
252
|
+
patch.enabled = b.enabled;
|
|
253
|
+
}
|
|
254
|
+
if ('aggregationDelaySeconds' in b)
|
|
255
|
+
patch.aggregationDelaySeconds = Number(b.aggregationDelaySeconds);
|
|
256
|
+
if ('includeResponse' in b) {
|
|
257
|
+
if (typeof b.includeResponse !== 'boolean')
|
|
258
|
+
throw Object.assign(new Error('includeResponse must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
|
|
259
|
+
patch.includeResponse = b.includeResponse;
|
|
260
|
+
}
|
|
261
|
+
if ('includeSessionDetails' in b) {
|
|
262
|
+
if (typeof b.includeSessionDetails !== 'boolean')
|
|
263
|
+
throw Object.assign(new Error('includeSessionDetails must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
|
|
264
|
+
patch.includeSessionDetails = b.includeSessionDetails;
|
|
265
|
+
}
|
|
266
|
+
const settings = this.mailNotifier.updateSettings(patch);
|
|
267
|
+
this.metadata.setSetting('mailNotifications', settings);
|
|
268
|
+
return json(res, 200, { data: { available: true, settings } });
|
|
269
|
+
}
|
|
270
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/workspaces')
|
|
271
|
+
return json(res, 200, { data: this.workspaces.list() });
|
|
272
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/workspaces') {
|
|
273
|
+
const b = await this.body(req);
|
|
274
|
+
if (typeof b.label !== 'string' || typeof b.rootPath !== 'string')
|
|
275
|
+
return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'label and rootPath required', requestId } });
|
|
276
|
+
const workspace = this.workspaces.add(b.label, b.rootPath);
|
|
277
|
+
this.metadata.saveWorkspace(workspace);
|
|
278
|
+
return json(res, 201, { data: workspace });
|
|
279
|
+
}
|
|
280
|
+
if (parts[2] === 'workspaces' && parts[4] === 'tree' && req.method === 'GET')
|
|
281
|
+
return json(res, 200, { data: await this.workspaces.tree(parts[3], u.searchParams.get('path') ?? '.') });
|
|
282
|
+
if (parts[2] === 'workspaces' && parts[4] === 'file' && req.method === 'GET')
|
|
283
|
+
return json(res, 200, { data: await this.workspaces.file(parts[3], u.searchParams.get('path') ?? '', Number(u.searchParams.get('offset') ?? 0), Number(u.searchParams.get('limit') ?? 1048576)) });
|
|
284
|
+
if (parts[2] === 'workspaces' && parts[4] === 'stat' && req.method === 'GET')
|
|
285
|
+
return json(res, 200, { data: await this.workspaces.stat(parts[3], u.searchParams.get('path') ?? '.') });
|
|
286
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/sessions') {
|
|
287
|
+
const workspace = this.workspaces.get(u.searchParams.get('workspaceId') ?? '');
|
|
288
|
+
if (!workspace)
|
|
289
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
290
|
+
const cwd = await this.workspaces.resolve(workspace, u.searchParams.get('path') ?? '.');
|
|
291
|
+
await this.ensureSessionIndex(workspace, cwd, u.searchParams.get('refresh') === 'true');
|
|
292
|
+
const limit = u.searchParams.has('limit') ? Number(u.searchParams.get('limit')) : undefined, offset = Number(u.searchParams.get('offset') ?? 0);
|
|
293
|
+
return json(res, 200, { data: this.metadata.listSessions(cwd, limit, offset) });
|
|
294
|
+
}
|
|
295
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/terminals')
|
|
296
|
+
return json(res, 200, { data: this.terminals.list() });
|
|
297
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/terminals') {
|
|
298
|
+
const b = await this.body(req);
|
|
299
|
+
const terminal = await this.terminals.create(String(b.workspaceId ?? ''), String(b.relativeCwd ?? '.'), Number(b.cols ?? 80), Number(b.rows ?? 24));
|
|
300
|
+
return json(res, 201, { data: terminal });
|
|
301
|
+
}
|
|
302
|
+
if (parts[2] === 'terminals') {
|
|
303
|
+
const id = parts[3];
|
|
304
|
+
if (req.method === 'GET' && parts.length === 4)
|
|
305
|
+
return json(res, 200, { data: this.terminals.get(id).record });
|
|
306
|
+
if (req.method === 'DELETE' && parts.length === 4) {
|
|
307
|
+
this.terminals.close(id);
|
|
308
|
+
return json(res, 200, { data: { closed: true } });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/agents')
|
|
312
|
+
return json(res, 200, { data: this.agents.list() });
|
|
313
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/agents') {
|
|
314
|
+
const b = await this.body(req);
|
|
315
|
+
let sessionFile;
|
|
316
|
+
if (b.sessionFile) {
|
|
317
|
+
const workspace = this.workspaces.get(b.workspaceId);
|
|
318
|
+
if (!workspace)
|
|
319
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
320
|
+
const cwd = await this.workspaces.resolve(workspace, b.relativeCwd ?? '.');
|
|
321
|
+
await this.ensureSessionIndex(workspace, cwd);
|
|
322
|
+
if (!this.metadata.hasSession(cwd, b.sessionFile))
|
|
323
|
+
throw Object.assign(new Error('Session is not valid for cwd'), { code: 'SESSION_NOT_FOUND' });
|
|
324
|
+
sessionFile = b.sessionFile;
|
|
325
|
+
}
|
|
326
|
+
const agent = await this.agents.create(b.workspaceId, b.relativeCwd ?? '.', sessionFile);
|
|
327
|
+
this.metadata.saveAgent(agent);
|
|
328
|
+
return json(res, 201, { data: agent });
|
|
329
|
+
}
|
|
330
|
+
if (parts[2] === 'agents') {
|
|
331
|
+
const id = parts[3];
|
|
332
|
+
if (req.method === 'GET' && parts.length === 4)
|
|
333
|
+
return json(res, 200, { data: this.agents.get(id).record });
|
|
334
|
+
if (req.method === 'DELETE' && parts.length === 4) {
|
|
335
|
+
await this.agents.dispose(id);
|
|
336
|
+
this.metadata.saveAgent(this.agents.get(id).record);
|
|
337
|
+
return json(res, 200, { data: { stopped: true } });
|
|
338
|
+
}
|
|
339
|
+
if (req.method === 'POST' && parts[4] === 'archive') {
|
|
340
|
+
const record = await this.agents.archive(id);
|
|
341
|
+
this.metadata.archiveAgent(record);
|
|
342
|
+
return json(res, 200, { data: { archived: true } });
|
|
343
|
+
}
|
|
344
|
+
if (req.method === 'GET' && parts[4] === 'state')
|
|
345
|
+
return json(res, 200, { data: await this.agents.state(id) });
|
|
346
|
+
if (req.method === 'GET' && parts[4] === 'messages') {
|
|
347
|
+
const hasPaging = u.searchParams.has('limit') || u.searchParams.has('before');
|
|
348
|
+
if (!hasPaging)
|
|
349
|
+
return json(res, 200, { data: await this.agents.messages(id) });
|
|
350
|
+
const limit = Number(u.searchParams.get('limit') ?? 25), before = u.searchParams.has('before') ? Number(u.searchParams.get('before')) : undefined;
|
|
351
|
+
return json(res, 200, { data: await this.agents.messagePage(id, limit, before) });
|
|
352
|
+
}
|
|
353
|
+
if (req.method === 'GET' && parts[4] === 'capabilities')
|
|
354
|
+
return json(res, 200, { data: await this.agents.capabilities(id) });
|
|
355
|
+
if (req.method === 'GET' && parts[4] === 'session')
|
|
356
|
+
return json(res, 200, { data: u.searchParams.get('summary') === 'true' ? await this.agents.sessionInfo(id) : await this.agents.session(id) });
|
|
357
|
+
if (req.method === 'POST' && parts[4] === 'compact') {
|
|
358
|
+
const b = await this.body(req);
|
|
359
|
+
return json(res, 200, { data: await this.agents.compact(id, b.instructions) });
|
|
360
|
+
}
|
|
361
|
+
if (req.method === 'POST' && parts[4] === 'model') {
|
|
362
|
+
const b = await this.body(req);
|
|
363
|
+
await this.agents.setModel(id, b.provider, b.modelId);
|
|
364
|
+
return json(res, 200, { data: await this.agents.capabilities(id) });
|
|
365
|
+
}
|
|
366
|
+
if (req.method === 'POST' && parts[4] === 'thinking') {
|
|
367
|
+
const b = await this.body(req);
|
|
368
|
+
await this.agents.setThinkingLevel(id, b.level);
|
|
369
|
+
return json(res, 200, { data: await this.agents.capabilities(id) });
|
|
370
|
+
}
|
|
371
|
+
if (req.method === 'POST' && parts[4] === 'session-name') {
|
|
372
|
+
const b = await this.body(req);
|
|
373
|
+
await this.agents.setSessionName(id, b.name);
|
|
374
|
+
const summary = await this.agents.sessionInfo(id), record = this.agents.get(id).record;
|
|
375
|
+
this.metadata.saveAgent(record);
|
|
376
|
+
this.metadata.upsertSessionFromAgent(record, summary);
|
|
377
|
+
return json(res, 200, { data: summary });
|
|
378
|
+
}
|
|
379
|
+
if (req.method === 'POST' && parts[4] === 'navigate') {
|
|
380
|
+
const b = await this.body(req);
|
|
381
|
+
return json(res, 200, { data: await this.agents.navigate(id, b.entryId) });
|
|
382
|
+
}
|
|
383
|
+
if (req.method === 'POST' && parts[4] === 'fork') {
|
|
384
|
+
const b = await this.body(req);
|
|
385
|
+
const agent = await this.agents.fork(id, b.entryId);
|
|
386
|
+
this.metadata.saveAgent(agent.agent);
|
|
387
|
+
return json(res, 201, { data: agent });
|
|
388
|
+
}
|
|
389
|
+
if (req.method === 'POST' && parts[4] === 'extension-response') {
|
|
390
|
+
const b = await this.body(req);
|
|
391
|
+
await this.agents.extensionResponse(id, b.requestId, b.value);
|
|
392
|
+
return json(res, 200, { data: { success: true } });
|
|
393
|
+
}
|
|
394
|
+
if (req.method === 'POST' && parts[4] === 'transcribe') {
|
|
395
|
+
if (!this.voice)
|
|
396
|
+
throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
|
|
397
|
+
this.agents.get(id);
|
|
398
|
+
const audio = await this.binaryBody(req), mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0], filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
|
|
399
|
+
return json(res, 200, { data: await this.voice.transcribe(audio, mime, filename) });
|
|
400
|
+
}
|
|
401
|
+
if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
|
|
402
|
+
const b = await this.body(req);
|
|
403
|
+
if (parts[4] !== 'abort')
|
|
404
|
+
this.voice?.recordUserPrompt(id, String(b.message ?? ''));
|
|
405
|
+
await this.agents.command(id, parts[4], b.message ?? '');
|
|
406
|
+
const record = this.agents.get(id).record;
|
|
407
|
+
this.metadata.scheduleAgent(record);
|
|
408
|
+
if (parts[4] === 'prompt')
|
|
409
|
+
void this.indexAgentSession(id);
|
|
410
|
+
return json(res, 200, { data: { success: true } });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
const x = e;
|
|
417
|
+
return json(res, x.code === 'WORKSPACE_PATH_OUTSIDE_ROOT' ? 403 : x.code === 'FILE_TOO_LARGE' || x.code === 'REQUEST_TOO_LARGE' ? 413 : x.code === 'VOICE_DISABLED' || x.code === 'VOICE_STT_DISABLED' || x.code === 'MAIL_NOTIFICATIONS_DISABLED' ? 503 : x.code?.endsWith('NOT_FOUND') ? 404 : 400, { error: { code: x.code ?? 'BAD_REQUEST', message: x.message ?? 'Request failed', requestId } });
|
|
418
|
+
} }
|
|
419
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import * as pty from 'node-pty';
|
|
6
|
+
const defaultFactory = (cwd, cols, rows) => {
|
|
7
|
+
const windows = process.platform === 'win32';
|
|
8
|
+
const shell = windows ? (process.env.COMSPEC || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
|
|
9
|
+
const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
|
|
10
|
+
return pty.spawn(shell, [], { name: 'xterm-256color', cwd, cols, rows, env, useConpty: windows });
|
|
11
|
+
};
|
|
12
|
+
export class TerminalManager {
|
|
13
|
+
workspaces;
|
|
14
|
+
factory;
|
|
15
|
+
maxRunning;
|
|
16
|
+
maxBuffer;
|
|
17
|
+
maxRetained;
|
|
18
|
+
terminals = new Map();
|
|
19
|
+
constructor(workspaces, factory = defaultFactory, maxRunning = 8, maxBuffer = 512 * 1024, maxRetained = 50) {
|
|
20
|
+
this.workspaces = workspaces;
|
|
21
|
+
this.factory = factory;
|
|
22
|
+
this.maxRunning = maxRunning;
|
|
23
|
+
this.maxBuffer = maxBuffer;
|
|
24
|
+
this.maxRetained = maxRetained;
|
|
25
|
+
}
|
|
26
|
+
list() { return [...this.terminals.values()].map(x => ({ ...x.record })).sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)); }
|
|
27
|
+
get(id) { const entry = this.terminals.get(id); if (!entry)
|
|
28
|
+
throw Object.assign(new Error('Terminal not found'), { code: 'TERMINAL_NOT_FOUND' }); return entry; }
|
|
29
|
+
async create(workspaceId, relativeCwd = '.', cols = 80, rows = 24) {
|
|
30
|
+
this.pruneExited();
|
|
31
|
+
if (this.list().filter(x => x.status === 'running').length >= this.maxRunning)
|
|
32
|
+
throw Object.assign(new Error(`At most ${this.maxRunning} terminals may run at once`), { code: 'TERMINAL_LIMIT' });
|
|
33
|
+
const workspace = this.workspaces.get(workspaceId);
|
|
34
|
+
if (!workspace)
|
|
35
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
36
|
+
const cwd = await this.workspaces.resolve(workspace, relativeCwd);
|
|
37
|
+
if (!(await stat(cwd)).isDirectory())
|
|
38
|
+
throw Object.assign(new Error('cwd is not a directory'), { code: 'NOT_A_DIRECTORY' });
|
|
39
|
+
const terminalId = `terminal-${randomUUID()}`, now = new Date().toISOString(), process = this.factory(cwd, this.dimension(cols, 80), this.dimension(rows, 24));
|
|
40
|
+
const record = { terminalId, workspaceId, cwd, title: path.basename(cwd) || cwd, status: 'running', createdAt: now, lastActiveAt: now };
|
|
41
|
+
const entry = { record, process, emitter: new EventEmitter(), buffer: '' };
|
|
42
|
+
this.terminals.set(terminalId, entry);
|
|
43
|
+
entry.dataSubscription = process.onData(data => { entry.buffer = (entry.buffer + data).slice(-this.maxBuffer); entry.record.lastActiveAt = new Date().toISOString(); entry.emitter.emit('event', { type: 'output', data }); });
|
|
44
|
+
const exitSubscription = process.onExit(event => { entry.record.status = 'exited'; entry.record.exitCode = event.exitCode; entry.record.lastActiveAt = new Date().toISOString(); entry.emitter.emit('event', { type: 'exit', exitCode: event.exitCode }); this.disposeProcessListeners(entry); this.pruneExited(); });
|
|
45
|
+
if (entry.record.status === 'exited')
|
|
46
|
+
exitSubscription.dispose();
|
|
47
|
+
else
|
|
48
|
+
entry.exitSubscription = exitSubscription;
|
|
49
|
+
return { ...record };
|
|
50
|
+
}
|
|
51
|
+
snapshot(id) { const entry = this.get(id); return { record: { ...entry.record }, data: entry.buffer }; }
|
|
52
|
+
write(id, data) { const entry = this.get(id); if (entry.record.status !== 'running')
|
|
53
|
+
throw Object.assign(new Error('Terminal has exited'), { code: 'TERMINAL_EXITED' }); if (typeof data !== 'string' || data.length > 64 * 1024)
|
|
54
|
+
throw Object.assign(new Error('Invalid terminal input'), { code: 'INVALID_REQUEST' }); entry.process.write(data); entry.record.lastActiveAt = new Date().toISOString(); }
|
|
55
|
+
resize(id, cols, rows) { const entry = this.get(id); if (entry.record.status === 'running')
|
|
56
|
+
entry.process.resize(this.dimension(cols, 80), this.dimension(rows, 24)); }
|
|
57
|
+
subscribe(id, listener) { const entry = this.get(id); entry.emitter.on('event', listener); return () => entry.emitter.off('event', listener); }
|
|
58
|
+
close(id) { const entry = this.get(id); this.terminals.delete(id); if (entry.record.status === 'running')
|
|
59
|
+
entry.emitter.emit('event', { type: 'exit', exitCode: -1 }); this.disposeProcessListeners(entry); if (entry.record.status === 'running')
|
|
60
|
+
try {
|
|
61
|
+
entry.process.kill();
|
|
62
|
+
}
|
|
63
|
+
catch { } entry.emitter.removeAllListeners(); }
|
|
64
|
+
closeAll() { for (const id of [...this.terminals.keys()])
|
|
65
|
+
this.close(id); }
|
|
66
|
+
disposeProcessListeners(entry) { entry.dataSubscription?.dispose(); entry.exitSubscription?.dispose(); entry.dataSubscription = undefined; entry.exitSubscription = undefined; }
|
|
67
|
+
pruneExited() { const exited = [...this.terminals.entries()].reverse().filter(([, entry]) => entry.record.status === 'exited').sort((a, b) => b[1].record.lastActiveAt.localeCompare(a[1].record.lastActiveAt)); for (const [id] of exited.slice(this.maxRetained))
|
|
68
|
+
this.close(id); }
|
|
69
|
+
dimension(value, fallback) { return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback; }
|
|
70
|
+
}
|