pi2web 0.2.0 → 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 (55) hide show
  1. package/README.md +209 -18
  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 +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  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 +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  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 +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
@@ -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,11 +1,23 @@
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 };
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();
19
+ /** Agents with a run in progress. A run may emit duplicate settled events; notify only once. */
20
+ activeRuns = new Set();
9
21
  queue = [];
10
22
  timer;
11
23
  pending = new Set();
@@ -16,15 +28,22 @@ export class MailNotifier {
16
28
  this.fetcher = fetcher;
17
29
  this.config = { priority: 100, timeoutMs: 15_000, ...config };
18
30
  }
19
- getSettings() { return { ...this.settings }; }
31
+ getSettings() {
32
+ return { ...this.settings };
33
+ }
20
34
  updateSettings(value) {
21
35
  const next = { ...this.settings, ...value };
22
36
  next.enabled = Boolean(next.enabled);
23
37
  next.includeResponse = Boolean(next.includeResponse);
24
38
  next.includeSessionDetails = Boolean(next.includeSessionDetails);
39
+ next.collabEscalations = Boolean(next.collabEscalations);
25
40
  next.aggregationDelaySeconds = Math.trunc(Number(next.aggregationDelaySeconds));
26
- if (!Number.isFinite(next.aggregationDelaySeconds) || next.aggregationDelaySeconds < 0 || next.aggregationDelaySeconds > 86400)
27
- 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
+ });
28
47
  this.settings = next;
29
48
  if (!next.enabled) {
30
49
  this.clearTimer();
@@ -40,22 +59,40 @@ export class MailNotifier {
40
59
  handleAgentEvent(agentId, event, context = {}) {
41
60
  if (this.closed)
42
61
  return;
62
+ if (event.type === 'before_agent_start' || event.type === 'agent_start') {
63
+ this.activeRuns.add(agentId);
64
+ return;
65
+ }
43
66
  if (event.type === 'message_end') {
67
+ this.activeRuns.add(agentId);
44
68
  const text = assistantText(event.message);
45
69
  if (text)
46
70
  this.lastAssistant.set(agentId, text);
47
71
  return;
48
72
  }
49
- if (event.type !== 'agent_settled')
73
+ if (event.type !== 'agent_settled' || !this.activeRuns.delete(agentId))
50
74
  return;
51
75
  const finalOutput = this.lastAssistant.get(agentId) ?? '';
52
76
  this.lastAssistant.delete(agentId);
53
77
  if (!this.settings.enabled)
54
78
  return;
55
- this.queue.push({ agentId, finalOutput, context });
79
+ // Pi 0.99+ reports whether the run was cancelled; a cancelled run is not a completed task.
80
+ this.queue.push({ agentId, finalOutput, context, aborted: event.aborted === true });
56
81
  if (!this.timer)
57
82
  this.scheduleFlush();
58
83
  }
84
+ /**
85
+ * Fire-and-forget notification that bypasses the agent-completion queue. Used by the collaboration hub:
86
+ * a pending human ruling blocks the whole session, so it must not wait for the aggregation window.
87
+ */
88
+ notifyCollab(input) {
89
+ if (this.closed || !this.settings.enabled || !this.settings.collabEscalations)
90
+ return;
91
+ const job = this.sendMail(input.subject, clip(input.text, 24_000), { kind: 'collab' }).catch(error => console.error(`MailDispatch collaboration notification failed: ${error.message}`));
92
+ this.pending.add(job);
93
+ void job.finally(() => this.pending.delete(job));
94
+ return job;
95
+ }
59
96
  async close() {
60
97
  this.closed = true;
61
98
  this.clearTimer();
@@ -63,6 +100,7 @@ export class MailNotifier {
63
100
  await this.flushQueue();
64
101
  await Promise.allSettled([...this.pending]);
65
102
  this.lastAssistant.clear();
103
+ this.activeRuns.clear();
66
104
  }
67
105
  scheduleFlush() {
68
106
  const delay = this.settings.aggregationDelaySeconds * 1000;
@@ -70,7 +108,10 @@ export class MailNotifier {
70
108
  this.startFlush();
71
109
  return;
72
110
  }
73
- this.timer = setTimeout(() => { this.timer = undefined; this.startFlush(); }, delay);
111
+ this.timer = setTimeout(() => {
112
+ this.timer = undefined;
113
+ this.startFlush();
114
+ }, delay);
74
115
  this.timer.unref?.();
75
116
  }
76
117
  startFlush() {
@@ -86,24 +127,39 @@ export class MailNotifier {
86
127
  if (this.queue.length && !this.timer && !this.closed)
87
128
  this.scheduleFlush();
88
129
  }
89
- clearTimer() { if (this.timer) {
90
- clearTimeout(this.timer);
91
- this.timer = undefined;
92
- } }
130
+ clearTimer() {
131
+ if (this.timer) {
132
+ clearTimeout(this.timer);
133
+ this.timer = undefined;
134
+ }
135
+ }
93
136
  async sendBatch(items) {
94
137
  const includeResponse = this.settings.includeResponse, includeSessionDetails = this.settings.includeSessionDetails, count = items.length;
95
- let subject = `[Remote Pi] ${count > 1 ? `${count} 个 Agent 任务` : 'Agent 任务'}已完成`;
138
+ const aborted = items.filter(item => item.aborted).length;
139
+ // A mixed batch is neither completed nor interrupted, so it says how many of each.
140
+ const verb = aborted === count ? '已中断' : aborted ? `已结束(${aborted} 个被中断)` : '已完成';
141
+ let subject = `[Remote Pi] ${count > 1 ? `${count} 个 Agent 任务` : 'Agent 任务'}${verb}`;
96
142
  if (count === 1 && includeSessionDetails) {
97
143
  const label = oneLine(items[0].context.sessionName || '', 80);
98
144
  if (label)
99
- subject = `[Remote Pi] ${label}已完成`;
145
+ subject = `[Remote Pi] ${label}${verb}`;
100
146
  }
101
147
  let text;
102
148
  if (!includeResponse && !includeSessionDetails)
103
- text = count > 1 ? `${count} 个 Agent 任务已完成。` : '有 Agent 任务完成。';
149
+ text =
150
+ count > 1
151
+ ? `${count} 个 Agent 任务${verb}。`
152
+ : aborted
153
+ ? '有 Agent 任务被中断。'
154
+ : '有 Agent 任务完成。';
104
155
  else
105
- text = items.map((item, index) => {
106
- const lines = count > 1 ? [`任务 ${index + 1}`] : [];
156
+ text = items
157
+ .map((item, index) => {
158
+ const lines = count > 1
159
+ ? [`任务 ${index + 1}${item.aborted ? '(已中断)' : ''}`]
160
+ : item.aborted
161
+ ? ['任务已中断']
162
+ : [];
107
163
  if (includeSessionDetails) {
108
164
  lines.push(`Session:${item.context.sessionName?.trim() || '未命名'}`);
109
165
  if (item.context.cwd)
@@ -115,14 +171,32 @@ export class MailNotifier {
115
171
  lines.push('Agent 回复:', clip(item.finalOutput.trim() || '(没有可用的最终文本回复)', 24_000));
116
172
  }
117
173
  return lines.join('\n');
118
- }).join('\n\n--------------------\n\n');
174
+ })
175
+ .join('\n\n--------------------\n\n');
176
+ await this.sendMail(subject, text, { task_count: count, aborted_count: aborted });
177
+ }
178
+ async sendMail(subject, text, metadata = {}) {
119
179
  const payload = {
120
- to: [this.config.recipient], subject, text, priority: this.config.priority, purpose: 'transactional',
121
- metadata: { source: 'remote-pi', task_count: count, 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() }
122
186
  };
123
187
  if (this.config.senderId)
124
188
  payload.sender_id = this.config.senderId;
125
- 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
+ });
126
200
  if (!response.ok)
127
201
  throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
128
202
  const result = await response.json().catch(() => undefined);
package/dist/main.js CHANGED
@@ -1,17 +1,30 @@
1
1
  #!/usr/bin/env node
2
- import { RemotePiServer } from './server.js';
2
+ import { RemotePiServer, LOOPBACK_HOSTS } from './server.js';
3
+ import { defaultDataDir } from './paths.js';
3
4
  import { VoiceManager } from './voice.js';
5
+ import { SessionNamer, DEFAULT_SESSION_NAMER } from './session-namer.js';
4
6
  import { MailNotifier } from './mail-notifier.js';
5
7
  import pkg from '../package.json' with { type: 'json' };
6
8
  const args = process.argv.slice(2);
7
- const value = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] ?? fallback : fallback; };
8
- const envSecret = (flag) => { const name = value(flag, '').trim(); if (!name)
9
- return undefined; const result = process.env[name]; if (!result) {
10
- console.error(`${flag} references missing environment variable ${name}`);
11
- process.exit(1);
12
- } 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
+ };
13
24
  if (args.includes('--help') || args.includes('-h')) {
14
- 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 --rotate-access-token Generate a new pairing code and exit\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. The Web UI is served at the same address. Its pairing code must be entered manually; the browser asks before saving it locally.`);
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
26
+ --set-access-token <token> Set a specific pairing code and exit
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.`);
15
28
  process.exit(0);
16
29
  }
17
30
  if (args.includes('--version') || args.includes('-v')) {
@@ -23,13 +36,26 @@ if (!Number.isInteger(port) || port < 0 || port > 65535) {
23
36
  console.error('Invalid --port');
24
37
  process.exit(1);
25
38
  }
39
+ const noAuth = args.includes('--no-auth'), allowInsecureNoAuth = args.includes('--allow-insecure-no-auth');
40
+ if (allowInsecureNoAuth && !noAuth) {
41
+ console.error('--allow-insecure-no-auth requires --no-auth');
42
+ process.exit(1);
43
+ }
44
+ if (noAuth && !allowInsecureNoAuth && !LOOPBACK_HOSTS.includes(host.trim())) {
45
+ console.error('--no-auth requires --host 127.0.0.1, ::1 or localhost; use --allow-insecure-no-auth to explicitly allow remote access without authentication');
46
+ process.exit(1);
47
+ }
26
48
  const voiceBaseUrl = value('--voice-base-url', '');
27
49
  let voice;
28
50
  if (voiceBaseUrl) {
29
- const required = (name) => { const result = value(name, '').trim(); if (!result) {
30
- console.error(`${name} is required when voice is enabled`);
31
- process.exit(1);
32
- } 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
+ };
33
59
  const ttsFormat = value('--voice-tts-format', 'pcm').trim();
34
60
  if (ttsFormat !== 'pcm' && ttsFormat !== 'mp3') {
35
61
  console.error('--voice-tts-format must be pcm or mp3');
@@ -40,9 +66,47 @@ if (voiceBaseUrl) {
40
66
  console.error('--voice-request-timeout must be an integer of at least 1000 milliseconds');
41
67
  process.exit(1);
42
68
  }
43
- 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
+ };
44
82
  voice = new VoiceManager(config);
45
83
  }
84
+ const namingMode = value('--session-naming', 'on').trim().toLowerCase();
85
+ if (!['on', 'off'].includes(namingMode)) {
86
+ console.error('--session-naming must be on or off');
87
+ process.exit(1);
88
+ }
89
+ let sessionNamer;
90
+ if (namingMode === 'on') {
91
+ const namingTimeoutMs = Number(value('--session-name-request-timeout', String(DEFAULT_SESSION_NAMER.requestTimeoutMs)));
92
+ if (!Number.isInteger(namingTimeoutMs) || namingTimeoutMs < 1000) {
93
+ console.error('--session-name-request-timeout must be an integer of at least 1000 milliseconds');
94
+ process.exit(1);
95
+ }
96
+ const namingMaxTokens = Number(value('--session-name-max-output-tokens', String(DEFAULT_SESSION_NAMER.maxOutputTokens)));
97
+ if (!Number.isInteger(namingMaxTokens) || namingMaxTokens < 16) {
98
+ console.error('--session-name-max-output-tokens must be an integer of at least 16');
99
+ process.exit(1);
100
+ }
101
+ sessionNamer = new SessionNamer({
102
+ baseUrl: value('--session-name-base-url', DEFAULT_SESSION_NAMER.baseUrl).trim() || DEFAULT_SESSION_NAMER.baseUrl,
103
+ model: value('--session-name-model', DEFAULT_SESSION_NAMER.model).trim() || DEFAULT_SESSION_NAMER.model,
104
+ apiKey: envSecret('--session-name-api-key-env'),
105
+ language: value('--session-name-language', DEFAULT_SESSION_NAMER.language),
106
+ requestTimeoutMs: namingTimeoutMs,
107
+ maxOutputTokens: namingMaxTokens
108
+ });
109
+ }
46
110
  const mailEndpoint = value('--maildispatch-endpoint', '').trim(), mailKeyEnv = value('--maildispatch-api-key-env', '').trim(), mailRecipient = value('--maildispatch-notify-to', '').trim();
47
111
  const mailParts = [mailEndpoint, mailKeyEnv, mailRecipient].filter(Boolean).length;
48
112
  if (mailParts > 0 && mailParts < 3) {
@@ -63,14 +127,69 @@ if (mailParts === 3) {
63
127
  console.error('--maildispatch-endpoint must use http or https');
64
128
  process.exit(1);
65
129
  }
66
- 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
+ });
67
136
  }
68
- const server = new RemotePiServer({ host, port, dataDir: value('--data-dir', process.env.PI_REMOTE_DIR ?? `${process.env.HOME ?? '.'}/.pi/remote-pi`), voice, 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
+ });
69
147
  if (args.includes('--rotate-access-token')) {
70
148
  await server.auth.init();
71
149
  console.log(await server.auth.rotate());
72
150
  process.exit(0);
73
151
  }
152
+ if (args.includes('--create-voice-token')) {
153
+ const first = await server.auth.init();
154
+ if (first.token)
155
+ console.error(`Remote Pi pairing code (shown once; save it securely): ${first.token}`);
156
+ console.error('Draft-only voice token (shown once):');
157
+ console.log(await server.auth.rotateVoice());
158
+ process.exit(0);
159
+ }
160
+ if (args.includes('--revoke-voice-token')) {
161
+ const first = await server.auth.init();
162
+ if (first.token)
163
+ console.error(`Remote Pi pairing code (shown once; save it securely): ${first.token}`);
164
+ await server.auth.revokeVoice();
165
+ console.error('Voice token revoked.');
166
+ process.exit(0);
167
+ }
168
+ if (args.includes('--set-access-token') || args.includes('--set-access-token-env')) {
169
+ const fromEnv = args.includes('--set-access-token-env'), flag = fromEnv ? '--set-access-token-env' : '--set-access-token';
170
+ let token = value(flag, '');
171
+ if (!token) {
172
+ console.error(`${flag} requires a value`);
173
+ process.exit(1);
174
+ }
175
+ if (fromEnv) {
176
+ const name = token;
177
+ token = process.env[name] ?? '';
178
+ if (!token) {
179
+ console.error(`${flag} references missing or empty environment variable ${name}`);
180
+ process.exit(1);
181
+ }
182
+ }
183
+ try {
184
+ await server.auth.setToken(token);
185
+ console.log('Pairing code updated. Use it to pair Web UI clients.');
186
+ }
187
+ catch (error) {
188
+ console.error(error.message);
189
+ process.exit(1);
190
+ }
191
+ process.exit(0);
192
+ }
74
193
  if (args.includes('--print-access-token')) {
75
194
  console.error('The pairing code is only shown on first startup or rotation.');
76
195
  process.exit(1);
@@ -79,12 +198,17 @@ try {
79
198
  await server.start();
80
199
  const address = server.address();
81
200
  console.log(`Remote Pi is ready: http://${host}:${address?.port ?? port}`);
82
- console.log('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.');
83
204
  }
84
205
  catch (error) {
85
206
  console.error(`Failed to start Remote Pi: ${error.message}`);
86
207
  process.exit(1);
87
208
  }
88
- const shutdown = async () => { await server.stop(); process.exit(0); };
209
+ const shutdown = async () => {
210
+ await server.stop();
211
+ process.exit(0);
212
+ };
89
213
  process.once('SIGINT', shutdown);
90
214
  process.once('SIGTERM', shutdown);