pi2web 0.2.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +44 -5
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +518 -141
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +190 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +221 -45
  33. package/dist/server.js +444 -620
  34. package/dist/session-namer.js +66 -17
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +153 -35
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +11 -1
  43. package/packages/protocol/openapi.yaml +6 -2
  44. package/packages/protocol/websocket.schema.json +58 -15
  45. package/web/activity-view.js +121 -0
  46. package/web/app.js +3098 -743
  47. package/web/backend-state.js +59 -27
  48. package/web/collab.js +668 -255
  49. package/web/hid-keyboard.js +173 -140
  50. package/web/index.html +4 -1
  51. package/web/styles.css +14 -0
package/dist/agents.js CHANGED
@@ -2,79 +2,295 @@ 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
- messages = [];
56
+ entries = [];
10
57
  timer;
11
58
  queue = { steering: [], followUp: [] };
12
- constructor(agentId, cwd, sessionId = randomUUID()) { this.state = { agentId, sessionId, cwd, status: 'idle' }; }
13
- emit(e) { this.emitter.emit('event', e); }
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
+ }
14
78
  /** The body of a turn. Tests override it to keep a run streaming while they inspect queued input. */
15
- runTurn() { return new Promise(r => { this.timer = setTimeout(r, 0); }); }
16
- async prompt(message) { if (this.state.status === 'streaming')
17
- throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' }); this.state.status = 'streaming'; this.emit({ type: 'agent_start', message }); await this.runTurn(); this.messages.push({ role: 'user', content: message }); this.emit({ type: 'message_start', role: 'assistant' }); this.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: `Mock response: ${message}` } }); this.emit({ type: 'message_end', message: `Mock response: ${message}` }); this.state.status = 'idle'; this.emit({ type: 'agent_end' }); }
18
- async steer(message) { const queued = this.state.status === 'streaming'; if (queued)
19
- this.queue.steering.push(message); this.emit({ type: 'steer', message }); if (queued)
20
- this.emitQueue(); return queued ? 'queued' : 'handled'; }
21
- async followUp(message) { const queued = this.state.status === 'streaming'; if (queued)
22
- this.queue.followUp.push(message); this.emit({ type: 'follow_up', message }); if (queued)
23
- this.emitQueue(); return queued ? 'queued' : 'handled'; }
24
- async getQueue() { return { steering: [...this.queue.steering], followUp: [...this.queue.followUp] }; }
25
- async clearQueue() { const cleared = await this.getQueue(); this.queue = { steering: [], followUp: [] }; this.emitQueue(); return cleared; }
26
- emitQueue() { this.emit({ type: 'queue_update', steering: [...this.queue.steering], followUp: [...this.queue.followUp] }); }
27
- async abort() { if (this.state.status === 'streaming') {
28
- if (this.timer)
29
- clearTimeout(this.timer);
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);
30
98
  this.state.status = 'idle';
31
- this.emit({ type: 'agent_end', aborted: true });
32
- } }
33
- async getState() { return { ...this.state }; }
34
- async getMessages() { return [...this.messages]; }
35
- async getCapabilities() { return { model: null, models: [], thinkingLevel: 'off', thinkingLevels: ['off'], supportsThinking: false }; }
36
- async getSession() { return { ...await this.getSessionInfo(), entries: [], tree: [] }; }
37
- async getSessionInfo() { return { sessionId: this.state.sessionId, sessionName: this.state.sessionName, stats: { totalMessages: this.messages.length, tokens: { total: 0 }, cost: 0 }, contextUsage: { tokens: null, contextWindow: 0, percent: null } }; }
38
- async compact() { return { summary: 'mock' }; }
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
+ }
39
177
  async setModel(_provider, _modelId) { }
40
178
  async setThinkingLevel(_level) { }
41
- async setSessionName(name) { this.state.sessionName = name; this.emit({ type: 'session_info_changed', name }); }
42
- async navigate(_entryId) { return { cancelled: false }; }
43
- async fork(_entryId) { return {}; }
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
+ }
44
193
  async extensionResponse(_requestId, _value) { }
45
- async listCommands() { return [{ name: 'mock', description: 'Mock prompt template', source: 'prompt' }]; }
46
- async runBash(command, excludeFromContext = false) { const id = randomUUID(), output = `Mock bash: ${command}`; this.emit({ type: 'bash_execution_start', id, command }); this.emit({ type: 'bash_execution_update', id, delta: `${output}\n` }); const result = { id, output, exitCode: 0, cancelled: false, truncated: false }; this.messages.push({ role: 'bashExecution', command, output, exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(), excludeFromContext }); this.emit({ type: 'bash_execution_end', command, ...result }); return result; }
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
+ }
47
214
  abortBash() { }
48
- subscribe(listener) { this.emitter.on('event', listener); return () => this.emitter.off('event', listener); }
49
- async dispose() { await this.abort(); this.state.status = 'stopped'; this.emitter.removeAllListeners(); }
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
+ }
50
224
  }
51
225
  const toStringList = (value) => Array.isArray(value) ? value.filter((x) => typeof x === 'string') : [];
52
226
  const emptyQueue = () => ({ steering: [], followUp: [] });
227
+ const LIFECYCLE_ONLY = new Set(['agent_loaded', 'agent_unloaded']);
53
228
  /** A pending dialog blocks the run until a human answers it, which is a different wait than streaming. */
54
229
  const runStatus = (entry, streaming) => entry.dialogRequests.size ? 'waiting_for_user' : streaming ? 'streaming' : 'idle';
55
230
  export class AgentManager {
56
231
  workspaces;
57
232
  factory;
58
233
  agents = new Map();
59
- listeners = new Map();
234
+ detailListeners = new Map();
60
235
  globalListeners = new Set();
61
- constructor(workspaces, factory = createSdkBackend) {
236
+ constructor(workspaces, factory = (id, cwd, sessionFile, profile, options) => createSdkBackend(id, cwd, sessionFile, profile, undefined, options)) {
62
237
  this.workspaces = workspaces;
63
238
  this.factory = factory;
64
239
  }
65
- async resolveCwd(workspaceId, relativeCwd = '.') { const ws = this.workspaces.get(workspaceId); if (!ws)
66
- throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const cwd = await this.workspaces.resolve(ws, relativeCwd); if (!(await import('node:fs/promises')).stat(cwd).then(s => s.isDirectory()))
67
- throw new Error('cwd is not a directory'); return cwd; }
68
- async create(workspaceId, relativeCwd = '.', sessionFile, profile = 'default') { const cwd = await this.resolveCwd(workspaceId, relativeCwd); const id = `agent-${randomUUID()}`, backend = await this.factory(id, cwd, sessionFile, profile), now = new Date().toISOString(); const record = { ...(await backend.getState()), workspaceId, profile, createdAt: now, lastActiveAt: now }; this.attach(id, record, backend); return { ...record }; }
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
+ }
69
263
  /** Build a disposable session so the UI sees the models actually available for this project and its extensions. */
70
- async capabilityPreview(workspaceId, relativeCwd = '.') { const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd); try {
71
- return await backend.getCapabilities();
264
+ async capabilityPreview(workspaceId, relativeCwd = '.') {
265
+ const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd);
266
+ try {
267
+ return await backend.getCapabilities();
268
+ }
269
+ finally {
270
+ await backend.dispose();
271
+ }
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
+ }
72
285
  }
73
- finally {
74
- await backend.dispose();
75
- } }
76
286
  connect(entry, backend) {
77
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;
78
294
  backend.subscribe(event => {
79
295
  if (entry.backend !== backend)
80
296
  return;
@@ -87,14 +303,8 @@ export class AgentManager {
87
303
  entry.streaming = false;
88
304
  if (type === 'queue_update')
89
305
  entry.queue = { steering: toStringList(event.steering), followUp: toStringList(event.followUp) };
90
- // Standalone shell runs and dialogs outlive LLM turns. Track them with the
91
- // event cursor; keep dialog payloads so reconnecting clients can rebuild controls.
92
- if (typeof event.id === 'string') {
93
- if (type === 'bash_execution_start' || type === 'bash_execution_update')
94
- entry.bashIds.add(event.id);
95
- else if (type === 'bash_execution_end')
96
- entry.bashIds.delete(event.id);
97
- }
306
+ const activityChanged = entry.activity.apply(event);
307
+ // Keep dialog payloads so reconnecting clients can rebuild their controls.
98
308
  if (typeof event.requestId === 'string') {
99
309
  if (type === 'extension_ui_request')
100
310
  entry.dialogRequests.set(event.requestId, event);
@@ -103,122 +313,289 @@ export class AgentManager {
103
313
  }
104
314
  record.status = runStatus(entry, entry.streaming === true);
105
315
  if (type === 'session_info_changed')
106
- record.sessionName = typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined;
107
- this.publish(entry, event);
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);
108
324
  });
109
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. */
110
330
  publish(entry, event) {
111
331
  const record = entry.record;
112
- record.lastActiveAt = new Date().toISOString();
332
+ if (!LIFECYCLE_ONLY.has(event.type))
333
+ record.lastActiveAt = new Date().toISOString();
113
334
  // Every event carries the authoritative status, so clients never have to derive it from event types.
114
- const item = { id: randomUUID(), sequence: entry.nextSequence++, timestamp: Math.floor(Date.now() / 1000), status: record.status, event };
115
- entry.events.push(item);
116
- if (entry.events.length > 1000)
117
- entry.events.shift();
118
- for (const listener of this.listeners.get(record.agentId) ?? [])
119
- listener(item);
335
+ const item = { timestamp: Date.now(), status: record.status, event };
120
336
  for (const listener of this.globalListeners)
121
337
  listener(record.agentId, item);
122
338
  }
123
- attach(id, record, backend) { const entry = { record, backend, events: [], nextSequence: 1, bashIds: new Set(), dialogRequests: new Map(), queue: emptyQueue() }; this.connect(entry, backend); this.agents.set(id, entry); }
124
- async restore(records) { for (const saved of records) {
125
- try {
126
- const ws = this.workspaces.get(saved.workspaceId);
127
- if (!ws)
128
- continue;
129
- const cwd = await this.workspaces.resolve(ws, pathRelative(ws.rootPath, saved.cwd));
130
- const sessionModified = saved.sessionFile ? await import('node:fs/promises').then(fs => fs.stat(saved.sessionFile).then(value => value.mtime.toISOString()).catch(() => undefined)) : undefined;
131
- const record = { ...saved, profile: saved.profile ?? 'default', cwd, status: 'unloaded', lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt };
132
- this.agents.set(saved.agentId, { record, events: [], nextSequence: 1, bashIds: new Set(), dialogRequests: new Map(), queue: emptyQueue() });
133
- }
134
- catch { /* Keep server startup resilient to deleted workspaces. */ }
135
- } }
136
- get(id) { const x = this.agents.get(id); if (!x)
137
- throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' }); return x; }
138
- list() { return [...this.agents.values()].map(x => ({ ...x.record })); }
139
- isLoaded(id) { return !!this.get(id).backend; }
140
- async ensureLoaded(id) { const entry = this.get(id); if (entry.backend)
141
- return entry.backend; if (entry.record.status === 'stopped')
142
- throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' }); if (entry.loading)
143
- return entry.loading; entry.record.status = 'starting'; entry.loading = (async () => { try {
144
- const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile);
145
- const state = await backend.getState();
146
- entry.record = { ...entry.record, ...state, sessionName: state.sessionName?.trim() || entry.record.sessionName, status: state.status === 'stopped' ? 'idle' : state.status };
339
+ attach(id, record, backend) {
340
+ const entry = {
341
+ record,
342
+ backend,
343
+ activity: new ActivityTracker(),
344
+ dialogRequests: new Map(),
345
+ queue: emptyQueue()
346
+ };
147
347
  this.connect(entry, backend);
148
- return backend;
149
- }
150
- catch (error) {
151
- entry.record.status = 'error';
152
- throw error;
153
- }
154
- finally {
155
- entry.loading = undefined;
156
- } })(); return entry.loading; }
157
- async command(id, kind, message = '') { const entry = this.get(id); if (kind === 'abort' && !entry.backend)
158
- return; if (entry.record.status === 'stopped')
159
- throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' }); const backend = await this.ensureLoaded(id); if (kind === 'abort')
160
- return backend.abort(); return backend[kind === 'follow-up' ? 'followUp' : kind](message); }
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
+ }
161
442
  /** Queued steering and follow-up messages waiting for the running turn. */
162
443
  // Both read through to the backend and leave the cache to `queue_update`, which stays correct when a
163
444
  // message is queued while these run.
164
- async queue(id) { const entry = this.get(id); return entry.backend ? entry.backend.getQueue() : { steering: [...entry.queue.steering], followUp: [...entry.queue.followUp] }; }
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
+ }
165
451
  /** Drop queued messages and return them so the caller can restore them to its composer. */
166
- async clearQueue(id) { const entry = this.get(id); return entry.backend ? entry.backend.clearQueue() : emptyQueue(); }
167
- events(id, last = 0) { return this.get(id).events.filter(e => e.sequence > last); }
168
- currentSequence(id) { return this.get(id).nextSequence - 1; }
452
+ async clearQueue(id) {
453
+ const entry = this.get(id);
454
+ return entry.backend ? entry.backend.clearQueue() : emptyQueue();
455
+ }
169
456
  snapshotState(id) {
170
457
  const a = this.get(id);
171
- return { ...a.record, activity: { bashIds: [...a.bashIds], dialogIds: [...a.dialogRequests.keys()], dialogRequests: [...a.dialogRequests.values()], queue: { steering: [...a.queue.steering], followUp: [...a.queue.followUp] } } };
172
- }
173
- async snapshot(id, messageLimit) {
174
- // Capture state and its cursor before yielding. Events emitted while messages
175
- // are being read must be delivered after, not swallowed by, this snapshot.
176
- const a = this.get(id), state = this.snapshotState(id), lastSequence = a.nextSequence - 1;
177
- const messages = a.backend ? await a.backend.getMessages() : [];
178
- if (messageLimit === undefined)
179
- return { state, messages, lastSequence };
180
- const page = this.paginateMessages(messages, messageLimit);
181
- return { state, messages: page.items, messagePage: page, lastSequence };
182
- }
183
- hasReplayGap(id, last) { const a = this.get(id); const current = a.nextSequence - 1; return last > current || (last > 0 && a.events.length > 0 && last < a.events[0].sequence - 1); }
184
- subscribe(id, listener) { this.get(id); if (!this.listeners.has(id))
185
- this.listeners.set(id, new Set()); this.listeners.get(id).add(listener); return () => this.listeners.get(id)?.delete(listener); }
186
- subscribeAll(listener) { this.globalListeners.add(listener); return () => this.globalListeners.delete(listener); }
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
+ }
187
493
  async dispose(id) {
188
494
  const a = this.get(id);
189
495
  if (a.loading)
190
496
  await a.loading.catch(() => { });
191
497
  await a.backend?.dispose();
192
498
  a.backend = undefined;
499
+ a.feed?.close();
500
+ a.feed = undefined;
193
501
  a.record.status = 'unloaded';
194
502
  a.streaming = false;
195
- a.bashIds.clear();
196
503
  a.dialogRequests.clear();
504
+ a.activity.reset(); // agent_unloaded below tells clients the activity is gone.
197
505
  a.queue = emptyQueue();
198
506
  this.publish(a, { type: 'agent_unloaded' });
199
507
  }
200
- async remove(id) { await this.dispose(id); this.agents.delete(id); this.listeners.delete(id); }
201
- async archive(id) { const current = this.get(id).record; if (['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(current.status))
202
- throw Object.assign(new Error('Active Agent cannot be archived'), { code: 'AGENT_ACTIVE' }); const record = { ...current, status: 'unloaded' }; await this.remove(id); return record; }
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
+ }
203
521
  // The record status, not the backend's, is authoritative: it also knows about pending dialogs.
204
- async state(id) { const state = await (await this.ensureLoaded(id)).getState(), record = this.get(id).record; return { ...state, status: record.status, sessionName: state.sessionName?.trim() || record.sessionName }; }
205
- async messages(id) { return (await this.ensureLoaded(id)).getMessages(); }
206
- async messagePage(id, limit = 25, before) { return this.paginateMessages(await (await this.ensureLoaded(id)).getMessages(), limit, before); }
207
- paginateMessages(messages, limit, before) { const safeLimit = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 25))), total = messages.length, end = Math.min(total, Math.max(0, before === undefined ? total : Math.trunc(Number(before) || 0))), start = Math.max(0, end - safeLimit); return { items: messages.slice(start, end), total, start, end, limit: safeLimit, hasMore: start > 0 }; }
208
- async capabilities(id) { return (await this.ensureLoaded(id)).getCapabilities(); }
209
- async session(id) { const info = await (await this.ensureLoaded(id)).getSession(), name = typeof info.sessionName === 'string' && info.sessionName.trim() ? info.sessionName : this.get(id).record.sessionName; return { ...info, sessionName: name }; }
210
- async sessionInfo(id) { const info = await (await this.ensureLoaded(id)).getSessionInfo(), name = typeof info.sessionName === 'string' && info.sessionName.trim() ? info.sessionName : this.get(id).record.sessionName; return { ...info, sessionName: name }; }
211
- async compact(id, instructions) { return (await this.ensureLoaded(id)).compact(instructions); }
212
- async setModel(id, provider, modelId) { return (await this.ensureLoaded(id)).setModel(provider, modelId); }
213
- async setThinkingLevel(id, level) { return (await this.ensureLoaded(id)).setThinkingLevel(level); }
214
- async setSessionName(id, name) { await (await this.ensureLoaded(id)).setSessionName(name); this.get(id).record.sessionName = name.trim(); }
215
- async navigate(id, entryId) { return (await this.ensureLoaded(id)).navigate(entryId); }
216
- async fork(id, entryId) { const source = this.get(id), result = await (await this.ensureLoaded(id)).fork(entryId); if (!result.sessionFile)
217
- throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' }); const ws = this.workspaces.get(source.record.workspaceId); const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile); return { agent, selectedText: result.selectedText }; }
218
- async extensionResponse(id, requestId, value) { return (await this.ensureLoaded(id)).extensionResponse(requestId, value); }
219
- async listCommands(id) { return (await this.ensureLoaded(id)).listCommands(); }
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
+ }
220
591
  /** Session-scoped bash. Output is streamed as `bash_execution_update` events and recorded in the Session. */
221
- async runBash(id, command, excludeFromContext = false) { return (await this.ensureLoaded(id)).runBash(command, excludeFromContext); }
222
- async abortBash(id) { const entry = this.get(id); if (entry.loading)
223
- await entry.loading; entry.backend?.abortBash(); }
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
+ }
224
601
  }