pi2web 0.2.1 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +53 -6
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +671 -152
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +222 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +47 -0
  25. package/dist/http/web-assets.js +64 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +203 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +249 -45
  33. package/dist/server.js +488 -614
  34. package/dist/session-namer.js +66 -17
  35. package/dist/session-performance.js +137 -0
  36. package/dist/sotto-stt.js +217 -0
  37. package/dist/terminals.js +94 -27
  38. package/dist/timeline.js +185 -0
  39. package/dist/voice.js +153 -35
  40. package/dist/workspaces.js +91 -26
  41. package/dist/ws/agent-socket.js +256 -0
  42. package/dist/ws/terminal-socket.js +46 -0
  43. package/npm-shrinkwrap.json +3685 -0
  44. package/package.json +11 -1
  45. package/packages/protocol/openapi.yaml +24 -2
  46. package/packages/protocol/websocket.schema.json +58 -15
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3647 -756
  49. package/web/backend-state.js +59 -27
  50. package/web/collab.js +684 -257
  51. package/web/hid-keyboard.js +173 -140
  52. package/web/index.html +7 -2
  53. package/web/styles.css +21 -2
  54. package/web/voice-pcm.js +36 -0
package/dist/server.js CHANGED
@@ -1,35 +1,33 @@
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
- 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';
11
+ import { SottoSttService } from './sotto-stt.js';
15
12
  import { CollabHub } from './collab/hub.js';
16
13
  import { CollabStore } from './collab/store.js';
17
14
  import { CollabRouter } from './collab/routes.js';
18
15
  import { CollabDispatcher } from './collab/dispatcher.js';
19
16
  import { DraftHub } from './drafts.js';
17
+ import { AGENT_TEMPLATES_DIR, defaultDataDir } from './paths.js';
18
+ import { AgentTemplateStore } from './agent-templates.js';
20
19
  import { SessionManager, VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
21
20
  import pkg from '../package.json' with { type: 'json' };
22
- export const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
23
- const isLoopbackHost = (value) => { try {
24
- return LOOPBACK_HOSTS.includes(new URL(`http://${value}`).hostname.replace(/^\[|\]$/g, ''));
25
- }
26
- catch {
27
- return false;
28
- } };
29
- const json = (res, status, data) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(data)); };
30
- const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web'), require = createRequire(import.meta.url);
31
- const markedBrowser = path.join(path.dirname(require.resolve('marked/package.json')), 'lib', 'marked.umd.js');
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' } };
21
+ import { LOOPBACK_HOSTS, errorStatus, isLoopbackHost, json, readBinaryBody, readJsonBody } from './http/utils.js';
22
+ import { serveWebAsset } from './http/web-assets.js';
23
+ import { LoginLimiter } from './http/login-limiter.js';
24
+ import { handleAgentSocket, PROTOCOL_VERSION } from './ws/agent-socket.js';
25
+ import { handleTerminalSocket } from './ws/terminal-socket.js';
26
+ import { handleAgentRoutes } from './http/agent-routes.js';
27
+ import { handleMailSettingsRoutes } from './http/mail-settings-routes.js';
28
+ import { handleWorkspaceRoutes } from './http/workspace-routes.js';
29
+ import { handleSystemPromptRoutes } from './http/system-prompt-routes.js';
30
+ export { LOOPBACK_HOSTS };
33
31
  export class RemotePiServer {
34
32
  server;
35
33
  wss;
@@ -38,6 +36,7 @@ export class RemotePiServer {
38
36
  agents;
39
37
  terminals;
40
38
  metadata;
39
+ templates;
41
40
  collab;
42
41
  collabRouter;
43
42
  collabDispatcher;
@@ -56,14 +55,60 @@ export class RemotePiServer {
56
55
  sessionIndexJobs = new Map();
57
56
  agentIndexJobs = new Map();
58
57
  voice;
58
+ sottoStt;
59
59
  sessionNamer;
60
60
  mailNotifier;
61
- loginFailures = new Map();
62
- loginWindowMs = 60_000;
63
- loginMaxFailures = 10;
64
- loginLockMs = 60_000;
65
- loginMaxLockMs = 15 * 60_000;
66
- constructor(options = {}) { this.opts = { host: options.host ?? '127.0.0.1', port: options.port ?? 11318 }; this.noAuth = options.noAuth ?? false; this.allowInsecureNoAuth = options.allowInsecureNoAuth ?? false; this.dataDir = options.dataDir ?? path.join(process.env.HOME ?? '.', '.pi', 'remote-pi'); this.auth = new AuthStore(this.dataDir); this.metadata = new MetadataStore(this.dataDir); this.workspaces = options.workspaces ?? new WorkspaceStore(); this.voice = options.voice; this.sessionNamer = options.sessionNamer; this.mailNotifier = options.mailNotifier; this.collab = new CollabHub(new CollabStore(() => this.metadata.connection), { agentStatus: agentId => this.agentStatus(agentId) }); this.agents = options.agents ?? new AgentManager(this.workspaces, (agentId, cwd, sessionFile, profile) => createSdkBackend(agentId, cwd, sessionFile, profile, profile === 'collab' ? this.collabToolBridge() : undefined)); this.terminals = options.terminals ?? new TerminalManager(this.workspaces); this.collabRouter = new CollabRouter(this.collab, { resolveCwd: (workspaceId, relativeCwd) => this.resolveWorkspaceCwd(workspaceId, relativeCwd), verifyHuman: token => this.auth.verify(token), noAuth: this.noAuth, isCollabAgent: agentId => this.isCollabAgent(agentId) }); this.collabDispatcher = new CollabDispatcher(this.collab, { command: (agentId, kind, message) => this.agents.command(agentId, kind, message), agentStatus: agentId => this.agentStatus(agentId), isCollabAgent: agentId => this.isCollabAgent(agentId), log: message => console.error(message) }); this.server = http.createServer((req, res) => void this.handle(req, res)); this.wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 }); this.server.on('upgrade', (req, socket, head) => void this.upgrade(req, socket, head)); this.wss.on('connection', (ws, req) => { const match = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname.match(/^\/api\/v1\/terminals\/([^/]+)\/ws$/); match ? this.terminalConnection(ws, decodeURIComponent(match[1])) : this.connection(ws, req); }); }
61
+ loginLimiter = new LoginLimiter();
62
+ constructor(options = {}) {
63
+ this.opts = { host: options.host ?? '127.0.0.1', port: options.port ?? 11318 };
64
+ this.noAuth = options.noAuth ?? false;
65
+ this.allowInsecureNoAuth = options.allowInsecureNoAuth ?? false;
66
+ this.dataDir = options.dataDir ?? defaultDataDir();
67
+ this.auth = new AuthStore(this.dataDir);
68
+ this.metadata = new MetadataStore(this.dataDir);
69
+ this.sottoStt = new SottoSttService(this.metadata);
70
+ this.templates = new AgentTemplateStore(path.join(this.dataDir, AGENT_TEMPLATES_DIR));
71
+ this.workspaces = options.workspaces ?? new WorkspaceStore();
72
+ this.voice = options.voice;
73
+ this.sessionNamer = options.sessionNamer;
74
+ this.mailNotifier = options.mailNotifier;
75
+ this.collab = new CollabHub(new CollabStore(() => this.metadata.connection), {
76
+ agentStatus: agentId => this.agentStatus(agentId)
77
+ });
78
+ this.agents =
79
+ options.agents ??
80
+ new AgentManager(this.workspaces, (agentId, cwd, sessionFile, profile, backendOptions) => createSdkBackend(agentId, cwd, sessionFile, profile, profile === 'collab' ? this.collabToolBridge() : undefined, backendOptions));
81
+ this.terminals = options.terminals ?? new TerminalManager(this.workspaces);
82
+ this.collabRouter = new CollabRouter(this.collab, {
83
+ resolveCwd: (workspaceId, relativeCwd) => this.resolveWorkspaceCwd(workspaceId, relativeCwd),
84
+ verifyHuman: token => this.auth.verify(token),
85
+ noAuth: this.noAuth,
86
+ isCollabAgent: agentId => this.isCollabAgent(agentId)
87
+ });
88
+ this.collabDispatcher = new CollabDispatcher(this.collab, {
89
+ command: (agentId, kind, message) => this.agents.command(agentId, kind, message),
90
+ agentStatus: agentId => this.agentStatus(agentId),
91
+ isCollabAgent: agentId => this.isCollabAgent(agentId),
92
+ log: message => console.error(message)
93
+ });
94
+ this.server = http.createServer((req, res) => void this.handle(req, res));
95
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 });
96
+ this.server.on('upgrade', (req, socket, head) => void this.upgrade(req, socket, head));
97
+ this.wss.on('connection', (ws, req) => {
98
+ const match = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname.match(/^\/api\/v1\/terminals\/([^/]+)\/ws$/);
99
+ if (match)
100
+ handleTerminalSocket({ terminals: this.terminals, clients: this.clients }, ws, decodeURIComponent(match[1]));
101
+ else
102
+ handleAgentSocket({
103
+ agents: this.agents,
104
+ drafts: this.drafts,
105
+ collab: this.collab,
106
+ voice: this.voice,
107
+ sessionNamer: this.sessionNamer,
108
+ clients: this.clients
109
+ }, ws);
110
+ });
111
+ }
67
112
  async start() {
68
113
  if (this.allowInsecureNoAuth && !this.noAuth)
69
114
  throw Object.assign(new Error('allowInsecureNoAuth requires noAuth (--allow-insecure-no-auth requires --no-auth)'), { code: 'INVALID_OPTIONS' });
@@ -73,14 +118,17 @@ export class RemotePiServer {
73
118
  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.');
74
119
  const first = this.noAuth ? {} : await this.auth.init();
75
120
  this.metadata.init();
121
+ this.sottoStt.load();
76
122
  this.collab.init();
77
123
  this.unsubscribeCollab = this.collab.subscribe(event => this.collabNotification(event));
78
- this.collabStallTimer = setInterval(() => { try {
79
- this.collab.checkStalls();
80
- }
81
- catch (error) {
82
- console.error(`Collaboration stall check failed: ${error.message}`);
83
- } }, 60_000);
124
+ this.collabStallTimer = setInterval(() => {
125
+ try {
126
+ this.collab.checkStalls();
127
+ }
128
+ catch (error) {
129
+ console.error(`Collaboration stall check failed: ${error.message}`);
130
+ }
131
+ }, 60_000);
84
132
  this.collabStallTimer.unref?.();
85
133
  if (this.mailNotifier) {
86
134
  const settings = this.metadata.getSetting('mailNotifications');
@@ -98,12 +146,26 @@ export class RemotePiServer {
98
146
  this.collabDispatcher.start();
99
147
  this.unsubscribeMetadata = this.agents.subscribeAll((agentId, event) => {
100
148
  const record = this.agents.get(agentId).record;
101
- const applyName = async (name) => { if (this.agents.get(agentId).record.sessionName?.trim())
102
- return; await this.agents.setSessionName(agentId, name); this.metadata.saveAgent(this.agents.get(agentId).record); };
149
+ const applyName = async (name) => {
150
+ if (this.agents.get(agentId).record.sessionName?.trim())
151
+ return;
152
+ await this.agents.setSessionName(agentId, name);
153
+ this.metadata.saveAgent(this.agents.get(agentId).record);
154
+ };
103
155
  // 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, { sessionName: record.sessionName, setSessionName: applyName });
105
- this.voice?.handleAgentEvent(agentId, event.event, { sessionName: record.sessionName, ...(this.sessionNamer ? {} : { setSessionName: applyName }) });
106
- this.mailNotifier?.handleAgentEvent(agentId, event.event, { sessionName: record.sessionName, workspaceLabel: this.workspaces.get(record.workspaceId)?.label, cwd: record.cwd });
156
+ this.sessionNamer?.handleAgentEvent(agentId, event.event, {
157
+ sessionName: record.sessionName,
158
+ setSessionName: applyName
159
+ });
160
+ this.voice?.handleAgentEvent(agentId, event.event, {
161
+ sessionName: record.sessionName,
162
+ ...(this.sessionNamer ? {} : { setSessionName: applyName })
163
+ });
164
+ this.mailNotifier?.handleAgentEvent(agentId, event.event, {
165
+ sessionName: record.sessionName,
166
+ workspaceLabel: this.workspaces.get(record.workspaceId)?.label,
167
+ cwd: record.cwd
168
+ });
107
169
  if (['message_end', 'agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type))) {
108
170
  const record = this.agents.get(agentId).record;
109
171
  this.metadata.scheduleAgent(record);
@@ -116,23 +178,33 @@ export class RemotePiServer {
116
178
  await new Promise(r => this.server.listen(this.opts.port, this.opts.host, r));
117
179
  return this.address();
118
180
  }
119
- address() { const a = this.server.address(); return typeof a === 'object' && a ? { host: this.opts.host, port: a.port } : undefined; }
181
+ address() {
182
+ const a = this.server.address();
183
+ return typeof a === 'object' && a ? { host: this.opts.host, port: a.port } : undefined;
184
+ }
120
185
  /** URL an agent running on this machine can use to reach the hub. Wildcard binds are rewritten to loopback. */
121
- publicBaseUrl() { 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; return `http://${host.includes(':') ? `[${host}]` : host}:${port}`; }
186
+ publicBaseUrl() {
187
+ 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;
188
+ return `http://${host.includes(':') ? `[${host}]` : host}:${port}`;
189
+ }
122
190
  /** 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) { try {
124
- return this.agents.get(agentId).record.status;
191
+ agentStatus(agentId) {
192
+ try {
193
+ return this.agents.get(agentId).record.status;
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
125
198
  }
126
- catch {
127
- return undefined;
128
- } }
129
199
  /** A collaboration seat is only ever bound to an existing `profile=collab` Agent: anything else can never be woken. */
130
- isCollabAgent(agentId) { try {
131
- return this.agents.get(agentId).record.profile === 'collab';
200
+ isCollabAgent(agentId) {
201
+ try {
202
+ return this.agents.get(agentId).record.profile === 'collab';
203
+ }
204
+ catch {
205
+ return false;
206
+ }
132
207
  }
133
- catch {
134
- return false;
135
- } }
136
208
  /** Pi extension bridge. It intentionally calls the hub in-process, so model context never contains tokens or URLs. */
137
209
  collabToolBridge() {
138
210
  return {
@@ -143,15 +215,24 @@ export class RemotePiServer {
143
215
  async submitCollabTool(agentId, action, payload) {
144
216
  const participant = this.collab.participantForAgent(agentId);
145
217
  switch (action) {
146
- case 'ready': return this.collab.markImplementationReady(participant, payload);
147
- case 'findings': return this.collab.submitFindings(participant, payload);
148
- case 'issue_votes': return this.collab.submitIssueVotes(participant, payload);
149
- case 'merge_votes': return this.collab.submitMergeVotes(participant, payload);
150
- case 'issue_discussions': return this.collab.submitIssueDiscussions(participant, payload);
151
- case 'nominations': return this.collab.submitNominations(participant, payload);
152
- case 'votes': return this.collab.submitVotes(participant, payload);
153
- case 'scores': return this.collab.submitScores(participant, payload);
154
- case 'escalate': return this.collab.raiseEscalation(participant, payload);
218
+ case 'ready':
219
+ return this.collab.markImplementationReady(participant, payload);
220
+ case 'findings':
221
+ return this.collab.submitFindings(participant, payload);
222
+ case 'issue_votes':
223
+ return this.collab.submitIssueVotes(participant, payload);
224
+ case 'merge_votes':
225
+ return this.collab.submitMergeVotes(participant, payload);
226
+ case 'issue_discussions':
227
+ return this.collab.submitIssueDiscussions(participant, payload);
228
+ case 'nominations':
229
+ return this.collab.submitNominations(participant, payload);
230
+ case 'votes':
231
+ return this.collab.submitVotes(participant, payload);
232
+ case 'scores':
233
+ return this.collab.submitScores(participant, payload);
234
+ case 'escalate':
235
+ return this.collab.raiseEscalation(participant, payload);
155
236
  case 'withdraw': {
156
237
  const issueId = String(payload.issueId ?? '');
157
238
  delete payload.issueId;
@@ -162,7 +243,8 @@ export class RemotePiServer {
162
243
  delete payload.debateId;
163
244
  return this.collab.submitDebateArgument(participant, debateId, payload);
164
245
  }
165
- case 'debate_arguments': return this.collab.submitDebateArguments(participant, payload);
246
+ case 'debate_arguments':
247
+ return this.collab.submitDebateArguments(participant, payload);
166
248
  }
167
249
  }
168
250
  /** Escalations and stalls block the whole session, so a human is mailed the moment one appears. */
@@ -175,13 +257,23 @@ export class RemotePiServer {
175
257
  if (!this.allowCollabMail(event.sessionId))
176
258
  return;
177
259
  const escalation = this.collab.getEscalation(String(event.payload.escalationId ?? '')), session = this.collab.getSession(event.sessionId);
178
- const lines = [`协作会话:${session.title}(${session.kind},phase=${session.phase},round=${session.round})`, `类型:${escalation.kind}|紧急度:${escalation.urgency}`, `发起者:${escalation.raisedBy}`, '', `摘要:${escalation.summary}`, `待裁定问题:${escalation.question}`];
260
+ const lines = [
261
+ `协作会话:${session.title}(${session.kind},phase=${session.phase},round=${session.round})`,
262
+ `类型:${escalation.kind}|紧急度:${escalation.urgency}`,
263
+ `发起者:${escalation.raisedBy}`,
264
+ '',
265
+ `摘要:${escalation.summary}`,
266
+ `待裁定问题:${escalation.question}`
267
+ ];
179
268
  if (escalation.options.length)
180
269
  lines.push(`候选方案:${escalation.options.join(' / ')}`);
181
270
  if (escalation.positions.length)
182
271
  lines.push('', '各方立场:', ...escalation.positions.map(position => `- ${position.participantId}(${position.stance}):${position.rationale}`));
183
272
  lines.push('', `处理入口:${this.publicBaseUrl()}/collab.html?session=${session.sessionId}`, `API:POST /api/v1/collab/escalations/${escalation.escalationId}/resolve`);
184
- this.mailNotifier.notifyCollab({ subject: `[Remote Pi] 协作会话需要人工裁定:${session.title}`, text: lines.join('\n') });
273
+ this.mailNotifier.notifyCollab({
274
+ subject: `[Remote Pi] 协作会话需要人工裁定:${session.title}`,
275
+ text: lines.join('\n')
276
+ });
185
277
  return;
186
278
  }
187
279
  if (event.type === 'participant_overdue') {
@@ -189,10 +281,19 @@ export class RemotePiServer {
189
281
  return;
190
282
  const session = this.collab.getSession(event.sessionId), waiting = event.payload.waitingOn ?? [];
191
283
  const neverCollected = event.payload.reason === 'task_never_collected';
192
- this.mailNotifier.notifyCollab({ subject: `[Remote Pi] 协作会话停滞:${session.title}`,
193
- text: [`协作会话:${session.title}(${session.kind},phase=${session.phase},round=${session.round})`,
194
- neverCollected ? '任务已派发但从未送达:绑定的本机 Agent 可能已被删除或卡住,请在看板上把这个座位改绑到另一个 Agent。' : `已等待:${Math.round(Number(event.payload.overdueBySec ?? 0))} 秒`,
195
- `未提交的参与者:${waiting.join(', ') || '(未知)'}`, '', '中枢不会自动跳过任何人;如需推进请强制推进并说明理由。', `处理入口:${this.publicBaseUrl()}/collab.html?session=${session.sessionId}`].join('\n') });
284
+ this.mailNotifier.notifyCollab({
285
+ subject: `[Remote Pi] 协作会话停滞:${session.title}`,
286
+ text: [
287
+ `协作会话:${session.title}(${session.kind},phase=${session.phase},round=${session.round})`,
288
+ neverCollected
289
+ ? '任务已派发但从未送达:绑定的本机 Agent 可能已被删除或卡住,请在看板上把这个座位改绑到另一个 Agent。'
290
+ : `已等待:${Math.round(Number(event.payload.overdueBySec ?? 0))} 秒`,
291
+ `未提交的参与者:${waiting.join(', ') || '(未知)'}`,
292
+ '',
293
+ '中枢不会自动跳过任何人;如需推进请强制推进并说明理由。',
294
+ `处理入口:${this.publicBaseUrl()}/collab.html?session=${session.sessionId}`
295
+ ].join('\n')
296
+ });
196
297
  }
197
298
  }
198
299
  catch (error) {
@@ -213,583 +314,356 @@ export class RemotePiServer {
213
314
  this.collabMailAt.delete(key);
214
315
  return true;
215
316
  }
216
- async stop() { if (this.collabStallTimer)
217
- clearInterval(this.collabStallTimer); this.collabStallTimer = undefined; this.unsubscribeCollab?.(); this.unsubscribeCollab = undefined; await this.collabDispatcher.stop(); this.collab.shutdown(); this.unsubscribeMetadata?.(); this.unsubscribeMetadata = undefined; this.voice?.close(); this.sessionNamer?.close(); await this.mailNotifier?.close(); await Promise.allSettled([...this.sessionIndexJobs.values(), ...this.agentIndexJobs.values()]); this.metadata.flush(); for (const c of this.clients)
218
- c.close(); for (const a of this.agents.list())
219
- await this.agents.dispose(a.agentId).catch(() => { }); this.terminals.closeAll(); this.wss.close(); await new Promise(r => this.server.close(() => r())); this.metadata.close(); }
220
- 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) {
221
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
222
- socket.destroy();
223
- return;
224
- } if (this.noAuth && !this.allowInsecureNoAuth && !this.localRequest(req)) {
225
- socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
226
- socket.destroy();
227
- return;
228
- } const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key); if (retryAfter > 0) {
229
- socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${Math.ceil(retryAfter / 1000)}\r\n\r\n`);
230
- socket.destroy();
231
- return;
232
- } const role = (await this.authRole(req.headers.authorization)) ?? (wsToken ? await this.auth.role(wsToken) : undefined); if (!role) {
233
- this.recordLoginFailure(key);
234
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
235
- socket.destroy();
236
- return;
237
- } if (role === 'voice') {
238
- socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
239
- socket.destroy();
240
- return;
241
- } this.loginFailures.delete(key); this.wss.handleUpgrade(req, socket, head, ws => this.wss.emit('connection', ws, req)); }
242
- clientKey(req) { return req.socket.remoteAddress ?? 'unknown'; }
243
- /** Rate limits pairing attempts per source address. Reverse proxies and tunnels share one bucket by design. */
244
- loginRetryAfterMs(key) { const entry = this.loginFailures.get(key); if (!entry)
245
- return 0; const now = Date.now(); if (entry.lockedUntil > now)
246
- return entry.lockedUntil - now; if (entry.expiresAt <= now)
247
- this.loginFailures.delete(key); return 0; }
248
- recordLoginFailure(key) { const now = Date.now(); if (this.loginFailures.size > 1000)
249
- for (const [id, value] of this.loginFailures)
250
- if (value.expiresAt <= now && value.lockedUntil <= now)
251
- this.loginFailures.delete(id); const entry = this.loginFailures.get(key); if (!entry || (entry.expiresAt <= now && entry.lockedUntil <= now)) {
252
- this.loginFailures.set(key, { count: 1, expiresAt: now + this.loginWindowMs, lockedUntil: 0, lockLevel: 0 });
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);
317
+ async stop() {
318
+ if (this.collabStallTimer)
319
+ clearInterval(this.collabStallTimer);
320
+ this.collabStallTimer = undefined;
321
+ this.unsubscribeCollab?.();
322
+ this.unsubscribeCollab = undefined;
323
+ await this.collabDispatcher.stop();
324
+ this.collab.shutdown();
325
+ this.unsubscribeMetadata?.();
326
+ this.unsubscribeMetadata = undefined;
327
+ this.voice?.close();
328
+ this.sessionNamer?.close();
329
+ await this.mailNotifier?.close();
330
+ await Promise.allSettled([...this.sessionIndexJobs.values(), ...this.agentIndexJobs.values()]);
331
+ this.metadata.flush();
332
+ for (const c of this.clients)
333
+ c.close();
334
+ for (const a of this.agents.list())
335
+ await this.agents.dispose(a.agentId).catch(() => { });
336
+ this.terminals.closeAll();
337
+ this.wss.close();
338
+ await new Promise(r => this.server.close(() => r()));
339
+ this.metadata.close();
340
+ }
341
+ async upgrade(req, socket, head) {
342
+ const protocol = req.headers['sec-websocket-protocol'];
343
+ const wsToken = Array.isArray(protocol)
344
+ ? protocol.find(x => x.startsWith('access-token.'))?.slice(13)
345
+ : protocol
346
+ ?.split(',')
347
+ .map(x => x.trim())
348
+ .find(x => x.startsWith('access-token.'))
349
+ ?.slice(13);
350
+ const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname, voiceMatch = pathname.match(/^\/api\/v1\/agents\/([^/]+)\/transcribe\/ws$/), isTranscription = pathname === '/api/v1/transcribe/ws', isSocket = pathname === '/api/v1/ws' ||
351
+ /^\/api\/v1\/terminals\/[^/]+\/ws$/.test(pathname) ||
352
+ !!voiceMatch ||
353
+ isTranscription;
354
+ if (!isSocket) {
355
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
356
+ socket.destroy();
278
357
  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
358
  }
285
- else {
286
- flushDelta();
287
- pendingDelta = { ...message, event: { ...message.event, assistantMessageEvent: { ...update } } };
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');
359
+ if (this.noAuth && !this.allowInsecureNoAuth && !this.localRequest(req)) {
360
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
361
+ socket.destroy();
399
362
  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
363
  }
414
- catch (error) {
415
- send({ type: 'error', message: error.message });
416
- } });
417
- }
418
- catch (error) {
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;
364
+ const key = this.clientKey(req), retryAfter = this.loginLimiter.retryAfterMs(key);
365
+ if (retryAfter > 0) {
366
+ socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${Math.ceil(retryAfter / 1000)}\r\n\r\n`);
367
+ socket.destroy();
368
+ return;
467
369
  }
468
- res.statusCode = 200;
469
- res.end(content);
470
- }
471
- catch {
472
- json(res, 500, { error: { code: 'WEB_UI_UNAVAILABLE', message: 'Web UI files are missing. Reinstall the pi2web package.' } });
473
- } return true; }
474
- /** Reads a JSON body as bytes so the size limit applies while streaming and multi-byte characters are never split across chunks. */
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();
370
+ const role = (await this.authRole(req.headers.authorization)) ??
371
+ (wsToken ? await this.auth.role(wsToken) : undefined);
372
+ if (!role) {
373
+ this.loginLimiter.recordFailure(key);
374
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
375
+ socket.destroy();
376
+ return;
494
377
  }
495
- const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
496
- if (req.method === 'GET' && await this.serveWeb(u.pathname, req, res))
378
+ if (role === 'voice') {
379
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
380
+ socket.destroy();
497
381
  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 } });
382
+ }
383
+ this.loginLimiter.clear(key);
384
+ if (voiceMatch || isTranscription) {
385
+ try {
386
+ if (voiceMatch)
387
+ this.agents.get(decodeURIComponent(voiceMatch[1]));
388
+ this.sottoStt.streamUrl();
506
389
  }
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 } });
390
+ catch {
391
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
392
+ socket.destroy();
393
+ return;
511
394
  }
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 } });
395
+ this.wss.handleUpgrade(req, socket, head, ws => {
396
+ this.clients.add(ws);
397
+ ws.on('close', () => this.clients.delete(ws));
398
+ this.sottoStt.stream(ws);
399
+ });
400
+ return;
516
401
  }
517
- if (parts[0] === 'api' && parts[1] === 'v1' && parts[2] === 'collab') {
518
- const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key);
519
- if (retryAfter > 0) {
520
- res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
521
- return json(res, 429, { error: { code: 'TOO_MANY_ATTEMPTS', message: `Too many failed attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`, requestId } });
522
- }
523
- const header = String(req.headers.authorization ?? ''), token = header.startsWith('Bearer ') ? header.slice(7) : '';
524
- const result = await this.collabRouter.handle({ method: req.method ?? 'GET', url: u, parts, token, body: () => this.body(req) });
525
- if (!result)
526
- return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
527
- if (result.authFailed)
528
- this.recordLoginFailure(key);
529
- else
530
- this.loginFailures.delete(key);
531
- return json(res, result.status, result.payload);
402
+ this.wss.handleUpgrade(req, socket, head, ws => this.wss.emit('connection', ws, req));
403
+ }
404
+ clientKey(req) {
405
+ return req.socket.remoteAddress ?? 'unknown';
406
+ }
407
+ async loadMetadata() {
408
+ let workspaces = this.metadata.listWorkspaces();
409
+ if (!workspaces.length) {
410
+ for (const workspace of this.workspaces.list())
411
+ this.metadata.saveWorkspace(workspace);
412
+ workspaces = this.metadata.listWorkspaces();
532
413
  }
533
- const role = await this.authRole(req.headers.authorization);
534
- if (!role)
535
- return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Authentication required', requestId } });
536
- if (role === 'voice' && !(req.method === 'POST' && u.pathname === '/api/v1/draft' || req.method === 'GET' && u.pathname === '/api/v1/system/status'))
537
- return json(res, 403, { error: { code: 'FORBIDDEN', message: 'This token cannot access this endpoint', requestId } });
538
- if (req.method === 'POST' && u.pathname === '/api/v1/draft') {
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 } });
414
+ this.workspaces.replace(workspaces);
415
+ let agents = this.metadata.listAgents();
416
+ if (!agents.length) {
417
+ for (const agent of this.agents.list())
418
+ this.metadata.saveAgent(agent);
419
+ agents = this.metadata.listAgents();
550
420
  }
551
- if (req.method === 'GET' && u.pathname === '/api/v1/system/status')
552
- return json(res, 200, { data: { version: pkg.version, protocolVersion: 1, authRequired: !this.noAuth, piVersion: PI_VERSION, nodeVersion: process.version, uptimeSeconds: Math.floor(process.uptime()), agentCount: this.agents.list().length, terminalCount: this.terminals.list().length, webSocketConnections: this.clients.size, voiceEnabled: !!this.voice, voiceCapabilities: this.voice?.capabilities() ?? { tts: false, stt: false }, sessionNamingEnabled: !!this.sessionNamer, mailNotificationsConfigured: !!this.mailNotifier, mailNotificationsEnabled: this.mailNotifier?.getSettings().enabled ?? false } });
553
- if (req.method === 'GET' && u.pathname === '/api/v1/mail-notifications')
554
- return json(res, 200, { data: { available: !!this.mailNotifier, settings: this.mailNotifier?.getSettings() ?? null } });
555
- if (req.method === 'POST' && u.pathname === '/api/v1/mail-notifications') {
556
- if (!this.mailNotifier)
557
- throw Object.assign(new Error('MailDispatch is not configured at server startup'), { code: 'MAIL_NOTIFICATIONS_DISABLED' });
558
- const b = await this.body(req), patch = {};
559
- if ('enabled' in b) {
560
- if (typeof b.enabled !== 'boolean')
561
- throw Object.assign(new Error('enabled must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
562
- patch.enabled = b.enabled;
563
- }
564
- if ('aggregationDelaySeconds' in b)
565
- patch.aggregationDelaySeconds = Number(b.aggregationDelaySeconds);
566
- if ('includeResponse' in b) {
567
- if (typeof b.includeResponse !== 'boolean')
568
- throw Object.assign(new Error('includeResponse must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
569
- patch.includeResponse = b.includeResponse;
570
- }
571
- if ('includeSessionDetails' in b) {
572
- if (typeof b.includeSessionDetails !== 'boolean')
573
- throw Object.assign(new Error('includeSessionDetails must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
574
- patch.includeSessionDetails = b.includeSessionDetails;
421
+ await this.agents.restore(agents);
422
+ }
423
+ async ensureSessionIndex(workspace, cwd, force = false) {
424
+ const key = `${workspace.id}:${cwd}`, last = this.sessionIndexTimes.get(key) ?? 0;
425
+ if (!force && Date.now() - last < 30000)
426
+ return;
427
+ const existing = this.sessionIndexJobs.get(key);
428
+ if (existing)
429
+ return existing;
430
+ const job = (async () => {
431
+ const sessions = await SessionManager.list(cwd);
432
+ this.metadata.syncSessions(workspace.id, cwd, sessions);
433
+ this.sessionIndexTimes.set(key, Date.now());
434
+ })().finally(() => this.sessionIndexJobs.delete(key));
435
+ this.sessionIndexJobs.set(key, job);
436
+ return job;
437
+ }
438
+ async indexAgentSession(agentId) {
439
+ const existing = this.agentIndexJobs.get(agentId);
440
+ if (existing)
441
+ return existing;
442
+ const job = (async () => {
443
+ try {
444
+ const record = this.agents.get(agentId).record, summary = await this.agents.sessionInfo(agentId);
445
+ this.metadata.upsertSessionFromAgent(record, summary);
446
+ this.metadata.scheduleAgent(record);
575
447
  }
576
- if ('collabEscalations' in b) {
577
- if (typeof b.collabEscalations !== 'boolean')
578
- throw Object.assign(new Error('collabEscalations must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
579
- patch.collabEscalations = b.collabEscalations;
448
+ catch {
449
+ /* Session files may not exist until the first assistant response is persisted. */
580
450
  }
581
- const settings = this.mailNotifier.updateSettings(patch);
582
- this.metadata.setSetting('mailNotifications', settings);
583
- return json(res, 200, { data: { available: true, settings } });
584
- }
585
- if (req.method === 'GET' && u.pathname === '/api/v1/workspaces')
586
- return json(res, 200, { data: this.workspaces.list() });
587
- if (req.method === 'POST' && u.pathname === '/api/v1/workspaces') {
588
- const b = await this.body(req);
589
- if (typeof b.label !== 'string' || typeof b.rootPath !== 'string')
590
- return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'label and rootPath required', requestId } });
591
- const workspace = this.workspaces.add(b.label, b.rootPath);
592
- this.metadata.saveWorkspace(workspace);
593
- return json(res, 201, { data: workspace });
594
- }
595
- if (parts[2] === 'workspaces' && parts[4] === 'tree' && req.method === 'GET')
596
- return json(res, 200, { data: await this.workspaces.tree(parts[3], u.searchParams.get('path') ?? '.') });
597
- if (parts[2] === 'workspaces' && parts[4] === 'file' && req.method === 'GET')
598
- return json(res, 200, { data: await this.workspaces.file(parts[3], u.searchParams.get('path') ?? '', Number(u.searchParams.get('offset') ?? 0), Number(u.searchParams.get('limit') ?? 1048576)) });
599
- if (parts[2] === 'workspaces' && parts[4] === 'stat' && req.method === 'GET')
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 });
451
+ })().finally(() => this.agentIndexJobs.delete(agentId));
452
+ this.agentIndexJobs.set(agentId, job);
453
+ return job;
454
+ }
455
+ async resolveWorkspaceCwd(workspaceId, relativeCwd) {
456
+ const workspace = this.workspaces.get(workspaceId);
457
+ if (!workspace)
458
+ throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
459
+ return this.workspaces.resolve(workspace, relativeCwd || '.');
460
+ }
461
+ /** Without a pairing code the only boundary left is the browser's: loopback Host and, if sent, a loopback Origin. */
462
+ localRequest(req) {
463
+ if (!isLoopbackHost(String(req.headers.host ?? '')))
464
+ return false;
465
+ const origin = String(req.headers.origin ?? '');
466
+ if (!origin)
467
+ return true;
468
+ try {
469
+ return isLoopbackHost(new URL(origin).host);
616
470
  }
617
- if (parts[2] === 'terminals') {
618
- const id = parts[3];
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
- }
471
+ catch {
472
+ return false;
625
473
  }
626
- if (req.method === 'GET' && u.pathname === '/api/v1/agents/models') {
627
- const workspaceId = u.searchParams.get('workspaceId') ?? '', relativeCwd = u.searchParams.get('relativeCwd') ?? '.';
628
- return json(res, 200, { data: await this.agents.capabilityPreview(workspaceId, relativeCwd) });
474
+ }
475
+ async authRole(value) {
476
+ if (this.noAuth)
477
+ return 'full';
478
+ return value?.startsWith('Bearer ') ? this.auth.role(value.slice(7)) : undefined;
479
+ }
480
+ async handle(req, res) {
481
+ const requestId = randomUUID();
482
+ res.setHeader('x-request-id', requestId);
483
+ if (this.noAuth) {
484
+ if (!this.allowInsecureNoAuth && !this.localRequest(req))
485
+ return json(res, 403, {
486
+ error: {
487
+ code: 'FORBIDDEN',
488
+ message: 'Without authentication only loopback requests are served',
489
+ requestId
490
+ }
491
+ });
492
+ if (req.headers.origin)
493
+ res.setHeader('access-control-allow-origin', String(req.headers.origin));
629
494
  }
630
- if (req.method === 'GET' && u.pathname === '/api/v1/agents')
631
- return json(res, 200, { data: this.agents.list() });
632
- if (req.method === 'POST' && u.pathname === '/api/v1/agents') {
633
- const b = await this.body(req), profile = b.profile === undefined ? 'default' : b.profile;
634
- if (profile !== 'default' && profile !== 'collab')
635
- throw Object.assign(new Error('profile must be default or collab'), { code: 'INVALID_REQUEST' });
636
- let sessionFile;
637
- if (b.sessionFile) {
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;
495
+ else
496
+ res.setHeader('access-control-allow-origin', '*');
497
+ res.setHeader('access-control-allow-headers', 'Authorization,Content-Type,X-Audio-Filename');
498
+ res.setHeader('access-control-allow-methods', 'GET,POST,PUT,DELETE,OPTIONS');
499
+ try {
500
+ if (req.method === 'OPTIONS') {
501
+ res.statusCode = 204;
502
+ return res.end();
646
503
  }
647
- const agent = await this.agents.create(b.workspaceId, b.relativeCwd ?? '.', sessionFile, profile);
648
- try {
649
- if (b.model !== undefined) {
650
- if (!b.model || typeof b.model.provider !== 'string' || typeof b.model.modelId !== 'string')
651
- throw Object.assign(new Error('model.provider and model.modelId are required'), { code: 'INVALID_REQUEST' });
652
- await this.agents.setModel(agent.agentId, b.model.provider, b.model.modelId);
504
+ const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
505
+ if (req.method === 'GET' && (await serveWebAsset(u.pathname, req, res, { noAuth: this.noAuth })))
506
+ return;
507
+ if (req.method === 'GET' && u.pathname === '/health')
508
+ return json(res, 200, { data: { status: 'ok', authRequired: !this.noAuth } });
509
+ const parts = u.pathname.split('/').filter(Boolean);
510
+ if (req.method === 'POST' && u.pathname === '/api/v1/auth/login') {
511
+ const key = this.clientKey(req), retryAfter = this.loginLimiter.retryAfterMs(key);
512
+ if (retryAfter > 0) {
513
+ res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
514
+ return json(res, 429, {
515
+ error: {
516
+ code: 'TOO_MANY_ATTEMPTS',
517
+ message: `Too many failed pairing attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`,
518
+ requestId
519
+ }
520
+ });
653
521
  }
654
- if (b.sessionName !== undefined) {
655
- if (typeof b.sessionName !== 'string')
656
- throw Object.assign(new Error('sessionName must be a string'), { code: 'INVALID_REQUEST' });
657
- await this.agents.setSessionName(agent.agentId, b.sessionName);
522
+ const b = await readJsonBody(req);
523
+ if (await this.auth.verify(String(b.token ?? ''))) {
524
+ this.loginLimiter.clear(key);
525
+ return json(res, 200, { data: { authenticated: true } });
658
526
  }
659
- const configured = this.agents.get(agent.agentId).record;
660
- this.metadata.saveAgent(configured);
661
- return json(res, 201, { data: { ...configured } });
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;
527
+ const lockedFor = this.loginLimiter.recordFailure(key);
528
+ if (lockedFor > 0)
529
+ res.setHeader('retry-after', String(Math.ceil(lockedFor / 1000)));
530
+ return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Invalid token', requestId } });
531
+ }
532
+ if (parts[0] === 'api' && parts[1] === 'v1' && parts[2] === 'collab') {
533
+ const key = this.clientKey(req), retryAfter = this.loginLimiter.retryAfterMs(key);
534
+ if (retryAfter > 0) {
535
+ res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
536
+ return json(res, 429, {
537
+ error: {
538
+ code: 'TOO_MANY_ATTEMPTS',
539
+ message: `Too many failed attempts. Retry in ${Math.ceil(retryAfter / 1000)} seconds`,
540
+ requestId
541
+ }
542
+ });
543
+ }
544
+ const header = String(req.headers.authorization ?? ''), token = header.startsWith('Bearer ') ? header.slice(7) : '';
545
+ const result = await this.collabRouter.handle({
546
+ method: req.method ?? 'GET',
547
+ url: u,
548
+ parts,
549
+ token,
550
+ body: () => readJsonBody(req)
551
+ });
552
+ if (!result)
553
+ return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
554
+ if (result.authFailed)
555
+ this.loginLimiter.recordFailure(key);
556
+ else
557
+ this.loginLimiter.clear(key);
558
+ return json(res, result.status, result.payload);
559
+ }
560
+ const role = await this.authRole(req.headers.authorization);
561
+ if (!role)
562
+ return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Authentication required', requestId } });
563
+ if (role === 'voice' &&
564
+ !((req.method === 'POST' && u.pathname === '/api/v1/draft') ||
565
+ (req.method === 'GET' && u.pathname === '/api/v1/system/status')))
566
+ return json(res, 403, {
567
+ error: { code: 'FORBIDDEN', message: 'This token cannot access this endpoint', requestId }
568
+ });
569
+ if (req.method === 'POST' && u.pathname === '/api/v1/draft') {
570
+ const b = await readJsonBody(req), text = typeof b?.text === 'string' ? b.text : '';
571
+ if (!text.trim())
572
+ return json(res, 400, { error: { code: 'INVALID_REQUEST', message: 'text is required', requestId } });
573
+ if (text.length > 20000)
574
+ return json(res, 413, { error: { code: 'REQUEST_TOO_LARGE', message: 'text is too long', requestId } });
575
+ if (b.agentId !== undefined || b.target !== undefined)
576
+ return json(res, 400, {
577
+ error: { code: 'INVALID_REQUEST', message: 'draft targets are not supported', requestId }
578
+ });
579
+ if (b.mode !== undefined && b.mode !== 'append' && b.mode !== 'replace')
580
+ return json(res, 400, {
581
+ error: { code: 'INVALID_REQUEST', message: 'mode must be append or replace', requestId }
582
+ });
583
+ const { draft, delivered } = this.drafts.add({
584
+ text,
585
+ mode: b.mode ?? 'append',
586
+ source: typeof b.source === 'string' ? b.source.slice(0, 32) : undefined
587
+ });
588
+ return json(res, 200, { data: { id: draft.id, mode: draft.mode, delivered, queued: true } });
589
+ }
590
+ if (req.method === 'GET' && u.pathname === '/api/v1/system/status')
591
+ return json(res, 200, {
592
+ data: {
593
+ version: pkg.version,
594
+ protocolVersion: PROTOCOL_VERSION,
595
+ authRequired: !this.noAuth,
596
+ piVersion: PI_VERSION,
597
+ nodeVersion: process.version,
598
+ uptimeSeconds: Math.floor(process.uptime()),
599
+ agentCount: this.agents.list().length,
600
+ terminalCount: this.terminals.list().length,
601
+ webSocketConnections: this.clients.size,
602
+ voiceEnabled: !!this.voice,
603
+ voiceCapabilities: {
604
+ tts: this.voice?.capabilities().tts ?? false,
605
+ stt: (this.voice?.capabilities().stt ?? false) || this.sottoStt.getSettings().enabled
606
+ },
607
+ sottoStt: this.sottoStt.getSettings(),
608
+ sessionNamingEnabled: !!this.sessionNamer,
609
+ mailNotificationsConfigured: !!this.mailNotifier,
610
+ mailNotificationsEnabled: this.mailNotifier?.getSettings().enabled ?? false
611
+ }
612
+ });
613
+ if (u.pathname === '/api/v1/sotto-stt') {
754
614
  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) });
615
+ return json(res, 200, { data: this.sottoStt.getSettings() });
616
+ if (req.method === 'PUT')
617
+ return json(res, 200, { data: await this.sottoStt.update(await readJsonBody(req)) });
618
+ }
619
+ if (req.method === 'POST' && u.pathname === '/api/v1/transcribe') {
620
+ const mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0];
621
+ if (this.sottoStt.getSettings().enabled) {
622
+ if (mime !== 'application/octet-stream')
623
+ throw Object.assign(new Error('Sotto requires 16 kHz float32 PCM'), { code: 'INVALID_REQUEST' });
624
+ return json(res, 200, { data: await this.sottoStt.transcribe(await readBinaryBody(req)) });
761
625
  }
762
- }
763
- if (req.method === 'POST' && parts[4] === 'extension-response') {
764
- const b = await this.body(req);
765
- await this.agents.extensionResponse(id, b.requestId, b.value);
766
- return json(res, 200, { data: { success: true } });
767
- }
768
- if (req.method === 'POST' && parts[4] === 'transcribe') {
769
626
  if (!this.voice)
770
627
  throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
771
- this.agents.get(id);
772
- const audio = await this.binaryBody(req), mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0], filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
773
- return json(res, 200, { data: await this.voice.transcribe(audio, mime, filename) });
774
- }
775
- if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
776
- const b = await this.body(req);
777
- if (parts[4] !== 'abort') {
778
- this.voice?.recordUserPrompt(id, String(b.message ?? ''));
779
- this.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
780
- }
781
- const disposition = await this.agents.command(id, parts[4], b.message ?? '');
782
- const record = this.agents.get(id).record;
783
- this.metadata.scheduleAgent(record);
784
- if (parts[4] === 'prompt')
785
- void this.indexAgentSession(id);
786
- return json(res, 200, { data: { success: true, disposition } });
628
+ const filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
629
+ return json(res, 200, {
630
+ data: await this.voice.transcribe(await readBinaryBody(req), mime, filename)
631
+ });
787
632
  }
633
+ if (await handleMailSettingsRoutes({ mailNotifier: this.mailNotifier, metadata: this.metadata }, req, res, u))
634
+ return;
635
+ const workspaceContext = {
636
+ workspaces: this.workspaces,
637
+ terminals: this.terminals,
638
+ metadata: this.metadata,
639
+ ensureSessionIndex: (workspace, cwd, force) => this.ensureSessionIndex(workspace, cwd, force)
640
+ };
641
+ if (await handleWorkspaceRoutes(workspaceContext, req, res, u, parts, requestId))
642
+ return;
643
+ if (await handleSystemPromptRoutes({ agents: this.agents, templates: this.templates, body: () => readJsonBody(req) }, req, res, u, parts))
644
+ return;
645
+ const agentContext = {
646
+ agents: this.agents,
647
+ templates: this.templates,
648
+ metadata: this.metadata,
649
+ workspaces: this.workspaces,
650
+ voice: this.voice,
651
+ sottoStt: this.sottoStt,
652
+ sessionNamer: this.sessionNamer,
653
+ ensureSessionIndex: (workspace, cwd, force) => this.ensureSessionIndex(workspace, cwd, force),
654
+ indexAgentSession: (agentId) => this.indexAgentSession(agentId),
655
+ body: () => readJsonBody(req),
656
+ binaryBody: () => readBinaryBody(req)
657
+ };
658
+ if (await handleAgentRoutes(agentContext, req, res, u, parts))
659
+ return;
660
+ return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
661
+ }
662
+ catch (e) {
663
+ const x = e;
664
+ return json(res, errorStatus(x.code), {
665
+ error: { code: x.code ?? 'BAD_REQUEST', message: x.message ?? 'Request failed', requestId }
666
+ });
788
667
  }
789
- return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });
790
668
  }
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
669
  }