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.
@@ -0,0 +1,148 @@
1
+ export class ValidationError extends Error {
2
+ fieldErrors;
3
+ code = 'VALIDATION_FAILED';
4
+ constructor(fieldErrors) {
5
+ super(`Invalid request: ${fieldErrors.map(e => `${e.path} ${e.code}`).join(', ')}`);
6
+ this.fieldErrors = fieldErrors;
7
+ }
8
+ }
9
+ const fail = (ctx, code, message, expected) => { ctx.errors.push({ path: ctx.path || '(root)', code, message, ...(expected ? { expected } : {}) }); };
10
+ const child = (ctx, key) => ({ path: typeof key === 'number' ? `${ctx.path}[${key}]` : ctx.path ? `${ctx.path}.${key}` : key, errors: ctx.errors });
11
+ export const str = (options = {}) => ({ parse(value, ctx) {
12
+ if (typeof value !== 'string') {
13
+ fail(ctx, 'NOT_A_STRING', 'Expected a string', 'string');
14
+ return '';
15
+ }
16
+ const text = options.trim === false ? value : value.trim();
17
+ const min = options.min ?? 1, max = options.max ?? 4000;
18
+ if (text.length < min)
19
+ fail(ctx, 'TOO_SHORT', `Expected at least ${min} characters, received ${text.length}`, `length >= ${min}`);
20
+ if (text.length > max)
21
+ fail(ctx, 'TOO_LONG', `Expected at most ${max} characters, received ${text.length}`, `length <= ${max}`);
22
+ if (options.pattern && !options.pattern.test(text))
23
+ fail(ctx, 'PATTERN_MISMATCH', `Expected a value matching ${options.pattern}`, String(options.pattern));
24
+ return text;
25
+ } });
26
+ export const num = (options = {}) => ({ parse(value, ctx) {
27
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
28
+ if (!Number.isFinite(parsed)) {
29
+ fail(ctx, 'NOT_A_NUMBER', 'Expected a finite number', 'number');
30
+ return 0;
31
+ }
32
+ if (options.integer && !Number.isInteger(parsed))
33
+ fail(ctx, 'NOT_AN_INTEGER', 'Expected an integer', 'integer');
34
+ if (options.min !== undefined && parsed < options.min)
35
+ fail(ctx, 'TOO_SMALL', `Expected a value >= ${options.min}`, `>= ${options.min}`);
36
+ if (options.max !== undefined && parsed > options.max)
37
+ fail(ctx, 'TOO_LARGE', `Expected a value <= ${options.max}`, `<= ${options.max}`);
38
+ // Floating point: 6.5 / 0.5 must be treated as an exact multiple, so compare against a rounded quotient.
39
+ if (options.step) {
40
+ const quotient = parsed / options.step;
41
+ if (Math.abs(quotient - Math.round(quotient)) > 1e-9)
42
+ fail(ctx, 'NOT_A_MULTIPLE', `Expected a multiple of ${options.step}`, `multiple of ${options.step}`);
43
+ }
44
+ return parsed;
45
+ } });
46
+ export const bool = () => ({ parse(value, ctx) { if (typeof value !== 'boolean') {
47
+ fail(ctx, 'NOT_A_BOOLEAN', 'Expected true or false', 'boolean');
48
+ return false;
49
+ } return value; } });
50
+ export const oneOf = (values) => ({ parse(value, ctx) {
51
+ if (typeof value !== 'string' || !values.includes(value)) {
52
+ fail(ctx, 'NOT_ALLOWED', `Expected one of: ${values.join(', ')}`, values.join('|'));
53
+ return values[0];
54
+ }
55
+ return value;
56
+ } });
57
+ export const arr = (item, options = {}) => ({ parse(value, ctx) {
58
+ if (!Array.isArray(value)) {
59
+ fail(ctx, 'NOT_AN_ARRAY', 'Expected an array', 'array');
60
+ return [];
61
+ }
62
+ const min = options.min ?? 0, max = options.max ?? 200;
63
+ if (value.length < min)
64
+ fail(ctx, 'TOO_FEW_ITEMS', `Expected at least ${min} items, received ${value.length}`, `length >= ${min}`);
65
+ if (value.length > max) {
66
+ fail(ctx, 'TOO_MANY_ITEMS', `Expected at most ${max} items, received ${value.length}`, `length <= ${max}`);
67
+ return [];
68
+ }
69
+ return value.map((entry, index) => item.parse(entry, child(ctx, index)));
70
+ } });
71
+ /**
72
+ * A map whose keys are data, not schema - rubric anchors are `{"0":"...","10":"..."}`. `obj()` cannot express
73
+ * this: it only ever copies the fields its shape declares, so an empty shape silently parses to `{}`.
74
+ */
75
+ export const record = (value, limits = {}) => ({ parse(input, ctx) {
76
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
77
+ fail(ctx, 'NOT_AN_OBJECT', 'Expected an object', 'object');
78
+ return {};
79
+ }
80
+ const source = input, keys = Object.keys(source), result = {};
81
+ if (limits.maxKeys !== undefined && keys.length > limits.maxKeys)
82
+ fail(ctx, 'TOO_LONG', `At most ${limits.maxKeys} entries are accepted`, `<= ${limits.maxKeys} entries`);
83
+ for (const key of keys) {
84
+ if (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength)
85
+ fail(child(ctx, key), 'TOO_LONG', `A key may be at most ${limits.maxKeyLength} characters`);
86
+ result[key] = value.parse(source[key], child(ctx, key));
87
+ }
88
+ return result;
89
+ } });
90
+ export const obj = (shape) => ({ parse(value, ctx) {
91
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
92
+ fail(ctx, 'NOT_AN_OBJECT', 'Expected an object', 'object');
93
+ return {};
94
+ }
95
+ const source = value, result = {};
96
+ for (const [key, validator] of Object.entries(shape)) {
97
+ const entry = source[key];
98
+ if (entry === undefined || entry === null) {
99
+ if (validator.optional) {
100
+ if (validator.fallback)
101
+ result[key] = validator.fallback();
102
+ continue;
103
+ }
104
+ fail(child(ctx, key), 'REQUIRED', 'This field is required');
105
+ continue;
106
+ }
107
+ result[key] = validator.parse(entry, child(ctx, key));
108
+ }
109
+ for (const key of Object.keys(source))
110
+ if (!(key in shape))
111
+ fail(child(ctx, key), 'UNKNOWN_FIELD', `Unknown field. Allowed fields: ${Object.keys(shape).join(', ')}`);
112
+ return result;
113
+ } });
114
+ /**
115
+ * Cross-field rule on top of a structurally valid value. Field-by-field checks cannot catch
116
+ * `{min:10,max:1}`: every field is in range, yet the combination is unusable.
117
+ * The check only runs when the inner validator produced no errors, so it never reports on garbage.
118
+ */
119
+ export const refine = (inner, check) => ({ parse(value, ctx) {
120
+ const before = ctx.errors.length, result = inner.parse(value, ctx);
121
+ if (ctx.errors.length > before)
122
+ return result;
123
+ for (const error of check(result) ?? [])
124
+ ctx.errors.push({ ...error, path: ctx.path ? `${ctx.path}.${error.path}` : error.path });
125
+ return result;
126
+ } });
127
+ export const optional = (inner) => ({ parse: (value, ctx) => inner.parse(value, ctx), optional: true });
128
+ export const withDefault = (inner, fallback) => ({ parse: (value, ctx) => inner.parse(value, ctx), optional: true, fallback });
129
+ /** Accepts any JSON value. Used for opaque payloads the hub stores but never interprets. */
130
+ export const anyJson = (maxBytes = 64 * 1024) => ({ parse(value, ctx) {
131
+ let size = 0;
132
+ try {
133
+ size = JSON.stringify(value)?.length ?? 0;
134
+ }
135
+ catch {
136
+ fail(ctx, 'NOT_SERIALIZABLE', 'Expected JSON-serializable data');
137
+ return null;
138
+ }
139
+ if (size > maxBytes)
140
+ fail(ctx, 'TOO_LARGE', `Expected at most ${maxBytes} serialized bytes, received ${size}`);
141
+ return value;
142
+ } });
143
+ export function parse(validator, value) {
144
+ const errors = [], result = validator.parse(value, { path: '', errors });
145
+ if (errors.length)
146
+ throw new ValidationError(errors.slice(0, 50));
147
+ return result;
148
+ }
package/dist/drafts.js ADDED
@@ -0,0 +1,30 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class DraftHub {
3
+ pending = [];
4
+ listeners = new Set();
5
+ max = 50;
6
+ ttlMs = 60 * 60_000;
7
+ /** Returns the stored draft and how many Web UIs were reached right now. */
8
+ add(input) {
9
+ this.expire();
10
+ const draft = { id: `draft-${randomUUID()}`, text: input.text, mode: input.mode ?? 'append', source: input.source, createdAt: new Date().toISOString() };
11
+ this.pending.push(draft);
12
+ while (this.pending.length > this.max)
13
+ this.pending.shift();
14
+ const delivered = this.listeners.size;
15
+ for (const listener of [...this.listeners])
16
+ listener(draft, false);
17
+ return { draft, delivered };
18
+ }
19
+ /** First acknowledgement wins: other tabs that already received it simply keep their copy. */
20
+ ack(id) { const before = this.pending.length; this.pending = this.pending.filter(d => d.id !== id); return this.pending.length < before; }
21
+ list() { this.expire(); return [...this.pending]; }
22
+ subscriberCount() { return this.listeners.size; }
23
+ subscribe(listener) {
24
+ this.listeners.add(listener);
25
+ for (const draft of this.list())
26
+ listener(draft, true);
27
+ return () => { this.listeners.delete(listener); };
28
+ }
29
+ expire() { const cutoff = Date.now() - this.ttlMs; this.pending = this.pending.filter(d => Date.parse(d.createdAt) >= cutoff); }
30
+ }
@@ -1,11 +1,13 @@
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 = { enabled: true, aggregationDelaySeconds: 0, includeResponse: true, includeSessionDetails: true, collabEscalations: true };
4
4
  const clip = (value, max) => value.length <= max ? value : `${value.slice(0, max)}\n\n[内容过长,已截断]`;
5
5
  const oneLine = (value, max) => value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, max);
6
6
  export class MailNotifier {
7
7
  fetcher;
8
8
  lastAssistant = new Map();
9
+ /** Agents with a run in progress. A run may emit duplicate settled events; notify only once. */
10
+ activeRuns = new Set();
9
11
  queue = [];
10
12
  timer;
11
13
  pending = new Set();
@@ -22,6 +24,7 @@ export class MailNotifier {
22
24
  next.enabled = Boolean(next.enabled);
23
25
  next.includeResponse = Boolean(next.includeResponse);
24
26
  next.includeSessionDetails = Boolean(next.includeSessionDetails);
27
+ next.collabEscalations = Boolean(next.collabEscalations);
25
28
  next.aggregationDelaySeconds = Math.trunc(Number(next.aggregationDelaySeconds));
26
29
  if (!Number.isFinite(next.aggregationDelaySeconds) || next.aggregationDelaySeconds < 0 || next.aggregationDelaySeconds > 86400)
27
30
  throw Object.assign(new Error('aggregationDelaySeconds must be an integer between 0 and 86400'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
@@ -40,22 +43,40 @@ export class MailNotifier {
40
43
  handleAgentEvent(agentId, event, context = {}) {
41
44
  if (this.closed)
42
45
  return;
46
+ if (event.type === 'before_agent_start' || event.type === 'agent_start') {
47
+ this.activeRuns.add(agentId);
48
+ return;
49
+ }
43
50
  if (event.type === 'message_end') {
51
+ this.activeRuns.add(agentId);
44
52
  const text = assistantText(event.message);
45
53
  if (text)
46
54
  this.lastAssistant.set(agentId, text);
47
55
  return;
48
56
  }
49
- if (event.type !== 'agent_settled')
57
+ if (event.type !== 'agent_settled' || !this.activeRuns.delete(agentId))
50
58
  return;
51
59
  const finalOutput = this.lastAssistant.get(agentId) ?? '';
52
60
  this.lastAssistant.delete(agentId);
53
61
  if (!this.settings.enabled)
54
62
  return;
55
- this.queue.push({ agentId, finalOutput, context });
63
+ // Pi 0.99+ reports whether the run was cancelled; a cancelled run is not a completed task.
64
+ this.queue.push({ agentId, finalOutput, context, aborted: event.aborted === true });
56
65
  if (!this.timer)
57
66
  this.scheduleFlush();
58
67
  }
68
+ /**
69
+ * Fire-and-forget notification that bypasses the agent-completion queue. Used by the collaboration hub:
70
+ * a pending human ruling blocks the whole session, so it must not wait for the aggregation window.
71
+ */
72
+ notifyCollab(input) {
73
+ if (this.closed || !this.settings.enabled || !this.settings.collabEscalations)
74
+ return;
75
+ const job = this.sendMail(input.subject, clip(input.text, 24_000), { kind: 'collab' }).catch(error => console.error(`MailDispatch collaboration notification failed: ${error.message}`));
76
+ this.pending.add(job);
77
+ void job.finally(() => this.pending.delete(job));
78
+ return job;
79
+ }
59
80
  async close() {
60
81
  this.closed = true;
61
82
  this.clearTimer();
@@ -63,6 +84,7 @@ export class MailNotifier {
63
84
  await this.flushQueue();
64
85
  await Promise.allSettled([...this.pending]);
65
86
  this.lastAssistant.clear();
87
+ this.activeRuns.clear();
66
88
  }
67
89
  scheduleFlush() {
68
90
  const delay = this.settings.aggregationDelaySeconds * 1000;
@@ -92,18 +114,21 @@ export class MailNotifier {
92
114
  } }
93
115
  async sendBatch(items) {
94
116
  const includeResponse = this.settings.includeResponse, includeSessionDetails = this.settings.includeSessionDetails, count = items.length;
95
- let subject = `[Remote Pi] ${count > 1 ? `${count} 个 Agent 任务` : 'Agent 任务'}已完成`;
117
+ const aborted = items.filter(item => item.aborted).length;
118
+ // A mixed batch is neither completed nor interrupted, so it says how many of each.
119
+ const verb = aborted === count ? '已中断' : aborted ? `已结束(${aborted} 个被中断)` : '已完成';
120
+ let subject = `[Remote Pi] ${count > 1 ? `${count} 个 Agent 任务` : 'Agent 任务'}${verb}`;
96
121
  if (count === 1 && includeSessionDetails) {
97
122
  const label = oneLine(items[0].context.sessionName || '', 80);
98
123
  if (label)
99
- subject = `[Remote Pi] ${label}已完成`;
124
+ subject = `[Remote Pi] ${label}${verb}`;
100
125
  }
101
126
  let text;
102
127
  if (!includeResponse && !includeSessionDetails)
103
- text = count > 1 ? `${count} 个 Agent 任务已完成。` : '有 Agent 任务完成。';
128
+ text = count > 1 ? `${count} 个 Agent 任务${verb}。` : aborted ? '有 Agent 任务被中断。' : '有 Agent 任务完成。';
104
129
  else
105
130
  text = items.map((item, index) => {
106
- const lines = count > 1 ? [`任务 ${index + 1}`] : [];
131
+ const lines = count > 1 ? [`任务 ${index + 1}${item.aborted ? '(已中断)' : ''}`] : item.aborted ? ['任务已中断'] : [];
107
132
  if (includeSessionDetails) {
108
133
  lines.push(`Session:${item.context.sessionName?.trim() || '未命名'}`);
109
134
  if (item.context.cwd)
@@ -116,9 +141,12 @@ export class MailNotifier {
116
141
  }
117
142
  return lines.join('\n');
118
143
  }).join('\n\n--------------------\n\n');
144
+ await this.sendMail(subject, text, { task_count: count, aborted_count: aborted });
145
+ }
146
+ async sendMail(subject, text, metadata = {}) {
119
147
  const payload = {
120
148
  to: [this.config.recipient], subject, text, priority: this.config.priority, purpose: 'transactional',
121
- metadata: { source: 'remote-pi', task_count: count, notification_id: randomUUID() }
149
+ metadata: { source: 'remote-pi', ...metadata, notification_id: randomUUID() }
122
150
  };
123
151
  if (this.config.senderId)
124
152
  payload.sender_id = this.config.senderId;
package/dist/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { RemotePiServer } from './server.js';
2
+ import { RemotePiServer, LOOPBACK_HOSTS } from './server.js';
3
3
  import { VoiceManager } from './voice.js';
4
+ import { SessionNamer, DEFAULT_SESSION_NAMER } from './session-namer.js';
4
5
  import { MailNotifier } from './mail-notifier.js';
5
6
  import pkg from '../package.json' with { type: 'json' };
6
7
  const args = process.argv.slice(2);
@@ -11,7 +12,9 @@ const envSecret = (flag) => { const name = value(flag, '').trim(); if (!name)
11
12
  process.exit(1);
12
13
  } return result; };
13
14
  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.`);
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
16
+ --set-access-token <token> Set a specific pairing code and exit
17
+ --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
18
  process.exit(0);
16
19
  }
17
20
  if (args.includes('--version') || args.includes('-v')) {
@@ -23,6 +26,15 @@ if (!Number.isInteger(port) || port < 0 || port > 65535) {
23
26
  console.error('Invalid --port');
24
27
  process.exit(1);
25
28
  }
29
+ const noAuth = args.includes('--no-auth'), allowInsecureNoAuth = args.includes('--allow-insecure-no-auth');
30
+ if (allowInsecureNoAuth && !noAuth) {
31
+ console.error('--allow-insecure-no-auth requires --no-auth');
32
+ process.exit(1);
33
+ }
34
+ if (noAuth && !allowInsecureNoAuth && !LOOPBACK_HOSTS.includes(host.trim())) {
35
+ 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');
36
+ process.exit(1);
37
+ }
26
38
  const voiceBaseUrl = value('--voice-base-url', '');
27
39
  let voice;
28
40
  if (voiceBaseUrl) {
@@ -43,6 +55,32 @@ if (voiceBaseUrl) {
43
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 };
44
56
  voice = new VoiceManager(config);
45
57
  }
58
+ const namingMode = value('--session-naming', 'on').trim().toLowerCase();
59
+ if (!['on', 'off'].includes(namingMode)) {
60
+ console.error('--session-naming must be on or off');
61
+ process.exit(1);
62
+ }
63
+ let sessionNamer;
64
+ if (namingMode === 'on') {
65
+ const namingTimeoutMs = Number(value('--session-name-request-timeout', String(DEFAULT_SESSION_NAMER.requestTimeoutMs)));
66
+ if (!Number.isInteger(namingTimeoutMs) || namingTimeoutMs < 1000) {
67
+ console.error('--session-name-request-timeout must be an integer of at least 1000 milliseconds');
68
+ process.exit(1);
69
+ }
70
+ const namingMaxTokens = Number(value('--session-name-max-output-tokens', String(DEFAULT_SESSION_NAMER.maxOutputTokens)));
71
+ if (!Number.isInteger(namingMaxTokens) || namingMaxTokens < 16) {
72
+ console.error('--session-name-max-output-tokens must be an integer of at least 16');
73
+ process.exit(1);
74
+ }
75
+ sessionNamer = new SessionNamer({
76
+ baseUrl: value('--session-name-base-url', DEFAULT_SESSION_NAMER.baseUrl).trim() || DEFAULT_SESSION_NAMER.baseUrl,
77
+ model: value('--session-name-model', DEFAULT_SESSION_NAMER.model).trim() || DEFAULT_SESSION_NAMER.model,
78
+ apiKey: envSecret('--session-name-api-key-env'),
79
+ language: value('--session-name-language', DEFAULT_SESSION_NAMER.language),
80
+ requestTimeoutMs: namingTimeoutMs,
81
+ maxOutputTokens: namingMaxTokens
82
+ });
83
+ }
46
84
  const mailEndpoint = value('--maildispatch-endpoint', '').trim(), mailKeyEnv = value('--maildispatch-api-key-env', '').trim(), mailRecipient = value('--maildispatch-notify-to', '').trim();
47
85
  const mailParts = [mailEndpoint, mailKeyEnv, mailRecipient].filter(Boolean).length;
48
86
  if (mailParts > 0 && mailParts < 3) {
@@ -65,12 +103,53 @@ if (mailParts === 3) {
65
103
  }
66
104
  mailNotifier = new MailNotifier({ endpoint: endpoint.toString(), apiKey: envSecret('--maildispatch-api-key-env'), recipient: mailRecipient, senderId: value('--maildispatch-sender-id', '').trim() || undefined });
67
105
  }
68
- const server = new RemotePiServer({ host, port, dataDir: value('--data-dir', process.env.PI_REMOTE_DIR ?? `${process.env.HOME ?? '.'}/.pi/remote-pi`), voice, mailNotifier });
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 });
69
107
  if (args.includes('--rotate-access-token')) {
70
108
  await server.auth.init();
71
109
  console.log(await server.auth.rotate());
72
110
  process.exit(0);
73
111
  }
112
+ if (args.includes('--create-voice-token')) {
113
+ const first = await server.auth.init();
114
+ if (first.token)
115
+ console.error(`Remote Pi pairing code (shown once; save it securely): ${first.token}`);
116
+ console.error('Draft-only voice token (shown once):');
117
+ console.log(await server.auth.rotateVoice());
118
+ process.exit(0);
119
+ }
120
+ if (args.includes('--revoke-voice-token')) {
121
+ const first = await server.auth.init();
122
+ if (first.token)
123
+ console.error(`Remote Pi pairing code (shown once; save it securely): ${first.token}`);
124
+ await server.auth.revokeVoice();
125
+ console.error('Voice token revoked.');
126
+ process.exit(0);
127
+ }
128
+ if (args.includes('--set-access-token') || args.includes('--set-access-token-env')) {
129
+ const fromEnv = args.includes('--set-access-token-env'), flag = fromEnv ? '--set-access-token-env' : '--set-access-token';
130
+ let token = value(flag, '');
131
+ if (!token) {
132
+ console.error(`${flag} requires a value`);
133
+ process.exit(1);
134
+ }
135
+ if (fromEnv) {
136
+ const name = token;
137
+ token = process.env[name] ?? '';
138
+ if (!token) {
139
+ console.error(`${flag} references missing or empty environment variable ${name}`);
140
+ process.exit(1);
141
+ }
142
+ }
143
+ try {
144
+ await server.auth.setToken(token);
145
+ console.log('Pairing code updated. Use it to pair Web UI clients.');
146
+ }
147
+ catch (error) {
148
+ console.error(error.message);
149
+ process.exit(1);
150
+ }
151
+ process.exit(0);
152
+ }
74
153
  if (args.includes('--print-access-token')) {
75
154
  console.error('The pairing code is only shown on first startup or rotation.');
76
155
  process.exit(1);
@@ -79,7 +158,7 @@ try {
79
158
  await server.start();
80
159
  const address = server.address();
81
160
  console.log(`Remote Pi is ready: http://${host}:${address?.port ?? port}`);
82
- console.log('Open this URL and enter the pairing code manually.');
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.');
83
162
  }
84
163
  catch (error) {
85
164
  console.error(`Failed to start Remote Pi: ${error.message}`);
package/dist/mcp.js ADDED
@@ -0,0 +1,113 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent';
4
+ const globalConfigPath = () => path.join(getAgentDir(), 'mcp.json');
5
+ const projectConfigPath = (cwd) => path.join(cwd, '.pi', 'mcp.json');
6
+ /** A project entry without transport fields only overrides settings of the global server. */
7
+ const isOverrideOnly = (config) => config.command === undefined && config.url === undefined && config.type === undefined;
8
+ function readConfig(file, errors) {
9
+ let text;
10
+ try {
11
+ text = readFileSync(file, 'utf8');
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ try {
17
+ const value = JSON.parse(text)?.mcpServers;
18
+ if (!value || typeof value !== 'object')
19
+ return {};
20
+ // One malformed entry must not hide the servers next to it.
21
+ const servers = {};
22
+ for (const [name, config] of Object.entries(value)) {
23
+ if (config && typeof config === 'object' && !Array.isArray(config))
24
+ servers[name] = config;
25
+ else
26
+ errors.push(`${file}: “${name}” is not a server configuration`);
27
+ }
28
+ return servers;
29
+ }
30
+ catch (error) {
31
+ errors.push(`${file}: ${error.message}`);
32
+ return {};
33
+ }
34
+ }
35
+ function summarize(entry) {
36
+ const config = entry.config, http = typeof config.url === 'string';
37
+ return {
38
+ name: entry.name,
39
+ enabled: config.enabled !== false,
40
+ exposure: typeof config.exposure === 'string' ? config.exposure : 'codemode',
41
+ transport: http ? 'http' : 'stdio',
42
+ target: http ? String(config.url) : [config.command, ...(Array.isArray(config.args) ? config.args : [])].filter(Boolean).join(' ').trim(),
43
+ description: typeof config.description === 'string' ? config.description : undefined,
44
+ scope: entry.scope,
45
+ source: entry.source,
46
+ override: entry.override
47
+ };
48
+ }
49
+ function load(cwd) {
50
+ const errors = [], globalFile = globalConfigPath(), projectFile = projectConfigPath(cwd);
51
+ let projectTrusted = false, explicitlyTrusted = false;
52
+ // Pi only reads project files such as .pi/mcp.json after the project was trusted interactively.
53
+ // A project without trust-requiring resources is read, but writing one into it would make Pi ask
54
+ // for trust first, so project overrides are only written where trust was actually granted.
55
+ try {
56
+ explicitlyTrusted = new ProjectTrustStore(getAgentDir()).get(cwd) === true;
57
+ projectTrusted = explicitlyTrusted || !hasTrustRequiringProjectResources(cwd);
58
+ }
59
+ catch { /* Untrusted is the safe default. */ }
60
+ const globals = readConfig(globalFile, errors), project = projectTrusted ? readConfig(projectFile, errors) : {};
61
+ const entries = new Map();
62
+ for (const [name, config] of Object.entries(globals))
63
+ entries.set(name, { name, config, scope: 'global', source: globalFile });
64
+ for (const [name, config] of Object.entries(project)) {
65
+ const existing = entries.get(name);
66
+ if (existing && isOverrideOnly(config))
67
+ entries.set(name, { ...existing, config: { ...existing.config, ...config }, override: projectFile });
68
+ else
69
+ entries.set(name, { name, config, scope: 'project', source: projectFile });
70
+ }
71
+ return { entries: [...entries.values()], globalFile, projectFile: projectTrusted ? projectFile : undefined, projectTrusted, explicitlyTrusted, errors };
72
+ }
73
+ /** Every configured MCP server for `cwd`, including disabled ones. */
74
+ export function listMcpServers(cwd) {
75
+ const config = load(cwd);
76
+ return { servers: config.entries.map(summarize), globalConfig: config.globalFile, projectConfig: config.projectFile, projectTrusted: config.projectTrusted, errors: config.errors };
77
+ }
78
+ /** Write one server's `enabled` flag into `file`, keeping the rest of the file as it is. */
79
+ function writeEnabled(file, name, enabled, explicit = false) {
80
+ // Only a missing file starts a new one: a file that cannot be read or parsed must not be overwritten.
81
+ let text;
82
+ try {
83
+ text = readFileSync(file, 'utf8');
84
+ }
85
+ catch (error) {
86
+ if (error.code !== 'ENOENT')
87
+ throw error;
88
+ }
89
+ const document = text?.trim() ? JSON.parse(text) : {};
90
+ const servers = document.mcpServers && typeof document.mcpServers === 'object' ? document.mcpServers : (document.mcpServers = {});
91
+ const current = servers[name] && typeof servers[name] === 'object' ? servers[name] : (servers[name] = {});
92
+ // `enabled: true` is the default, so it is dropped where the server itself is defined. A project
93
+ // override only changes what it states, so there the flag is always written out.
94
+ if (enabled && !explicit)
95
+ delete current.enabled;
96
+ else
97
+ current.enabled = enabled;
98
+ mkdirSync(path.dirname(file), { recursive: true });
99
+ writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
100
+ }
101
+ /**
102
+ * Enable or disable one server. A global server is changed with a project override when the project is
103
+ * trusted, which keeps the change local to this project, and in the global file otherwise.
104
+ */
105
+ export function setMcpServerEnabled(cwd, name, enabled) {
106
+ const config = load(cwd);
107
+ const entry = config.entries.find(item => item.name === name);
108
+ if (!entry)
109
+ throw Object.assign(new Error('MCP server not found'), { code: 'MCP_SERVER_NOT_FOUND' });
110
+ const asOverride = entry.scope === 'global' && config.explicitlyTrusted && !!config.projectFile;
111
+ writeEnabled(asOverride ? config.projectFile : entry.source, name, enabled, asOverride);
112
+ return listMcpServers(cwd);
113
+ }
@@ -45,6 +45,7 @@ export class MetadataStore {
45
45
  session_name TEXT,
46
46
  cwd TEXT NOT NULL,
47
47
  status TEXT NOT NULL,
48
+ profile TEXT NOT NULL DEFAULT 'default',
48
49
  created_at INTEGER NOT NULL,
49
50
  last_active_at INTEGER NOT NULL,
50
51
  archived_at INTEGER,
@@ -77,11 +78,19 @@ export class MetadataStore {
77
78
  CREATE INDEX IF NOT EXISTS sessions_workspace_active_recent ON sessions(workspace_id,last_active_at DESC,session_id DESC) WHERE archived_at IS NULL;
78
79
  CREATE INDEX IF NOT EXISTS sessions_cwd_active_recent ON sessions(cwd,last_active_at DESC,session_id DESC) WHERE archived_at IS NULL;
79
80
  `);
80
- db.pragma('user_version = 1');
81
+ // Additive migration: old normal Agents stay normal after an upgrade.
82
+ const agentColumns = db.prepare('PRAGMA table_info(agents)').all();
83
+ if (!agentColumns.some(column => column.name === 'profile'))
84
+ db.exec("ALTER TABLE agents ADD COLUMN profile TEXT NOT NULL DEFAULT 'default'");
85
+ // Only stamp the initial version: later migrations (collaboration tables) raise it and must not be reset on restart.
86
+ if (Number(db.pragma('user_version', { simple: true })) < 1)
87
+ db.pragma('user_version = 1');
81
88
  this.archivedAgents = new Set(db.prepare('SELECT id FROM agents WHERE archived_at IS NOT NULL').all().map(row => row.id));
82
89
  }
83
90
  get database() { if (!this.db)
84
91
  throw new Error('Metadata store is not initialized'); return this.db; }
92
+ /** Shared handle so collaboration tables live in the same file and the same backup. */
93
+ get connection() { return this.database; }
85
94
  getSetting(key) { const row = this.database.prepare('SELECT value FROM settings WHERE key=?').get(key); if (!row)
86
95
  return; try {
87
96
  return JSON.parse(row.value);
@@ -92,8 +101,8 @@ export class MetadataStore {
92
101
  setSetting(key, value) { this.database.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').run(key, JSON.stringify(value), Date.now()); }
93
102
  listWorkspaces() { return this.database.prepare('SELECT id,label,root_path,created_at FROM workspaces ORDER BY created_at,id').all().map(row => ({ id: row.id, label: row.label, rootPath: row.root_path, createdAt: new Date(row.created_at).toISOString() })); }
94
103
  saveWorkspace(workspace) { this.database.prepare(`INSERT INTO workspaces(id,label,root_path,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET label=excluded.label,root_path=excluded.root_path`).run(workspace.id, workspace.label, workspace.rootPath, asTime(workspace.createdAt)); }
95
- listAgents() { return this.database.prepare('SELECT * FROM agents WHERE archived_at IS NULL ORDER BY last_active_at DESC,id DESC').all().map(row => ({ agentId: row.id, workspaceId: row.workspace_id, sessionId: row.session_id, sessionFile: row.session_file ?? undefined, sessionName: row.session_name ?? undefined, cwd: row.cwd, status: row.status, createdAt: new Date(row.created_at).toISOString(), lastActiveAt: new Date(row.last_active_at).toISOString() })); }
96
- writeAgent(record, archivedAt) { this.database.prepare(`INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`).run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt); }
104
+ listAgents() { return this.database.prepare('SELECT * FROM agents WHERE archived_at IS NULL ORDER BY last_active_at DESC,id DESC').all().map(row => ({ agentId: row.id, workspaceId: row.workspace_id, sessionId: row.session_id, sessionFile: row.session_file ?? undefined, sessionName: row.session_name ?? undefined, cwd: row.cwd, status: row.status, profile: row.profile === 'collab' ? 'collab' : 'default', createdAt: new Date(row.created_at).toISOString(), lastActiveAt: new Date(row.last_active_at).toISOString() })); }
105
+ writeAgent(record, archivedAt) { this.database.prepare(`INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,profile,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,profile=excluded.profile,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`).run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, record.profile ?? 'default', asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt); }
97
106
  saveAgent(record) { if (this.archivedAgents.has(record.agentId))
98
107
  return; this.pendingAgents.delete(record.agentId); this.writeAgent(record, null); }
99
108
  scheduleAgent(record) { if (this.archivedAgents.has(record.agentId))