pi2web 0.2.0 → 0.2.1

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/dist/server.js CHANGED
@@ -3,19 +3,33 @@ import path from 'node:path';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createRequire } from 'node:module';
6
- import { randomUUID } from 'node:crypto';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
8
  import { AuthStore } from './auth.js';
9
9
  import { WorkspaceStore } from './workspaces.js';
10
10
  import { AgentManager } from './agents.js';
11
+ import { createSdkBackend } from './sdk-backend.js';
12
+ import { listMcpServers, setMcpServerEnabled } from './mcp.js';
11
13
  import { TerminalManager } from './terminals.js';
12
14
  import { MetadataStore } from './metadata-store.js';
15
+ import { CollabHub } from './collab/hub.js';
16
+ import { CollabStore } from './collab/store.js';
17
+ import { CollabRouter } from './collab/routes.js';
18
+ import { CollabDispatcher } from './collab/dispatcher.js';
19
+ import { DraftHub } from './drafts.js';
13
20
  import { SessionManager, VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
14
21
  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
+ } };
15
29
  const json = (res, status, data) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(data)); };
16
30
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web'), require = createRequire(import.meta.url);
17
31
  const markedBrowser = path.join(path.dirname(require.resolve('marked/package.json')), 'lib', 'marked.umd.js');
18
- const webFiles = { '/': { file: 'index.html', type: 'text/html; charset=utf-8' }, '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' }, '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' }, '/styles.css': { file: 'styles.css', type: 'text/css; charset=utf-8' }, '/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' }, '/favicon.ico': { file: 'icon.svg', type: 'image/svg+xml' }, '/vendor/xterm.js': { file: require.resolve('@xterm/xterm'), type: 'text/javascript; charset=utf-8' }, '/vendor/xterm.css': { file: require.resolve('@xterm/xterm/css/xterm.css'), type: 'text/css; charset=utf-8' }, '/vendor/addon-fit.js': { file: require.resolve('@xterm/addon-fit'), type: 'text/javascript; charset=utf-8' }, '/vendor/marked.js': { file: markedBrowser, type: 'text/javascript; charset=utf-8' }, '/vendor/dompurify.js': { file: require.resolve('dompurify/purify.min.js'), type: 'text/javascript; charset=utf-8' } };
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' } };
19
33
  export class RemotePiServer {
20
34
  server;
21
35
  wss;
@@ -24,55 +38,206 @@ export class RemotePiServer {
24
38
  agents;
25
39
  terminals;
26
40
  metadata;
41
+ collab;
42
+ collabRouter;
43
+ collabDispatcher;
44
+ unsubscribeCollab;
45
+ collabStallTimer;
46
+ collabMailAt = new Map();
47
+ collabMailCooldownMs = 5 * 60_000;
27
48
  opts;
49
+ noAuth;
50
+ allowInsecureNoAuth;
28
51
  dataDir;
29
52
  clients = new Set();
53
+ drafts = new DraftHub();
30
54
  unsubscribeMetadata;
31
55
  sessionIndexTimes = new Map();
32
56
  sessionIndexJobs = new Map();
33
57
  agentIndexJobs = new Map();
34
58
  voice;
59
+ sessionNamer;
35
60
  mailNotifier;
36
61
  loginFailures = new Map();
37
62
  loginWindowMs = 60_000;
38
63
  loginMaxFailures = 10;
39
64
  loginLockMs = 60_000;
40
65
  loginMaxLockMs = 15 * 60_000;
41
- constructor(options = {}) { this.opts = { host: options.host ?? '127.0.0.1', port: options.port ?? 11318 }; this.dataDir = options.dataDir ?? path.join(process.env.HOME ?? '.', '.pi', 'remote-pi'); this.auth = new AuthStore(this.dataDir); this.metadata = new MetadataStore(this.dataDir); this.workspaces = options.workspaces ?? new WorkspaceStore(); this.agents = options.agents ?? new AgentManager(this.workspaces); this.terminals = options.terminals ?? new TerminalManager(this.workspaces); this.voice = options.voice; this.mailNotifier = options.mailNotifier; this.server = http.createServer((req, res) => void this.handle(req, res)); this.wss = new WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 }); this.server.on('upgrade', (req, socket, head) => void this.upgrade(req, socket, head)); this.wss.on('connection', (ws, req) => { const match = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname.match(/^\/api\/v1\/terminals\/([^/]+)\/ws$/); match ? this.terminalConnection(ws, decodeURIComponent(match[1])) : this.connection(ws, req); }); }
42
- async start() { const first = await this.auth.init(); this.metadata.init(); if (this.mailNotifier) {
43
- const settings = this.metadata.getSetting('mailNotifications');
44
- if (settings)
45
- try {
46
- this.mailNotifier.updateSettings(settings);
47
- }
48
- catch (error) {
49
- console.error(`Ignoring invalid stored mail notification settings: ${error.message}`);
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); }); }
67
+ async start() {
68
+ if (this.allowInsecureNoAuth && !this.noAuth)
69
+ throw Object.assign(new Error('allowInsecureNoAuth requires noAuth (--allow-insecure-no-auth requires --no-auth)'), { code: 'INVALID_OPTIONS' });
70
+ if (this.noAuth && !this.allowInsecureNoAuth && !LOOPBACK_HOSTS.includes(this.opts.host))
71
+ throw Object.assign(new Error('noAuth requires a loopback host (127.0.0.1, ::1 or localhost); use --allow-insecure-no-auth (allowInsecureNoAuth: true) to explicitly allow remote access without authentication'), { code: 'INVALID_BIND_ADDRESS' });
72
+ if (this.allowInsecureNoAuth)
73
+ 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
+ const first = this.noAuth ? {} : await this.auth.init();
75
+ this.metadata.init();
76
+ this.collab.init();
77
+ 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);
84
+ this.collabStallTimer.unref?.();
85
+ if (this.mailNotifier) {
86
+ const settings = this.metadata.getSetting('mailNotifications');
87
+ if (settings)
88
+ try {
89
+ this.mailNotifier.updateSettings(settings);
90
+ }
91
+ catch (error) {
92
+ console.error(`Ignoring invalid stored mail notification settings: ${error.message}`);
93
+ }
94
+ }
95
+ await this.loadMetadata();
96
+ // Only now: resuming a queued wake-up calls agents.command(), which fails with "unknown agent" until the
97
+ // persisted agents have been restored, and a failed dispatch is logged, not retried.
98
+ this.collabDispatcher.start();
99
+ this.unsubscribeMetadata = this.agents.subscribeAll((agentId, event) => {
100
+ 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); };
103
+ // 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 });
107
+ if (['message_end', 'agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type))) {
108
+ const record = this.agents.get(agentId).record;
109
+ this.metadata.scheduleAgent(record);
110
+ if (['agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type)))
111
+ void this.indexAgentSession(agentId);
50
112
  }
51
- } await this.loadMetadata(); this.unsubscribeMetadata = this.agents.subscribeAll((agentId, event) => { const record = this.agents.get(agentId).record; this.voice?.handleAgentEvent(agentId, event.event, { sessionName: record.sessionName, setSessionName: async (name) => { if (this.agents.get(agentId).record.sessionName?.trim())
52
- return; await this.agents.setSessionName(agentId, name); } }); this.mailNotifier?.handleAgentEvent(agentId, event.event, { sessionName: record.sessionName, workspaceLabel: this.workspaces.get(record.workspaceId)?.label, cwd: record.cwd }); if (['message_end', 'agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type))) {
53
- const record = this.agents.get(agentId).record;
54
- this.metadata.scheduleAgent(record);
55
- if (['agent_end', 'agent_settled', 'session_info_changed'].includes(String(event.event.type)))
56
- void this.indexAgentSession(agentId);
57
- } }); if (first.token)
58
- console.log(`Remote Pi pairing code (shown once; save it securely): ${first.token}`); await new Promise(r => this.server.listen(this.opts.port, this.opts.host, r)); return this.address(); }
113
+ });
114
+ if (first.token)
115
+ console.log(`Remote Pi pairing code (shown once; save it securely): ${first.token}`);
116
+ await new Promise(r => this.server.listen(this.opts.port, this.opts.host, r));
117
+ return this.address();
118
+ }
59
119
  address() { const a = this.server.address(); return typeof a === 'object' && a ? { host: this.opts.host, port: a.port } : undefined; }
60
- async stop() { this.unsubscribeMetadata?.(); this.unsubscribeMetadata = undefined; this.voice?.close(); await this.mailNotifier?.close(); await Promise.allSettled([...this.sessionIndexJobs.values(), ...this.agentIndexJobs.values()]); this.metadata.flush(); for (const c of this.clients)
120
+ /** 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}`; }
122
+ /** 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;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ } }
129
+ /** 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';
132
+ }
133
+ catch {
134
+ return false;
135
+ } }
136
+ /** Pi extension bridge. It intentionally calls the hub in-process, so model context never contains tokens or URLs. */
137
+ collabToolBridge() {
138
+ return {
139
+ getTask: async (agentId) => this.collab.claimTaskForAgent(agentId),
140
+ submit: async (agentId, action, payload) => this.submitCollabTool(agentId, action, payload)
141
+ };
142
+ }
143
+ async submitCollabTool(agentId, action, payload) {
144
+ const participant = this.collab.participantForAgent(agentId);
145
+ 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);
155
+ case 'withdraw': {
156
+ const issueId = String(payload.issueId ?? '');
157
+ delete payload.issueId;
158
+ return this.collab.withdrawIssue(participant, issueId);
159
+ }
160
+ case 'debate_argument': {
161
+ const debateId = String(payload.debateId ?? '');
162
+ delete payload.debateId;
163
+ return this.collab.submitDebateArgument(participant, debateId, payload);
164
+ }
165
+ case 'debate_arguments': return this.collab.submitDebateArguments(participant, payload);
166
+ }
167
+ }
168
+ /** Escalations and stalls block the whole session, so a human is mailed the moment one appears. */
169
+ collabNotification(event) {
170
+ if (!this.mailNotifier)
171
+ return;
172
+ try {
173
+ if (event.type === 'escalation_raised') {
174
+ // Agents can raise escalations on their own, so one runaway session must not be able to flood the mailbox.
175
+ if (!this.allowCollabMail(event.sessionId))
176
+ return;
177
+ 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}`];
179
+ if (escalation.options.length)
180
+ lines.push(`候选方案:${escalation.options.join(' / ')}`);
181
+ if (escalation.positions.length)
182
+ lines.push('', '各方立场:', ...escalation.positions.map(position => `- ${position.participantId}(${position.stance}):${position.rationale}`));
183
+ 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') });
185
+ return;
186
+ }
187
+ if (event.type === 'participant_overdue') {
188
+ if (!this.allowCollabMail(event.sessionId))
189
+ return;
190
+ const session = this.collab.getSession(event.sessionId), waiting = event.payload.waitingOn ?? [];
191
+ 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') });
196
+ }
197
+ }
198
+ catch (error) {
199
+ console.error(`Collaboration mail notification failed: ${error.message}`);
200
+ }
201
+ }
202
+ /** One collaboration mail per session per cooldown window; anything suppressed is still visible on the board. */
203
+ allowCollabMail(sessionId) {
204
+ const last = this.collabMailAt.get(sessionId) ?? 0, now = Date.now();
205
+ if (now - last < this.collabMailCooldownMs) {
206
+ console.error(`Collaboration mail for session ${sessionId} suppressed by the cooldown window; see /collab.html?session=${sessionId}`);
207
+ return false;
208
+ }
209
+ this.collabMailAt.set(sessionId, now);
210
+ if (this.collabMailAt.size > 500)
211
+ for (const [key, at] of this.collabMailAt)
212
+ if (now - at > this.collabMailCooldownMs)
213
+ this.collabMailAt.delete(key);
214
+ return true;
215
+ }
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)
61
218
  c.close(); for (const a of this.agents.list())
62
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(); }
63
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) {
64
221
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
65
222
  socket.destroy();
66
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;
67
228
  } const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key); if (retryAfter > 0) {
68
229
  socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${Math.ceil(retryAfter / 1000)}\r\n\r\n`);
69
230
  socket.destroy();
70
231
  return;
71
- } if (!(await this.authorized(req.headers.authorization)) && !(wsToken && await this.auth.verify(wsToken))) {
232
+ } const role = (await this.authRole(req.headers.authorization)) ?? (wsToken ? await this.auth.role(wsToken) : undefined); if (!role) {
72
233
  this.recordLoginFailure(key);
73
234
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
74
235
  socket.destroy();
75
236
  return;
237
+ } if (role === 'voice') {
238
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
239
+ socket.destroy();
240
+ return;
76
241
  } this.loginFailures.delete(key); this.wss.handleUpgrade(req, socket, head, ws => this.wss.emit('connection', ws, req)); }
77
242
  clientKey(req) { return req.socket.remoteAddress ?? 'unknown'; }
78
243
  /** Rate limits pairing attempts per source address. Reverse proxies and tunnels share one bucket by design. */
@@ -91,46 +256,132 @@ export class RemotePiServer {
91
256
  connection(ws, _req) {
92
257
  this.clients.add(ws);
93
258
  const subscriptions = new Map();
94
- const send = (message) => { if (ws.readyState === WebSocket.OPEN)
95
- ws.send(JSON.stringify(message)); };
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);
278
+ 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
+ }
285
+ else {
286
+ flushDelta();
287
+ pendingDelta = { ...message, event: { ...message.event, assistantMessageEvent: { ...update } } };
288
+ } if (!deltaTimer)
289
+ deltaTimer = setTimeout(flushDelta, 40); };
96
290
  const unsubscribeVoice = this.voice?.subscribe((agentId, event) => { if (subscriptions.has('*') || subscriptions.has(agentId))
97
291
  send({ type: 'voice_event', agentId, event }); });
98
- ws.on('close', () => { this.clients.delete(ws); unsubscribeVoice?.(); for (const unsubscribe of subscriptions.values())
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())
99
296
  unsubscribe(); });
100
297
  ws.on('message', raw => {
101
298
  void (async () => {
102
299
  let m;
103
300
  try {
104
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
+ }
105
318
  if (m.type === 'subscribe_all') {
106
319
  subscriptions.get('*')?.();
107
- const forward = (agentId, e) => !subscriptions.has(agentId) && send({ type: 'agent_event', agentId, eventId: e.id, sequence: e.sequence, timestamp: e.timestamp, event: e.event });
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 });
108
321
  subscriptions.set('*', this.agents.subscribeAll(forward));
109
322
  send({ type: 'subscribed_all', protocolVersion: 1 });
110
323
  return;
111
324
  }
112
325
  if (m.type === 'subscribe') {
113
- subscriptions.get(m.agentId)?.();
114
- const forward = (e) => send({ type: 'agent_event', agentId: m.agentId, eventId: e.id, sequence: e.sequence, timestamp: e.timestamp, event: e.event });
115
- subscriptions.set(m.agentId, this.agents.subscribe(m.agentId, forward));
116
- send({ type: 'subscribed', agentId: m.agentId, protocolVersion: 1, currentSequence: this.agents.currentSequence(m.agentId) });
117
- if (m.fromNow === true)
118
- return;
119
- const last = Number(m.lastSequence ?? 0);
120
- if (this.agents.hasReplayGap(m.agentId, last)) {
121
- const messageLimit = m.messageLimit === undefined ? undefined : Number(m.messageLimit);
122
- send({ type: 'agent_snapshot', agentId: m.agentId, ...await this.agents.snapshot(m.agentId, Number.isFinite(messageLimit) ? messageLimit : undefined) });
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;
123
371
  }
124
- else
125
- for (const e of this.agents.events(m.agentId, last))
126
- forward(e);
127
372
  return;
128
373
  }
129
374
  if (['prompt', 'steer', 'follow-up', 'abort'].includes(m.type)) {
130
- if (m.type !== 'abort')
375
+ if (m.type !== 'abort') {
131
376
  this.voice?.recordUserPrompt(m.agentId, String(m.message ?? ''));
132
- await this.agents.command(m.agentId, m.type, m.message);
133
- send({ type: 'command_result', requestId: m.requestId, success: true });
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) });
134
385
  return;
135
386
  }
136
387
  throw new Error('Unknown command');
@@ -187,14 +438,34 @@ export class RemotePiServer {
187
438
  this.metadata.scheduleAgent(record);
188
439
  }
189
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; }
190
- async authorized(value) { return !!value && value.startsWith('Bearer ') && this.auth.verify(value.slice(7)); }
191
- async serveWeb(pathname, res) { const asset = webFiles[pathname]; if (!asset)
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)
192
455
  return false; try {
193
- const content = await readFile(path.isAbsolute(asset.file) ? asset.file : path.join(webRoot, asset.file));
194
- res.statusCode = 200;
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\//, ''));
195
457
  res.setHeader('content-type', asset.type);
196
- res.setHeader('cache-control', 'no-cache');
458
+ res.setHeader('cache-control', pathname.startsWith('/vendor/') ? 'public, max-age=86400' : 'no-cache');
459
+ res.setHeader('etag', etag);
197
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;
467
+ }
468
+ res.statusCode = 200;
198
469
  res.end(content);
199
470
  }
200
471
  catch {
@@ -209,16 +480,23 @@ export class RemotePiServer {
209
480
  throw Object.assign(new Error('Request too large'), { code: 'REQUEST_TOO_LARGE' });
210
481
  chunks.push(chunk);
211
482
  } return Buffer.concat(chunks); }
212
- async handle(req, res) { const requestId = randomUUID(); res.setHeader('x-request-id', requestId); res.setHeader('access-control-allow-origin', '*'); res.setHeader('access-control-allow-headers', 'Authorization,Content-Type,X-Audio-Filename'); res.setHeader('access-control-allow-methods', 'GET,POST,DELETE,OPTIONS'); try {
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 {
213
491
  if (req.method === 'OPTIONS') {
214
492
  res.statusCode = 204;
215
493
  return res.end();
216
494
  }
217
495
  const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
218
- if (req.method === 'GET' && await this.serveWeb(u.pathname, res))
496
+ if (req.method === 'GET' && await this.serveWeb(u.pathname, req, res))
219
497
  return;
220
498
  if (req.method === 'GET' && u.pathname === '/health')
221
- return json(res, 200, { data: { status: 'ok' } });
499
+ return json(res, 200, { data: { status: 'ok', authRequired: !this.noAuth } });
222
500
  const parts = u.pathname.split('/').filter(Boolean);
223
501
  if (req.method === 'POST' && u.pathname === '/api/v1/auth/login') {
224
502
  const key = this.clientKey(req), retryAfter = this.loginRetryAfterMs(key);
@@ -236,10 +514,42 @@ export class RemotePiServer {
236
514
  res.setHeader('retry-after', String(Math.ceil(lockedFor / 1000)));
237
515
  return json(res, 401, { error: { code: 'UNAUTHORIZED', message: 'Invalid token', requestId } });
238
516
  }
239
- if (!(await this.authorized(req.headers.authorization)))
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);
532
+ }
533
+ const role = await this.authRole(req.headers.authorization);
534
+ if (!role)
240
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 } });
550
+ }
241
551
  if (req.method === 'GET' && u.pathname === '/api/v1/system/status')
242
- return json(res, 200, { data: { version: pkg.version, protocolVersion: 1, piVersion: PI_VERSION, nodeVersion: process.version, uptimeSeconds: Math.floor(process.uptime()), agentCount: this.agents.list().length, terminalCount: this.terminals.list().length, webSocketConnections: this.clients.size, voiceEnabled: !!this.voice, voiceCapabilities: this.voice?.capabilities() ?? { tts: false, stt: false }, mailNotificationsConfigured: !!this.mailNotifier, mailNotificationsEnabled: this.mailNotifier?.getSettings().enabled ?? false } });
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 } });
243
553
  if (req.method === 'GET' && u.pathname === '/api/v1/mail-notifications')
244
554
  return json(res, 200, { data: { available: !!this.mailNotifier, settings: this.mailNotifier?.getSettings() ?? null } });
245
555
  if (req.method === 'POST' && u.pathname === '/api/v1/mail-notifications') {
@@ -263,6 +573,11 @@ export class RemotePiServer {
263
573
  throw Object.assign(new Error('includeSessionDetails must be a boolean'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
264
574
  patch.includeSessionDetails = b.includeSessionDetails;
265
575
  }
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;
580
+ }
266
581
  const settings = this.mailNotifier.updateSettings(patch);
267
582
  this.metadata.setSetting('mailNotifications', settings);
268
583
  return json(res, 200, { data: { available: true, settings } });
@@ -308,10 +623,16 @@ export class RemotePiServer {
308
623
  return json(res, 200, { data: { closed: true } });
309
624
  }
310
625
  }
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) });
629
+ }
311
630
  if (req.method === 'GET' && u.pathname === '/api/v1/agents')
312
631
  return json(res, 200, { data: this.agents.list() });
313
632
  if (req.method === 'POST' && u.pathname === '/api/v1/agents') {
314
- const b = await this.body(req);
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' });
315
636
  let sessionFile;
316
637
  if (b.sessionFile) {
317
638
  const workspace = this.workspaces.get(b.workspaceId);
@@ -323,9 +644,26 @@ export class RemotePiServer {
323
644
  throw Object.assign(new Error('Session is not valid for cwd'), { code: 'SESSION_NOT_FOUND' });
324
645
  sessionFile = b.sessionFile;
325
646
  }
326
- const agent = await this.agents.create(b.workspaceId, b.relativeCwd ?? '.', sessionFile);
327
- this.metadata.saveAgent(agent);
328
- return json(res, 201, { data: agent });
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);
653
+ }
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);
658
+ }
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
+ }
329
667
  }
330
668
  if (parts[2] === 'agents') {
331
669
  const id = parts[3];
@@ -386,6 +724,42 @@ export class RemotePiServer {
386
724
  this.metadata.saveAgent(agent.agent);
387
725
  return json(res, 201, { data: agent });
388
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) });
761
+ }
762
+ }
389
763
  if (req.method === 'POST' && parts[4] === 'extension-response') {
390
764
  const b = await this.body(req);
391
765
  await this.agents.extensionResponse(id, b.requestId, b.value);
@@ -400,14 +774,16 @@ export class RemotePiServer {
400
774
  }
401
775
  if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
402
776
  const b = await this.body(req);
403
- if (parts[4] !== 'abort')
777
+ if (parts[4] !== 'abort') {
404
778
  this.voice?.recordUserPrompt(id, String(b.message ?? ''));
405
- await this.agents.command(id, parts[4], b.message ?? '');
779
+ this.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
780
+ }
781
+ const disposition = await this.agents.command(id, parts[4], b.message ?? '');
406
782
  const record = this.agents.get(id).record;
407
783
  this.metadata.scheduleAgent(record);
408
784
  if (parts[4] === 'prompt')
409
785
  void this.indexAgentSession(id);
410
- return json(res, 200, { data: { success: true } });
786
+ return json(res, 200, { data: { success: true, disposition } });
411
787
  }
412
788
  }
413
789
  return json(res, 404, { error: { code: 'NOT_FOUND', message: 'Not found', requestId } });