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.
- package/README.md +209 -18
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +575 -97
- package/dist/auth.js +85 -20
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +221 -0
- package/dist/collab/hub.js +2888 -0
- package/dist/collab/pi-extension.js +494 -0
- package/dist/collab/review-flow.js +459 -0
- package/dist/collab/routes.js +285 -0
- package/dist/collab/schemas.js +281 -0
- package/dist/collab/scoring-flow.js +336 -0
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +918 -0
- package/dist/collab/types.js +137 -0
- package/dist/collab/validate.js +185 -0
- package/dist/drafts.js +50 -0
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +95 -21
- package/dist/main.js +141 -17
- package/dist/mcp.js +132 -0
- package/dist/metadata-store.js +199 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +288 -49
- package/dist/server.js +558 -358
- package/dist/session-namer.js +163 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +196 -40
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +18 -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 +8 -2
- package/packages/protocol/websocket.schema.json +94 -10
- package/web/activity-view.js +121 -0
- package/web/app.js +3770 -404
- package/web/backend-state.js +269 -0
- package/web/collab.html +95 -0
- package/web/collab.js +1080 -0
- package/web/hid-keyboard.js +453 -0
- package/web/index.html +14 -9
- package/web/styles.css +73 -0
- package/ANDROID_APP_FEATURES.md +0 -256
package/dist/agents.js
CHANGED
|
@@ -2,122 +2,600 @@ import { EventEmitter } from 'node:events';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { relative as pathRelative } from 'node:path';
|
|
4
4
|
import { createSdkBackend } from './sdk-backend.js';
|
|
5
|
+
import { ActivityTracker } from './activity.js';
|
|
6
|
+
import { AgentFeed } from './agent-feed.js';
|
|
7
|
+
import { buildTimeline, itemBody, pageTimeline } from './timeline.js';
|
|
8
|
+
/**
|
|
9
|
+
* Reduce a Pi SDK event to the fields server-side consumers and clients use, or drop it.
|
|
10
|
+
* Heavy SDK events are whitelisted field by field (Pi attaches whole messages to many of
|
|
11
|
+
* them) so new SDK fields never leak into memory or onto the wire; pi2web's own events pass through.
|
|
12
|
+
*/
|
|
13
|
+
export function slimEvent(event) {
|
|
14
|
+
const e = event;
|
|
15
|
+
switch (event.type) {
|
|
16
|
+
// Streamed text and running tool output reach viewing clients through AgentFeed, not as events.
|
|
17
|
+
case 'message_update':
|
|
18
|
+
case 'tool_execution_update':
|
|
19
|
+
return undefined;
|
|
20
|
+
case 'message_start':
|
|
21
|
+
return { type: 'message_start', role: e.message?.role ?? e.role };
|
|
22
|
+
case 'turn_start':
|
|
23
|
+
case 'turn_end':
|
|
24
|
+
return { type: event.type };
|
|
25
|
+
case 'message_end':
|
|
26
|
+
return { type: 'message_end', message: e.message };
|
|
27
|
+
case 'agent_end':
|
|
28
|
+
return { type: 'agent_end', willRetry: e.willRetry, aborted: e.aborted };
|
|
29
|
+
case 'tool_execution_start':
|
|
30
|
+
return {
|
|
31
|
+
type: event.type,
|
|
32
|
+
toolCallId: e.toolCallId,
|
|
33
|
+
toolName: e.toolName,
|
|
34
|
+
args: e.args,
|
|
35
|
+
parentToolCallId: e.parentToolCallId
|
|
36
|
+
};
|
|
37
|
+
case 'tool_execution_end':
|
|
38
|
+
return {
|
|
39
|
+
type: event.type,
|
|
40
|
+
toolCallId: e.toolCallId,
|
|
41
|
+
toolName: e.toolName,
|
|
42
|
+
parentToolCallId: e.parentToolCallId,
|
|
43
|
+
result: e.result,
|
|
44
|
+
isError: e.isError,
|
|
45
|
+
durationMs: e.durationMs
|
|
46
|
+
};
|
|
47
|
+
default:
|
|
48
|
+
return event;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
5
51
|
/** Deterministic backend used by the server and tests. The SDK adapter can implement the same contract. */
|
|
6
52
|
export class MockBackend {
|
|
53
|
+
options;
|
|
7
54
|
emitter = new EventEmitter();
|
|
8
55
|
state;
|
|
9
|
-
|
|
56
|
+
entries = [];
|
|
10
57
|
timer;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
58
|
+
queue = { steering: [], followUp: [] };
|
|
59
|
+
constructor(agentId, cwd, sessionId = randomUUID(), options = {}) {
|
|
60
|
+
this.options = options;
|
|
61
|
+
this.state = { agentId, sessionId, cwd, status: 'idle' };
|
|
62
|
+
}
|
|
63
|
+
emit(e) {
|
|
64
|
+
this.emitter.emit('event', e);
|
|
65
|
+
}
|
|
66
|
+
/** Persist like Pi: append a session entry after listeners have seen the message. */
|
|
67
|
+
append(message) {
|
|
68
|
+
const id = randomUUID().slice(0, 8);
|
|
69
|
+
this.entries.push({
|
|
70
|
+
type: 'message',
|
|
71
|
+
id,
|
|
72
|
+
parentId: this.entries.at(-1)?.id ?? null,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
message: { timestamp: Date.now(), ...message }
|
|
75
|
+
});
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
/** The body of a turn. Tests override it to keep a run streaming while they inspect queued input. */
|
|
79
|
+
runTurn() {
|
|
80
|
+
return new Promise(r => {
|
|
81
|
+
this.timer = setTimeout(r, 0);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async prompt(message) {
|
|
85
|
+
if (this.state.status === 'streaming')
|
|
86
|
+
throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' });
|
|
87
|
+
this.state.status = 'streaming';
|
|
88
|
+
this.emit({ type: 'agent_start', message });
|
|
89
|
+
await this.runTurn();
|
|
90
|
+
const user = { role: 'user', content: message };
|
|
91
|
+
this.emit({ type: 'message_end', message: user });
|
|
92
|
+
this.append(user);
|
|
93
|
+
const text = `Mock response: ${message}`, assistant = { role: 'assistant', content: [{ type: 'text', text }] };
|
|
94
|
+
this.emit({ type: 'message_start', message: { role: 'assistant', content: [] } });
|
|
95
|
+
this.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: text } });
|
|
96
|
+
this.emit({ type: 'message_end', message: assistant });
|
|
97
|
+
this.append(assistant);
|
|
20
98
|
this.state.status = 'idle';
|
|
21
|
-
this.emit({ type: 'agent_end'
|
|
22
|
-
}
|
|
23
|
-
async
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
99
|
+
this.emit({ type: 'agent_end' });
|
|
100
|
+
}
|
|
101
|
+
async steer(message) {
|
|
102
|
+
const queued = this.state.status === 'streaming';
|
|
103
|
+
if (queued)
|
|
104
|
+
this.queue.steering.push(message);
|
|
105
|
+
this.emit({ type: 'steer', message });
|
|
106
|
+
if (queued)
|
|
107
|
+
this.emitQueue();
|
|
108
|
+
return queued ? 'queued' : 'handled';
|
|
109
|
+
}
|
|
110
|
+
async followUp(message) {
|
|
111
|
+
const queued = this.state.status === 'streaming';
|
|
112
|
+
if (queued)
|
|
113
|
+
this.queue.followUp.push(message);
|
|
114
|
+
this.emit({ type: 'follow_up', message });
|
|
115
|
+
if (queued)
|
|
116
|
+
this.emitQueue();
|
|
117
|
+
return queued ? 'queued' : 'handled';
|
|
118
|
+
}
|
|
119
|
+
async getQueue() {
|
|
120
|
+
return { steering: [...this.queue.steering], followUp: [...this.queue.followUp] };
|
|
121
|
+
}
|
|
122
|
+
async clearQueue() {
|
|
123
|
+
const cleared = await this.getQueue();
|
|
124
|
+
this.queue = { steering: [], followUp: [] };
|
|
125
|
+
this.emitQueue();
|
|
126
|
+
return cleared;
|
|
127
|
+
}
|
|
128
|
+
emitQueue() {
|
|
129
|
+
this.emit({ type: 'queue_update', steering: [...this.queue.steering], followUp: [...this.queue.followUp] });
|
|
130
|
+
}
|
|
131
|
+
async abort() {
|
|
132
|
+
if (this.state.status === 'streaming') {
|
|
133
|
+
if (this.timer)
|
|
134
|
+
clearTimeout(this.timer);
|
|
135
|
+
this.state.status = 'idle';
|
|
136
|
+
this.emit({ type: 'agent_end', aborted: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async getState() {
|
|
140
|
+
return { ...this.state };
|
|
141
|
+
}
|
|
142
|
+
timeline() {
|
|
143
|
+
return {
|
|
144
|
+
sessionId: this.state.sessionId,
|
|
145
|
+
leafId: this.entries.at(-1)?.id ?? null,
|
|
146
|
+
entries: () => [...this.entries]
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async getSystemPrompt() {
|
|
150
|
+
const template = this.options.piDefaultPrompt ? undefined : this.options.template;
|
|
151
|
+
const base = template?.mode === 'replace' ? template.systemPrompt : 'Mock Pi system prompt';
|
|
152
|
+
return {
|
|
153
|
+
systemPrompt: [base, template?.mode === 'append' ? template.systemPrompt : '', `cwd: ${this.state.cwd}`]
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join('\n\n'),
|
|
156
|
+
appendFiles: [],
|
|
157
|
+
contextFiles: []
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async getCapabilities() {
|
|
161
|
+
return { model: null, models: [], thinkingLevel: 'off', thinkingLevels: ['off'], supportsThinking: false };
|
|
162
|
+
}
|
|
163
|
+
async getSession() {
|
|
164
|
+
return { ...(await this.getSessionInfo()), entries: [...this.entries], tree: [] };
|
|
165
|
+
}
|
|
166
|
+
async getSessionInfo() {
|
|
167
|
+
return {
|
|
168
|
+
sessionId: this.state.sessionId,
|
|
169
|
+
sessionName: this.state.sessionName,
|
|
170
|
+
stats: { totalMessages: this.entries.length, tokens: { total: 0 }, cost: 0 },
|
|
171
|
+
contextUsage: { tokens: null, contextWindow: 0, percent: null }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async compact() {
|
|
175
|
+
return { summary: 'mock' };
|
|
176
|
+
}
|
|
29
177
|
async setModel(_provider, _modelId) { }
|
|
30
178
|
async setThinkingLevel(_level) { }
|
|
31
|
-
async setSessionName(name) {
|
|
32
|
-
|
|
33
|
-
|
|
179
|
+
async setSessionName(name) {
|
|
180
|
+
this.state.sessionName = name;
|
|
181
|
+
this.emit({ type: 'session_info_changed', name });
|
|
182
|
+
}
|
|
183
|
+
/** Move the leaf back to an earlier entry, like rewinding a Pi session. */
|
|
184
|
+
async navigate(entryId) {
|
|
185
|
+
const index = this.entries.findIndex(entry => entry.id === entryId);
|
|
186
|
+
if (index >= 0)
|
|
187
|
+
this.entries = this.entries.slice(0, index + 1);
|
|
188
|
+
return { cancelled: false };
|
|
189
|
+
}
|
|
190
|
+
async fork(_entryId) {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
34
193
|
async extensionResponse(_requestId, _value) { }
|
|
35
|
-
|
|
36
|
-
|
|
194
|
+
async listCommands() {
|
|
195
|
+
return [{ name: 'mock', description: 'Mock prompt template', source: 'prompt' }];
|
|
196
|
+
}
|
|
197
|
+
async runBash(command, excludeFromContext = false) {
|
|
198
|
+
const id = randomUUID(), output = `Mock bash: ${command}`;
|
|
199
|
+
this.emit({ type: 'bash_execution_start', id, command });
|
|
200
|
+
this.emit({ type: 'bash_execution_update', id, delta: `${output}\n` });
|
|
201
|
+
const result = { id, output, exitCode: 0, cancelled: false, truncated: false };
|
|
202
|
+
this.append({
|
|
203
|
+
role: 'bashExecution',
|
|
204
|
+
command,
|
|
205
|
+
output,
|
|
206
|
+
exitCode: 0,
|
|
207
|
+
cancelled: false,
|
|
208
|
+
truncated: false,
|
|
209
|
+
excludeFromContext
|
|
210
|
+
});
|
|
211
|
+
this.emit({ type: 'bash_execution_end', command, ...result });
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
abortBash() { }
|
|
215
|
+
subscribe(listener) {
|
|
216
|
+
this.emitter.on('event', listener);
|
|
217
|
+
return () => this.emitter.off('event', listener);
|
|
218
|
+
}
|
|
219
|
+
async dispose() {
|
|
220
|
+
await this.abort();
|
|
221
|
+
this.state.status = 'stopped';
|
|
222
|
+
this.emitter.removeAllListeners();
|
|
223
|
+
}
|
|
37
224
|
}
|
|
225
|
+
const toStringList = (value) => Array.isArray(value) ? value.filter((x) => typeof x === 'string') : [];
|
|
226
|
+
const emptyQueue = () => ({ steering: [], followUp: [] });
|
|
227
|
+
const LIFECYCLE_ONLY = new Set(['agent_loaded', 'agent_unloaded']);
|
|
228
|
+
/** A pending dialog blocks the run until a human answers it, which is a different wait than streaming. */
|
|
229
|
+
const runStatus = (entry, streaming) => entry.dialogRequests.size ? 'waiting_for_user' : streaming ? 'streaming' : 'idle';
|
|
38
230
|
export class AgentManager {
|
|
39
231
|
workspaces;
|
|
40
232
|
factory;
|
|
41
233
|
agents = new Map();
|
|
42
|
-
|
|
234
|
+
detailListeners = new Map();
|
|
43
235
|
globalListeners = new Set();
|
|
44
|
-
constructor(workspaces, factory = createSdkBackend) {
|
|
236
|
+
constructor(workspaces, factory = (id, cwd, sessionFile, profile, options) => createSdkBackend(id, cwd, sessionFile, profile, undefined, options)) {
|
|
45
237
|
this.workspaces = workspaces;
|
|
46
238
|
this.factory = factory;
|
|
47
239
|
}
|
|
48
|
-
async
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
async
|
|
240
|
+
async resolveCwd(workspaceId, relativeCwd = '.') {
|
|
241
|
+
const ws = this.workspaces.get(workspaceId);
|
|
242
|
+
if (!ws)
|
|
243
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
244
|
+
const cwd = await this.workspaces.resolve(ws, relativeCwd);
|
|
245
|
+
if (!(await import('node:fs/promises')).stat(cwd).then(s => s.isDirectory()))
|
|
246
|
+
throw new Error('cwd is not a directory');
|
|
247
|
+
return cwd;
|
|
248
|
+
}
|
|
249
|
+
async create(workspaceId, relativeCwd = '.', sessionFile, profile = 'default', template) {
|
|
250
|
+
const cwd = await this.resolveCwd(workspaceId, relativeCwd);
|
|
251
|
+
const id = `agent-${randomUUID()}`, backend = await this.factory(id, cwd, sessionFile, profile, template ? { template } : {}), now = new Date().toISOString();
|
|
252
|
+
const record = {
|
|
253
|
+
...(await backend.getState()),
|
|
254
|
+
workspaceId,
|
|
255
|
+
profile,
|
|
256
|
+
...(template ? { template: { ...template } } : {}),
|
|
257
|
+
createdAt: now,
|
|
258
|
+
lastActiveAt: now
|
|
259
|
+
};
|
|
260
|
+
this.attach(id, record, backend);
|
|
261
|
+
return { ...record };
|
|
262
|
+
}
|
|
263
|
+
/** Build a disposable session so the UI sees the models actually available for this project and its extensions. */
|
|
264
|
+
async capabilityPreview(workspaceId, relativeCwd = '.') {
|
|
265
|
+
const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd);
|
|
58
266
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const sessionModified = saved.sessionFile ? await import('node:fs/promises').then(fs => fs.stat(saved.sessionFile).then(value => value.mtime.toISOString()).catch(() => undefined)) : undefined;
|
|
64
|
-
const record = { ...saved, cwd, status: 'unloaded', lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt };
|
|
65
|
-
this.agents.set(saved.agentId, { record, events: [], nextSequence: 1 });
|
|
267
|
+
return await backend.getCapabilities();
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await backend.dispose();
|
|
66
271
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The system prompt an Agent created here would start with. `template` previews a template;
|
|
275
|
+
* `piDefaultPrompt` ignores SYSTEM.md / APPEND_SYSTEM.md to show Pi's built-in prompt.
|
|
276
|
+
*/
|
|
277
|
+
async systemPromptPreview(workspaceId, relativeCwd = '.', options = {}) {
|
|
278
|
+
const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd, undefined, 'default', options);
|
|
279
|
+
try {
|
|
280
|
+
return { ...(await backend.getSystemPrompt()), cwd };
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await backend.dispose();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
connect(entry, backend) {
|
|
287
|
+
entry.backend = backend;
|
|
288
|
+
entry.feed?.close();
|
|
289
|
+
const agentId = entry.record.agentId, feed = new AgentFeed(() => backend.timeline(), message => {
|
|
290
|
+
for (const listener of this.detailListeners.get(agentId) ?? [])
|
|
291
|
+
listener(message);
|
|
292
|
+
});
|
|
293
|
+
entry.feed = feed;
|
|
294
|
+
backend.subscribe(event => {
|
|
295
|
+
if (entry.backend !== backend)
|
|
296
|
+
return;
|
|
297
|
+
const record = entry.record, type = event.type;
|
|
298
|
+
if (type === 'agent_start' || type === 'auto_retry_start')
|
|
299
|
+
entry.streaming = true;
|
|
300
|
+
else if (type === 'agent_end')
|
|
301
|
+
entry.streaming = event.willRetry === true;
|
|
302
|
+
else if (type === 'agent_settled')
|
|
303
|
+
entry.streaming = false;
|
|
304
|
+
if (type === 'queue_update')
|
|
305
|
+
entry.queue = { steering: toStringList(event.steering), followUp: toStringList(event.followUp) };
|
|
306
|
+
const activityChanged = entry.activity.apply(event);
|
|
307
|
+
// Keep dialog payloads so reconnecting clients can rebuild their controls.
|
|
308
|
+
if (typeof event.requestId === 'string') {
|
|
309
|
+
if (type === 'extension_ui_request')
|
|
310
|
+
entry.dialogRequests.set(event.requestId, event);
|
|
311
|
+
else if (type === 'extension_ui_response')
|
|
312
|
+
entry.dialogRequests.delete(event.requestId);
|
|
313
|
+
}
|
|
314
|
+
record.status = runStatus(entry, entry.streaming === true);
|
|
315
|
+
if (type === 'session_info_changed')
|
|
316
|
+
record.sessionName =
|
|
317
|
+
typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined;
|
|
318
|
+
feed.handle(event);
|
|
319
|
+
const slim = slimEvent(event);
|
|
320
|
+
if (slim)
|
|
321
|
+
this.publish(entry, slim);
|
|
322
|
+
if (activityChanged)
|
|
323
|
+
this.publishActivity(entry);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
publishActivity(entry) {
|
|
327
|
+
this.publish(entry, { type: 'activity_changed', activity: entry.activity.snapshot() });
|
|
328
|
+
}
|
|
329
|
+
/** Loading or unloading a backend is not activity: it must not reorder the Agent list. */
|
|
330
|
+
publish(entry, event) {
|
|
331
|
+
const record = entry.record;
|
|
332
|
+
if (!LIFECYCLE_ONLY.has(event.type))
|
|
333
|
+
record.lastActiveAt = new Date().toISOString();
|
|
334
|
+
// Every event carries the authoritative status, so clients never have to derive it from event types.
|
|
335
|
+
const item = { timestamp: Date.now(), status: record.status, event };
|
|
336
|
+
for (const listener of this.globalListeners)
|
|
337
|
+
listener(record.agentId, item);
|
|
338
|
+
}
|
|
339
|
+
attach(id, record, backend) {
|
|
340
|
+
const entry = {
|
|
341
|
+
record,
|
|
342
|
+
backend,
|
|
343
|
+
activity: new ActivityTracker(),
|
|
344
|
+
dialogRequests: new Map(),
|
|
345
|
+
queue: emptyQueue()
|
|
346
|
+
};
|
|
80
347
|
this.connect(entry, backend);
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
348
|
+
this.agents.set(id, entry);
|
|
349
|
+
}
|
|
350
|
+
async restore(records) {
|
|
351
|
+
for (const saved of records) {
|
|
352
|
+
try {
|
|
353
|
+
const ws = this.workspaces.get(saved.workspaceId);
|
|
354
|
+
if (!ws)
|
|
355
|
+
continue;
|
|
356
|
+
const cwd = await this.workspaces.resolve(ws, pathRelative(ws.rootPath, saved.cwd));
|
|
357
|
+
const sessionModified = saved.sessionFile
|
|
358
|
+
? await import('node:fs/promises').then(fs => fs
|
|
359
|
+
.stat(saved.sessionFile)
|
|
360
|
+
.then(value => value.mtime.toISOString())
|
|
361
|
+
.catch(() => undefined))
|
|
362
|
+
: undefined;
|
|
363
|
+
const record = {
|
|
364
|
+
...saved,
|
|
365
|
+
profile: saved.profile ?? 'default',
|
|
366
|
+
cwd,
|
|
367
|
+
status: 'unloaded',
|
|
368
|
+
lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt
|
|
369
|
+
};
|
|
370
|
+
this.agents.set(saved.agentId, {
|
|
371
|
+
record,
|
|
372
|
+
activity: new ActivityTracker(),
|
|
373
|
+
dialogRequests: new Map(),
|
|
374
|
+
queue: emptyQueue()
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
/* Keep server startup resilient to deleted workspaces. */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
get(id) {
|
|
383
|
+
const x = this.agents.get(id);
|
|
384
|
+
if (!x)
|
|
385
|
+
throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' });
|
|
386
|
+
return x;
|
|
387
|
+
}
|
|
388
|
+
list() {
|
|
389
|
+
return [...this.agents.values()].map(x => ({ ...x.record }));
|
|
390
|
+
}
|
|
391
|
+
isLoaded(id) {
|
|
392
|
+
return !!this.get(id).backend;
|
|
393
|
+
}
|
|
394
|
+
async ensureLoaded(id) {
|
|
395
|
+
const entry = this.get(id);
|
|
396
|
+
if (entry.backend)
|
|
397
|
+
return entry.backend;
|
|
398
|
+
if (entry.record.status === 'stopped')
|
|
399
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
|
|
400
|
+
if (entry.loading)
|
|
401
|
+
return entry.loading;
|
|
402
|
+
entry.record.status = 'starting';
|
|
403
|
+
entry.loading = (async () => {
|
|
404
|
+
try {
|
|
405
|
+
const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile, entry.record.template ? { template: entry.record.template } : {});
|
|
406
|
+
const state = await backend.getState();
|
|
407
|
+
entry.record = {
|
|
408
|
+
...entry.record,
|
|
409
|
+
...state,
|
|
410
|
+
sessionName: state.sessionName?.trim() || entry.record.sessionName,
|
|
411
|
+
status: state.status === 'stopped' ? 'idle' : state.status
|
|
412
|
+
};
|
|
413
|
+
this.connect(entry, backend);
|
|
414
|
+
// The status moved from unloaded to idle without a Pi event; tell clients.
|
|
415
|
+
this.publish(entry, { type: 'agent_loaded' });
|
|
416
|
+
return backend;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
entry.record.status = 'error';
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
entry.loading = undefined;
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
return entry.loading;
|
|
427
|
+
}
|
|
428
|
+
async command(id, kind, message = '') {
|
|
429
|
+
const entry = this.get(id);
|
|
430
|
+
if (kind === 'abort' && !entry.backend)
|
|
431
|
+
return;
|
|
432
|
+
if (entry.record.status === 'stopped')
|
|
433
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
|
|
434
|
+
const backend = await this.ensureLoaded(id);
|
|
435
|
+
if (kind === 'abort') {
|
|
436
|
+
if (entry.activity.stopping())
|
|
437
|
+
this.publishActivity(entry);
|
|
438
|
+
return backend.abort();
|
|
439
|
+
}
|
|
440
|
+
return backend[kind === 'follow-up' ? 'followUp' : kind](message);
|
|
441
|
+
}
|
|
442
|
+
/** Queued steering and follow-up messages waiting for the running turn. */
|
|
443
|
+
// Both read through to the backend and leave the cache to `queue_update`, which stays correct when a
|
|
444
|
+
// message is queued while these run.
|
|
445
|
+
async queue(id) {
|
|
446
|
+
const entry = this.get(id);
|
|
447
|
+
return entry.backend
|
|
448
|
+
? entry.backend.getQueue()
|
|
449
|
+
: { steering: [...entry.queue.steering], followUp: [...entry.queue.followUp] };
|
|
450
|
+
}
|
|
451
|
+
/** Drop queued messages and return them so the caller can restore them to its composer. */
|
|
452
|
+
async clearQueue(id) {
|
|
453
|
+
const entry = this.get(id);
|
|
454
|
+
return entry.backend ? entry.backend.clearQueue() : emptyQueue();
|
|
455
|
+
}
|
|
456
|
+
snapshotState(id) {
|
|
457
|
+
const a = this.get(id);
|
|
458
|
+
return {
|
|
459
|
+
...a.record,
|
|
460
|
+
activity: {
|
|
461
|
+
...a.activity.snapshot(),
|
|
462
|
+
dialogRequests: [...a.dialogRequests.values()],
|
|
463
|
+
queue: { steering: [...a.queue.steering], followUp: [...a.queue.followUp] }
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Start viewing an Agent in detail: load it, then subscribe and take a snapshot together, with
|
|
469
|
+
* nothing in between, so every message the listener gets is newer than the snapshot.
|
|
470
|
+
*/
|
|
471
|
+
async focus(id, listener, limit) {
|
|
472
|
+
await this.ensureLoaded(id);
|
|
473
|
+
const entry = this.get(id);
|
|
474
|
+
// Snapshot first: it publishes pending changes, which it includes, before this listener exists.
|
|
475
|
+
const snapshot = {
|
|
476
|
+
...entry.feed.snapshot(limit),
|
|
477
|
+
// Dialog controls live in the transcript, which a viewer replaces when it starts viewing.
|
|
478
|
+
dialogRequests: [...entry.dialogRequests.values()]
|
|
479
|
+
};
|
|
480
|
+
if (!this.detailListeners.has(id))
|
|
481
|
+
this.detailListeners.set(id, new Set());
|
|
482
|
+
this.detailListeners.get(id).add(listener);
|
|
483
|
+
return { unsubscribe: () => this.detailListeners.get(id)?.delete(listener), snapshot };
|
|
484
|
+
}
|
|
485
|
+
toolOutput(id, toolCallId) {
|
|
486
|
+
return this.get(id).feed?.output(toolCallId);
|
|
487
|
+
}
|
|
488
|
+
/** Events of every Agent: server-side consumers and the overview stream. */
|
|
489
|
+
subscribeAll(listener) {
|
|
490
|
+
this.globalListeners.add(listener);
|
|
491
|
+
return () => this.globalListeners.delete(listener);
|
|
492
|
+
}
|
|
493
|
+
async dispose(id) {
|
|
494
|
+
const a = this.get(id);
|
|
495
|
+
if (a.loading)
|
|
496
|
+
await a.loading.catch(() => { });
|
|
497
|
+
await a.backend?.dispose();
|
|
498
|
+
a.backend = undefined;
|
|
499
|
+
a.feed?.close();
|
|
500
|
+
a.feed = undefined;
|
|
501
|
+
a.record.status = 'unloaded';
|
|
502
|
+
a.streaming = false;
|
|
503
|
+
a.dialogRequests.clear();
|
|
504
|
+
a.activity.reset(); // agent_unloaded below tells clients the activity is gone.
|
|
505
|
+
a.queue = emptyQueue();
|
|
506
|
+
this.publish(a, { type: 'agent_unloaded' });
|
|
507
|
+
}
|
|
508
|
+
async remove(id) {
|
|
509
|
+
await this.dispose(id);
|
|
510
|
+
this.agents.delete(id);
|
|
511
|
+
this.detailListeners.delete(id);
|
|
512
|
+
}
|
|
513
|
+
async archive(id) {
|
|
514
|
+
const current = this.get(id).record;
|
|
515
|
+
if (['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(current.status))
|
|
516
|
+
throw Object.assign(new Error('Active Agent cannot be archived'), { code: 'AGENT_ACTIVE' });
|
|
517
|
+
const record = { ...current, status: 'unloaded' };
|
|
518
|
+
await this.remove(id);
|
|
519
|
+
return record;
|
|
520
|
+
}
|
|
521
|
+
// The record status, not the backend's, is authoritative: it also knows about pending dialogs.
|
|
522
|
+
async state(id) {
|
|
523
|
+
const state = await (await this.ensureLoaded(id)).getState(), record = this.get(id).record;
|
|
524
|
+
return { ...state, status: record.status, sessionName: state.sessionName?.trim() || record.sessionName };
|
|
525
|
+
}
|
|
526
|
+
/** Older history: the page before item `before` (or the newest one). Live viewers get it from focus(). */
|
|
527
|
+
async timeline(id, limit, before) {
|
|
528
|
+
const source = (await this.ensureLoaded(id)).timeline(), page = pageTimeline(buildTimeline(source.entries()), limit, before);
|
|
529
|
+
if (!page)
|
|
530
|
+
throw Object.assign(new Error('Timeline item not found'), { code: 'TIMELINE_ITEM_NOT_FOUND' });
|
|
531
|
+
return { sessionId: source.sessionId, ...page };
|
|
532
|
+
}
|
|
533
|
+
/** The full body of one timeline item of the given session. It never changes. */
|
|
534
|
+
async item(id, sessionId, itemId) {
|
|
535
|
+
const source = (await this.ensureLoaded(id)).timeline(), body = source.sessionId === sessionId ? itemBody(buildTimeline(source.entries()), itemId) : undefined;
|
|
536
|
+
if (body === undefined)
|
|
537
|
+
throw Object.assign(new Error('Timeline item not found'), { code: 'TIMELINE_ITEM_NOT_FOUND' });
|
|
538
|
+
return { id: itemId, body };
|
|
539
|
+
}
|
|
540
|
+
async systemPrompt(id) {
|
|
541
|
+
const info = await (await this.ensureLoaded(id)).getSystemPrompt();
|
|
542
|
+
return { ...info, template: this.get(id).record.template ?? null };
|
|
543
|
+
}
|
|
544
|
+
async capabilities(id) {
|
|
545
|
+
return (await this.ensureLoaded(id)).getCapabilities();
|
|
546
|
+
}
|
|
547
|
+
async session(id) {
|
|
548
|
+
const info = await (await this.ensureLoaded(id)).getSession(), name = typeof info.sessionName === 'string' && info.sessionName.trim()
|
|
549
|
+
? info.sessionName
|
|
550
|
+
: this.get(id).record.sessionName;
|
|
551
|
+
return { ...info, sessionName: name };
|
|
552
|
+
}
|
|
553
|
+
async sessionInfo(id) {
|
|
554
|
+
const info = await (await this.ensureLoaded(id)).getSessionInfo(), name = typeof info.sessionName === 'string' && info.sessionName.trim()
|
|
555
|
+
? info.sessionName
|
|
556
|
+
: this.get(id).record.sessionName;
|
|
557
|
+
return { ...info, sessionName: name };
|
|
558
|
+
}
|
|
559
|
+
async compact(id, instructions) {
|
|
560
|
+
return (await this.ensureLoaded(id)).compact(instructions);
|
|
561
|
+
}
|
|
562
|
+
async setModel(id, provider, modelId) {
|
|
563
|
+
return (await this.ensureLoaded(id)).setModel(provider, modelId);
|
|
564
|
+
}
|
|
565
|
+
async setThinkingLevel(id, level) {
|
|
566
|
+
return (await this.ensureLoaded(id)).setThinkingLevel(level);
|
|
567
|
+
}
|
|
568
|
+
async setSessionName(id, name) {
|
|
569
|
+
await (await this.ensureLoaded(id)).setSessionName(name);
|
|
570
|
+
this.get(id).record.sessionName = name.trim();
|
|
571
|
+
}
|
|
572
|
+
async navigate(id, entryId) {
|
|
573
|
+
const result = await (await this.ensureLoaded(id)).navigate(entryId);
|
|
574
|
+
this.get(id).feed?.sync(); // Moving the leaf back is no event; tell viewers to reload.
|
|
575
|
+
return result;
|
|
576
|
+
}
|
|
577
|
+
async fork(id, entryId) {
|
|
578
|
+
const source = this.get(id), result = await (await this.ensureLoaded(id)).fork(entryId);
|
|
579
|
+
if (!result.sessionFile)
|
|
580
|
+
throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
|
|
581
|
+
const ws = this.workspaces.get(source.record.workspaceId);
|
|
582
|
+
const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile, source.record.template);
|
|
583
|
+
return { agent, selectedText: result.selectedText };
|
|
584
|
+
}
|
|
585
|
+
async extensionResponse(id, requestId, value) {
|
|
586
|
+
return (await this.ensureLoaded(id)).extensionResponse(requestId, value);
|
|
587
|
+
}
|
|
588
|
+
async listCommands(id) {
|
|
589
|
+
return (await this.ensureLoaded(id)).listCommands();
|
|
590
|
+
}
|
|
591
|
+
/** Session-scoped bash. Output is streamed as `bash_execution_update` events and recorded in the Session. */
|
|
592
|
+
async runBash(id, command, excludeFromContext = false) {
|
|
593
|
+
return (await this.ensureLoaded(id)).runBash(command, excludeFromContext);
|
|
594
|
+
}
|
|
595
|
+
async abortBash(id) {
|
|
596
|
+
const entry = this.get(id);
|
|
597
|
+
if (entry.loading)
|
|
598
|
+
await entry.loading;
|
|
599
|
+
entry.backend?.abortBash();
|
|
600
|
+
}
|
|
123
601
|
}
|