pi2web 0.2.0 → 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.
- package/README.md +209 -18
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +575 -97
- package/dist/auth.js +85 -20
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +221 -0
- package/dist/collab/hub.js +2888 -0
- package/dist/collab/pi-extension.js +494 -0
- package/dist/collab/review-flow.js +459 -0
- package/dist/collab/routes.js +285 -0
- package/dist/collab/schemas.js +281 -0
- package/dist/collab/scoring-flow.js +336 -0
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +918 -0
- package/dist/collab/types.js +137 -0
- package/dist/collab/validate.js +185 -0
- package/dist/drafts.js +50 -0
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +95 -21
- package/dist/main.js +141 -17
- package/dist/mcp.js +132 -0
- package/dist/metadata-store.js +199 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +288 -49
- package/dist/server.js +558 -358
- package/dist/session-namer.js +163 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +196 -40
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +18 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +8 -2
- package/packages/protocol/websocket.schema.json +94 -10
- package/web/activity-view.js +121 -0
- package/web/app.js +3770 -404
- package/web/backend-state.js +269 -0
- package/web/collab.html +95 -0
- package/web/collab.js +1080 -0
- package/web/hid-keyboard.js +453 -0
- package/web/index.html +14 -9
- package/web/styles.css +73 -0
- package/ANDROID_APP_FEATURES.md +0 -256
package/dist/server.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
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
3
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { WebSocketServer
|
|
4
|
+
import { WebSocketServer } from 'ws';
|
|
8
5
|
import { AuthStore } from './auth.js';
|
|
9
6
|
import { WorkspaceStore } from './workspaces.js';
|
|
10
7
|
import { AgentManager } from './agents.js';
|
|
8
|
+
import { createSdkBackend } from './sdk-backend.js';
|
|
11
9
|
import { TerminalManager } from './terminals.js';
|
|
12
10
|
import { MetadataStore } from './metadata-store.js';
|
|
11
|
+
import { CollabHub } from './collab/hub.js';
|
|
12
|
+
import { CollabStore } from './collab/store.js';
|
|
13
|
+
import { CollabRouter } from './collab/routes.js';
|
|
14
|
+
import { CollabDispatcher } from './collab/dispatcher.js';
|
|
15
|
+
import { DraftHub } from './drafts.js';
|
|
16
|
+
import { AGENT_TEMPLATES_DIR, defaultDataDir } from './paths.js';
|
|
17
|
+
import { AgentTemplateStore } from './agent-templates.js';
|
|
13
18
|
import { SessionManager, VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
|
|
14
19
|
import pkg from '../package.json' with { type: 'json' };
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
import { LOOPBACK_HOSTS, errorStatus, isLoopbackHost, json, readBinaryBody, readJsonBody } from './http/utils.js';
|
|
21
|
+
import { serveWebAsset } from './http/web-assets.js';
|
|
22
|
+
import { LoginLimiter } from './http/login-limiter.js';
|
|
23
|
+
import { handleAgentSocket, PROTOCOL_VERSION } from './ws/agent-socket.js';
|
|
24
|
+
import { handleTerminalSocket } from './ws/terminal-socket.js';
|
|
25
|
+
import { handleAgentRoutes } from './http/agent-routes.js';
|
|
26
|
+
import { handleMailSettingsRoutes } from './http/mail-settings-routes.js';
|
|
27
|
+
import { handleWorkspaceRoutes } from './http/workspace-routes.js';
|
|
28
|
+
import { handleSystemPromptRoutes } from './http/system-prompt-routes.js';
|
|
29
|
+
export { LOOPBACK_HOSTS };
|
|
19
30
|
export class RemotePiServer {
|
|
20
31
|
server;
|
|
21
32
|
wss;
|
|
@@ -24,396 +35,585 @@ export class RemotePiServer {
|
|
|
24
35
|
agents;
|
|
25
36
|
terminals;
|
|
26
37
|
metadata;
|
|
38
|
+
templates;
|
|
39
|
+
collab;
|
|
40
|
+
collabRouter;
|
|
41
|
+
collabDispatcher;
|
|
42
|
+
unsubscribeCollab;
|
|
43
|
+
collabStallTimer;
|
|
44
|
+
collabMailAt = new Map();
|
|
45
|
+
collabMailCooldownMs = 5 * 60_000;
|
|
27
46
|
opts;
|
|
47
|
+
noAuth;
|
|
48
|
+
allowInsecureNoAuth;
|
|
28
49
|
dataDir;
|
|
29
50
|
clients = new Set();
|
|
51
|
+
drafts = new DraftHub();
|
|
30
52
|
unsubscribeMetadata;
|
|
31
53
|
sessionIndexTimes = new Map();
|
|
32
54
|
sessionIndexJobs = new Map();
|
|
33
55
|
agentIndexJobs = new Map();
|
|
34
56
|
voice;
|
|
57
|
+
sessionNamer;
|
|
35
58
|
mailNotifier;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
loginLimiter = new LoginLimiter();
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
this.opts = { host: options.host ?? '127.0.0.1', port: options.port ?? 11318 };
|
|
62
|
+
this.noAuth = options.noAuth ?? false;
|
|
63
|
+
this.allowInsecureNoAuth = options.allowInsecureNoAuth ?? false;
|
|
64
|
+
this.dataDir = options.dataDir ?? defaultDataDir();
|
|
65
|
+
this.auth = new AuthStore(this.dataDir);
|
|
66
|
+
this.metadata = new MetadataStore(this.dataDir);
|
|
67
|
+
this.templates = new AgentTemplateStore(path.join(this.dataDir, AGENT_TEMPLATES_DIR));
|
|
68
|
+
this.workspaces = options.workspaces ?? new WorkspaceStore();
|
|
69
|
+
this.voice = options.voice;
|
|
70
|
+
this.sessionNamer = options.sessionNamer;
|
|
71
|
+
this.mailNotifier = options.mailNotifier;
|
|
72
|
+
this.collab = new CollabHub(new CollabStore(() => this.metadata.connection), {
|
|
73
|
+
agentStatus: agentId => this.agentStatus(agentId)
|
|
74
|
+
});
|
|
75
|
+
this.agents =
|
|
76
|
+
options.agents ??
|
|
77
|
+
new AgentManager(this.workspaces, (agentId, cwd, sessionFile, profile, backendOptions) => createSdkBackend(agentId, cwd, sessionFile, profile, profile === 'collab' ? this.collabToolBridge() : undefined, backendOptions));
|
|
78
|
+
this.terminals = options.terminals ?? new TerminalManager(this.workspaces);
|
|
79
|
+
this.collabRouter = new CollabRouter(this.collab, {
|
|
80
|
+
resolveCwd: (workspaceId, relativeCwd) => this.resolveWorkspaceCwd(workspaceId, relativeCwd),
|
|
81
|
+
verifyHuman: token => this.auth.verify(token),
|
|
82
|
+
noAuth: this.noAuth,
|
|
83
|
+
isCollabAgent: agentId => this.isCollabAgent(agentId)
|
|
84
|
+
});
|
|
85
|
+
this.collabDispatcher = new CollabDispatcher(this.collab, {
|
|
86
|
+
command: (agentId, kind, message) => this.agents.command(agentId, kind, message),
|
|
87
|
+
agentStatus: agentId => this.agentStatus(agentId),
|
|
88
|
+
isCollabAgent: agentId => this.isCollabAgent(agentId),
|
|
89
|
+
log: message => console.error(message)
|
|
90
|
+
});
|
|
91
|
+
this.server = http.createServer((req, res) => void this.handle(req, res));
|
|
92
|
+
this.wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 });
|
|
93
|
+
this.server.on('upgrade', (req, socket, head) => void this.upgrade(req, socket, head));
|
|
94
|
+
this.wss.on('connection', (ws, req) => {
|
|
95
|
+
const match = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname.match(/^\/api\/v1\/terminals\/([^/]+)\/ws$/);
|
|
96
|
+
if (match)
|
|
97
|
+
handleTerminalSocket({ terminals: this.terminals, clients: this.clients }, ws, decodeURIComponent(match[1]));
|
|
98
|
+
else
|
|
99
|
+
handleAgentSocket({
|
|
100
|
+
agents: this.agents,
|
|
101
|
+
drafts: this.drafts,
|
|
102
|
+
collab: this.collab,
|
|
103
|
+
voice: this.voice,
|
|
104
|
+
sessionNamer: this.sessionNamer,
|
|
105
|
+
clients: this.clients
|
|
106
|
+
}, ws);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async start() {
|
|
110
|
+
if (this.allowInsecureNoAuth && !this.noAuth)
|
|
111
|
+
throw Object.assign(new Error('allowInsecureNoAuth requires noAuth (--allow-insecure-no-auth requires --no-auth)'), { code: 'INVALID_OPTIONS' });
|
|
112
|
+
if (this.noAuth && !this.allowInsecureNoAuth && !LOOPBACK_HOSTS.includes(this.opts.host))
|
|
113
|
+
throw Object.assign(new Error('noAuth requires a loopback host (127.0.0.1, ::1 or localhost); use --allow-insecure-no-auth (allowInsecureNoAuth: true) to explicitly allow remote access without authentication'), { code: 'INVALID_BIND_ADDRESS' });
|
|
114
|
+
if (this.allowInsecureNoAuth)
|
|
115
|
+
console.warn('WARNING: --allow-insecure-no-auth disables loopback bind and Host/Origin restrictions. Anyone who can reach this server, including through a web page, can access workspaces and control agents and terminals without a pairing code. Use only on a trusted, access-controlled network.');
|
|
116
|
+
const first = this.noAuth ? {} : await this.auth.init();
|
|
117
|
+
this.metadata.init();
|
|
118
|
+
this.collab.init();
|
|
119
|
+
this.unsubscribeCollab = this.collab.subscribe(event => this.collabNotification(event));
|
|
120
|
+
this.collabStallTimer = setInterval(() => {
|
|
45
121
|
try {
|
|
46
|
-
this.
|
|
122
|
+
this.collab.checkStalls();
|
|
47
123
|
}
|
|
48
124
|
catch (error) {
|
|
49
|
-
console.error(`
|
|
125
|
+
console.error(`Collaboration stall check failed: ${error.message}`);
|
|
50
126
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
127
|
+
}, 60_000);
|
|
128
|
+
this.collabStallTimer.unref?.();
|
|
129
|
+
if (this.mailNotifier) {
|
|
130
|
+
const settings = this.metadata.getSetting('mailNotifications');
|
|
131
|
+
if (settings)
|
|
103
132
|
try {
|
|
104
|
-
|
|
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');
|
|
133
|
+
this.mailNotifier.updateSettings(settings);
|
|
137
134
|
}
|
|
138
|
-
catch (
|
|
139
|
-
|
|
135
|
+
catch (error) {
|
|
136
|
+
console.error(`Ignoring invalid stored mail notification settings: ${error.message}`);
|
|
140
137
|
}
|
|
141
|
-
|
|
138
|
+
}
|
|
139
|
+
await this.loadMetadata();
|
|
140
|
+
// Only now: resuming a queued wake-up calls agents.command(), which fails with "unknown agent" until the
|
|
141
|
+
// persisted agents have been restored, and a failed dispatch is logged, not retried.
|
|
142
|
+
this.collabDispatcher.start();
|
|
143
|
+
this.unsubscribeMetadata = this.agents.subscribeAll((agentId, event) => {
|
|
144
|
+
const record = this.agents.get(agentId).record;
|
|
145
|
+
const applyName = async (name) => {
|
|
146
|
+
if (this.agents.get(agentId).record.sessionName?.trim())
|
|
147
|
+
return;
|
|
148
|
+
await this.agents.setSessionName(agentId, name);
|
|
149
|
+
this.metadata.saveAgent(this.agents.get(agentId).record);
|
|
150
|
+
};
|
|
151
|
+
// The namer owns naming whenever it is enabled, so the voice summary never races it for the same name.
|
|
152
|
+
this.sessionNamer?.handleAgentEvent(agentId, event.event, {
|
|
153
|
+
sessionName: record.sessionName,
|
|
154
|
+
setSessionName: applyName
|
|
155
|
+
});
|
|
156
|
+
this.voice?.handleAgentEvent(agentId, event.event, {
|
|
157
|
+
sessionName: record.sessionName,
|
|
158
|
+
...(this.sessionNamer ? {} : { setSessionName: applyName })
|
|
159
|
+
});
|
|
160
|
+
this.mailNotifier?.handleAgentEvent(agentId, event.event, {
|
|
161
|
+
sessionName: record.sessionName,
|
|
162
|
+
workspaceLabel: this.workspaces.get(record.workspaceId)?.label,
|
|
163
|
+
cwd: record.cwd
|
|
164
|
+
});
|
|
165
|
+
if (['message_end', 'agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type))) {
|
|
166
|
+
const record = this.agents.get(agentId).record;
|
|
167
|
+
this.metadata.scheduleAgent(record);
|
|
168
|
+
if (['agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type)))
|
|
169
|
+
void this.indexAgentSession(agentId);
|
|
170
|
+
}
|
|
142
171
|
});
|
|
172
|
+
if (first.token)
|
|
173
|
+
console.log(`Remote Pi pairing code (shown once; save it securely): ${first.token}`);
|
|
174
|
+
await new Promise(r => this.server.listen(this.opts.port, this.opts.host, r));
|
|
175
|
+
return this.address();
|
|
143
176
|
}
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
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
|
-
} });
|
|
177
|
+
address() {
|
|
178
|
+
const a = this.server.address();
|
|
179
|
+
return typeof a === 'object' && a ? { host: this.opts.host, port: a.port } : undefined;
|
|
166
180
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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);
|
|
181
|
+
/** URL an agent running on this machine can use to reach the hub. Wildcard binds are rewritten to loopback. */
|
|
182
|
+
publicBaseUrl() {
|
|
183
|
+
const port = this.address()?.port ?? this.opts.port, host = ['0.0.0.0', '::', '::1', ''].includes(this.opts.host) ? '127.0.0.1' : this.opts.host;
|
|
184
|
+
return `http://${host.includes(':') ? `[${host}]` : host}:${port}`;
|
|
188
185
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
res.setHeader('x-content-type-options', 'nosniff');
|
|
198
|
-
res.end(content);
|
|
186
|
+
/** Lifecycle of a local Agent; undefined once it no longer exists. The hub uses it to tell "still working" from "never answered". */
|
|
187
|
+
agentStatus(agentId) {
|
|
188
|
+
try {
|
|
189
|
+
return this.agents.get(agentId).record.status;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
199
194
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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();
|
|
195
|
+
/** A collaboration seat is only ever bound to an existing `profile=collab` Agent: anything else can never be woken. */
|
|
196
|
+
isCollabAgent(agentId) {
|
|
197
|
+
try {
|
|
198
|
+
return this.agents.get(agentId).record.profile === 'collab';
|
|
216
199
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Pi extension bridge. It intentionally calls the hub in-process, so model context never contains tokens or URLs. */
|
|
205
|
+
collabToolBridge() {
|
|
206
|
+
return {
|
|
207
|
+
getTask: async (agentId) => this.collab.claimTaskForAgent(agentId),
|
|
208
|
+
submit: async (agentId, action, payload) => this.submitCollabTool(agentId, action, payload)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async submitCollabTool(agentId, action, payload) {
|
|
212
|
+
const participant = this.collab.participantForAgent(agentId);
|
|
213
|
+
switch (action) {
|
|
214
|
+
case 'ready':
|
|
215
|
+
return this.collab.markImplementationReady(participant, payload);
|
|
216
|
+
case 'findings':
|
|
217
|
+
return this.collab.submitFindings(participant, payload);
|
|
218
|
+
case 'issue_votes':
|
|
219
|
+
return this.collab.submitIssueVotes(participant, payload);
|
|
220
|
+
case 'merge_votes':
|
|
221
|
+
return this.collab.submitMergeVotes(participant, payload);
|
|
222
|
+
case 'issue_discussions':
|
|
223
|
+
return this.collab.submitIssueDiscussions(participant, payload);
|
|
224
|
+
case 'nominations':
|
|
225
|
+
return this.collab.submitNominations(participant, payload);
|
|
226
|
+
case 'votes':
|
|
227
|
+
return this.collab.submitVotes(participant, payload);
|
|
228
|
+
case 'scores':
|
|
229
|
+
return this.collab.submitScores(participant, payload);
|
|
230
|
+
case 'escalate':
|
|
231
|
+
return this.collab.raiseEscalation(participant, payload);
|
|
232
|
+
case 'withdraw': {
|
|
233
|
+
const issueId = String(payload.issueId ?? '');
|
|
234
|
+
delete payload.issueId;
|
|
235
|
+
return this.collab.withdrawIssue(participant, issueId);
|
|
228
236
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
237
|
+
case 'debate_argument': {
|
|
238
|
+
const debateId = String(payload.debateId ?? '');
|
|
239
|
+
delete payload.debateId;
|
|
240
|
+
return this.collab.submitDebateArgument(participant, debateId, payload);
|
|
233
241
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
res.setHeader('retry-after', String(Math.ceil(lockedFor / 1000)));
|
|
237
|
-
return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Invalid token', requestId } });
|
|
242
|
+
case 'debate_arguments':
|
|
243
|
+
return this.collab.submitDebateArguments(participant, payload);
|
|
238
244
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
245
|
+
}
|
|
246
|
+
/** Escalations and stalls block the whole session, so a human is mailed the moment one appears. */
|
|
247
|
+
collabNotification(event) {
|
|
248
|
+
if (!this.mailNotifier)
|
|
249
|
+
return;
|
|
250
|
+
try {
|
|
251
|
+
if (event.type === 'escalation_raised') {
|
|
252
|
+
// Agents can raise escalations on their own, so one runaway session must not be able to flood the mailbox.
|
|
253
|
+
if (!this.allowCollabMail(event.sessionId))
|
|
254
|
+
return;
|
|
255
|
+
const escalation = this.collab.getEscalation(String(event.payload.escalationId ?? '')), session = this.collab.getSession(event.sessionId);
|
|
256
|
+
const lines = [
|
|
257
|
+
`协作会话:${session.title}(${session.kind},phase=${session.phase},round=${session.round})`,
|
|
258
|
+
`类型:${escalation.kind}|紧急度:${escalation.urgency}`,
|
|
259
|
+
`发起者:${escalation.raisedBy}`,
|
|
260
|
+
'',
|
|
261
|
+
`摘要:${escalation.summary}`,
|
|
262
|
+
`待裁定问题:${escalation.question}`
|
|
263
|
+
];
|
|
264
|
+
if (escalation.options.length)
|
|
265
|
+
lines.push(`候选方案:${escalation.options.join(' / ')}`);
|
|
266
|
+
if (escalation.positions.length)
|
|
267
|
+
lines.push('', '各方立场:', ...escalation.positions.map(position => `- ${position.participantId}(${position.stance}):${position.rationale}`));
|
|
268
|
+
lines.push('', `处理入口:${this.publicBaseUrl()}/collab.html?session=${session.sessionId}`, `API:POST /api/v1/collab/escalations/${escalation.escalationId}/resolve`);
|
|
269
|
+
this.mailNotifier.notifyCollab({
|
|
270
|
+
subject: `[Remote Pi] 协作会话需要人工裁定:${session.title}`,
|
|
271
|
+
text: lines.join('\n')
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
260
274
|
}
|
|
261
|
-
if ('
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
275
|
+
if (event.type === 'participant_overdue') {
|
|
276
|
+
if (!this.allowCollabMail(event.sessionId))
|
|
277
|
+
return;
|
|
278
|
+
const session = this.collab.getSession(event.sessionId), waiting = event.payload.waitingOn ?? [];
|
|
279
|
+
const neverCollected = event.payload.reason === 'task_never_collected';
|
|
280
|
+
this.mailNotifier.notifyCollab({
|
|
281
|
+
subject: `[Remote Pi] 协作会话停滞:${session.title}`,
|
|
282
|
+
text: [
|
|
283
|
+
`协作会话:${session.title}(${session.kind},phase=${session.phase},round=${session.round})`,
|
|
284
|
+
neverCollected
|
|
285
|
+
? '任务已派发但从未送达:绑定的本机 Agent 可能已被删除或卡住,请在看板上把这个座位改绑到另一个 Agent。'
|
|
286
|
+
: `已等待:${Math.round(Number(event.payload.overdueBySec ?? 0))} 秒`,
|
|
287
|
+
`未提交的参与者:${waiting.join(', ') || '(未知)'}`,
|
|
288
|
+
'',
|
|
289
|
+
'中枢不会自动跳过任何人;如需推进请强制推进并说明理由。',
|
|
290
|
+
`处理入口:${this.publicBaseUrl()}/collab.html?session=${session.sessionId}`
|
|
291
|
+
].join('\n')
|
|
292
|
+
});
|
|
265
293
|
}
|
|
266
|
-
const settings = this.mailNotifier.updateSettings(patch);
|
|
267
|
-
this.metadata.setSetting('mailNotifications', settings);
|
|
268
|
-
return json(res, 200, { data: { available: true, settings } });
|
|
269
294
|
}
|
|
270
|
-
|
|
271
|
-
|
|
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 });
|
|
295
|
+
catch (error) {
|
|
296
|
+
console.error(`Collaboration mail notification failed: ${error.message}`);
|
|
279
297
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
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) });
|
|
298
|
+
}
|
|
299
|
+
/** One collaboration mail per session per cooldown window; anything suppressed is still visible on the board. */
|
|
300
|
+
allowCollabMail(sessionId) {
|
|
301
|
+
const last = this.collabMailAt.get(sessionId) ?? 0, now = Date.now();
|
|
302
|
+
if (now - last < this.collabMailCooldownMs) {
|
|
303
|
+
console.error(`Collaboration mail for session ${sessionId} suppressed by the cooldown window; see /collab.html?session=${sessionId}`);
|
|
304
|
+
return false;
|
|
294
305
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
306
|
+
this.collabMailAt.set(sessionId, now);
|
|
307
|
+
if (this.collabMailAt.size > 500)
|
|
308
|
+
for (const [key, at] of this.collabMailAt)
|
|
309
|
+
if (now - at > this.collabMailCooldownMs)
|
|
310
|
+
this.collabMailAt.delete(key);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
async stop() {
|
|
314
|
+
if (this.collabStallTimer)
|
|
315
|
+
clearInterval(this.collabStallTimer);
|
|
316
|
+
this.collabStallTimer = undefined;
|
|
317
|
+
this.unsubscribeCollab?.();
|
|
318
|
+
this.unsubscribeCollab = undefined;
|
|
319
|
+
await this.collabDispatcher.stop();
|
|
320
|
+
this.collab.shutdown();
|
|
321
|
+
this.unsubscribeMetadata?.();
|
|
322
|
+
this.unsubscribeMetadata = undefined;
|
|
323
|
+
this.voice?.close();
|
|
324
|
+
this.sessionNamer?.close();
|
|
325
|
+
await this.mailNotifier?.close();
|
|
326
|
+
await Promise.allSettled([...this.sessionIndexJobs.values(), ...this.agentIndexJobs.values()]);
|
|
327
|
+
this.metadata.flush();
|
|
328
|
+
for (const c of this.clients)
|
|
329
|
+
c.close();
|
|
330
|
+
for (const a of this.agents.list())
|
|
331
|
+
await this.agents.dispose(a.agentId).catch(() => { });
|
|
332
|
+
this.terminals.closeAll();
|
|
333
|
+
this.wss.close();
|
|
334
|
+
await new Promise(r => this.server.close(() => r()));
|
|
335
|
+
this.metadata.close();
|
|
336
|
+
}
|
|
337
|
+
async upgrade(req, socket, head) {
|
|
338
|
+
const protocol = req.headers['sec-websocket-protocol'];
|
|
339
|
+
const wsToken = Array.isArray(protocol)
|
|
340
|
+
? protocol.find(x => x.startsWith('access-token.'))?.slice(13)
|
|
341
|
+
: protocol
|
|
342
|
+
?.split(',')
|
|
343
|
+
.map(x => x.trim())
|
|
344
|
+
.find(x => x.startsWith('access-token.'))
|
|
345
|
+
?.slice(13);
|
|
346
|
+
const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname, isSocket = pathname === '/api/v1/ws' || /^\/api\/v1\/terminals\/[^/]+\/ws$/.test(pathname);
|
|
347
|
+
if (!isSocket) {
|
|
348
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
349
|
+
socket.destroy();
|
|
350
|
+
return;
|
|
301
351
|
}
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (req.method === 'DELETE' && parts.length === 4) {
|
|
307
|
-
this.terminals.close(id);
|
|
308
|
-
return json(res, 200, { data: { closed: true } });
|
|
309
|
-
}
|
|
352
|
+
if (this.noAuth && !this.allowInsecureNoAuth && !this.localRequest(req)) {
|
|
353
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
354
|
+
socket.destroy();
|
|
355
|
+
return;
|
|
310
356
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 });
|
|
357
|
+
const key = this.clientKey(req), retryAfter = this.loginLimiter.retryAfterMs(key);
|
|
358
|
+
if (retryAfter > 0) {
|
|
359
|
+
socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${Math.ceil(retryAfter / 1000)}\r\n\r\n`);
|
|
360
|
+
socket.destroy();
|
|
361
|
+
return;
|
|
329
362
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
363
|
+
const role = (await this.authRole(req.headers.authorization)) ??
|
|
364
|
+
(wsToken ? await this.auth.role(wsToken) : undefined);
|
|
365
|
+
if (!role) {
|
|
366
|
+
this.loginLimiter.recordFailure(key);
|
|
367
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
368
|
+
socket.destroy();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (role === 'voice') {
|
|
372
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
373
|
+
socket.destroy();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.loginLimiter.clear(key);
|
|
377
|
+
this.wss.handleUpgrade(req, socket, head, ws => this.wss.emit('connection', ws, req));
|
|
378
|
+
}
|
|
379
|
+
clientKey(req) {
|
|
380
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
381
|
+
}
|
|
382
|
+
async loadMetadata() {
|
|
383
|
+
let workspaces = this.metadata.listWorkspaces();
|
|
384
|
+
if (!workspaces.length) {
|
|
385
|
+
for (const workspace of this.workspaces.list())
|
|
386
|
+
this.metadata.saveWorkspace(workspace);
|
|
387
|
+
workspaces = this.metadata.listWorkspaces();
|
|
388
|
+
}
|
|
389
|
+
this.workspaces.replace(workspaces);
|
|
390
|
+
let agents = this.metadata.listAgents();
|
|
391
|
+
if (!agents.length) {
|
|
392
|
+
for (const agent of this.agents.list())
|
|
393
|
+
this.metadata.saveAgent(agent);
|
|
394
|
+
agents = this.metadata.listAgents();
|
|
395
|
+
}
|
|
396
|
+
await this.agents.restore(agents);
|
|
397
|
+
}
|
|
398
|
+
async ensureSessionIndex(workspace, cwd, force = false) {
|
|
399
|
+
const key = `${workspace.id}:${cwd}`, last = this.sessionIndexTimes.get(key) ?? 0;
|
|
400
|
+
if (!force && Date.now() - last < 30000)
|
|
401
|
+
return;
|
|
402
|
+
const existing = this.sessionIndexJobs.get(key);
|
|
403
|
+
if (existing)
|
|
404
|
+
return existing;
|
|
405
|
+
const job = (async () => {
|
|
406
|
+
const sessions = await SessionManager.list(cwd);
|
|
407
|
+
this.metadata.syncSessions(workspace.id, cwd, sessions);
|
|
408
|
+
this.sessionIndexTimes.set(key, Date.now());
|
|
409
|
+
})().finally(() => this.sessionIndexJobs.delete(key));
|
|
410
|
+
this.sessionIndexJobs.set(key, job);
|
|
411
|
+
return job;
|
|
412
|
+
}
|
|
413
|
+
async indexAgentSession(agentId) {
|
|
414
|
+
const existing = this.agentIndexJobs.get(agentId);
|
|
415
|
+
if (existing)
|
|
416
|
+
return existing;
|
|
417
|
+
const job = (async () => {
|
|
418
|
+
try {
|
|
419
|
+
const record = this.agents.get(agentId).record, summary = await this.agents.sessionInfo(agentId);
|
|
376
420
|
this.metadata.upsertSessionFromAgent(record, summary);
|
|
377
|
-
|
|
421
|
+
this.metadata.scheduleAgent(record);
|
|
378
422
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return json(res, 200, { data: await this.agents.navigate(id, b.entryId) });
|
|
423
|
+
catch {
|
|
424
|
+
/* Session files may not exist until the first assistant response is persisted. */
|
|
382
425
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
426
|
+
})().finally(() => this.agentIndexJobs.delete(agentId));
|
|
427
|
+
this.agentIndexJobs.set(agentId, job);
|
|
428
|
+
return job;
|
|
429
|
+
}
|
|
430
|
+
async resolveWorkspaceCwd(workspaceId, relativeCwd) {
|
|
431
|
+
const workspace = this.workspaces.get(workspaceId);
|
|
432
|
+
if (!workspace)
|
|
433
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
434
|
+
return this.workspaces.resolve(workspace, relativeCwd || '.');
|
|
435
|
+
}
|
|
436
|
+
/** Without a pairing code the only boundary left is the browser's: loopback Host and, if sent, a loopback Origin. */
|
|
437
|
+
localRequest(req) {
|
|
438
|
+
if (!isLoopbackHost(String(req.headers.host ?? '')))
|
|
439
|
+
return false;
|
|
440
|
+
const origin = String(req.headers.origin ?? '');
|
|
441
|
+
if (!origin)
|
|
442
|
+
return true;
|
|
443
|
+
try {
|
|
444
|
+
return isLoopbackHost(new URL(origin).host);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async authRole(value) {
|
|
451
|
+
if (this.noAuth)
|
|
452
|
+
return 'full';
|
|
453
|
+
return value?.startsWith('Bearer ') ? this.auth.role(value.slice(7)) : undefined;
|
|
454
|
+
}
|
|
455
|
+
async handle(req, res) {
|
|
456
|
+
const requestId = randomUUID();
|
|
457
|
+
res.setHeader('x-request-id', requestId);
|
|
458
|
+
if (this.noAuth) {
|
|
459
|
+
if (!this.allowInsecureNoAuth && !this.localRequest(req))
|
|
460
|
+
return json(res, 403, {
|
|
461
|
+
error: {
|
|
462
|
+
code: 'FORBIDDEN',
|
|
463
|
+
message: 'Without authentication only loopback requests are served',
|
|
464
|
+
requestId
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
if (req.headers.origin)
|
|
468
|
+
res.setHeader('access-control-allow-origin', String(req.headers.origin));
|
|
469
|
+
}
|
|
470
|
+
else
|
|
471
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
472
|
+
res.setHeader('access-control-allow-headers', 'Authorization,Content-Type,X-Audio-Filename');
|
|
473
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
|
474
|
+
try {
|
|
475
|
+
if (req.method === 'OPTIONS') {
|
|
476
|
+
res.statusCode = 204;
|
|
477
|
+
return res.end();
|
|
388
478
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
479
|
+
const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
480
|
+
if (req.method === 'GET' && (await serveWebAsset(u.pathname, req, res, { noAuth: this.noAuth })))
|
|
481
|
+
return;
|
|
482
|
+
if (req.method === 'GET' && u.pathname === '/health')
|
|
483
|
+
return json(res, 200, { data: { status: 'ok', authRequired: !this.noAuth } });
|
|
484
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
485
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/auth/login') {
|
|
486
|
+
const key = this.clientKey(req), retryAfter = this.loginLimiter.retryAfterMs(key);
|
|
487
|
+
if (retryAfter > 0) {
|
|
488
|
+
res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
|
|
489
|
+
return json(res, 429, {
|
|
490
|
+
error: {
|
|
491
|
+
code: 'TOO_MANY_ATTEMPTS',
|
|
492
|
+
message: `Too many failed pairing attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`,
|
|
493
|
+
requestId
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const b = await readJsonBody(req);
|
|
498
|
+
if (await this.auth.verify(String(b.token ?? ''))) {
|
|
499
|
+
this.loginLimiter.clear(key);
|
|
500
|
+
return json(res, 200, { data: { authenticated: true } });
|
|
501
|
+
}
|
|
502
|
+
const lockedFor = this.loginLimiter.recordFailure(key);
|
|
503
|
+
if (lockedFor > 0)
|
|
504
|
+
res.setHeader('retry-after', String(Math.ceil(lockedFor / 1000)));
|
|
505
|
+
return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Invalid token', requestId } });
|
|
393
506
|
}
|
|
394
|
-
if (
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
507
|
+
if (parts[0] === 'api' && parts[1] === 'v1' && parts[2] === 'collab') {
|
|
508
|
+
const key = this.clientKey(req), retryAfter = this.loginLimiter.retryAfterMs(key);
|
|
509
|
+
if (retryAfter > 0) {
|
|
510
|
+
res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
|
|
511
|
+
return json(res, 429, {
|
|
512
|
+
error: {
|
|
513
|
+
code: 'TOO_MANY_ATTEMPTS',
|
|
514
|
+
message: `Too many failed attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`,
|
|
515
|
+
requestId
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const header = String(req.headers.authorization ?? ''), token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
520
|
+
const result = await this.collabRouter.handle({
|
|
521
|
+
method: req.method ?? 'GET',
|
|
522
|
+
url: u,
|
|
523
|
+
parts,
|
|
524
|
+
token,
|
|
525
|
+
body: () => readJsonBody(req)
|
|
526
|
+
});
|
|
527
|
+
if (!result)
|
|
528
|
+
return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
|
|
529
|
+
if (result.authFailed)
|
|
530
|
+
this.loginLimiter.recordFailure(key);
|
|
531
|
+
else
|
|
532
|
+
this.loginLimiter.clear(key);
|
|
533
|
+
return json(res, result.status, result.payload);
|
|
400
534
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
535
|
+
const role = await this.authRole(req.headers.authorization);
|
|
536
|
+
if (!role)
|
|
537
|
+
return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Authentication required', requestId } });
|
|
538
|
+
if (role === 'voice' &&
|
|
539
|
+
!((req.method === 'POST' && u.pathname === '/api/v1/draft') ||
|
|
540
|
+
(req.method === 'GET' && u.pathname === '/api/v1/system/status')))
|
|
541
|
+
return json(res, 403, {
|
|
542
|
+
error: { code: 'FORBIDDEN', message: 'This token cannot access this endpoint', requestId }
|
|
543
|
+
});
|
|
544
|
+
if (req.method === 'POST' && u.pathname === '/api/v1/draft') {
|
|
545
|
+
const b = await readJsonBody(req), text = typeof b?.text === 'string' ? b.text : '';
|
|
546
|
+
if (!text.trim())
|
|
547
|
+
return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'text is required', requestId } });
|
|
548
|
+
if (text.length > 20000)
|
|
549
|
+
return json(res, 413, { error: { code: 'REQUEST_TOO_LARGE', message: 'text is too long', requestId } });
|
|
550
|
+
if (b.agentId !== undefined || b.target !== undefined)
|
|
551
|
+
return json(res, 400, {
|
|
552
|
+
error: { code: 'INVALID_REQUEST', message: 'draft targets are not supported', requestId }
|
|
553
|
+
});
|
|
554
|
+
if (b.mode !== undefined && b.mode !== 'append' && b.mode !== 'replace')
|
|
555
|
+
return json(res, 400, {
|
|
556
|
+
error: { code: 'INVALID_REQUEST', message: 'mode must be append or replace', requestId }
|
|
557
|
+
});
|
|
558
|
+
const { draft, delivered } = this.drafts.add({
|
|
559
|
+
text,
|
|
560
|
+
mode: b.mode ?? 'append',
|
|
561
|
+
source: typeof b.source === 'string' ? b.source.slice(0, 32) : undefined
|
|
562
|
+
});
|
|
563
|
+
return json(res, 200, { data: { id: draft.id, mode: draft.mode, delivered, queued: true } });
|
|
411
564
|
}
|
|
565
|
+
if (req.method === 'GET' && u.pathname === '/api/v1/system/status')
|
|
566
|
+
return json(res, 200, {
|
|
567
|
+
data: {
|
|
568
|
+
version: pkg.version,
|
|
569
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
570
|
+
authRequired: !this.noAuth,
|
|
571
|
+
piVersion: PI_VERSION,
|
|
572
|
+
nodeVersion: process.version,
|
|
573
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
574
|
+
agentCount: this.agents.list().length,
|
|
575
|
+
terminalCount: this.terminals.list().length,
|
|
576
|
+
webSocketConnections: this.clients.size,
|
|
577
|
+
voiceEnabled: !!this.voice,
|
|
578
|
+
voiceCapabilities: this.voice?.capabilities() ?? { tts: false, stt: false },
|
|
579
|
+
sessionNamingEnabled: !!this.sessionNamer,
|
|
580
|
+
mailNotificationsConfigured: !!this.mailNotifier,
|
|
581
|
+
mailNotificationsEnabled: this.mailNotifier?.getSettings().enabled ?? false
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
if (await handleMailSettingsRoutes({ mailNotifier: this.mailNotifier, metadata: this.metadata }, req, res, u))
|
|
585
|
+
return;
|
|
586
|
+
const workspaceContext = {
|
|
587
|
+
workspaces: this.workspaces,
|
|
588
|
+
terminals: this.terminals,
|
|
589
|
+
metadata: this.metadata,
|
|
590
|
+
ensureSessionIndex: (workspace, cwd, force) => this.ensureSessionIndex(workspace, cwd, force)
|
|
591
|
+
};
|
|
592
|
+
if (await handleWorkspaceRoutes(workspaceContext, req, res, u, parts, requestId))
|
|
593
|
+
return;
|
|
594
|
+
if (await handleSystemPromptRoutes({ agents: this.agents, templates: this.templates, body: () => readJsonBody(req) }, req, res, u, parts))
|
|
595
|
+
return;
|
|
596
|
+
const agentContext = {
|
|
597
|
+
agents: this.agents,
|
|
598
|
+
templates: this.templates,
|
|
599
|
+
metadata: this.metadata,
|
|
600
|
+
workspaces: this.workspaces,
|
|
601
|
+
voice: this.voice,
|
|
602
|
+
sessionNamer: this.sessionNamer,
|
|
603
|
+
ensureSessionIndex: (workspace, cwd, force) => this.ensureSessionIndex(workspace, cwd, force),
|
|
604
|
+
indexAgentSession: (agentId) => this.indexAgentSession(agentId),
|
|
605
|
+
body: () => readJsonBody(req),
|
|
606
|
+
binaryBody: () => readBinaryBody(req)
|
|
607
|
+
};
|
|
608
|
+
if (await handleAgentRoutes(agentContext, req, res, u, parts))
|
|
609
|
+
return;
|
|
610
|
+
return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
const x = e;
|
|
614
|
+
return json(res, errorStatus(x.code), {
|
|
615
|
+
error: { code: x.code ?? 'BAD_REQUEST', message: x.message ?? 'Request failed', requestId }
|
|
616
|
+
});
|
|
412
617
|
}
|
|
413
|
-
return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
|
|
414
618
|
}
|
|
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
619
|
}
|