@larose/pi-web 0.3.0

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 (43) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +50 -0
  3. package/THIRD_PARTY_LICENSES.md +40 -0
  4. package/dist/client/home.js +1619 -0
  5. package/dist/client/session.js +3703 -0
  6. package/dist/server/api.js +485 -0
  7. package/dist/server/cli.js +51 -0
  8. package/dist/server/directory-browser.js +104 -0
  9. package/dist/server/errors.js +10 -0
  10. package/dist/server/event-buffer.js +40 -0
  11. package/dist/server/extension-ui.js +245 -0
  12. package/dist/server/git-workspaces.js +559 -0
  13. package/dist/server/runtime-registry.js +703 -0
  14. package/dist/server/server.js +190 -0
  15. package/dist/server/session-repository.js +374 -0
  16. package/package.json +46 -0
  17. package/public/home.html +139 -0
  18. package/public/session.html +144 -0
  19. package/public/styles.css +2463 -0
  20. package/screenshots/home.png +0 -0
  21. package/screenshots/session.png +0 -0
  22. package/src/client/display-title.ts +36 -0
  23. package/src/client/event-stream.ts +194 -0
  24. package/src/client/home.ts +1575 -0
  25. package/src/client/markdown.ts +98 -0
  26. package/src/client/message-queue.ts +67 -0
  27. package/src/client/path-combobox.ts +271 -0
  28. package/src/client/session.ts +2174 -0
  29. package/src/client/shared.ts +99 -0
  30. package/src/client/slash-completion.ts +184 -0
  31. package/src/client/transcript-activity.ts +188 -0
  32. package/src/client/usage-format.ts +156 -0
  33. package/src/client/workspace-browser.ts +36 -0
  34. package/src/server/api.ts +652 -0
  35. package/src/server/cli.ts +63 -0
  36. package/src/server/directory-browser.ts +137 -0
  37. package/src/server/errors.ts +11 -0
  38. package/src/server/event-buffer.ts +59 -0
  39. package/src/server/extension-ui.ts +359 -0
  40. package/src/server/git-workspaces.ts +750 -0
  41. package/src/server/runtime-registry.ts +943 -0
  42. package/src/server/server.ts +248 -0
  43. package/src/server/session-repository.ts +488 -0
@@ -0,0 +1,943 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import {
3
+ type AgentSession,
4
+ type AgentSessionEvent,
5
+ type AgentSessionRuntime,
6
+ type ContextUsage,
7
+ createAgentSessionFromServices,
8
+ createAgentSessionRuntime,
9
+ createAgentSessionServices,
10
+ getAgentDir,
11
+ ModelRuntime,
12
+ type SessionManager,
13
+ type SessionStats,
14
+ type SlashCommandInfo,
15
+ } from "@earendil-works/pi-coding-agent";
16
+
17
+ import { AppError } from "./errors.js";
18
+ import { EventBuffer, type BufferedEvent } from "./event-buffer.js";
19
+ import {
20
+ type ExtensionUIEvent,
21
+ type ExtensionUIResponse,
22
+ type ExtensionUIState,
23
+ WebExtensionUI,
24
+ } from "./extension-ui.js";
25
+ import type { SessionRepository } from "./session-repository.js";
26
+
27
+ export type RuntimeEvent =
28
+ | { type: "agent_event"; event: AgentSessionEvent }
29
+ | ExtensionUIEvent
30
+ | { type: "session_replaced"; previousId: string; sessionId: string }
31
+ | { type: "runtime_error"; message: string }
32
+ | { type: "runtime_disposed" };
33
+
34
+ export interface RuntimeUsage {
35
+ tokens: SessionStats["tokens"];
36
+ context: ContextUsage | null;
37
+ }
38
+
39
+ export interface RuntimeState {
40
+ id: string;
41
+ cwd: string;
42
+ sessionName?: string;
43
+ isStreaming: boolean;
44
+ isCompacting: boolean;
45
+ isWorking: boolean;
46
+ pendingMessageCount: number;
47
+ queue: {
48
+ steering: string[];
49
+ followUp: string[];
50
+ };
51
+ connectedClients: number;
52
+ model: { provider: string; id: string; name: string } | null;
53
+ thinkingLevel: string;
54
+ autoCompactionEnabled: boolean;
55
+ usage: RuntimeUsage;
56
+ messages: AgentMessage[];
57
+ commands: SlashCommandInfo[];
58
+ extensionUI: ExtensionUIState;
59
+ streamingMessage?: AgentMessage;
60
+ latestEventId: number;
61
+ }
62
+
63
+ export interface RuntimeSession {
64
+ readonly sessionId: string;
65
+ readonly sessionManager: SessionManager;
66
+ readonly sessionName: string | undefined;
67
+ readonly cwd?: string;
68
+ readonly isStreaming: boolean;
69
+ readonly isCompacting: boolean;
70
+ readonly pendingMessageCount: number;
71
+ getSteeringMessages(): readonly string[];
72
+ getFollowUpMessages(): readonly string[];
73
+ readonly messages: AgentMessage[];
74
+ readonly commands: SlashCommandInfo[];
75
+ readonly model: AgentSession["model"];
76
+ readonly thinkingLevel: string;
77
+ readonly autoCompactionEnabled: boolean;
78
+ readonly agent: { state: { streamingMessage?: AgentMessage } };
79
+ getSessionStats(): SessionStats;
80
+ readonly extensionUI?: WebExtensionUI;
81
+ readonly ready?: Promise<void>;
82
+ subscribe(listener: (event: AgentSessionEvent) => void): () => void;
83
+ onSessionReplaced?(listener: (previousId: string, sessionId: string) => void): () => void;
84
+ prompt(
85
+ message: string,
86
+ options: { streamingBehavior?: "steer"; preflightResult(success: boolean): void },
87
+ ): Promise<void>;
88
+ steer(message: string): Promise<void>;
89
+ clearQueue(): { steering: string[]; followUp: string[] };
90
+ restoreQueue(queue: { steering: string[]; followUp: string[] }): Promise<void>;
91
+ abort(): Promise<void>;
92
+ setSessionName(name: string): void;
93
+ dispose(): void | Promise<void>;
94
+ }
95
+
96
+ export type RuntimeSessionFactory = (manager: SessionManager) => Promise<RuntimeSession>;
97
+
98
+ export interface RuntimeRegistryOptions {
99
+ idleTimeoutMs?: number;
100
+ eventCapacity?: number;
101
+ createSession: RuntimeSessionFactory;
102
+ }
103
+
104
+ function errorMessage(error: unknown): string {
105
+ return error instanceof Error ? error.message : String(error);
106
+ }
107
+
108
+ function sessionCommands(session: AgentSession): SlashCommandInfo[] {
109
+ const commands: SlashCommandInfo[] = [];
110
+
111
+ for (const command of session.extensionRunner.getRegisteredCommands()) {
112
+ commands.push({
113
+ name: command.invocationName,
114
+ ...(command.description === undefined ? {} : { description: command.description }),
115
+ source: "extension",
116
+ sourceInfo: command.sourceInfo,
117
+ });
118
+ }
119
+
120
+ for (const template of session.promptTemplates) {
121
+ commands.push({
122
+ name: template.name,
123
+ ...(template.description === undefined ? {} : { description: template.description }),
124
+ source: "prompt",
125
+ sourceInfo: template.sourceInfo,
126
+ });
127
+ }
128
+
129
+ for (const skill of session.resourceLoader.getSkills().skills) {
130
+ commands.push({
131
+ name: `skill:${skill.name}`,
132
+ ...(skill.description === undefined ? {} : { description: skill.description }),
133
+ source: "skill",
134
+ sourceInfo: skill.sourceInfo,
135
+ });
136
+ }
137
+
138
+ return commands;
139
+ }
140
+
141
+ class SdkRuntimeSession implements RuntimeSession {
142
+ readonly extensionUI = new WebExtensionUI();
143
+
144
+ private readonly runtime: AgentSessionRuntime;
145
+ private readonly listeners = new Set<(event: AgentSessionEvent) => void>();
146
+ private readonly replacementListeners = new Set<(previousId: string, sessionId: string) => void>();
147
+ private unsubscribeSession: (() => void) | undefined;
148
+ private binding: Promise<void>;
149
+ private commandCatalogue: SlashCommandInfo[];
150
+ private currentSessionId: string;
151
+ private currentSessionManager: SessionManager;
152
+ private disposed = false;
153
+
154
+ constructor(runtime: AgentSessionRuntime) {
155
+ this.runtime = runtime;
156
+ this.commandCatalogue = sessionCommands(runtime.session);
157
+ this.currentSessionId = runtime.session.sessionId;
158
+ this.currentSessionManager = runtime.session.sessionManager;
159
+ this.attachSession(runtime.session);
160
+ this.binding = this.bindSession(runtime.session);
161
+ void this.binding.catch(() => undefined);
162
+
163
+ runtime.setBeforeSessionInvalidate(() => this.extensionUI.reset());
164
+ runtime.setRebindSession(async (session) => {
165
+ const previousId = this.currentSessionId;
166
+ this.commandCatalogue = sessionCommands(session);
167
+ this.currentSessionId = session.sessionId;
168
+ this.currentSessionManager = session.sessionManager;
169
+ this.attachSession(session);
170
+ this.binding = this.bindSession(session);
171
+ void this.binding.catch(() => undefined);
172
+
173
+ for (const listener of this.replacementListeners) {
174
+ listener(previousId, session.sessionId);
175
+ }
176
+ await this.binding;
177
+ });
178
+ }
179
+
180
+ get sessionId(): string {
181
+ return this.currentSessionId;
182
+ }
183
+ get sessionManager(): SessionManager {
184
+ return this.currentSessionManager;
185
+ }
186
+ get sessionName(): string | undefined {
187
+ return this.runtime.session.sessionName;
188
+ }
189
+ get cwd(): string {
190
+ return this.runtime.session.sessionManager.getCwd();
191
+ }
192
+ get isStreaming(): boolean {
193
+ return this.runtime.session.isStreaming;
194
+ }
195
+ get isCompacting(): boolean {
196
+ return this.runtime.session.isCompacting;
197
+ }
198
+ get pendingMessageCount(): number {
199
+ return this.runtime.session.pendingMessageCount;
200
+ }
201
+ getSteeringMessages(): readonly string[] {
202
+ return [...this.runtime.session.getSteeringMessages()];
203
+ }
204
+ getFollowUpMessages(): readonly string[] {
205
+ return [...this.runtime.session.getFollowUpMessages()];
206
+ }
207
+ get messages(): AgentMessage[] {
208
+ return this.runtime.session.messages;
209
+ }
210
+ get commands(): SlashCommandInfo[] {
211
+ return this.commandCatalogue;
212
+ }
213
+ get model(): AgentSession["model"] {
214
+ return this.runtime.session.model;
215
+ }
216
+ get thinkingLevel(): string {
217
+ return this.runtime.session.thinkingLevel;
218
+ }
219
+ get autoCompactionEnabled(): boolean {
220
+ return this.runtime.session.autoCompactionEnabled;
221
+ }
222
+ get agent(): RuntimeSession["agent"] {
223
+ return this.runtime.session.agent;
224
+ }
225
+ get ready(): Promise<void> {
226
+ return this.binding;
227
+ }
228
+
229
+ getSessionStats(): SessionStats {
230
+ return this.runtime.session.getSessionStats();
231
+ }
232
+
233
+ subscribe(listener: (event: AgentSessionEvent) => void): () => void {
234
+ this.listeners.add(listener);
235
+ return () => this.listeners.delete(listener);
236
+ }
237
+
238
+ onSessionReplaced(listener: (previousId: string, sessionId: string) => void): () => void {
239
+ this.replacementListeners.add(listener);
240
+ return () => this.replacementListeners.delete(listener);
241
+ }
242
+
243
+ prompt(
244
+ message: string,
245
+ options: { streamingBehavior?: "steer"; preflightResult(success: boolean): void },
246
+ ): Promise<void> {
247
+ return this.runtime.session.prompt(message, options);
248
+ }
249
+
250
+ steer(message: string): Promise<void> {
251
+ return this.runtime.session.steer(message);
252
+ }
253
+
254
+ clearQueue(): { steering: string[]; followUp: string[] } {
255
+ return this.runtime.session.clearQueue();
256
+ }
257
+
258
+ async restoreQueue(queue: { steering: string[]; followUp: string[] }): Promise<void> {
259
+ for (const message of queue.steering) {
260
+ await this.runtime.session.sendUserMessage(message, {
261
+ deliverAs: "steer",
262
+ expandPromptTemplates: false,
263
+ });
264
+ }
265
+ for (const message of queue.followUp) {
266
+ await this.runtime.session.sendUserMessage(message, {
267
+ deliverAs: "followUp",
268
+ expandPromptTemplates: false,
269
+ });
270
+ }
271
+ }
272
+
273
+ abort(): Promise<void> {
274
+ return this.runtime.session.abort();
275
+ }
276
+
277
+ setSessionName(name: string): void {
278
+ this.runtime.session.setSessionName(name);
279
+ }
280
+
281
+ async dispose(): Promise<void> {
282
+ if (this.disposed) {
283
+ return;
284
+ }
285
+ this.disposed = true;
286
+ this.extensionUI.dispose();
287
+ await this.runtime.dispose();
288
+ this.unsubscribeSession?.();
289
+ this.listeners.clear();
290
+ this.replacementListeners.clear();
291
+ }
292
+
293
+ private attachSession(session: AgentSession): void {
294
+ this.unsubscribeSession?.();
295
+ this.unsubscribeSession = session.subscribe((event) => {
296
+ for (const listener of this.listeners) {
297
+ listener(event);
298
+ }
299
+ });
300
+ }
301
+
302
+ private bindSession(session: AgentSession): Promise<void> {
303
+ return session.bindExtensions({
304
+ mode: "rpc",
305
+ uiContext: this.extensionUI.context,
306
+ commandContextActions: {
307
+ waitForIdle: () => session.waitForIdle(),
308
+ newSession: (options) => this.runtime.newSession(options),
309
+ fork: async (entryId, options) => {
310
+ const result = await this.runtime.fork(entryId, options);
311
+ return { cancelled: result.cancelled };
312
+ },
313
+ navigateTree: async (targetId, options) => {
314
+ const result = await session.navigateTree(targetId, options);
315
+ return { cancelled: result.cancelled };
316
+ },
317
+ switchSession: (sessionPath, options) => this.runtime.switchSession(sessionPath, options),
318
+ reload: async () => {
319
+ await session.reload();
320
+ this.commandCatalogue = sessionCommands(session);
321
+ },
322
+ },
323
+ shutdownHandler: () =>
324
+ this.extensionUI.context.notify(
325
+ "An extension requested shutdown. Stop Pi Web from its server process.",
326
+ "warning",
327
+ ),
328
+ onError: (error) =>
329
+ this.extensionUI.context.notify(
330
+ `Extension error in ${error.extensionPath} (${error.event}): ${error.error}`,
331
+ "error",
332
+ ),
333
+ });
334
+ }
335
+ }
336
+
337
+ export class RuntimeHandle {
338
+ readonly events: EventBuffer<RuntimeEvent>;
339
+
340
+ private readonly session: RuntimeSession;
341
+ private readonly initialCwd: string;
342
+ private readonly idleTimeoutMs: number;
343
+ private readonly onIdle: (handle: RuntimeHandle) => void;
344
+ private readonly onSessionIdChanged: (
345
+ handle: RuntimeHandle,
346
+ previousId: string,
347
+ sessionId: string,
348
+ manager: SessionManager,
349
+ ) => void;
350
+ private readonly unsubscribeSession: () => void;
351
+ private readonly unsubscribeExtensionUI: (() => void) | undefined;
352
+ private readonly unsubscribeReplacement: (() => void) | undefined;
353
+ private sessionId: string;
354
+ private activeTools = new Set<string>();
355
+ private activePrompt: Promise<void> | undefined;
356
+ private connectedClients = 0;
357
+ private disposed = false;
358
+ private idleTimer: NodeJS.Timeout | undefined;
359
+ private lastSettledAt = Date.now();
360
+ private operationTail: Promise<void> = Promise.resolve();
361
+ private queuedMessages = 0;
362
+ private retrying = false;
363
+ private suppressQueueUpdates = false;
364
+ private suppressedQueueUpdate: Extract<AgentSessionEvent, { type: "queue_update" }> | undefined;
365
+
366
+ constructor(
367
+ session: RuntimeSession,
368
+ cwd: string,
369
+ idleTimeoutMs: number,
370
+ eventCapacity: number,
371
+ onIdle: (handle: RuntimeHandle) => void,
372
+ onSessionIdChanged: (
373
+ handle: RuntimeHandle,
374
+ previousId: string,
375
+ sessionId: string,
376
+ manager: SessionManager,
377
+ ) => void = () => undefined,
378
+ ) {
379
+ this.session = session;
380
+ this.sessionId = session.sessionId;
381
+ this.initialCwd = cwd;
382
+ this.idleTimeoutMs = idleTimeoutMs;
383
+ this.events = new EventBuffer(eventCapacity);
384
+ this.onIdle = onIdle;
385
+ this.onSessionIdChanged = onSessionIdChanged;
386
+ this.unsubscribeSession = session.subscribe((event) => this.onSessionEvent(event));
387
+ this.unsubscribeExtensionUI = session.extensionUI?.setPublisher((event) => {
388
+ this.events.publish(event);
389
+
390
+ if (
391
+ event.type === "extension_ui_request" &&
392
+ (event.method === "select" ||
393
+ event.method === "confirm" ||
394
+ event.method === "input" ||
395
+ event.method === "editor")
396
+ ) {
397
+ this.cancelEviction();
398
+ }
399
+
400
+ if (event.type === "extension_ui_closed") {
401
+ this.scheduleEviction();
402
+ }
403
+ });
404
+ this.unsubscribeReplacement = session.onSessionReplaced?.((previousId, sessionId) => {
405
+ this.sessionId = sessionId;
406
+ this.onSessionIdChanged(this, previousId, sessionId, session.sessionManager);
407
+ this.events.publish({ type: "session_replaced", previousId, sessionId });
408
+ });
409
+ this.scheduleEviction();
410
+ }
411
+
412
+ get id(): string {
413
+ return this.sessionId;
414
+ }
415
+
416
+ get cwd(): string {
417
+ return this.session.cwd ?? this.initialCwd;
418
+ }
419
+
420
+ snapshot(): RuntimeState {
421
+ const streamingMessage = this.session.agent.state.streamingMessage;
422
+ const stats = this.session.getSessionStats();
423
+
424
+ return {
425
+ id: this.id,
426
+ cwd: this.cwd,
427
+ ...(this.session.sessionName ? { sessionName: this.session.sessionName } : {}),
428
+ isStreaming: this.session.isStreaming,
429
+ isCompacting: this.session.isCompacting,
430
+ isWorking: this.isWorking(),
431
+ pendingMessageCount: this.session.pendingMessageCount,
432
+ queue: {
433
+ steering: [...this.session.getSteeringMessages()],
434
+ followUp: [...this.session.getFollowUpMessages()],
435
+ },
436
+ connectedClients: this.connectedClients,
437
+ model: this.session.model
438
+ ? {
439
+ provider: this.session.model.provider,
440
+ id: this.session.model.id,
441
+ name: this.session.model.name,
442
+ }
443
+ : null,
444
+ thinkingLevel: this.session.thinkingLevel,
445
+ autoCompactionEnabled: this.session.autoCompactionEnabled,
446
+ usage: {
447
+ tokens: stats.tokens,
448
+ context: stats.contextUsage ?? null,
449
+ },
450
+ messages: this.session.messages,
451
+ commands: this.session.commands,
452
+ extensionUI: this.session.extensionUI?.snapshot() ?? { pending: [], statuses: [], widgets: [] },
453
+ ...(streamingMessage ? { streamingMessage } : {}),
454
+ latestEventId: this.events.latestId,
455
+ };
456
+ }
457
+
458
+ attachClient(listener: (event: BufferedEvent<RuntimeEvent>) => void): () => void {
459
+ this.assertOpen();
460
+ this.connectedClients += 1;
461
+ this.cancelEviction();
462
+ const unsubscribe = this.events.subscribe(listener);
463
+ let attached = true;
464
+
465
+ return () => {
466
+ if (!attached) {
467
+ return;
468
+ }
469
+ attached = false;
470
+ unsubscribe();
471
+ this.connectedClients -= 1;
472
+ this.scheduleEviction();
473
+ };
474
+ }
475
+
476
+ send(message: string): Promise<{ queued: boolean }> {
477
+ const trimmed = message.trim();
478
+ if (!trimmed) {
479
+ return Promise.reject(new AppError("invalid_message", "A message is required"));
480
+ }
481
+
482
+ return this.enqueue(async () => {
483
+ this.assertOpen();
484
+ this.cancelEviction();
485
+
486
+ if (this.session.isStreaming || this.activePrompt) {
487
+ await this.session.prompt(message, {
488
+ streamingBehavior: "steer",
489
+ preflightResult: () => undefined,
490
+ });
491
+ return { queued: true };
492
+ }
493
+
494
+ await this.startPrompt(message);
495
+ return { queued: false };
496
+ });
497
+ }
498
+
499
+ removePendingSteering(index: number, expectedMessage: string, expectedQueue: string[]): Promise<void> {
500
+ return this.enqueue(async () => {
501
+ this.assertOpen();
502
+ this.cancelEviction();
503
+
504
+ const steering = [...this.session.getSteeringMessages()];
505
+ const unchanged =
506
+ steering.length === expectedQueue.length &&
507
+ steering.every((message, queueIndex) => message === expectedQueue[queueIndex]);
508
+ if (!unchanged || steering[index] !== expectedMessage) {
509
+ throw new AppError("stale_pending_message", "The pending message queue has changed", 409);
510
+ }
511
+
512
+ this.suppressQueueUpdates = true;
513
+ try {
514
+ const queue = this.session.clearQueue();
515
+ queue.steering.splice(index, 1);
516
+ await this.session.restoreQueue(queue);
517
+ } finally {
518
+ this.suppressQueueUpdates = false;
519
+ const update = this.suppressedQueueUpdate;
520
+ this.suppressedQueueUpdate = undefined;
521
+ if (update) {
522
+ this.events.publish({ type: "agent_event", event: update });
523
+ }
524
+ this.scheduleEviction();
525
+ }
526
+ });
527
+ }
528
+
529
+ respondToExtensionUI(response: ExtensionUIResponse): void {
530
+ this.assertOpen();
531
+ if (!this.session.extensionUI?.respond(response)) {
532
+ throw new AppError("unknown_ui_request", `Unknown extension UI request: ${response.id}`, 409);
533
+ }
534
+ }
535
+
536
+ setSessionName(name: string | undefined): string | undefined {
537
+ this.assertOpen();
538
+ this.session.setSessionName(name ?? "");
539
+ return this.session.sessionName;
540
+ }
541
+
542
+ abort(): Promise<{ restoredMessages: string[] }> {
543
+ return this.enqueue(async () => {
544
+ this.assertOpen();
545
+ this.cancelEviction();
546
+ const queued = this.session.clearQueue();
547
+ this.queuedMessages = 0;
548
+ this.session.extensionUI?.cancelPending();
549
+ await this.session.abort();
550
+ this.retrying = false;
551
+ this.activeTools.clear();
552
+ this.lastSettledAt = Date.now();
553
+ this.scheduleEviction();
554
+
555
+ return { restoredMessages: [...queued.steering, ...queued.followUp] };
556
+ });
557
+ }
558
+
559
+ isEvictable(): boolean {
560
+ return !this.disposed && this.connectedClients === 0 && !this.isWorking();
561
+ }
562
+
563
+ async dispose(): Promise<void> {
564
+ if (this.disposed) {
565
+ return;
566
+ }
567
+ this.disposed = true;
568
+ this.cancelEviction();
569
+ this.session.clearQueue();
570
+
571
+ try {
572
+ await this.session.abort();
573
+ } finally {
574
+ this.events.publish({ type: "runtime_disposed" });
575
+ this.unsubscribeSession();
576
+ this.unsubscribeExtensionUI?.();
577
+ this.unsubscribeReplacement?.();
578
+ await this.session.dispose();
579
+ }
580
+ }
581
+
582
+ private enqueue<T>(operation: () => Promise<T>): Promise<T> {
583
+ const result = this.operationTail.then(operation, operation);
584
+ this.operationTail = result.then(
585
+ () => undefined,
586
+ () => undefined,
587
+ );
588
+ return result;
589
+ }
590
+
591
+ private async startPrompt(message: string): Promise<void> {
592
+ let preflightSettled = false;
593
+ let acceptPrompt!: () => void;
594
+ let rejectPrompt!: (error: unknown) => void;
595
+ const accepted = new Promise<void>((resolve, reject) => {
596
+ acceptPrompt = resolve;
597
+ rejectPrompt = reject;
598
+ });
599
+
600
+ const completion = (async () => {
601
+ await this.session.ready;
602
+ await this.session.prompt(message, {
603
+ preflightResult: (success) => {
604
+ if (preflightSettled) {
605
+ return;
606
+ }
607
+ preflightSettled = true;
608
+
609
+ if (success) {
610
+ acceptPrompt();
611
+ } else {
612
+ rejectPrompt(new AppError("prompt_rejected", "Pi rejected the message", 409));
613
+ }
614
+ },
615
+ });
616
+ })();
617
+ this.activePrompt = completion;
618
+
619
+ void completion
620
+ .then(() => {
621
+ if (preflightSettled) {
622
+ return;
623
+ }
624
+ preflightSettled = true;
625
+ acceptPrompt();
626
+ })
627
+ .catch((error: unknown) => {
628
+ if (!preflightSettled) {
629
+ preflightSettled = true;
630
+ rejectPrompt(error);
631
+ } else {
632
+ this.events.publish({ type: "runtime_error", message: errorMessage(error) });
633
+ }
634
+ })
635
+ .finally(() => {
636
+ if (this.activePrompt === completion) {
637
+ this.activePrompt = undefined;
638
+ }
639
+ this.scheduleEviction();
640
+ });
641
+
642
+ await accepted;
643
+ }
644
+
645
+ private onSessionEvent(event: AgentSessionEvent): void {
646
+ if (this.disposed) {
647
+ return;
648
+ }
649
+
650
+ switch (event.type) {
651
+ case "agent_start":
652
+ this.cancelEviction();
653
+ break;
654
+ case "tool_execution_start":
655
+ this.activeTools.add(event.toolCallId);
656
+ this.cancelEviction();
657
+ break;
658
+ case "tool_execution_end":
659
+ this.activeTools.delete(event.toolCallId);
660
+ break;
661
+ case "queue_update":
662
+ this.queuedMessages = event.steering.length + event.followUp.length;
663
+ if (this.queuedMessages > 0) {
664
+ this.cancelEviction();
665
+ }
666
+ break;
667
+ case "compaction_start":
668
+ case "auto_retry_start":
669
+ case "summarization_retry_scheduled":
670
+ case "summarization_retry_attempt_start":
671
+ this.retrying = true;
672
+ this.cancelEviction();
673
+ break;
674
+ case "compaction_end":
675
+ this.retrying = event.willRetry;
676
+ break;
677
+ case "auto_retry_end":
678
+ case "summarization_retry_finished":
679
+ this.retrying = false;
680
+ break;
681
+ case "agent_settled":
682
+ this.retrying = false;
683
+ this.activeTools.clear();
684
+ this.lastSettledAt = Date.now();
685
+ this.scheduleEviction();
686
+ break;
687
+ }
688
+
689
+ if (event.type === "queue_update" && this.suppressQueueUpdates) {
690
+ this.suppressedQueueUpdate = event;
691
+ return;
692
+ }
693
+
694
+ this.events.publish({ type: "agent_event", event });
695
+ }
696
+
697
+ isWorking(): boolean {
698
+ return (
699
+ this.session.isStreaming ||
700
+ this.session.isCompacting ||
701
+ this.activePrompt !== undefined ||
702
+ this.activeTools.size > 0 ||
703
+ this.queuedMessages > 0 ||
704
+ (this.session.extensionUI?.snapshot().pending.length ?? 0) > 0 ||
705
+ this.retrying
706
+ );
707
+ }
708
+
709
+ private scheduleEviction(): void {
710
+ this.cancelEviction();
711
+ if (!this.isEvictable()) {
712
+ return;
713
+ }
714
+
715
+ const elapsed = Date.now() - this.lastSettledAt;
716
+ const delay = Math.max(0, this.idleTimeoutMs - elapsed);
717
+ this.idleTimer = setTimeout(() => {
718
+ this.idleTimer = undefined;
719
+ if (this.isEvictable()) {
720
+ this.onIdle(this);
721
+ }
722
+ }, delay);
723
+ this.idleTimer.unref();
724
+ }
725
+
726
+ private cancelEviction(): void {
727
+ if (!this.idleTimer) {
728
+ return;
729
+ }
730
+ clearTimeout(this.idleTimer);
731
+ this.idleTimer = undefined;
732
+ }
733
+
734
+ private assertOpen(): void {
735
+ if (this.disposed) {
736
+ throw new AppError("runtime_closed", "The session runtime is closed", 409);
737
+ }
738
+ }
739
+ }
740
+
741
+ export class RuntimeRegistry {
742
+ private readonly repository: SessionRepository;
743
+ private readonly options: Required<Pick<RuntimeRegistryOptions, "idleTimeoutMs" | "eventCapacity">> &
744
+ RuntimeRegistryOptions;
745
+ private readonly handles = new Map<string, RuntimeHandle>();
746
+ private readonly starts = new Map<string, Promise<RuntimeHandle>>();
747
+ private readonly deletions = new Map<string, Promise<void>>();
748
+ private closing = false;
749
+
750
+ constructor(repository: SessionRepository, options: RuntimeRegistryOptions) {
751
+ this.repository = repository;
752
+ this.options = {
753
+ idleTimeoutMs: options.idleTimeoutMs ?? 10 * 60_000,
754
+ eventCapacity: options.eventCapacity ?? 512,
755
+ createSession: options.createSession,
756
+ };
757
+ }
758
+
759
+ async get(id: string): Promise<RuntimeHandle> {
760
+ if (this.closing) {
761
+ throw new AppError("server_stopping", "The server is stopping", 503);
762
+ }
763
+
764
+ const deleting = this.deletions.get(id);
765
+ if (deleting) {
766
+ await deleting;
767
+ }
768
+ if (this.closing) {
769
+ throw new AppError("server_stopping", "The server is stopping", 503);
770
+ }
771
+
772
+ const existing = this.handles.get(id);
773
+ if (existing) {
774
+ return existing;
775
+ }
776
+
777
+ const starting = this.starts.get(id);
778
+ if (starting) {
779
+ return starting;
780
+ }
781
+
782
+ const start = this.start(id);
783
+ this.starts.set(id, start);
784
+
785
+ try {
786
+ return await start;
787
+ } finally {
788
+ if (this.starts.get(id) === start) {
789
+ this.starts.delete(id);
790
+ }
791
+ }
792
+ }
793
+
794
+ getActive(id: string): RuntimeHandle | undefined {
795
+ return this.handles.get(id);
796
+ }
797
+
798
+ activeIds(): string[] {
799
+ return [...this.handles.keys()];
800
+ }
801
+
802
+ async delete(id: string): Promise<void> {
803
+ if (this.closing) {
804
+ throw new AppError("server_stopping", "The server is stopping", 503);
805
+ }
806
+
807
+ const existing = this.deletions.get(id);
808
+ if (existing) {
809
+ return existing;
810
+ }
811
+
812
+ const deletion = this.deleteSession(id);
813
+ this.deletions.set(id, deletion);
814
+
815
+ try {
816
+ await deletion;
817
+ } finally {
818
+ if (this.deletions.get(id) === deletion) {
819
+ this.deletions.delete(id);
820
+ }
821
+ }
822
+ }
823
+
824
+ async close(): Promise<void> {
825
+ if (this.closing) {
826
+ return;
827
+ }
828
+ this.closing = true;
829
+
830
+ await Promise.allSettled([...this.starts.values()]);
831
+ await Promise.allSettled([...this.deletions.values()]);
832
+ const handles = [...this.handles.values()];
833
+ this.handles.clear();
834
+ await Promise.allSettled(handles.map((handle) => handle.dispose()));
835
+ }
836
+
837
+ private async deleteSession(id: string): Promise<void> {
838
+ const starting = this.starts.get(id);
839
+ if (starting) {
840
+ await starting.catch(() => undefined);
841
+ }
842
+
843
+ const handle = this.handles.get(id);
844
+ if (handle?.isWorking()) {
845
+ throw new AppError("session_busy", "The session is busy and cannot be deleted", 409);
846
+ }
847
+
848
+ if (handle) {
849
+ for (const [key, candidate] of this.handles) {
850
+ if (candidate === handle) {
851
+ this.handles.delete(key);
852
+ }
853
+ }
854
+ await handle.dispose();
855
+ }
856
+
857
+ await this.repository.delete(id);
858
+ }
859
+
860
+ private async start(id: string): Promise<RuntimeHandle> {
861
+ const manager = await this.repository.openManager(id);
862
+ const session = await this.options.createSession(manager);
863
+
864
+ if (this.closing) {
865
+ await session.dispose();
866
+ throw new AppError("server_stopping", "The server is stopping", 503);
867
+ }
868
+
869
+ const handle = new RuntimeHandle(
870
+ session,
871
+ manager.getCwd(),
872
+ this.options.idleTimeoutMs,
873
+ this.options.eventCapacity,
874
+ (idleHandle) => void this.evict(idleHandle),
875
+ (changedHandle, previousId, sessionId, replacementManager) => {
876
+ this.rekey(changedHandle, previousId, sessionId, replacementManager);
877
+ },
878
+ );
879
+ this.handles.set(id, handle);
880
+ return handle;
881
+ }
882
+
883
+ private rekey(handle: RuntimeHandle, previousId: string, sessionId: string, manager: SessionManager): void {
884
+ this.repository.adoptManager(manager);
885
+ if (this.handles.get(previousId) === handle) {
886
+ this.handles.delete(previousId);
887
+ }
888
+
889
+ const existing = this.handles.get(sessionId);
890
+ if (existing && existing !== handle) {
891
+ void existing.dispose();
892
+ }
893
+ this.handles.set(sessionId, handle);
894
+ }
895
+
896
+ private async evict(handle: RuntimeHandle): Promise<void> {
897
+ if (!handle.isEvictable()) {
898
+ return;
899
+ }
900
+
901
+ for (const [id, candidate] of this.handles) {
902
+ if (candidate === handle) {
903
+ this.handles.delete(id);
904
+ }
905
+ }
906
+ await handle.dispose();
907
+ }
908
+ }
909
+
910
+ export async function createRuntimeRegistry(repository: SessionRepository): Promise<RuntimeRegistry> {
911
+ const modelRuntime = await ModelRuntime.create();
912
+ const agentDir = getAgentDir();
913
+
914
+ const createSession: RuntimeSessionFactory = async (manager) => {
915
+ const createRuntime = async ({
916
+ cwd,
917
+ sessionManager,
918
+ sessionStartEvent,
919
+ }: {
920
+ cwd: string;
921
+ sessionManager: SessionManager;
922
+ sessionStartEvent?: Parameters<typeof createAgentSessionFromServices>[0]["sessionStartEvent"];
923
+ }) => {
924
+ const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
925
+ const created = await createAgentSessionFromServices({
926
+ services,
927
+ sessionManager,
928
+ ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
929
+ });
930
+
931
+ return { ...created, services, diagnostics: services.diagnostics };
932
+ };
933
+ const runtime = await createAgentSessionRuntime(createRuntime, {
934
+ cwd: manager.getCwd(),
935
+ agentDir,
936
+ sessionManager: manager,
937
+ });
938
+
939
+ return new SdkRuntimeSession(runtime);
940
+ };
941
+
942
+ return new RuntimeRegistry(repository, { createSession });
943
+ }