pi2web 0.2.1 → 0.2.8

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 (54) hide show
  1. package/README.md +53 -6
  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 +671 -152
  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 +222 -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 +47 -0
  25. package/dist/http/web-assets.js +64 -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 +203 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +249 -45
  33. package/dist/server.js +488 -614
  34. package/dist/session-namer.js +66 -17
  35. package/dist/session-performance.js +137 -0
  36. package/dist/sotto-stt.js +217 -0
  37. package/dist/terminals.js +94 -27
  38. package/dist/timeline.js +185 -0
  39. package/dist/voice.js +153 -35
  40. package/dist/workspaces.js +91 -26
  41. package/dist/ws/agent-socket.js +256 -0
  42. package/dist/ws/terminal-socket.js +46 -0
  43. package/npm-shrinkwrap.json +3685 -0
  44. package/package.json +11 -1
  45. package/packages/protocol/openapi.yaml +24 -2
  46. package/packages/protocol/websocket.schema.json +58 -15
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3647 -756
  49. package/web/backend-state.js +59 -27
  50. package/web/collab.js +684 -257
  51. package/web/hid-keyboard.js +173 -140
  52. package/web/index.html +7 -2
  53. package/web/styles.css +21 -2
  54. package/web/voice-pcm.js +36 -0
package/dist/agents.js CHANGED
@@ -2,79 +2,307 @@ 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
+ import { sessionPerformance } from './session-performance.js';
9
+ /**
10
+ * Reduce a Pi SDK event to the fields server-side consumers and clients use, or drop it.
11
+ * Heavy SDK events are whitelisted field by field (Pi attaches whole messages to many of
12
+ * them) so new SDK fields never leak into memory or onto the wire; pi2web's own events pass through.
13
+ */
14
+ export function slimEvent(event) {
15
+ const e = event;
16
+ switch (event.type) {
17
+ // Streamed text and running tool output reach viewing clients through AgentFeed, not as events.
18
+ case 'message_update':
19
+ case 'tool_execution_update':
20
+ return undefined;
21
+ case 'message_start':
22
+ return { type: 'message_start', role: e.message?.role ?? e.role };
23
+ case 'turn_start':
24
+ case 'turn_end':
25
+ return { type: event.type };
26
+ case 'message_end':
27
+ return { type: 'message_end', message: e.message };
28
+ case 'agent_end':
29
+ return { type: 'agent_end', willRetry: e.willRetry, aborted: e.aborted };
30
+ case 'tool_execution_start':
31
+ return {
32
+ type: event.type,
33
+ toolCallId: e.toolCallId,
34
+ toolName: e.toolName,
35
+ args: e.args,
36
+ parentToolCallId: e.parentToolCallId
37
+ };
38
+ case 'tool_execution_end':
39
+ return {
40
+ type: event.type,
41
+ toolCallId: e.toolCallId,
42
+ toolName: e.toolName,
43
+ parentToolCallId: e.parentToolCallId,
44
+ result: e.result,
45
+ isError: e.isError,
46
+ durationMs: e.durationMs
47
+ };
48
+ default:
49
+ return event;
50
+ }
51
+ }
5
52
  /** Deterministic backend used by the server and tests. The SDK adapter can implement the same contract. */
6
53
  export class MockBackend {
54
+ options;
7
55
  emitter = new EventEmitter();
8
56
  state;
9
- messages = [];
57
+ entries = [];
10
58
  timer;
11
59
  queue = { steering: [], followUp: [] };
12
- constructor(agentId, cwd, sessionId = randomUUID()) { this.state = { agentId, sessionId, cwd, status: 'idle' }; }
13
- emit(e) { this.emitter.emit('event', e); }
60
+ constructor(agentId, cwd, sessionId = randomUUID(), options = {}) {
61
+ this.options = options;
62
+ this.state = { agentId, sessionId, cwd, status: 'idle' };
63
+ }
64
+ emit(e) {
65
+ this.emitter.emit('event', e);
66
+ }
67
+ /** Persist like Pi: append a session entry after listeners have seen the message. */
68
+ append(message) {
69
+ const id = randomUUID().slice(0, 8);
70
+ this.entries.push({
71
+ type: 'message',
72
+ id,
73
+ parentId: this.entries.at(-1)?.id ?? null,
74
+ timestamp: new Date().toISOString(),
75
+ message: { timestamp: Date.now(), ...message }
76
+ });
77
+ return id;
78
+ }
14
79
  /** 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);
80
+ runTurn() {
81
+ return new Promise(r => {
82
+ this.timer = setTimeout(r, 0);
83
+ });
84
+ }
85
+ async prompt(message) {
86
+ if (this.state.status === 'streaming')
87
+ throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' });
88
+ this.state.status = 'streaming';
89
+ this.emit({ type: 'agent_start', message });
90
+ await this.runTurn();
91
+ const user = { role: 'user', content: message };
92
+ this.emit({ type: 'message_end', message: user });
93
+ this.append(user);
94
+ const text = `Mock response: ${message}`, assistant = { role: 'assistant', content: [{ type: 'text', text }] };
95
+ this.emit({ type: 'message_start', message: { role: 'assistant', content: [] } });
96
+ this.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: text } });
97
+ this.emit({ type: 'message_end', message: assistant });
98
+ this.append(assistant);
30
99
  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' }; }
100
+ this.emit({ type: 'agent_end' });
101
+ }
102
+ async steer(message) {
103
+ const queued = this.state.status === 'streaming';
104
+ if (queued)
105
+ this.queue.steering.push(message);
106
+ this.emit({ type: 'steer', message });
107
+ if (queued)
108
+ this.emitQueue();
109
+ return queued ? 'queued' : 'handled';
110
+ }
111
+ async followUp(message) {
112
+ const queued = this.state.status === 'streaming';
113
+ if (queued)
114
+ this.queue.followUp.push(message);
115
+ this.emit({ type: 'follow_up', message });
116
+ if (queued)
117
+ this.emitQueue();
118
+ return queued ? 'queued' : 'handled';
119
+ }
120
+ async getQueue() {
121
+ return { steering: [...this.queue.steering], followUp: [...this.queue.followUp] };
122
+ }
123
+ async clearQueue() {
124
+ const cleared = await this.getQueue();
125
+ this.queue = { steering: [], followUp: [] };
126
+ this.emitQueue();
127
+ return cleared;
128
+ }
129
+ async moveQueue(from, index, expected) {
130
+ if (this.state.status !== 'streaming' || this.queue[from][index] !== expected)
131
+ throw Object.assign(new Error('Queued message has changed or already been consumed'), {
132
+ code: 'QUEUE_CHANGED'
133
+ });
134
+ const to = from === 'steering' ? 'followUp' : 'steering';
135
+ this.queue[from].splice(index, 1);
136
+ this.queue[to].push(expected);
137
+ this.emitQueue();
138
+ return this.getQueue();
139
+ }
140
+ emitQueue() {
141
+ this.emit({ type: 'queue_update', steering: [...this.queue.steering], followUp: [...this.queue.followUp] });
142
+ }
143
+ async abort() {
144
+ if (this.state.status === 'streaming') {
145
+ if (this.timer)
146
+ clearTimeout(this.timer);
147
+ this.state.status = 'idle';
148
+ this.emit({ type: 'agent_end', aborted: true });
149
+ }
150
+ }
151
+ async getState() {
152
+ return { ...this.state };
153
+ }
154
+ timeline() {
155
+ return {
156
+ sessionId: this.state.sessionId,
157
+ leafId: this.entries.at(-1)?.id ?? null,
158
+ entries: () => [...this.entries]
159
+ };
160
+ }
161
+ async getSystemPrompt() {
162
+ const template = this.options.piDefaultPrompt ? undefined : this.options.template;
163
+ const base = template?.mode === 'replace' ? template.systemPrompt : 'Mock Pi system prompt';
164
+ return {
165
+ systemPrompt: [base, template?.mode === 'append' ? template.systemPrompt : '', `cwd: ${this.state.cwd}`]
166
+ .filter(Boolean)
167
+ .join('\n\n'),
168
+ appendFiles: [],
169
+ contextFiles: []
170
+ };
171
+ }
172
+ async getCapabilities() {
173
+ return { model: null, models: [], thinkingLevel: 'off', thinkingLevels: ['off'], supportsThinking: false };
174
+ }
175
+ async getSession() {
176
+ return { ...(await this.getSessionInfo()), entries: [...this.entries], tree: [] };
177
+ }
178
+ async getSessionInfo() {
179
+ return {
180
+ sessionId: this.state.sessionId,
181
+ sessionName: this.state.sessionName,
182
+ stats: { totalMessages: this.entries.length, tokens: { total: 0 }, cost: 0 },
183
+ contextUsage: { tokens: null, contextWindow: 0, percent: null }
184
+ };
185
+ }
186
+ async compact() {
187
+ return { summary: 'mock' };
188
+ }
39
189
  async setModel(_provider, _modelId) { }
40
190
  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 {}; }
191
+ async setSessionName(name) {
192
+ this.state.sessionName = name;
193
+ this.emit({ type: 'session_info_changed', name });
194
+ }
195
+ /** Move the leaf back to an earlier entry, like rewinding a Pi session. */
196
+ async navigate(entryId) {
197
+ const index = this.entries.findIndex(entry => entry.id === entryId);
198
+ if (index >= 0)
199
+ this.entries = this.entries.slice(0, index + 1);
200
+ return { cancelled: false };
201
+ }
202
+ async fork(_entryId) {
203
+ return {};
204
+ }
44
205
  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; }
206
+ async listCommands() {
207
+ return [{ name: 'mock', description: 'Mock prompt template', source: 'prompt' }];
208
+ }
209
+ async runBash(command, excludeFromContext = false) {
210
+ const id = randomUUID(), output = `Mock bash: ${command}`;
211
+ this.emit({ type: 'bash_execution_start', id, command });
212
+ this.emit({ type: 'bash_execution_update', id, delta: `${output}\n` });
213
+ const result = { id, output, exitCode: 0, cancelled: false, truncated: false };
214
+ this.append({
215
+ role: 'bashExecution',
216
+ command,
217
+ output,
218
+ exitCode: 0,
219
+ cancelled: false,
220
+ truncated: false,
221
+ excludeFromContext
222
+ });
223
+ this.emit({ type: 'bash_execution_end', command, ...result });
224
+ return result;
225
+ }
47
226
  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(); }
227
+ subscribe(listener) {
228
+ this.emitter.on('event', listener);
229
+ return () => this.emitter.off('event', listener);
230
+ }
231
+ async dispose() {
232
+ await this.abort();
233
+ this.state.status = 'stopped';
234
+ this.emitter.removeAllListeners();
235
+ }
50
236
  }
51
237
  const toStringList = (value) => Array.isArray(value) ? value.filter((x) => typeof x === 'string') : [];
52
238
  const emptyQueue = () => ({ steering: [], followUp: [] });
239
+ const LIFECYCLE_ONLY = new Set(['agent_loaded', 'agent_unloaded']);
53
240
  /** A pending dialog blocks the run until a human answers it, which is a different wait than streaming. */
54
241
  const runStatus = (entry, streaming) => entry.dialogRequests.size ? 'waiting_for_user' : streaming ? 'streaming' : 'idle';
55
242
  export class AgentManager {
56
243
  workspaces;
57
244
  factory;
58
245
  agents = new Map();
59
- listeners = new Map();
246
+ detailListeners = new Map();
60
247
  globalListeners = new Set();
61
- constructor(workspaces, factory = createSdkBackend) {
248
+ constructor(workspaces, factory = (id, cwd, sessionFile, profile, options) => createSdkBackend(id, cwd, sessionFile, profile, undefined, options)) {
62
249
  this.workspaces = workspaces;
63
250
  this.factory = factory;
64
251
  }
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 }; }
252
+ async resolveCwd(workspaceId, relativeCwd = '.') {
253
+ const ws = this.workspaces.get(workspaceId);
254
+ if (!ws)
255
+ throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
256
+ const cwd = await this.workspaces.resolve(ws, relativeCwd);
257
+ if (!(await import('node:fs/promises')).stat(cwd).then(s => s.isDirectory()))
258
+ throw new Error('cwd is not a directory');
259
+ return cwd;
260
+ }
261
+ async create(workspaceId, relativeCwd = '.', sessionFile, profile = 'default', template) {
262
+ const cwd = await this.resolveCwd(workspaceId, relativeCwd);
263
+ const id = `agent-${randomUUID()}`, backend = await this.factory(id, cwd, sessionFile, profile, template ? { template } : {}), now = new Date().toISOString();
264
+ const record = {
265
+ ...(await backend.getState()),
266
+ workspaceId,
267
+ profile,
268
+ ...(template ? { template: { ...template } } : {}),
269
+ createdAt: now,
270
+ lastActiveAt: now
271
+ };
272
+ this.attach(id, record, backend);
273
+ return { ...record };
274
+ }
69
275
  /** 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();
276
+ async capabilityPreview(workspaceId, relativeCwd = '.') {
277
+ const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd);
278
+ try {
279
+ return await backend.getCapabilities();
280
+ }
281
+ finally {
282
+ await backend.dispose();
283
+ }
284
+ }
285
+ /**
286
+ * The system prompt an Agent created here would start with. `template` previews a template;
287
+ * `piDefaultPrompt` ignores SYSTEM.md / APPEND_SYSTEM.md to show Pi's built-in prompt.
288
+ */
289
+ async systemPromptPreview(workspaceId, relativeCwd = '.', options = {}) {
290
+ const cwd = await this.resolveCwd(workspaceId, relativeCwd), backend = await this.factory(`preview-${randomUUID()}`, cwd, undefined, 'default', options);
291
+ try {
292
+ return { ...(await backend.getSystemPrompt()), cwd };
293
+ }
294
+ finally {
295
+ await backend.dispose();
296
+ }
72
297
  }
73
- finally {
74
- await backend.dispose();
75
- } }
76
298
  connect(entry, backend) {
77
299
  entry.backend = backend;
300
+ entry.feed?.close();
301
+ const agentId = entry.record.agentId, feed = new AgentFeed(() => backend.timeline(), message => {
302
+ for (const listener of this.detailListeners.get(agentId) ?? [])
303
+ listener(message);
304
+ });
305
+ entry.feed = feed;
78
306
  backend.subscribe(event => {
79
307
  if (entry.backend !== backend)
80
308
  return;
@@ -87,14 +315,8 @@ export class AgentManager {
87
315
  entry.streaming = false;
88
316
  if (type === 'queue_update')
89
317
  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
- }
318
+ const activityChanged = entry.activity.apply(event);
319
+ // Keep dialog payloads so reconnecting clients can rebuild their controls.
98
320
  if (typeof event.requestId === 'string') {
99
321
  if (type === 'extension_ui_request')
100
322
  entry.dialogRequests.set(event.requestId, event);
@@ -103,122 +325,419 @@ export class AgentManager {
103
325
  }
104
326
  record.status = runStatus(entry, entry.streaming === true);
105
327
  if (type === 'session_info_changed')
106
- record.sessionName = typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined;
107
- this.publish(entry, event);
328
+ record.sessionName =
329
+ typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined;
330
+ feed.handle(event);
331
+ const slim = slimEvent(event);
332
+ if (slim)
333
+ this.publish(entry, slim);
334
+ if (activityChanged)
335
+ this.publishActivity(entry);
108
336
  });
109
337
  }
338
+ publishActivity(entry) {
339
+ this.publish(entry, { type: 'activity_changed', activity: entry.activity.snapshot() });
340
+ }
341
+ /** Loading or unloading a backend is not activity: it must not reorder the Agent list. */
110
342
  publish(entry, event) {
111
343
  const record = entry.record;
112
- record.lastActiveAt = new Date().toISOString();
344
+ if (!LIFECYCLE_ONLY.has(event.type))
345
+ record.lastActiveAt = new Date().toISOString();
113
346
  // 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);
347
+ const item = { timestamp: Date.now(), status: record.status, event };
120
348
  for (const listener of this.globalListeners)
121
349
  listener(record.agentId, item);
122
350
  }
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) {
351
+ attach(id, record, backend) {
352
+ const entry = {
353
+ record,
354
+ backend,
355
+ writes: 0,
356
+ activity: new ActivityTracker(),
357
+ dialogRequests: new Map(),
358
+ queue: emptyQueue()
359
+ };
360
+ this.connect(entry, backend);
361
+ this.agents.set(id, entry);
362
+ }
363
+ async restore(records) {
364
+ for (const saved of records) {
365
+ try {
366
+ const ws = this.workspaces.get(saved.workspaceId);
367
+ if (!ws)
368
+ continue;
369
+ const cwd = await this.workspaces.resolve(ws, pathRelative(ws.rootPath, saved.cwd));
370
+ const sessionModified = saved.sessionFile
371
+ ? await import('node:fs/promises').then(fs => fs
372
+ .stat(saved.sessionFile)
373
+ .then(value => value.mtime.toISOString())
374
+ .catch(() => undefined))
375
+ : undefined;
376
+ const record = {
377
+ ...saved,
378
+ profile: saved.profile ?? 'default',
379
+ cwd,
380
+ status: 'unloaded',
381
+ lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt
382
+ };
383
+ this.agents.set(saved.agentId, {
384
+ record,
385
+ writes: 0,
386
+ activity: new ActivityTracker(),
387
+ dialogRequests: new Map(),
388
+ queue: emptyQueue()
389
+ });
390
+ }
391
+ catch {
392
+ /* Keep server startup resilient to deleted workspaces. */
393
+ }
394
+ }
395
+ }
396
+ get(id) {
397
+ const x = this.agents.get(id);
398
+ if (!x)
399
+ throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' });
400
+ return x;
401
+ }
402
+ list() {
403
+ return [...this.agents.values()].map(x => ({ ...x.record }));
404
+ }
405
+ isLoaded(id) {
406
+ return !!this.get(id).backend;
407
+ }
408
+ assertCurrent(entry, backend) {
409
+ if (entry.unloading ||
410
+ this.agents.get(entry.record.agentId) !== entry ||
411
+ (backend && entry.backend !== backend))
412
+ throw Object.assign(new Error('Agent session changed; retry the command'), { code: 'SESSION_BUSY' });
413
+ }
414
+ /** All session mutations share admission. Replacement is exclusive; ordinary writes remain concurrent. */
415
+ async writeSession(id, action) {
416
+ const entry = this.get(id);
417
+ this.assertCurrent(entry);
418
+ if (entry.templateChanging)
419
+ throw Object.assign(new Error('System Prompt is being changed'), { code: 'SESSION_BUSY' });
420
+ entry.writes++;
125
421
  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() });
422
+ const backend = await this.ensureLoaded(id);
423
+ this.assertCurrent(entry, backend);
424
+ return await action(backend, entry);
133
425
  }
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 };
147
- 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); }
426
+ finally {
427
+ entry.writes--;
428
+ }
429
+ }
430
+ async ensureLoaded(id) {
431
+ const entry = this.get(id);
432
+ this.assertCurrent(entry);
433
+ if (entry.backend)
434
+ return entry.backend;
435
+ if (entry.record.status === 'stopped')
436
+ throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' });
437
+ if (entry.loading)
438
+ return entry.loading;
439
+ entry.record.status = 'starting';
440
+ entry.loading = (async () => {
441
+ try {
442
+ const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile, entry.record.template ? { template: entry.record.template } : {});
443
+ let state;
444
+ try {
445
+ state = await backend.getState();
446
+ this.assertCurrent(entry);
447
+ }
448
+ catch (error) {
449
+ await backend.dispose();
450
+ throw error;
451
+ }
452
+ entry.record = {
453
+ ...entry.record,
454
+ ...state,
455
+ sessionName: state.sessionName?.trim() || entry.record.sessionName,
456
+ status: state.status === 'stopped' ? 'idle' : state.status
457
+ };
458
+ this.connect(entry, backend);
459
+ // The status moved from unloaded to idle without a Pi event; tell clients.
460
+ this.publish(entry, { type: 'agent_loaded' });
461
+ return backend;
462
+ }
463
+ catch (error) {
464
+ if (!entry.unloading)
465
+ entry.record.status = 'error';
466
+ throw error;
467
+ }
468
+ finally {
469
+ entry.loading = undefined;
470
+ }
471
+ })();
472
+ return entry.loading;
473
+ }
474
+ async command(id, kind, message = '') {
475
+ if (kind === 'abort') {
476
+ const entry = this.get(id);
477
+ if (!entry.backend)
478
+ return;
479
+ if (entry.activity.stopping())
480
+ this.publishActivity(entry);
481
+ return entry.backend.abort();
482
+ }
483
+ return this.writeSession(id, async (backend, entry) => {
484
+ const state = await backend.getState();
485
+ this.assertCurrent(entry, backend);
486
+ // Read and reserve without yielding: simultaneous idle inputs start exactly one turn.
487
+ const busy = entry.promptPending || entry.streaming || state.status === 'streaming';
488
+ const effective = busy ? (kind === 'prompt' ? 'follow-up' : kind) : 'prompt';
489
+ if (effective === 'prompt')
490
+ entry.promptPending = true;
491
+ try {
492
+ return await backend[effective === 'follow-up' ? 'followUp' : effective](message);
493
+ }
494
+ finally {
495
+ if (effective === 'prompt' && entry.backend === backend)
496
+ entry.promptPending = false;
497
+ }
498
+ });
499
+ }
161
500
  /** Queued steering and follow-up messages waiting for the running turn. */
162
501
  // Both read through to the backend and leave the cache to `queue_update`, which stays correct when a
163
502
  // 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] }; }
503
+ async queue(id) {
504
+ const entry = this.get(id);
505
+ return entry.backend
506
+ ? entry.backend.getQueue()
507
+ : { steering: [...entry.queue.steering], followUp: [...entry.queue.followUp] };
508
+ }
165
509
  /** 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; }
510
+ async clearQueue(id) {
511
+ const entry = this.get(id);
512
+ return entry.backend ? this.writeSession(id, backend => backend.clearQueue()) : emptyQueue();
513
+ }
514
+ async moveQueue(id, from, index, expected) {
515
+ return this.writeSession(id, backend => backend.moveQueue(from, index, expected));
516
+ }
169
517
  snapshotState(id) {
170
518
  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); }
187
- async dispose(id) {
188
- const a = this.get(id);
189
- if (a.loading)
190
- await a.loading.catch(() => { });
191
- await a.backend?.dispose();
192
- a.backend = undefined;
193
- a.record.status = 'unloaded';
194
- a.streaming = false;
195
- a.bashIds.clear();
196
- a.dialogRequests.clear();
197
- a.queue = emptyQueue();
198
- this.publish(a, { type: 'agent_unloaded' });
199
- }
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; }
519
+ return {
520
+ ...a.record,
521
+ activity: {
522
+ ...a.activity.snapshot(),
523
+ dialogRequests: [...a.dialogRequests.values()],
524
+ queue: { steering: [...a.queue.steering], followUp: [...a.queue.followUp] }
525
+ }
526
+ };
527
+ }
528
+ /**
529
+ * Start viewing an Agent in detail: load it, then subscribe and take a snapshot together, with
530
+ * nothing in between, so every message the listener gets is newer than the snapshot.
531
+ */
532
+ async focus(id, listener, limit) {
533
+ await this.ensureLoaded(id);
534
+ const entry = this.get(id);
535
+ // Snapshot first: it publishes pending changes, which it includes, before this listener exists.
536
+ const snapshot = {
537
+ ...entry.feed.snapshot(limit),
538
+ // Dialog controls live in the transcript, which a viewer replaces when it starts viewing.
539
+ dialogRequests: [...entry.dialogRequests.values()]
540
+ };
541
+ if (!this.detailListeners.has(id))
542
+ this.detailListeners.set(id, new Set());
543
+ this.detailListeners.get(id).add(listener);
544
+ return { unsubscribe: () => this.detailListeners.get(id)?.delete(listener), snapshot };
545
+ }
546
+ toolOutput(id, toolCallId) {
547
+ return this.get(id).feed?.output(toolCallId);
548
+ }
549
+ /** Events of every Agent: server-side consumers and the overview stream. */
550
+ subscribeAll(listener) {
551
+ this.globalListeners.add(listener);
552
+ return () => this.globalListeners.delete(listener);
553
+ }
554
+ dispose(id) {
555
+ const entry = this.get(id);
556
+ if (entry.unloading)
557
+ return entry.unloading;
558
+ // Reserve Stop synchronously. In-flight loads/replacements must validate before attaching.
559
+ entry.unloading = Promise.resolve()
560
+ .then(async () => {
561
+ if (entry.loading)
562
+ await entry.loading.catch(() => { });
563
+ const backend = entry.backend;
564
+ entry.backend = undefined;
565
+ entry.feed?.close();
566
+ entry.feed = undefined;
567
+ try {
568
+ await backend?.dispose();
569
+ }
570
+ finally {
571
+ entry.record.status = 'unloaded';
572
+ entry.streaming = false;
573
+ entry.promptPending = false;
574
+ entry.dialogRequests.clear();
575
+ entry.activity.reset();
576
+ entry.queue = emptyQueue();
577
+ this.publish(entry, { type: 'agent_unloaded' });
578
+ }
579
+ })
580
+ .finally(() => {
581
+ entry.unloading = undefined;
582
+ });
583
+ return entry.unloading;
584
+ }
585
+ async remove(id) {
586
+ await this.dispose(id);
587
+ this.agents.delete(id);
588
+ this.detailListeners.delete(id);
589
+ }
590
+ async archive(id) {
591
+ const current = this.get(id).record;
592
+ if (['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(current.status))
593
+ throw Object.assign(new Error('Active Agent cannot be archived'), { code: 'AGENT_ACTIVE' });
594
+ const record = { ...current, status: 'unloaded' };
595
+ await this.remove(id);
596
+ return record;
597
+ }
203
598
  // 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(); }
599
+ async state(id) {
600
+ const state = await (await this.ensureLoaded(id)).getState(), record = this.get(id).record;
601
+ return { ...state, status: record.status, sessionName: state.sessionName?.trim() || record.sessionName };
602
+ }
603
+ /** Older history: the page before item `before` (or the newest one). Live viewers get it from focus(). */
604
+ async timeline(id, limit, before) {
605
+ const source = (await this.ensureLoaded(id)).timeline(), page = pageTimeline(buildTimeline(source.entries()), limit, before);
606
+ if (!page)
607
+ throw Object.assign(new Error('Timeline item not found'), { code: 'TIMELINE_ITEM_NOT_FOUND' });
608
+ return { sessionId: source.sessionId, ...page };
609
+ }
610
+ /** All timing records of the current branch; older sessions only have partial tool timings. */
611
+ async performance(id) {
612
+ const source = (await this.ensureLoaded(id)).timeline();
613
+ return { sessionId: source.sessionId, ...sessionPerformance(source.entries()) };
614
+ }
615
+ /** The full body of one timeline item of the given session. It never changes. */
616
+ async item(id, sessionId, itemId) {
617
+ const source = (await this.ensureLoaded(id)).timeline(), body = source.sessionId === sessionId ? itemBody(buildTimeline(source.entries()), itemId) : undefined;
618
+ if (body === undefined)
619
+ throw Object.assign(new Error('Timeline item not found'), { code: 'TIMELINE_ITEM_NOT_FOUND' });
620
+ return { id: itemId, body };
621
+ }
622
+ async systemPrompt(id) {
623
+ const info = await (await this.ensureLoaded(id)).getSystemPrompt();
624
+ return { ...info, template: this.get(id).record.template ?? null };
625
+ }
626
+ /** Prepare → persist → activate under the template lock. A failed save leaves the old session intact. */
627
+ async setTemplate(id, template, persist) {
628
+ const entry = this.get(id);
629
+ this.assertCurrent(entry);
630
+ if (entry.record.profile !== 'default' ||
631
+ entry.record.status === 'stopped' ||
632
+ entry.streaming ||
633
+ entry.writes)
634
+ throw Object.assign(new Error('System Prompt can only change before the first turn'), {
635
+ code: 'SESSION_STARTED'
636
+ });
637
+ if (entry.templateChanging)
638
+ throw Object.assign(new Error('System Prompt is being changed'), { code: 'SESSION_BUSY' });
639
+ entry.templateChanging = true;
640
+ try {
641
+ const old = await this.ensureLoaded(id);
642
+ this.assertCurrent(entry, old);
643
+ const session = await old.getSession();
644
+ if (session.entries.some(x => x.type === 'message'))
645
+ throw Object.assign(new Error('This session already has messages'), { code: 'SESSION_STARTED' });
646
+ const capabilities = await old.getCapabilities();
647
+ this.assertCurrent(entry, old);
648
+ const replacement = await this.factory(id, entry.record.cwd, entry.record.sessionFile, entry.record.profile, template ? { template } : {});
649
+ try {
650
+ const model = capabilities.model;
651
+ if (model?.provider && model?.id)
652
+ await replacement.setModel(model.provider, model.id);
653
+ if (typeof capabilities.thinkingLevel === 'string')
654
+ await replacement.setThinkingLevel(capabilities.thinkingLevel);
655
+ if (entry.record.sessionName)
656
+ await replacement.setSessionName(entry.record.sessionName);
657
+ const state = await replacement.getState();
658
+ this.assertCurrent(entry, old);
659
+ const record = { ...entry.record, ...state, template };
660
+ // Persistence is synchronous: no command can observe a choice that has not been committed.
661
+ persist(record);
662
+ entry.record = record;
663
+ this.connect(entry, replacement);
664
+ this.publish(entry, { type: 'agent_loaded' });
665
+ await old.dispose().catch(() => { });
666
+ return record;
667
+ }
668
+ catch (error) {
669
+ await replacement.dispose();
670
+ throw error;
671
+ }
672
+ }
673
+ finally {
674
+ entry.templateChanging = false;
675
+ }
676
+ }
677
+ async capabilities(id) {
678
+ return (await this.ensureLoaded(id)).getCapabilities();
679
+ }
680
+ async session(id) {
681
+ const info = await (await this.ensureLoaded(id)).getSession(), name = typeof info.sessionName === 'string' && info.sessionName.trim()
682
+ ? info.sessionName
683
+ : this.get(id).record.sessionName;
684
+ return { ...info, sessionName: name };
685
+ }
686
+ async sessionInfo(id) {
687
+ const info = await (await this.ensureLoaded(id)).getSessionInfo(), name = typeof info.sessionName === 'string' && info.sessionName.trim()
688
+ ? info.sessionName
689
+ : this.get(id).record.sessionName;
690
+ return { ...info, sessionName: name };
691
+ }
692
+ async compact(id, instructions) {
693
+ return this.writeSession(id, backend => backend.compact(instructions));
694
+ }
695
+ async setModel(id, provider, modelId) {
696
+ return this.writeSession(id, backend => backend.setModel(provider, modelId));
697
+ }
698
+ async setThinkingLevel(id, level) {
699
+ return this.writeSession(id, backend => backend.setThinkingLevel(level));
700
+ }
701
+ async setSessionName(id, name) {
702
+ return this.writeSession(id, async (backend, entry) => {
703
+ await backend.setSessionName(name);
704
+ this.assertCurrent(entry, backend);
705
+ entry.record.sessionName = name.trim();
706
+ });
707
+ }
708
+ async navigate(id, entryId) {
709
+ return this.writeSession(id, async (backend, entry) => {
710
+ const result = await backend.navigate(entryId);
711
+ this.assertCurrent(entry, backend);
712
+ entry.feed?.sync(); // Moving the leaf back is no event; tell viewers to reload.
713
+ return result;
714
+ });
715
+ }
716
+ async fork(id, entryId) {
717
+ return this.writeSession(id, async (backend, source) => {
718
+ const result = await backend.fork(entryId);
719
+ this.assertCurrent(source, backend);
720
+ if (!result.sessionFile)
721
+ throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
722
+ const ws = this.workspaces.get(source.record.workspaceId);
723
+ const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile, source.record.profile, source.record.template);
724
+ return { agent, selectedText: result.selectedText };
725
+ });
726
+ }
727
+ async extensionResponse(id, requestId, value) {
728
+ return this.writeSession(id, backend => backend.extensionResponse(requestId, value));
729
+ }
730
+ async listCommands(id) {
731
+ return (await this.ensureLoaded(id)).listCommands();
732
+ }
220
733
  /** 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(); }
734
+ async runBash(id, command, excludeFromContext = false) {
735
+ return this.writeSession(id, backend => backend.runBash(command, excludeFromContext));
736
+ }
737
+ async abortBash(id) {
738
+ const entry = this.get(id);
739
+ if (entry.loading)
740
+ await entry.loading;
741
+ entry.backend?.abortBash();
742
+ }
224
743
  }