pi2web 0.2.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +44 -5
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +518 -141
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +190 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +221 -45
  33. package/dist/server.js +444 -620
  34. package/dist/session-namer.js +66 -17
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +153 -35
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +11 -1
  43. package/packages/protocol/openapi.yaml +6 -2
  44. package/packages/protocol/websocket.schema.json +58 -15
  45. package/web/activity-view.js +121 -0
  46. package/web/app.js +3098 -743
  47. package/web/backend-state.js +59 -27
  48. package/web/collab.js +668 -255
  49. package/web/hid-keyboard.js +173 -140
  50. package/web/index.html +4 -1
  51. package/web/styles.css +14 -0
@@ -0,0 +1,52 @@
1
+ import { readPiPromptFiles, writePiPromptFile } from '../agent-templates.js';
2
+ import { json } from './utils.js';
3
+ const invalid = (message) => Object.assign(new Error(message), { code: 'INVALID_REQUEST' });
4
+ /**
5
+ * `/api/v1/system-prompt` exposes Pi's own global prompt files (`~/.pi/agent/SYSTEM.md`, `APPEND_SYSTEM.md`) and a
6
+ * preview of the effective prompt; `/api/v1/agent-templates` manages the shareable template files in the data dir.
7
+ */
8
+ export async function handleSystemPromptRoutes(ctx, req, res, u, parts) {
9
+ const send = (status, data) => {
10
+ json(res, status, data);
11
+ return true;
12
+ };
13
+ if (u.pathname === '/api/v1/system-prompt') {
14
+ if (req.method === 'GET')
15
+ return send(200, { data: readPiPromptFiles() });
16
+ if (req.method === 'PUT') {
17
+ const b = await ctx.body();
18
+ if (b.kind !== 'system' && b.kind !== 'append')
19
+ throw invalid('kind must be system or append');
20
+ if (typeof b.content !== 'string')
21
+ throw invalid('content must be a string');
22
+ writePiPromptFile(b.kind, b.content);
23
+ return send(200, { data: readPiPromptFiles() });
24
+ }
25
+ }
26
+ if (req.method === 'GET' && u.pathname === '/api/v1/system-prompt/preview') {
27
+ const workspaceId = u.searchParams.get('workspaceId') ?? '', relativeCwd = u.searchParams.get('relativeCwd') ?? '.', templateId = u.searchParams.get('templateId') || '', piDefaultPrompt = u.searchParams.get('source') === 'pi-default';
28
+ const template = templateId && !piDefaultPrompt ? ctx.templates.snapshot(templateId) : undefined;
29
+ return send(200, {
30
+ data: {
31
+ ...(await ctx.agents.systemPromptPreview(workspaceId, relativeCwd, { template, piDefaultPrompt })),
32
+ template: template ?? null
33
+ }
34
+ });
35
+ }
36
+ if (parts[2] === 'agent-templates') {
37
+ if (parts.length === 3 && req.method === 'GET')
38
+ return send(200, { data: { dir: ctx.templates.dir, ...ctx.templates.list() } });
39
+ if (parts.length === 4) {
40
+ const id = decodeURIComponent(parts[3]);
41
+ if (req.method === 'GET')
42
+ return send(200, { data: ctx.templates.get(id) });
43
+ if (req.method === 'PUT')
44
+ return send(200, { data: ctx.templates.save(id, await ctx.body()) });
45
+ if (req.method === 'DELETE') {
46
+ ctx.templates.remove(id);
47
+ return send(200, { data: { deleted: true } });
48
+ }
49
+ }
50
+ }
51
+ return false;
52
+ }
@@ -0,0 +1,43 @@
1
+ export const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
2
+ export const isLoopbackHost = (value) => {
3
+ try {
4
+ return LOOPBACK_HOSTS.includes(new URL(`http://${value}`).hostname.replace(/^\[|\]$/g, ''));
5
+ }
6
+ catch {
7
+ return false;
8
+ }
9
+ };
10
+ export const json = (res, status, data) => {
11
+ res.statusCode = status;
12
+ res.setHeader('content-type', 'application/json');
13
+ res.end(JSON.stringify(data));
14
+ };
15
+ export async function readBinaryBody(req, maxBytes = 25 * 1024 * 1024) {
16
+ const chunks = [];
17
+ let size = 0;
18
+ for await (const value of req) {
19
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
20
+ size += chunk.length;
21
+ if (size > maxBytes)
22
+ throw Object.assign(new Error('Request too large'), { code: 'REQUEST_TOO_LARGE' });
23
+ chunks.push(chunk);
24
+ }
25
+ return Buffer.concat(chunks);
26
+ }
27
+ /** Reads a JSON body as bytes so the size limit applies while streaming and multi-byte characters are never split across chunks. */
28
+ export async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
29
+ const raw = await readBinaryBody(req, maxBytes);
30
+ return raw.length ? JSON.parse(raw.toString('utf8')) : {};
31
+ }
32
+ /** Maps a thrown error's `code` to the HTTP status used by every REST endpoint. */
33
+ export function errorStatus(code) {
34
+ if (code === 'WORKSPACE_PATH_OUTSIDE_ROOT')
35
+ return 403;
36
+ if (code === 'FILE_TOO_LARGE' || code === 'REQUEST_TOO_LARGE')
37
+ return 413;
38
+ if (code === 'VOICE_DISABLED' || code === 'VOICE_STT_DISABLED' || code === 'MAIL_NOTIFICATIONS_DISABLED')
39
+ return 503;
40
+ if (code?.endsWith('NOT_FOUND'))
41
+ return 404;
42
+ return 400;
43
+ }
@@ -0,0 +1,63 @@
1
+ import path from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createRequire } from 'node:module';
5
+ import { createHash } from 'node:crypto';
6
+ import { json } from './utils.js';
7
+ const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../web'), require = createRequire(import.meta.url);
8
+ const markedBrowser = path.join(path.dirname(require.resolve('marked/package.json')), 'lib', 'marked.umd.js');
9
+ const webFiles = {
10
+ '/': { file: 'index.html', type: 'text/html; charset=utf-8' },
11
+ '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
12
+ '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
13
+ '/backend-state.js': { file: 'backend-state.js', type: 'text/javascript; charset=utf-8' },
14
+ '/hid-keyboard.js': { file: 'hid-keyboard.js', type: 'text/javascript; charset=utf-8' },
15
+ '/activity-view.js': { file: 'activity-view.js', type: 'text/javascript; charset=utf-8' },
16
+ '/collab': { file: 'collab.html', type: 'text/html; charset=utf-8' },
17
+ '/collab.html': { file: 'collab.html', type: 'text/html; charset=utf-8' },
18
+ '/collab.js': { file: 'collab.js', type: 'text/javascript; charset=utf-8' },
19
+ '/styles.css': { file: 'styles.css', type: 'text/css; charset=utf-8' },
20
+ '/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
21
+ '/favicon.ico': { file: 'icon.svg', type: 'image/svg+xml' },
22
+ '/vendor/xterm.js': { file: require.resolve('@xterm/xterm'), type: 'text/javascript; charset=utf-8' },
23
+ '/vendor/xterm.css': { file: require.resolve('@xterm/xterm/css/xterm.css'), type: 'text/css; charset=utf-8' },
24
+ '/vendor/addon-fit.js': { file: require.resolve('@xterm/addon-fit'), type: 'text/javascript; charset=utf-8' },
25
+ '/vendor/marked.js': { file: markedBrowser, type: 'text/javascript; charset=utf-8' },
26
+ '/vendor/dompurify.js': {
27
+ file: require.resolve('dompurify/purify.min.js'),
28
+ type: 'text/javascript; charset=utf-8'
29
+ }
30
+ };
31
+ /** Serves a bundled Web UI or vendor file. Returns false when `pathname` is not a static asset. */
32
+ export async function serveWebAsset(pathname, req, res, options) {
33
+ const asset = webFiles[pathname];
34
+ if (!asset)
35
+ return false;
36
+ try {
37
+ 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'] ?? '')
38
+ .split(',')
39
+ .map(value => value.trim().replace(/^W\//, ''));
40
+ res.setHeader('content-type', asset.type);
41
+ res.setHeader('cache-control', pathname.startsWith('/vendor/') ? 'public, max-age=86400' : 'no-cache');
42
+ res.setHeader('etag', etag);
43
+ res.setHeader('x-content-type-options', 'nosniff');
44
+ if (options.noAuth && asset.type.startsWith('text/html'))
45
+ res.setHeader('content-security-policy', "frame-ancestors 'self'");
46
+ if (ifNoneMatch.includes(etag) || ifNoneMatch.includes('*')) {
47
+ res.statusCode = 304;
48
+ res.end();
49
+ return true;
50
+ }
51
+ res.statusCode = 200;
52
+ res.end(content);
53
+ }
54
+ catch {
55
+ json(res, 500, {
56
+ error: {
57
+ code: 'WEB_UI_UNAVAILABLE',
58
+ message: 'Web UI files are missing. Reinstall the pi2web package.'
59
+ }
60
+ });
61
+ }
62
+ return true;
63
+ }
@@ -0,0 +1,58 @@
1
+ import { json, readJsonBody } from './utils.js';
2
+ /** Handles Workspace files, the Session index and Terminal REST routes. Returns false for any other route. */
3
+ export async function handleWorkspaceRoutes(ctx, req, res, u, parts, requestId) {
4
+ const send = (status, data) => {
5
+ json(res, status, data);
6
+ return true;
7
+ };
8
+ if (req.method === 'GET' && u.pathname === '/api/v1/workspaces')
9
+ return send(200, { data: ctx.workspaces.list() });
10
+ if (req.method === 'POST' && u.pathname === '/api/v1/workspaces') {
11
+ const b = await readJsonBody(req);
12
+ if (typeof b.label !== 'string' || typeof b.rootPath !== 'string')
13
+ return send(400, {
14
+ error: { code: 'INVALID_REQUEST', message: 'label and rootPath required', requestId }
15
+ });
16
+ const workspace = ctx.workspaces.add(b.label, b.rootPath);
17
+ ctx.metadata.saveWorkspace(workspace);
18
+ return send(201, { data: workspace });
19
+ }
20
+ if (parts[2] === 'workspaces' && parts[4] === 'tree' && req.method === 'GET')
21
+ return send(200, {
22
+ data: await ctx.workspaces.tree(parts[3], u.searchParams.get('path') ?? '.')
23
+ });
24
+ if (parts[2] === 'workspaces' && parts[4] === 'file' && req.method === 'GET')
25
+ return send(200, {
26
+ data: await ctx.workspaces.file(parts[3], u.searchParams.get('path') ?? '', Number(u.searchParams.get('offset') ?? 0), Number(u.searchParams.get('limit') ?? 1048576))
27
+ });
28
+ if (parts[2] === 'workspaces' && parts[4] === 'stat' && req.method === 'GET')
29
+ return send(200, {
30
+ data: await ctx.workspaces.stat(parts[3], u.searchParams.get('path') ?? '.')
31
+ });
32
+ if (req.method === 'GET' && u.pathname === '/api/v1/sessions') {
33
+ const workspace = ctx.workspaces.get(u.searchParams.get('workspaceId') ?? '');
34
+ if (!workspace)
35
+ throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
36
+ const cwd = await ctx.workspaces.resolve(workspace, u.searchParams.get('path') ?? '.');
37
+ await ctx.ensureSessionIndex(workspace, cwd, u.searchParams.get('refresh') === 'true');
38
+ const limit = u.searchParams.has('limit') ? Number(u.searchParams.get('limit')) : undefined, offset = Number(u.searchParams.get('offset') ?? 0);
39
+ return send(200, { data: ctx.metadata.listSessions(cwd, limit, offset) });
40
+ }
41
+ if (req.method === 'GET' && u.pathname === '/api/v1/terminals')
42
+ return send(200, { data: ctx.terminals.list() });
43
+ if (req.method === 'POST' && u.pathname === '/api/v1/terminals') {
44
+ const b = await readJsonBody(req);
45
+ const terminal = await ctx.terminals.create(String(b.workspaceId ?? ''), String(b.relativeCwd ?? '.'), Number(b.cols ?? 80), Number(b.rows ?? 24));
46
+ return send(201, { data: terminal });
47
+ }
48
+ if (parts[2] === 'terminals') {
49
+ const id = parts[3];
50
+ if (req.method === 'GET' && parts.length === 4)
51
+ return send(200, { data: ctx.terminals.get(id).record });
52
+ if (req.method === 'DELETE' && parts.length === 4) {
53
+ ctx.terminals.close(id);
54
+ return send(200, { data: { closed: true } });
55
+ }
56
+ }
57
+ return false;
58
+ }
@@ -1,8 +1,18 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assistantText } from './voice.js';
3
- const defaultSettings = { enabled: true, aggregationDelaySeconds: 0, includeResponse: true, includeSessionDetails: true, collabEscalations: true };
3
+ const defaultSettings = {
4
+ enabled: true,
5
+ aggregationDelaySeconds: 0,
6
+ includeResponse: true,
7
+ includeSessionDetails: true,
8
+ collabEscalations: true
9
+ };
4
10
  const clip = (value, max) => value.length <= max ? value : `${value.slice(0, max)}\n\n[内容过长,已截断]`;
5
- const oneLine = (value, max) => value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, max);
11
+ const oneLine = (value, max) => value
12
+ .replace(/[\r\n\t]+/g, ' ')
13
+ .replace(/\s{2,}/g, ' ')
14
+ .trim()
15
+ .slice(0, max);
6
16
  export class MailNotifier {
7
17
  fetcher;
8
18
  lastAssistant = new Map();
@@ -18,7 +28,9 @@ export class MailNotifier {
18
28
  this.fetcher = fetcher;
19
29
  this.config = { priority: 100, timeoutMs: 15_000, ...config };
20
30
  }
21
- getSettings() { return { ...this.settings }; }
31
+ getSettings() {
32
+ return { ...this.settings };
33
+ }
22
34
  updateSettings(value) {
23
35
  const next = { ...this.settings, ...value };
24
36
  next.enabled = Boolean(next.enabled);
@@ -26,8 +38,12 @@ export class MailNotifier {
26
38
  next.includeSessionDetails = Boolean(next.includeSessionDetails);
27
39
  next.collabEscalations = Boolean(next.collabEscalations);
28
40
  next.aggregationDelaySeconds = Math.trunc(Number(next.aggregationDelaySeconds));
29
- if (!Number.isFinite(next.aggregationDelaySeconds) || next.aggregationDelaySeconds < 0 || next.aggregationDelaySeconds > 86400)
30
- throw Object.assign(new Error('aggregationDelaySeconds must be an integer between 0 and 86400'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
41
+ if (!Number.isFinite(next.aggregationDelaySeconds) ||
42
+ next.aggregationDelaySeconds < 0 ||
43
+ next.aggregationDelaySeconds > 86400)
44
+ throw Object.assign(new Error('aggregationDelaySeconds must be an integer between 0 and 86400'), {
45
+ code: 'INVALID_MAIL_NOTIFICATION_SETTINGS'
46
+ });
31
47
  this.settings = next;
32
48
  if (!next.enabled) {
33
49
  this.clearTimer();
@@ -92,7 +108,10 @@ export class MailNotifier {
92
108
  this.startFlush();
93
109
  return;
94
110
  }
95
- this.timer = setTimeout(() => { this.timer = undefined; this.startFlush(); }, delay);
111
+ this.timer = setTimeout(() => {
112
+ this.timer = undefined;
113
+ this.startFlush();
114
+ }, delay);
96
115
  this.timer.unref?.();
97
116
  }
98
117
  startFlush() {
@@ -108,10 +127,12 @@ export class MailNotifier {
108
127
  if (this.queue.length && !this.timer && !this.closed)
109
128
  this.scheduleFlush();
110
129
  }
111
- clearTimer() { if (this.timer) {
112
- clearTimeout(this.timer);
113
- this.timer = undefined;
114
- } }
130
+ clearTimer() {
131
+ if (this.timer) {
132
+ clearTimeout(this.timer);
133
+ this.timer = undefined;
134
+ }
135
+ }
115
136
  async sendBatch(items) {
116
137
  const includeResponse = this.settings.includeResponse, includeSessionDetails = this.settings.includeSessionDetails, count = items.length;
117
138
  const aborted = items.filter(item => item.aborted).length;
@@ -125,10 +146,20 @@ export class MailNotifier {
125
146
  }
126
147
  let text;
127
148
  if (!includeResponse && !includeSessionDetails)
128
- text = count > 1 ? `${count} 个 Agent 任务${verb}。` : aborted ? '有 Agent 任务被中断。' : '有 Agent 任务完成。';
149
+ text =
150
+ count > 1
151
+ ? `${count} 个 Agent 任务${verb}。`
152
+ : aborted
153
+ ? '有 Agent 任务被中断。'
154
+ : '有 Agent 任务完成。';
129
155
  else
130
- text = items.map((item, index) => {
131
- const lines = count > 1 ? [`任务 ${index + 1}${item.aborted ? '(已中断)' : ''}`] : item.aborted ? ['任务已中断'] : [];
156
+ text = items
157
+ .map((item, index) => {
158
+ const lines = count > 1
159
+ ? [`任务 ${index + 1}${item.aborted ? '(已中断)' : ''}`]
160
+ : item.aborted
161
+ ? ['任务已中断']
162
+ : [];
132
163
  if (includeSessionDetails) {
133
164
  lines.push(`Session:${item.context.sessionName?.trim() || '未命名'}`);
134
165
  if (item.context.cwd)
@@ -140,17 +171,32 @@ export class MailNotifier {
140
171
  lines.push('Agent 回复:', clip(item.finalOutput.trim() || '(没有可用的最终文本回复)', 24_000));
141
172
  }
142
173
  return lines.join('\n');
143
- }).join('\n\n--------------------\n\n');
174
+ })
175
+ .join('\n\n--------------------\n\n');
144
176
  await this.sendMail(subject, text, { task_count: count, aborted_count: aborted });
145
177
  }
146
178
  async sendMail(subject, text, metadata = {}) {
147
179
  const payload = {
148
- to: [this.config.recipient], subject, text, priority: this.config.priority, purpose: 'transactional',
149
- metadata: { source: 'remote-pi', ...metadata, notification_id: randomUUID() }
180
+ to: [this.config.recipient],
181
+ subject,
182
+ text,
183
+ priority: this.config.priority,
184
+ purpose: 'transactional',
185
+ metadata: { source: 'pi2web', ...metadata, notification_id: randomUUID() }
150
186
  };
151
187
  if (this.config.senderId)
152
188
  payload.sender_id = this.config.senderId;
153
- const response = await this.fetcher(this.config.endpoint, { method: 'POST', signal: AbortSignal.timeout(this.config.timeoutMs), headers: { authorization: `Bearer ${this.config.apiKey}`, 'content-type': 'application/json; charset=utf-8', accept: 'application/json', 'idempotency-key': randomUUID() }, body: JSON.stringify(payload) });
189
+ const response = await this.fetcher(this.config.endpoint, {
190
+ method: 'POST',
191
+ signal: AbortSignal.timeout(this.config.timeoutMs),
192
+ headers: {
193
+ authorization: `Bearer ${this.config.apiKey}`,
194
+ 'content-type': 'application/json; charset=utf-8',
195
+ accept: 'application/json',
196
+ 'idempotency-key': randomUUID()
197
+ },
198
+ body: JSON.stringify(payload)
199
+ });
154
200
  if (!response.ok)
155
201
  throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
156
202
  const result = await response.json().catch(() => undefined);
package/dist/main.js CHANGED
@@ -1,18 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import { RemotePiServer, LOOPBACK_HOSTS } from './server.js';
3
+ import { defaultDataDir } from './paths.js';
3
4
  import { VoiceManager } from './voice.js';
4
5
  import { SessionNamer, DEFAULT_SESSION_NAMER } from './session-namer.js';
5
6
  import { MailNotifier } from './mail-notifier.js';
6
7
  import pkg from '../package.json' with { type: 'json' };
7
8
  const args = process.argv.slice(2);
8
- const value = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] ?? fallback : fallback; };
9
- const envSecret = (flag) => { const name = value(flag, '').trim(); if (!name)
10
- return undefined; const result = process.env[name]; if (!result) {
11
- console.error(`${flag} references missing environment variable ${name}`);
12
- process.exit(1);
13
- } return result; };
9
+ const value = (name, fallback) => {
10
+ const i = args.indexOf(name);
11
+ return i >= 0 ? (args[i + 1] ?? fallback) : fallback;
12
+ };
13
+ const envSecret = (flag) => {
14
+ const name = value(flag, '').trim();
15
+ if (!name)
16
+ return undefined;
17
+ const result = process.env[name];
18
+ if (!result) {
19
+ console.error(`${flag} references missing environment variable ${name}`);
20
+ process.exit(1);
21
+ }
22
+ return result;
23
+ };
14
24
  if (args.includes('--help') || args.includes('-h')) {
15
- console.log(`pi2web ${pkg.version}\n\nUsage: pi2web [options]\n\nOptions:\n --host <address> Listen address (default: 127.0.0.1)\n --port <number> Listen port (default: 11318)\n --data-dir <path> Metadata directory (default: ~/.pi/remote-pi)\n --maildispatch-endpoint <url> MailDispatch message API endpoint\n --maildispatch-api-key-env <name> Environment variable containing the MailDispatch API key\n --maildispatch-notify-to <email> Email address notified when an Agent task settles\n --maildispatch-sender-id <id> Optional MailDispatch sender ID\n --voice-base-url <url> OpenAI-compatible TTS endpoint, e.g. Speaches or openai-edge-tts\n --voice-stt-model <id> Optional speech-to-text model ID; omit for TTS-only mode\n --voice-tts-model <id> Text-to-speech model ID\n --voice-tts-voice <id> TTS voice ID\n --voice-tts-format <pcm|mp3> TTS response format (default: pcm)\n --voice-api-key-env <name> Environment variable containing the speech API key\n --voice-summary-base-url <url> OpenAI-compatible summary LLM endpoint\n --voice-summary-model <id> Summary LLM model ID\n --voice-summary-api-key-env <name> Environment variable containing the summary API key\n --voice-language <tag> Spoken summary language (default: zh-CN)\n --voice-request-timeout <ms> Speech and summary request timeout (default: 120000)\n --session-naming <on|off> Automatic session naming (default: on)\n --session-name-base-url <url> OpenAI-compatible naming LLM endpoint (default: ${DEFAULT_SESSION_NAMER.baseUrl})\n --session-name-model <id> Naming LLM model ID (default: ${DEFAULT_SESSION_NAMER.model})\n --session-name-api-key-env <name> Environment variable containing the naming API key (default: none)\n --session-name-language <tag> Session name language (default: zh-CN)\n --session-name-request-timeout <ms> Naming request timeout (default: 60000)\n --session-name-max-output-tokens <n> Naming output token budget (default: ${DEFAULT_SESSION_NAMER.maxOutputTokens})\n --rotate-access-token Generate a new pairing code and exit\n --create-voice-token Create or replace a restricted voice token and exit\n --revoke-voice-token Revoke the restricted voice token and exit
25
+ console.log(`pi2web ${pkg.version}\n\nUsage: pi2web [options]\n\nOptions:\n --host <address> Listen address (default: 127.0.0.1)\n --port <number> Listen port (default: 11318)\n --data-dir <path> Data directory (default: ~/.pi/pi2web, env PI2WEB_DIR)\n --maildispatch-endpoint <url> MailDispatch message API endpoint\n --maildispatch-api-key-env <name> Environment variable containing the MailDispatch API key\n --maildispatch-notify-to <email> Email address notified when an Agent task settles\n --maildispatch-sender-id <id> Optional MailDispatch sender ID\n --voice-base-url <url> OpenAI-compatible TTS endpoint, e.g. Speaches or openai-edge-tts\n --voice-stt-model <id> Optional speech-to-text model ID; omit for TTS-only mode\n --voice-tts-model <id> Text-to-speech model ID\n --voice-tts-voice <id> TTS voice ID\n --voice-tts-format <pcm|mp3> TTS response format (default: pcm)\n --voice-api-key-env <name> Environment variable containing the speech API key\n --voice-summary-base-url <url> OpenAI-compatible summary LLM endpoint\n --voice-summary-model <id> Summary LLM model ID\n --voice-summary-api-key-env <name> Environment variable containing the summary API key\n --voice-language <tag> Spoken summary language (default: zh-CN)\n --voice-request-timeout <ms> Speech and summary request timeout (default: 120000)\n --session-naming <on|off> Automatic session naming (default: on)\n --session-name-base-url <url> OpenAI-compatible naming LLM endpoint (default: ${DEFAULT_SESSION_NAMER.baseUrl})\n --session-name-model <id> Naming LLM model ID (default: ${DEFAULT_SESSION_NAMER.model})\n --session-name-api-key-env <name> Environment variable containing the naming API key (default: none)\n --session-name-language <tag> Session name language (default: zh-CN)\n --session-name-request-timeout <ms> Naming request timeout (default: 60000)\n --session-name-max-output-tokens <n> Naming output token budget (default: ${DEFAULT_SESSION_NAMER.maxOutputTokens})\n --rotate-access-token Generate a new pairing code and exit\n --create-voice-token Create or replace a restricted voice token and exit\n --revoke-voice-token Revoke the restricted voice token and exit
16
26
  --set-access-token <token> Set a specific pairing code and exit
17
27
  --set-access-token-env <name> Set the pairing code from an environment variable and exit\n --no-auth Disable pairing-code auth (loopback only by default)\n --allow-insecure-no-auth Allow non-loopback binds and any Host/Origin (requires --no-auth; DANGEROUS)\n --version Print version\n -h, --help Show help\n\nMail notifications require endpoint, API-key environment variable, and recipient. Voice is disabled unless --voice-base-url is set. Automatic session naming is independent of voice and on by default; disable it with --session-naming off. The Web UI is served at the same address. Its pairing code must be entered manually; the browser asks before saving it locally.`);
18
28
  process.exit(0);
@@ -38,10 +48,14 @@ if (noAuth && !allowInsecureNoAuth && !LOOPBACK_HOSTS.includes(host.trim())) {
38
48
  const voiceBaseUrl = value('--voice-base-url', '');
39
49
  let voice;
40
50
  if (voiceBaseUrl) {
41
- const required = (name) => { const result = value(name, '').trim(); if (!result) {
42
- console.error(`${name} is required when voice is enabled`);
43
- process.exit(1);
44
- } return result; };
51
+ const required = (name) => {
52
+ const result = value(name, '').trim();
53
+ if (!result) {
54
+ console.error(`${name} is required when voice is enabled`);
55
+ process.exit(1);
56
+ }
57
+ return result;
58
+ };
45
59
  const ttsFormat = value('--voice-tts-format', 'pcm').trim();
46
60
  if (ttsFormat !== 'pcm' && ttsFormat !== 'mp3') {
47
61
  console.error('--voice-tts-format must be pcm or mp3');
@@ -52,7 +66,19 @@ if (voiceBaseUrl) {
52
66
  console.error('--voice-request-timeout must be an integer of at least 1000 milliseconds');
53
67
  process.exit(1);
54
68
  }
55
- const config = { speechBaseUrl: voiceBaseUrl, sttModel: value('--voice-stt-model', '').trim() || undefined, ttsModel: required('--voice-tts-model'), ttsVoice: required('--voice-tts-voice'), ttsFormat, speechApiKey: envSecret('--voice-api-key-env'), summaryBaseUrl: required('--voice-summary-base-url'), summaryModel: required('--voice-summary-model'), summaryApiKey: envSecret('--voice-summary-api-key-env'), language: value('--voice-language', 'zh-CN'), requestTimeoutMs };
69
+ const config = {
70
+ speechBaseUrl: voiceBaseUrl,
71
+ sttModel: value('--voice-stt-model', '').trim() || undefined,
72
+ ttsModel: required('--voice-tts-model'),
73
+ ttsVoice: required('--voice-tts-voice'),
74
+ ttsFormat,
75
+ speechApiKey: envSecret('--voice-api-key-env'),
76
+ summaryBaseUrl: required('--voice-summary-base-url'),
77
+ summaryModel: required('--voice-summary-model'),
78
+ summaryApiKey: envSecret('--voice-summary-api-key-env'),
79
+ language: value('--voice-language', 'zh-CN'),
80
+ requestTimeoutMs
81
+ };
56
82
  voice = new VoiceManager(config);
57
83
  }
58
84
  const namingMode = value('--session-naming', 'on').trim().toLowerCase();
@@ -101,9 +127,23 @@ if (mailParts === 3) {
101
127
  console.error('--maildispatch-endpoint must use http or https');
102
128
  process.exit(1);
103
129
  }
104
- mailNotifier = new MailNotifier({ endpoint: endpoint.toString(), apiKey: envSecret('--maildispatch-api-key-env'), recipient: mailRecipient, senderId: value('--maildispatch-sender-id', '').trim() || undefined });
130
+ mailNotifier = new MailNotifier({
131
+ endpoint: endpoint.toString(),
132
+ apiKey: envSecret('--maildispatch-api-key-env'),
133
+ recipient: mailRecipient,
134
+ senderId: value('--maildispatch-sender-id', '').trim() || undefined
135
+ });
105
136
  }
106
- const server = new RemotePiServer({ host, port, noAuth, allowInsecureNoAuth, dataDir: value('--data-dir', process.env.PI_REMOTE_DIR ?? `${process.env.HOME ?? '.'}/.pi/remote-pi`), voice, sessionNamer, mailNotifier });
137
+ const server = new RemotePiServer({
138
+ host,
139
+ port,
140
+ noAuth,
141
+ allowInsecureNoAuth,
142
+ dataDir: value('--data-dir', defaultDataDir()),
143
+ voice,
144
+ sessionNamer,
145
+ mailNotifier
146
+ });
107
147
  if (args.includes('--rotate-access-token')) {
108
148
  await server.auth.init();
109
149
  console.log(await server.auth.rotate());
@@ -158,12 +198,17 @@ try {
158
198
  await server.start();
159
199
  const address = server.address();
160
200
  console.log(`Remote Pi is ready: http://${host}:${address?.port ?? port}`);
161
- console.log(noAuth ? 'Auth is disabled (--no-auth). Anyone who can reach this address can control the agents; leave the pairing code empty in the Web UI.' : 'Open this URL and enter the pairing code manually.');
201
+ console.log(noAuth
202
+ ? 'Auth is disabled (--no-auth). Anyone who can reach this address can control the agents; leave the pairing code empty in the Web UI.'
203
+ : 'Open this URL and enter the pairing code manually.');
162
204
  }
163
205
  catch (error) {
164
206
  console.error(`Failed to start Remote Pi: ${error.message}`);
165
207
  process.exit(1);
166
208
  }
167
- const shutdown = async () => { await server.stop(); process.exit(0); };
209
+ const shutdown = async () => {
210
+ await server.stop();
211
+ process.exit(0);
212
+ };
168
213
  process.once('SIGINT', shutdown);
169
214
  process.once('SIGTERM', shutdown);
package/dist/mcp.js CHANGED
@@ -39,7 +39,9 @@ function summarize(entry) {
39
39
  enabled: config.enabled !== false,
40
40
  exposure: typeof config.exposure === 'string' ? config.exposure : 'codemode',
41
41
  transport: http ? 'http' : 'stdio',
42
- target: http ? String(config.url) : [config.command, ...(Array.isArray(config.args) ? config.args : [])].filter(Boolean).join(' ').trim(),
42
+ target: http
43
+ ? String(config.url)
44
+ : [config.command, ...(Array.isArray(config.args) ? config.args : [])].filter(Boolean).join(' ').trim(),
43
45
  description: typeof config.description === 'string' ? config.description : undefined,
44
46
  scope: entry.scope,
45
47
  source: entry.source,
@@ -56,7 +58,9 @@ function load(cwd) {
56
58
  explicitlyTrusted = new ProjectTrustStore(getAgentDir()).get(cwd) === true;
57
59
  projectTrusted = explicitlyTrusted || !hasTrustRequiringProjectResources(cwd);
58
60
  }
59
- catch { /* Untrusted is the safe default. */ }
61
+ catch {
62
+ /* Untrusted is the safe default. */
63
+ }
60
64
  const globals = readConfig(globalFile, errors), project = projectTrusted ? readConfig(projectFile, errors) : {};
61
65
  const entries = new Map();
62
66
  for (const [name, config] of Object.entries(globals))
@@ -68,12 +72,25 @@ function load(cwd) {
68
72
  else
69
73
  entries.set(name, { name, config, scope: 'project', source: projectFile });
70
74
  }
71
- return { entries: [...entries.values()], globalFile, projectFile: projectTrusted ? projectFile : undefined, projectTrusted, explicitlyTrusted, errors };
75
+ return {
76
+ entries: [...entries.values()],
77
+ globalFile,
78
+ projectFile: projectTrusted ? projectFile : undefined,
79
+ projectTrusted,
80
+ explicitlyTrusted,
81
+ errors
82
+ };
72
83
  }
73
84
  /** Every configured MCP server for `cwd`, including disabled ones. */
74
85
  export function listMcpServers(cwd) {
75
86
  const config = load(cwd);
76
- return { servers: config.entries.map(summarize), globalConfig: config.globalFile, projectConfig: config.projectFile, projectTrusted: config.projectTrusted, errors: config.errors };
87
+ return {
88
+ servers: config.entries.map(summarize),
89
+ globalConfig: config.globalFile,
90
+ projectConfig: config.projectFile,
91
+ projectTrusted: config.projectTrusted,
92
+ errors: config.errors
93
+ };
77
94
  }
78
95
  /** Write one server's `enabled` flag into `file`, keeping the rest of the file as it is. */
79
96
  function writeEnabled(file, name, enabled, explicit = false) {
@@ -87,7 +104,9 @@ function writeEnabled(file, name, enabled, explicit = false) {
87
104
  throw error;
88
105
  }
89
106
  const document = text?.trim() ? JSON.parse(text) : {};
90
- const servers = document.mcpServers && typeof document.mcpServers === 'object' ? document.mcpServers : (document.mcpServers = {});
107
+ const servers = document.mcpServers && typeof document.mcpServers === 'object'
108
+ ? document.mcpServers
109
+ : (document.mcpServers = {});
91
110
  const current = servers[name] && typeof servers[name] === 'object' ? servers[name] : (servers[name] = {});
92
111
  // `enabled: true` is the default, so it is dropped where the server itself is defined. A project
93
112
  // override only changes what it states, so there the flag is always written out.