pi2web 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -17
- package/dist/agents.js +126 -25
- package/dist/auth.js +83 -20
- package/dist/collab/dispatcher.js +186 -0
- package/dist/collab/hub.js +1880 -0
- package/dist/collab/pi-extension.js +237 -0
- package/dist/collab/review-flow.js +310 -0
- package/dist/collab/routes.js +214 -0
- package/dist/collab/schemas.js +225 -0
- package/dist/collab/scoring-flow.js +230 -0
- package/dist/collab/store.js +563 -0
- package/dist/collab/types.js +53 -0
- package/dist/collab/validate.js +148 -0
- package/dist/drafts.js +30 -0
- package/dist/mail-notifier.js +36 -8
- package/dist/main.js +83 -4
- package/dist/mcp.js +113 -0
- package/dist/metadata-store.js +12 -3
- package/dist/sdk-backend.js +82 -19
- package/dist/server.js +434 -58
- package/dist/session-namer.js +114 -0
- package/dist/voice.js +47 -9
- package/package.json +8 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +2 -0
- package/packages/protocol/websocket.schema.json +47 -6
- package/web/app.js +1187 -176
- package/web/backend-state.js +237 -0
- package/web/collab.html +95 -0
- package/web/collab.js +667 -0
- package/web/hid-keyboard.js +420 -0
- package/web/index.html +11 -9
- package/web/styles.css +59 -0
- package/ANDROID_APP_FEATURES.md +0 -256
|
@@ -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
|
+
}
|
package/dist/mail-notifier.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
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 --
|
|
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
|
+
}
|
package/dist/metadata-store.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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))
|