pi2web 0.2.1 → 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 +44 -5
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +518 -141
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- 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 +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +190 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +221 -45
- package/dist/server.js +444 -620
- package/dist/session-namer.js +66 -17
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- 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 +11 -1
- package/packages/protocol/openapi.yaml +6 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3098 -743
- package/web/backend-state.js +59 -27
- package/web/collab.js +668 -255
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +4 -1
- package/web/styles.css +14 -0
package/dist/server.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { createRequire } from 'node:module';
|
|
6
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
-
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
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';
|
|
11
8
|
import { createSdkBackend } from './sdk-backend.js';
|
|
12
|
-
import { listMcpServers, setMcpServerEnabled } from './mcp.js';
|
|
13
9
|
import { TerminalManager } from './terminals.js';
|
|
14
10
|
import { MetadataStore } from './metadata-store.js';
|
|
15
11
|
import { CollabHub } from './collab/hub.js';
|
|
@@ -17,19 +13,20 @@ import { CollabStore } from './collab/store.js';
|
|
|
17
13
|
import { CollabRouter } from './collab/routes.js';
|
|
18
14
|
import { CollabDispatcher } from './collab/dispatcher.js';
|
|
19
15
|
import { DraftHub } from './drafts.js';
|
|
16
|
+
import { AGENT_TEMPLATES_DIR, defaultDataDir } from './paths.js';
|
|
17
|
+
import { AgentTemplateStore } from './agent-templates.js';
|
|
20
18
|
import { SessionManager, VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
|
|
21
19
|
import pkg from '../package.json' with { type: 'json' };
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
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' }, '/backend-state.js': { file: 'backend-state.js', type: 'text/javascript; charset=utf-8' }, '/hid-keyboard.js': { file: 'hid-keyboard.js', type: 'text/javascript; charset=utf-8' }, '/collab': { file: 'collab.html', type: 'text/html; charset=utf-8' }, '/collab.html': { file: 'collab.html', type: 'text/html; charset=utf-8' }, '/collab.js': { file: 'collab.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' } };
|
|
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 };
|
|
33
30
|
export class RemotePiServer {
|
|
34
31
|
server;
|
|
35
32
|
wss;
|
|
@@ -38,6 +35,7 @@ export class RemotePiServer {
|
|
|
38
35
|
agents;
|
|
39
36
|
terminals;
|
|
40
37
|
metadata;
|
|
38
|
+
templates;
|
|
41
39
|
collab;
|
|
42
40
|
collabRouter;
|
|
43
41
|
collabDispatcher;
|
|
@@ -58,12 +56,56 @@ export class RemotePiServer {
|
|
|
58
56
|
voice;
|
|
59
57
|
sessionNamer;
|
|
60
58
|
mailNotifier;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
109
|
async start() {
|
|
68
110
|
if (this.allowInsecureNoAuth && !this.noAuth)
|
|
69
111
|
throw Object.assign(new Error('allowInsecureNoAuth requires noAuth (--allow-insecure-no-auth requires --no-auth)'), { code: 'INVALID_OPTIONS' });
|
|
@@ -75,12 +117,14 @@ export class RemotePiServer {
|
|
|
75
117
|
this.metadata.init();
|
|
76
118
|
this.collab.init();
|
|
77
119
|
this.unsubscribeCollab = this.collab.subscribe(event => this.collabNotification(event));
|
|
78
|
-
this.collabStallTimer = setInterval(() => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
120
|
+
this.collabStallTimer = setInterval(() => {
|
|
121
|
+
try {
|
|
122
|
+
this.collab.checkStalls();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error(`Collaboration stall check failed: ${error.message}`);
|
|
126
|
+
}
|
|
127
|
+
}, 60_000);
|
|
84
128
|
this.collabStallTimer.unref?.();
|
|
85
129
|
if (this.mailNotifier) {
|
|
86
130
|
const settings = this.metadata.getSetting('mailNotifications');
|
|
@@ -98,12 +142,26 @@ export class RemotePiServer {
|
|
|
98
142
|
this.collabDispatcher.start();
|
|
99
143
|
this.unsubscribeMetadata = this.agents.subscribeAll((agentId, event) => {
|
|
100
144
|
const record = this.agents.get(agentId).record;
|
|
101
|
-
const applyName = async (name) => {
|
|
102
|
-
|
|
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
|
+
};
|
|
103
151
|
// The namer owns naming whenever it is enabled, so the voice summary never races it for the same name.
|
|
104
|
-
this.sessionNamer?.handleAgentEvent(agentId, event.event, {
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
});
|
|
107
165
|
if (['message_end', 'agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type))) {
|
|
108
166
|
const record = this.agents.get(agentId).record;
|
|
109
167
|
this.metadata.scheduleAgent(record);
|
|
@@ -116,23 +174,33 @@ export class RemotePiServer {
|
|
|
116
174
|
await new Promise(r => this.server.listen(this.opts.port, this.opts.host, r));
|
|
117
175
|
return this.address();
|
|
118
176
|
}
|
|
119
|
-
address() {
|
|
177
|
+
address() {
|
|
178
|
+
const a = this.server.address();
|
|
179
|
+
return typeof a === 'object' && a ? { host: this.opts.host, port: a.port } : undefined;
|
|
180
|
+
}
|
|
120
181
|
/** URL an agent running on this machine can use to reach the hub. Wildcard binds are rewritten to loopback. */
|
|
121
|
-
publicBaseUrl() {
|
|
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}`;
|
|
185
|
+
}
|
|
122
186
|
/** Lifecycle of a local Agent; undefined once it no longer exists. The hub uses it to tell "still working" from "never answered". */
|
|
123
|
-
agentStatus(agentId) {
|
|
124
|
-
|
|
187
|
+
agentStatus(agentId) {
|
|
188
|
+
try {
|
|
189
|
+
return this.agents.get(agentId).record.status;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
125
194
|
}
|
|
126
|
-
catch {
|
|
127
|
-
return undefined;
|
|
128
|
-
} }
|
|
129
195
|
/** A collaboration seat is only ever bound to an existing `profile=collab` Agent: anything else can never be woken. */
|
|
130
|
-
isCollabAgent(agentId) {
|
|
131
|
-
|
|
196
|
+
isCollabAgent(agentId) {
|
|
197
|
+
try {
|
|
198
|
+
return this.agents.get(agentId).record.profile === 'collab';
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
132
203
|
}
|
|
133
|
-
catch {
|
|
134
|
-
return false;
|
|
135
|
-
} }
|
|
136
204
|
/** Pi extension bridge. It intentionally calls the hub in-process, so model context never contains tokens or URLs. */
|
|
137
205
|
collabToolBridge() {
|
|
138
206
|
return {
|
|
@@ -143,15 +211,24 @@ export class RemotePiServer {
|
|
|
143
211
|
async submitCollabTool(agentId, action, payload) {
|
|
144
212
|
const participant = this.collab.participantForAgent(agentId);
|
|
145
213
|
switch (action) {
|
|
146
|
-
case 'ready':
|
|
147
|
-
|
|
148
|
-
case '
|
|
149
|
-
|
|
150
|
-
case '
|
|
151
|
-
|
|
152
|
-
case '
|
|
153
|
-
|
|
154
|
-
case '
|
|
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);
|
|
155
232
|
case 'withdraw': {
|
|
156
233
|
const issueId = String(payload.issueId ?? '');
|
|
157
234
|
delete payload.issueId;
|
|
@@ -162,7 +239,8 @@ export class RemotePiServer {
|
|
|
162
239
|
delete payload.debateId;
|
|
163
240
|
return this.collab.submitDebateArgument(participant, debateId, payload);
|
|
164
241
|
}
|
|
165
|
-
case 'debate_arguments':
|
|
242
|
+
case 'debate_arguments':
|
|
243
|
+
return this.collab.submitDebateArguments(participant, payload);
|
|
166
244
|
}
|
|
167
245
|
}
|
|
168
246
|
/** Escalations and stalls block the whole session, so a human is mailed the moment one appears. */
|
|
@@ -175,13 +253,23 @@ export class RemotePiServer {
|
|
|
175
253
|
if (!this.allowCollabMail(event.sessionId))
|
|
176
254
|
return;
|
|
177
255
|
const escalation = this.collab.getEscalation(String(event.payload.escalationId ?? '')), session = this.collab.getSession(event.sessionId);
|
|
178
|
-
const lines = [
|
|
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
|
+
];
|
|
179
264
|
if (escalation.options.length)
|
|
180
265
|
lines.push(`候选方案:${escalation.options.join(' / ')}`);
|
|
181
266
|
if (escalation.positions.length)
|
|
182
267
|
lines.push('', '各方立场:', ...escalation.positions.map(position => `- ${position.participantId}(${position.stance}):${position.rationale}`));
|
|
183
268
|
lines.push('', `处理入口:${this.publicBaseUrl()}/collab.html?session=${session.sessionId}`, `API:POST /api/v1/collab/escalations/${escalation.escalationId}/resolve`);
|
|
184
|
-
this.mailNotifier.notifyCollab({
|
|
269
|
+
this.mailNotifier.notifyCollab({
|
|
270
|
+
subject: `[Remote Pi] 协作会话需要人工裁定:${session.title}`,
|
|
271
|
+
text: lines.join('\n')
|
|
272
|
+
});
|
|
185
273
|
return;
|
|
186
274
|
}
|
|
187
275
|
if (event.type === 'participant_overdue') {
|
|
@@ -189,10 +277,19 @@ export class RemotePiServer {
|
|
|
189
277
|
return;
|
|
190
278
|
const session = this.collab.getSession(event.sessionId), waiting = event.payload.waitingOn ?? [];
|
|
191
279
|
const neverCollected = event.payload.reason === 'task_never_collected';
|
|
192
|
-
this.mailNotifier.notifyCollab({
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
});
|
|
196
293
|
}
|
|
197
294
|
}
|
|
198
295
|
catch (error) {
|
|
@@ -213,583 +310,310 @@ export class RemotePiServer {
|
|
|
213
310
|
this.collabMailAt.delete(key);
|
|
214
311
|
return true;
|
|
215
312
|
}
|
|
216
|
-
async stop() {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return 0;
|
|
254
|
-
} entry.count += 1; entry.expiresAt = now + this.loginWindowMs; if (entry.count < this.loginMaxFailures)
|
|
255
|
-
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; }
|
|
256
|
-
connection(ws, _req) {
|
|
257
|
-
this.clients.add(ws);
|
|
258
|
-
const subscriptions = new Map();
|
|
259
|
-
// Pi providers may emit one delta per character. Sending every delta as its own
|
|
260
|
-
// WebSocket frame is especially expensive over a high-latency/low-bandwidth
|
|
261
|
-
// link: agent_end gets stuck behind thousands of tiny frames. Coalesce adjacent
|
|
262
|
-
// text/thinking deltas while preserving their sequence cursor and event order.
|
|
263
|
-
let pendingDelta, deltaTimer;
|
|
264
|
-
const sendNow = (message) => { if (ws.readyState !== WebSocket.OPEN)
|
|
265
|
-
return; if (ws.bufferedAmount > 2 * 1024 * 1024) {
|
|
266
|
-
ws.close(1013, 'Agent client is too slow');
|
|
267
|
-
return;
|
|
268
|
-
} ws.send(JSON.stringify(message)); };
|
|
269
|
-
const flushDelta = () => { if (deltaTimer)
|
|
270
|
-
clearTimeout(deltaTimer); deltaTimer = undefined; if (pendingDelta) {
|
|
271
|
-
const message = pendingDelta;
|
|
272
|
-
pendingDelta = undefined;
|
|
273
|
-
sendNow(message);
|
|
274
|
-
} };
|
|
275
|
-
const send = (message) => { const update = message?.type === 'agent_event' && message.event?.type === 'message_update' ? message.event.assistantMessageEvent : undefined, isDelta = update && (update.type === 'text_delta' || update.type === 'thinking_delta') && typeof update.delta === 'string'; if (!isDelta) {
|
|
276
|
-
flushDelta();
|
|
277
|
-
sendNow(message);
|
|
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();
|
|
278
350
|
return;
|
|
279
|
-
} const previous = pendingDelta?.event?.assistantMessageEvent; if (pendingDelta && pendingDelta.agentId === message.agentId && previous?.type === update.type) {
|
|
280
|
-
previous.delta += update.delta;
|
|
281
|
-
pendingDelta.eventId = message.eventId;
|
|
282
|
-
pendingDelta.sequence = message.sequence;
|
|
283
|
-
pendingDelta.timestamp = message.timestamp;
|
|
284
351
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
} if (!deltaTimer)
|
|
289
|
-
deltaTimer = setTimeout(flushDelta, 40); };
|
|
290
|
-
const unsubscribeVoice = this.voice?.subscribe((agentId, event) => { if (subscriptions.has('*') || subscriptions.has(agentId))
|
|
291
|
-
send({ type: 'voice_event', agentId, event }); });
|
|
292
|
-
const unsubscribeNamer = this.sessionNamer?.subscribe((agentId, event) => { if (subscriptions.has('*') || subscriptions.has(agentId))
|
|
293
|
-
send({ type: 'session_namer_event', agentId, event }); });
|
|
294
|
-
ws.on('close', () => { if (deltaTimer)
|
|
295
|
-
clearTimeout(deltaTimer); pendingDelta = undefined; this.clients.delete(ws); unsubscribeVoice?.(); unsubscribeNamer?.(); for (const unsubscribe of subscriptions.values())
|
|
296
|
-
unsubscribe(); });
|
|
297
|
-
ws.on('message', raw => {
|
|
298
|
-
void (async () => {
|
|
299
|
-
let m;
|
|
300
|
-
try {
|
|
301
|
-
m = JSON.parse(raw.toString());
|
|
302
|
-
if (m.type === 'subscribe_drafts') {
|
|
303
|
-
subscriptions.get('drafts')?.();
|
|
304
|
-
send({ type: 'subscribed_drafts', protocolVersion: 1 });
|
|
305
|
-
subscriptions.set('drafts', this.drafts.subscribe((draft, replay) => send({ type: 'draft_event', draft, replay })));
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (m.type === 'draft_ack') {
|
|
309
|
-
this.drafts.ack(String(m.id ?? ''));
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (m.type === 'subscribe_collab') {
|
|
313
|
-
subscriptions.get('collab')?.();
|
|
314
|
-
subscriptions.set('collab', this.collab.subscribe(event => send({ type: 'collab_event', event })));
|
|
315
|
-
send({ type: 'subscribed_collab', protocolVersion: 1 });
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
if (m.type === 'subscribe_all') {
|
|
319
|
-
subscriptions.get('*')?.();
|
|
320
|
-
const forward = (agentId, e) => !subscriptions.has(agentId) && send({ type: 'agent_event', agentId, eventId: e.id, sequence: e.sequence, timestamp: e.timestamp, status: e.status, event: e.event });
|
|
321
|
-
subscriptions.set('*', this.agents.subscribeAll(forward));
|
|
322
|
-
send({ type: 'subscribed_all', protocolVersion: 1 });
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
if (m.type === 'subscribe') {
|
|
326
|
-
const agentId = m.agentId, fromNow = m.fromNow === true;
|
|
327
|
-
subscriptions.get(agentId)?.();
|
|
328
|
-
const forward = (e) => send({ type: 'agent_event', agentId, eventId: e.id, sequence: e.sequence, timestamp: e.timestamp, status: e.status, event: e.event });
|
|
329
|
-
let ready = false, active = true;
|
|
330
|
-
const unsubscribe = this.agents.subscribe(agentId, e => { if (ready)
|
|
331
|
-
forward(e); });
|
|
332
|
-
const stop = () => { active = false; ready = false; unsubscribe(); };
|
|
333
|
-
subscriptions.set(agentId, stop);
|
|
334
|
-
try {
|
|
335
|
-
send({ type: 'subscribed', agentId, protocolVersion: 1, currentSequence: this.agents.currentSequence(agentId) });
|
|
336
|
-
if (!fromNow) {
|
|
337
|
-
const last = Number(m.lastSequence ?? 0), replay = this.agents.events(agentId, last);
|
|
338
|
-
if (this.agents.hasReplayGap(agentId, last) || replay.length > 250) {
|
|
339
|
-
const messageLimit = m.messageLimit === undefined ? undefined : Number(m.messageLimit);
|
|
340
|
-
const snapshot = await this.agents.snapshot(agentId, Number.isFinite(messageLimit) ? messageLimit : undefined);
|
|
341
|
-
if (!active)
|
|
342
|
-
return; // Closed or replaced by a newer subscription while awaiting messages.
|
|
343
|
-
// Do not forward live events ahead of the snapshot. Catch up from the
|
|
344
|
-
// existing bounded replay cache instead of maintaining another buffer.
|
|
345
|
-
const updates = this.agents.events(agentId, snapshot.lastSequence);
|
|
346
|
-
if (this.agents.hasReplayGap(agentId, snapshot.lastSequence) || (updates.length && updates[0].sequence !== snapshot.lastSequence + 1)) {
|
|
347
|
-
stop();
|
|
348
|
-
ws.close(1013, 'Replay gap during snapshot');
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
send({ type: 'agent_snapshot', agentId, ...snapshot });
|
|
352
|
-
for (const event of updates)
|
|
353
|
-
forward(event);
|
|
354
|
-
}
|
|
355
|
-
else
|
|
356
|
-
for (const event of replay)
|
|
357
|
-
forward(event);
|
|
358
|
-
}
|
|
359
|
-
// Refresh/reconnect may already have a saved cursor but no in-memory
|
|
360
|
-
// activity. Send the current IDs after replay, even if replay was empty.
|
|
361
|
-
send({ type: 'agent_state', agentId, sequence: this.agents.currentSequence(agentId), state: this.agents.snapshotState(agentId) });
|
|
362
|
-
ready = true;
|
|
363
|
-
}
|
|
364
|
-
catch (error) {
|
|
365
|
-
if (!active)
|
|
366
|
-
return;
|
|
367
|
-
stop();
|
|
368
|
-
if (subscriptions.get(agentId) === stop)
|
|
369
|
-
subscriptions.delete(agentId);
|
|
370
|
-
throw error;
|
|
371
|
-
}
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (['prompt', 'steer', 'follow-up', 'abort'].includes(m.type)) {
|
|
375
|
-
if (m.type !== 'abort') {
|
|
376
|
-
this.voice?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
377
|
-
this.sessionNamer?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
378
|
-
}
|
|
379
|
-
const disposition = await this.agents.command(m.agentId, m.type, m.message);
|
|
380
|
-
send({ type: 'command_result', requestId: m.requestId, success: true, disposition });
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
if (m.type === 'clear-queue') {
|
|
384
|
-
send({ type: 'command_result', requestId: m.requestId, success: true, queue: await this.agents.clearQueue(m.agentId) });
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
throw new Error('Unknown command');
|
|
388
|
-
}
|
|
389
|
-
catch (e) {
|
|
390
|
-
send({ type: 'command_result', requestId: m?.requestId, success: false, error: { code: e.code ?? 'BAD_REQUEST', message: e instanceof SyntaxError ? 'Invalid JSON' : e.message } });
|
|
391
|
-
}
|
|
392
|
-
})();
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
terminalConnection(ws, terminalId) { this.clients.add(ws); let unsubscribe = () => { }; ws.on('close', () => { this.clients.delete(ws); unsubscribe(); }); try {
|
|
396
|
-
const send = (message) => { if (ws.readyState !== WebSocket.OPEN)
|
|
397
|
-
return; if (ws.bufferedAmount > 1024 * 1024) {
|
|
398
|
-
ws.close(1013, 'Terminal client is too slow');
|
|
352
|
+
if (this.noAuth && !this.allowInsecureNoAuth && !this.localRequest(req)) {
|
|
353
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
354
|
+
socket.destroy();
|
|
399
355
|
return;
|
|
400
|
-
} ws.send(JSON.stringify(message)); };
|
|
401
|
-
unsubscribe = this.terminals.subscribe(terminalId, event => { send(event); if (event.type === 'exit')
|
|
402
|
-
setTimeout(() => ws.close(), 50); });
|
|
403
|
-
const snapshot = this.terminals.snapshot(terminalId);
|
|
404
|
-
send({ type: 'snapshot', data: snapshot.data, record: snapshot.record });
|
|
405
|
-
ws.on('message', raw => { try {
|
|
406
|
-
const message = JSON.parse(raw.toString());
|
|
407
|
-
if (message.type === 'input')
|
|
408
|
-
this.terminals.write(terminalId, message.data);
|
|
409
|
-
else if (message.type === 'resize')
|
|
410
|
-
this.terminals.resize(terminalId, Number(message.cols), Number(message.rows));
|
|
411
|
-
else
|
|
412
|
-
throw new Error('Unknown terminal command');
|
|
413
356
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
ws.send(JSON.stringify({ type: 'error', message: error.message }));
|
|
420
|
-
ws.close();
|
|
421
|
-
} }
|
|
422
|
-
async loadMetadata() { let workspaces = this.metadata.listWorkspaces(); if (!workspaces.length) {
|
|
423
|
-
for (const workspace of this.workspaces.list())
|
|
424
|
-
this.metadata.saveWorkspace(workspace);
|
|
425
|
-
workspaces = this.metadata.listWorkspaces();
|
|
426
|
-
} this.workspaces.replace(workspaces); let agents = this.metadata.listAgents(); if (!agents.length) {
|
|
427
|
-
for (const agent of this.agents.list())
|
|
428
|
-
this.metadata.saveAgent(agent);
|
|
429
|
-
agents = this.metadata.listAgents();
|
|
430
|
-
} await this.agents.restore(agents); }
|
|
431
|
-
async ensureSessionIndex(workspace, cwd, force = false) { const key = `${workspace.id}:${cwd}`, last = this.sessionIndexTimes.get(key) ?? 0; if (!force && Date.now() - last < 30000)
|
|
432
|
-
return; const existing = this.sessionIndexJobs.get(key); if (existing)
|
|
433
|
-
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; }
|
|
434
|
-
async indexAgentSession(agentId) { const existing = this.agentIndexJobs.get(agentId); if (existing)
|
|
435
|
-
return existing; const job = (async () => { try {
|
|
436
|
-
const record = this.agents.get(agentId).record, summary = await this.agents.sessionInfo(agentId);
|
|
437
|
-
this.metadata.upsertSessionFromAgent(record, summary);
|
|
438
|
-
this.metadata.scheduleAgent(record);
|
|
439
|
-
}
|
|
440
|
-
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; }
|
|
441
|
-
async resolveWorkspaceCwd(workspaceId, relativeCwd) { const workspace = this.workspaces.get(workspaceId); if (!workspace)
|
|
442
|
-
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); return this.workspaces.resolve(workspace, relativeCwd || '.'); }
|
|
443
|
-
/** Without a pairing code the only boundary left is the browser's: loopback Host and, if sent, a loopback Origin. */
|
|
444
|
-
localRequest(req) { if (!isLoopbackHost(String(req.headers.host ?? '')))
|
|
445
|
-
return false; const origin = String(req.headers.origin ?? ''); if (!origin)
|
|
446
|
-
return true; try {
|
|
447
|
-
return isLoopbackHost(new URL(origin).host);
|
|
448
|
-
}
|
|
449
|
-
catch {
|
|
450
|
-
return false;
|
|
451
|
-
} }
|
|
452
|
-
async authRole(value) { if (this.noAuth)
|
|
453
|
-
return 'full'; return value?.startsWith('Bearer ') ? this.auth.role(value.slice(7)) : undefined; }
|
|
454
|
-
async serveWeb(pathname, req, res) { const asset = webFiles[pathname]; if (!asset)
|
|
455
|
-
return false; try {
|
|
456
|
-
const content = await readFile(path.isAbsolute(asset.file) ? asset.file : path.join(webRoot, asset.file)), etag = `"${createHash('sha256').update(content).digest('base64url')}"`, ifNoneMatch = String(req.headers['if-none-match'] ?? '').split(',').map(value => value.trim().replace(/^W\//, ''));
|
|
457
|
-
res.setHeader('content-type', asset.type);
|
|
458
|
-
res.setHeader('cache-control', pathname.startsWith('/vendor/') ? 'public, max-age=86400' : 'no-cache');
|
|
459
|
-
res.setHeader('etag', etag);
|
|
460
|
-
res.setHeader('x-content-type-options', 'nosniff');
|
|
461
|
-
if (this.noAuth && asset.type.startsWith('text/html'))
|
|
462
|
-
res.setHeader('content-security-policy', "frame-ancestors 'self'");
|
|
463
|
-
if (ifNoneMatch.includes(etag) || ifNoneMatch.includes('*')) {
|
|
464
|
-
res.statusCode = 304;
|
|
465
|
-
res.end();
|
|
466
|
-
return true;
|
|
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;
|
|
467
362
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
async body(req, maxBytes = 2 * 1024 * 1024) { const raw = await this.binaryBody(req, maxBytes); return raw.length ? JSON.parse(raw.toString('utf8')) : {}; }
|
|
476
|
-
async binaryBody(req, maxBytes = 25 * 1024 * 1024) { const chunks = []; let size = 0; for await (const value of req) {
|
|
477
|
-
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
478
|
-
size += chunk.length;
|
|
479
|
-
if (size > maxBytes)
|
|
480
|
-
throw Object.assign(new Error('Request too large'), { code: 'REQUEST_TOO_LARGE' });
|
|
481
|
-
chunks.push(chunk);
|
|
482
|
-
} return Buffer.concat(chunks); }
|
|
483
|
-
async handle(req, res) { const requestId = randomUUID(); res.setHeader('x-request-id', requestId); if (this.noAuth) {
|
|
484
|
-
if (!this.allowInsecureNoAuth && !this.localRequest(req))
|
|
485
|
-
return json(res, 403, { error: { code: 'FORBIDDEN', message: 'Without authentication only loopback requests are served', requestId } });
|
|
486
|
-
if (req.headers.origin)
|
|
487
|
-
res.setHeader('access-control-allow-origin', String(req.headers.origin));
|
|
488
|
-
}
|
|
489
|
-
else
|
|
490
|
-
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,PUT,DELETE,OPTIONS'); try {
|
|
491
|
-
if (req.method === 'OPTIONS') {
|
|
492
|
-
res.statusCode = 204;
|
|
493
|
-
return res.end();
|
|
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;
|
|
494
370
|
}
|
|
495
|
-
|
|
496
|
-
|
|
371
|
+
if (role === 'voice') {
|
|
372
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
373
|
+
socket.destroy();
|
|
497
374
|
return;
|
|
498
|
-
if (req.method === 'GET' && u.pathname === '/health')
|
|
499
|
-
return json(res, 200, { data: { status: 'ok', authRequired: !this.noAuth } });
|
|
500
|
-
const parts = u.pathname.split('/').filter(Boolean);
|
|
501
|
-
if (req.method === 'POST' && u.pathname === '/api/v1/auth/login') {
|
|
502
|
-
const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key);
|
|
503
|
-
if (retryAfter > 0) {
|
|
504
|
-
res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
|
|
505
|
-
return json(res, 429, { error: { code: 'TOO_MANY_ATTEMPTS', message: `Too many failed pairing attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`, requestId } });
|
|
506
|
-
}
|
|
507
|
-
const b = await this.body(req);
|
|
508
|
-
if (await this.auth.verify(String(b.token ?? ''))) {
|
|
509
|
-
this.loginFailures.delete(key);
|
|
510
|
-
return json(res, 200, { data: { authenticated: true } });
|
|
511
|
-
}
|
|
512
|
-
const lockedFor = this.recordLoginFailure(key);
|
|
513
|
-
if (lockedFor > 0)
|
|
514
|
-
res.setHeader('retry-after', String(Math.ceil(lockedFor / 1000)));
|
|
515
|
-
return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Invalid token', requestId } });
|
|
516
375
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
else
|
|
530
|
-
this.loginFailures.delete(key);
|
|
531
|
-
return json(res, result.status, result.payload);
|
|
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();
|
|
532
388
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const b = await this.body(req), text = typeof b?.text === 'string' ? b.text : '';
|
|
540
|
-
if (!text.trim())
|
|
541
|
-
return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'text is required', requestId } });
|
|
542
|
-
if (text.length > 20000)
|
|
543
|
-
return json(res, 413, { error: { code: 'REQUEST_TOO_LARGE', message: 'text is too long', requestId } });
|
|
544
|
-
if (b.agentId !== undefined || b.target !== undefined)
|
|
545
|
-
return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'draft targets are not supported', requestId } });
|
|
546
|
-
if (b.mode !== undefined && b.mode !== 'append' && b.mode !== 'replace')
|
|
547
|
-
return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'mode must be append or replace', requestId } });
|
|
548
|
-
const { draft, delivered } = this.drafts.add({ text, mode: b.mode ?? 'append', source: typeof b.source === 'string' ? b.source.slice(0, 32) : undefined });
|
|
549
|
-
return json(res, 200, { data: { id: draft.id, mode: draft.mode, delivered, queued: true } });
|
|
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();
|
|
550
395
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
if (
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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);
|
|
420
|
+
this.metadata.upsertSessionFromAgent(record, summary);
|
|
421
|
+
this.metadata.scheduleAgent(record);
|
|
575
422
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
throw Object.assign(new Error('collabEscalations must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
|
|
579
|
-
patch.collabEscalations = b.collabEscalations;
|
|
423
|
+
catch {
|
|
424
|
+
/* Session files may not exist until the first assistant response is persisted. */
|
|
580
425
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if (
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
return json(res, 200, { data: await this.workspaces.stat(parts[3], u.searchParams.get('path') ?? '.') });
|
|
601
|
-
if (req.method === 'GET' && u.pathname === '/api/v1/sessions') {
|
|
602
|
-
const workspace = this.workspaces.get(u.searchParams.get('workspaceId') ?? '');
|
|
603
|
-
if (!workspace)
|
|
604
|
-
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
605
|
-
const cwd = await this.workspaces.resolve(workspace, u.searchParams.get('path') ?? '.');
|
|
606
|
-
await this.ensureSessionIndex(workspace, cwd, u.searchParams.get('refresh') === 'true');
|
|
607
|
-
const limit = u.searchParams.has('limit') ? Number(u.searchParams.get('limit')) : undefined, offset = Number(u.searchParams.get('offset') ?? 0);
|
|
608
|
-
return json(res, 200, { data: this.metadata.listSessions(cwd, limit, offset) });
|
|
609
|
-
}
|
|
610
|
-
if (req.method === 'GET' && u.pathname === '/api/v1/terminals')
|
|
611
|
-
return json(res, 200, { data: this.terminals.list() });
|
|
612
|
-
if (req.method === 'POST' && u.pathname === '/api/v1/terminals') {
|
|
613
|
-
const b = await this.body(req);
|
|
614
|
-
const terminal = await this.terminals.create(String(b.workspaceId ?? ''), String(b.relativeCwd ?? '.'), Number(b.cols ?? 80), Number(b.rows ?? 24));
|
|
615
|
-
return json(res, 201, { data: terminal });
|
|
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);
|
|
616
445
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (req.method === 'GET' && parts.length === 4)
|
|
620
|
-
return json(res, 200, { data: this.terminals.get(id).record });
|
|
621
|
-
if (req.method === 'DELETE' && parts.length === 4) {
|
|
622
|
-
this.terminals.close(id);
|
|
623
|
-
return json(res, 200, { data: { closed: true } });
|
|
624
|
-
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
625
448
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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));
|
|
629
469
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const workspace = this.workspaces.get(b.workspaceId);
|
|
639
|
-
if (!workspace)
|
|
640
|
-
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
641
|
-
const cwd = await this.workspaces.resolve(workspace, b.relativeCwd ?? '.');
|
|
642
|
-
await this.ensureSessionIndex(workspace, cwd);
|
|
643
|
-
if (!this.metadata.hasSession(cwd, b.sessionFile))
|
|
644
|
-
throw Object.assign(new Error('Session is not valid for cwd'), { code: 'SESSION_NOT_FOUND' });
|
|
645
|
-
sessionFile = b.sessionFile;
|
|
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();
|
|
646
478
|
}
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
});
|
|
658
496
|
}
|
|
659
|
-
const
|
|
660
|
-
this.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
catch (error) {
|
|
664
|
-
await this.agents.remove(agent.agentId).catch(() => { });
|
|
665
|
-
throw error;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
if (parts[2] === 'agents') {
|
|
669
|
-
const id = parts[3];
|
|
670
|
-
if (req.method === 'GET' && parts.length === 4)
|
|
671
|
-
return json(res, 200, { data: this.agents.get(id).record });
|
|
672
|
-
if (req.method === 'DELETE' && parts.length === 4) {
|
|
673
|
-
await this.agents.dispose(id);
|
|
674
|
-
this.metadata.saveAgent(this.agents.get(id).record);
|
|
675
|
-
return json(res, 200, { data: { stopped: true } });
|
|
676
|
-
}
|
|
677
|
-
if (req.method === 'POST' && parts[4] === 'archive') {
|
|
678
|
-
const record = await this.agents.archive(id);
|
|
679
|
-
this.metadata.archiveAgent(record);
|
|
680
|
-
return json(res, 200, { data: { archived: true } });
|
|
681
|
-
}
|
|
682
|
-
if (req.method === 'GET' && parts[4] === 'state')
|
|
683
|
-
return json(res, 200, { data: await this.agents.state(id) });
|
|
684
|
-
if (req.method === 'GET' && parts[4] === 'messages') {
|
|
685
|
-
const hasPaging = u.searchParams.has('limit') || u.searchParams.has('before');
|
|
686
|
-
if (!hasPaging)
|
|
687
|
-
return json(res, 200, { data: await this.agents.messages(id) });
|
|
688
|
-
const limit = Number(u.searchParams.get('limit') ?? 25), before = u.searchParams.has('before') ? Number(u.searchParams.get('before')) : undefined;
|
|
689
|
-
return json(res, 200, { data: await this.agents.messagePage(id, limit, before) });
|
|
690
|
-
}
|
|
691
|
-
if (req.method === 'GET' && parts[4] === 'capabilities')
|
|
692
|
-
return json(res, 200, { data: await this.agents.capabilities(id) });
|
|
693
|
-
if (req.method === 'GET' && parts[4] === 'session')
|
|
694
|
-
return json(res, 200, { data: u.searchParams.get('summary') === 'true' ? await this.agents.sessionInfo(id) : await this.agents.session(id) });
|
|
695
|
-
if (req.method === 'POST' && parts[4] === 'compact') {
|
|
696
|
-
const b = await this.body(req);
|
|
697
|
-
return json(res, 200, { data: await this.agents.compact(id, b.instructions) });
|
|
698
|
-
}
|
|
699
|
-
if (req.method === 'POST' && parts[4] === 'model') {
|
|
700
|
-
const b = await this.body(req);
|
|
701
|
-
await this.agents.setModel(id, b.provider, b.modelId);
|
|
702
|
-
return json(res, 200, { data: await this.agents.capabilities(id) });
|
|
703
|
-
}
|
|
704
|
-
if (req.method === 'POST' && parts[4] === 'thinking') {
|
|
705
|
-
const b = await this.body(req);
|
|
706
|
-
await this.agents.setThinkingLevel(id, b.level);
|
|
707
|
-
return json(res, 200, { data: await this.agents.capabilities(id) });
|
|
708
|
-
}
|
|
709
|
-
if (req.method === 'POST' && parts[4] === 'session-name') {
|
|
710
|
-
const b = await this.body(req);
|
|
711
|
-
await this.agents.setSessionName(id, b.name);
|
|
712
|
-
const summary = await this.agents.sessionInfo(id), record = this.agents.get(id).record;
|
|
713
|
-
this.metadata.saveAgent(record);
|
|
714
|
-
this.metadata.upsertSessionFromAgent(record, summary);
|
|
715
|
-
return json(res, 200, { data: summary });
|
|
716
|
-
}
|
|
717
|
-
if (req.method === 'POST' && parts[4] === 'navigate') {
|
|
718
|
-
const b = await this.body(req);
|
|
719
|
-
return json(res, 200, { data: await this.agents.navigate(id, b.entryId) });
|
|
720
|
-
}
|
|
721
|
-
if (req.method === 'POST' && parts[4] === 'fork') {
|
|
722
|
-
const b = await this.body(req);
|
|
723
|
-
const agent = await this.agents.fork(id, b.entryId);
|
|
724
|
-
this.metadata.saveAgent(agent.agent);
|
|
725
|
-
return json(res, 201, { data: agent });
|
|
726
|
-
}
|
|
727
|
-
if (req.method === 'GET' && parts[4] === 'commands')
|
|
728
|
-
return json(res, 200, { data: await this.agents.listCommands(id) });
|
|
729
|
-
if (req.method === 'POST' && parts[4] === 'bash') {
|
|
730
|
-
const b = await this.body(req);
|
|
731
|
-
if (typeof b.command !== 'string')
|
|
732
|
-
throw Object.assign(new Error('command must be a string'), { code: 'INVALID_REQUEST' });
|
|
733
|
-
const command = b.command.trim();
|
|
734
|
-
if (!command)
|
|
735
|
-
throw Object.assign(new Error('command is required'), { code: 'INVALID_REQUEST' });
|
|
736
|
-
if (command.length > 8000)
|
|
737
|
-
throw Object.assign(new Error('command is too long'), { code: 'INVALID_REQUEST' });
|
|
738
|
-
const result = await this.agents.runBash(id, command, b.excludeFromContext === true);
|
|
739
|
-
this.metadata.scheduleAgent(this.agents.get(id).record);
|
|
740
|
-
return json(res, 200, { data: result });
|
|
741
|
-
}
|
|
742
|
-
if (req.method === 'POST' && parts[4] === 'bash-abort') {
|
|
743
|
-
await this.agents.abortBash(id);
|
|
744
|
-
return json(res, 200, { data: { aborted: true } });
|
|
745
|
-
}
|
|
746
|
-
if (parts[4] === 'queue') {
|
|
747
|
-
if (req.method === 'GET')
|
|
748
|
-
return json(res, 200, { data: await this.agents.queue(id) });
|
|
749
|
-
if (req.method === 'DELETE')
|
|
750
|
-
return json(res, 200, { data: await this.agents.clearQueue(id) });
|
|
751
|
-
}
|
|
752
|
-
if (parts[4] === 'mcp') {
|
|
753
|
-
const cwd = this.agents.get(id).record.cwd;
|
|
754
|
-
if (req.method === 'GET')
|
|
755
|
-
return json(res, 200, { data: listMcpServers(cwd) });
|
|
756
|
-
if (req.method === 'POST') {
|
|
757
|
-
const b = await this.body(req);
|
|
758
|
-
if (typeof b.name !== 'string' || typeof b.enabled !== 'boolean')
|
|
759
|
-
throw Object.assign(new Error('name and enabled are required'), { code: 'INVALID_MCP_REQUEST' });
|
|
760
|
-
return json(res, 200, { data: setMcpServerEnabled(cwd, b.name, b.enabled) });
|
|
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 } });
|
|
761
501
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
this.voice?.recordUserPrompt(id, String(b.message ?? ''));
|
|
779
|
-
this.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
|
|
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 } });
|
|
506
|
+
}
|
|
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
|
+
});
|
|
780
518
|
}
|
|
781
|
-
const
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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);
|
|
534
|
+
}
|
|
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 } });
|
|
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
|
+
});
|
|
788
617
|
}
|
|
789
|
-
return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
|
|
790
618
|
}
|
|
791
|
-
catch (e) {
|
|
792
|
-
const x = e;
|
|
793
|
-
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 } });
|
|
794
|
-
} }
|
|
795
619
|
}
|