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/README.md +169 -17
- package/dist/agents.js +126 -25
- package/dist/auth.js +83 -20
- package/dist/collab/dispatcher.js +186 -0
- package/dist/collab/hub.js +1880 -0
- package/dist/collab/pi-extension.js +237 -0
- package/dist/collab/review-flow.js +310 -0
- package/dist/collab/routes.js +214 -0
- package/dist/collab/schemas.js +225 -0
- package/dist/collab/scoring-flow.js +230 -0
- package/dist/collab/store.js +563 -0
- package/dist/collab/types.js +53 -0
- package/dist/collab/validate.js +148 -0
- package/dist/drafts.js +30 -0
- package/dist/mail-notifier.js +36 -8
- package/dist/main.js +83 -4
- package/dist/mcp.js +113 -0
- package/dist/metadata-store.js +12 -3
- package/dist/sdk-backend.js +82 -19
- package/dist/server.js +434 -58
- package/dist/session-namer.js +114 -0
- package/dist/voice.js +47 -9
- package/package.json +8 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +2 -0
- package/packages/protocol/websocket.schema.json +47 -6
- package/web/app.js +1187 -176
- package/web/backend-state.js +237 -0
- package/web/collab.html +95 -0
- package/web/collab.js +667 -0
- package/web/hid-keyboard.js +420 -0
- package/web/index.html +11 -9
- package/web/styles.css +59 -0
- package/ANDROID_APP_FEATURES.md +0 -256
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.
|
|
42
|
-
async start() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
send({ type: '
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
191
|
-
|
|
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);
|
|
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 (
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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 } });
|