@tt-a1i/openpi 0.4.0 → 0.6.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 (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -0,0 +1,1206 @@
1
+ import { mkdir, realpath, stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import {
4
+ type AgentSession,
5
+ type AgentSessionEvent,
6
+ type AgentSessionRuntime,
7
+ type CreateAgentSessionRuntimeFactory,
8
+ ProjectTrustStore,
9
+ SessionManager,
10
+ SettingsManager,
11
+ createAgentSessionFromServices,
12
+ createAgentSessionRuntime,
13
+ createAgentSessionServices,
14
+ getAgentDir,
15
+ hasTrustRequiringProjectResources,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ type WebActiveTurn,
19
+ type WebModelSelectionOptions,
20
+ type WebPromptOptions,
21
+ type WebPromptAdmissionReceipt,
22
+ type WebRuntimeController,
23
+ type WebRuntimeEvent,
24
+ type WebSessionCreationOptions,
25
+ type WebTurnCancellationOptions,
26
+ type WebTurnCancellationResult,
27
+ WebRuntimeRequestError,
28
+ } from "./types.ts";
29
+ import { projectMessage } from "../protocol/types.ts";
30
+ import { elapsed, traceWeb } from "../trace.ts";
31
+ import {
32
+ applyHttpProxySettings,
33
+ configureHttpDispatcher,
34
+ type HttpDispatcherLease,
35
+ } from "../http-dispatcher.ts";
36
+ import {
37
+ acquireWebHostLease,
38
+ type WebHostLease,
39
+ } from "./web-host-lease.ts";
40
+
41
+ const STARTUP_TIMEOUT_MS = 15_000;
42
+ const TURN_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 10_000;
43
+ const BOOTSTRAP_WORKSPACE_DIRECTORY = ".bootstrap-workspace";
44
+
45
+ type PromptTrace = {
46
+ commandId: string;
47
+ sessionId: string;
48
+ startedAt: number;
49
+ started: boolean;
50
+ queued: boolean;
51
+ userMessageObserved: boolean;
52
+ epoch?: number;
53
+ outcome?: "completed" | "cancelled" | "failed" | "uncertain";
54
+ };
55
+
56
+ type TurnSettlement = WebActiveTurn & {
57
+ outcome: "completed" | "cancelled" | "failed" | "uncertain";
58
+ };
59
+
60
+ function errorText(error: unknown) {
61
+ return error instanceof Error ? error.message : String(error);
62
+ }
63
+
64
+ async function canonicalDirectory(path: string) {
65
+ const canonical = await realpath(resolve(path));
66
+ if (!(await stat(canonical)).isDirectory()) {
67
+ throw new Error("Workspace path is not a directory");
68
+ }
69
+ return canonical;
70
+ }
71
+
72
+ export class PiWebRuntime implements WebRuntimeController {
73
+ private runtime: AgentSessionRuntime;
74
+ private unsubscribeSession?: () => void;
75
+ private readonly listeners = new Set<(event: WebRuntimeEvent) => void>();
76
+ private readonly retainedRuntimes = new Set<AgentSessionRuntime>();
77
+ private readonly retainedSubscriptions = new Map<AgentSessionRuntime, () => void>();
78
+ private readonly inFlightRuntimes = new Map<AgentSessionRuntime, number>();
79
+ private readonly promptOperations = new Set<Promise<void>>();
80
+ private readonly runtimeOperations = new Set<Promise<void>>();
81
+ private readonly candidateRuntimes = new Set<AgentSessionRuntime>();
82
+ private readonly runtimeDisposals = new Set<Promise<void>>();
83
+ private runtimeDisposalFailure?: unknown;
84
+ private readonly runtimeDisposalPromises = new WeakMap<
85
+ AgentSessionRuntime,
86
+ Promise<void>
87
+ >();
88
+ private controllerMutation: Promise<void> = Promise.resolve();
89
+ private promptAdmission: Promise<void> = Promise.resolve();
90
+ private activePromptTrace?: PromptTrace;
91
+ private readonly pendingPromptTraces: PromptTrace[] = [];
92
+ private nextTurnEpoch = 0;
93
+ private readonly terminalTurnKeys = new Set<string>();
94
+ private readonly turnSettlementWaiters = new Map<
95
+ string,
96
+ Set<(settlement: TurnSettlement) => void>
97
+ >();
98
+ /** Native aborts remain owned by Pi until its agent_settled event arrives. */
99
+ private readonly turnAbortOperations = new Map<string, Promise<unknown>>();
100
+ private liveMessageKey?: string;
101
+ private liveMessageSequence = 0;
102
+ private readonly webSessionDirectory: string;
103
+ private readonly dispatcherLease: HttpDispatcherLease;
104
+ private readonly webHostLease: WebHostLease;
105
+ private disposed = false;
106
+ private disposePromise?: Promise<void>;
107
+ private hasSelectedWorkspace: boolean;
108
+
109
+ private constructor(
110
+ runtime: AgentSessionRuntime,
111
+ webSessionDirectory: string,
112
+ dispatcherLease: HttpDispatcherLease,
113
+ webHostLease: WebHostLease,
114
+ workspaceSelected: boolean,
115
+ ) {
116
+ this.runtime = runtime;
117
+ this.webSessionDirectory = webSessionDirectory;
118
+ this.dispatcherLease = dispatcherLease;
119
+ this.webHostLease = webHostLease;
120
+ this.hasSelectedWorkspace = workspaceSelected;
121
+ }
122
+
123
+ static async create(cwd: string) {
124
+ const canonicalCwd = await canonicalDirectory(cwd);
125
+ return PiWebRuntime.createForWorkspace(canonicalCwd, true);
126
+ }
127
+
128
+ static async createWithoutWorkspace() {
129
+ const webSessionDirectory = join(getAgentDir(), "web-sessions");
130
+ await mkdir(webSessionDirectory, { recursive: true, mode: 0o700 });
131
+ const bootstrapDirectory = join(
132
+ webSessionDirectory,
133
+ BOOTSTRAP_WORKSPACE_DIRECTORY,
134
+ );
135
+ await mkdir(bootstrapDirectory, { recursive: true, mode: 0o700 });
136
+ const canonicalCwd = await canonicalDirectory(bootstrapDirectory);
137
+ return PiWebRuntime.createForWorkspace(canonicalCwd, false);
138
+ }
139
+
140
+ private static async createForWorkspace(
141
+ canonicalCwd: string,
142
+ workspaceSelected: boolean,
143
+ ) {
144
+ const webSessionDirectory = join(getAgentDir(), "web-sessions");
145
+ const webHostLease = await acquireWebHostLease(webSessionDirectory);
146
+ let runtime: PiWebRuntime | undefined;
147
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
148
+ try {
149
+ const created = await PiWebRuntime.createRuntime(
150
+ canonicalCwd,
151
+ workspaceSelected
152
+ ? SessionManager.create(canonicalCwd, webSessionDirectory)
153
+ : SessionManager.inMemory(canonicalCwd),
154
+ );
155
+ runtime = new PiWebRuntime(
156
+ created.runtime,
157
+ webSessionDirectory,
158
+ created.dispatcherLease,
159
+ webHostLease,
160
+ workspaceSelected,
161
+ );
162
+ if (workspaceSelected) await runtime.startRuntimeSession();
163
+ return runtime;
164
+ } catch (error) {
165
+ try {
166
+ if (runtime) await runtime.dispose();
167
+ else await webHostLease.release();
168
+ } catch (cleanupError) {
169
+ throw new AggregateError(
170
+ [error, cleanupError],
171
+ "Failed to start and clean up the Web runtime",
172
+ );
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ get cwd() {
179
+ return this.runtime.cwd;
180
+ }
181
+
182
+ get workspaceSelected() {
183
+ return this.hasSelectedWorkspace;
184
+ }
185
+
186
+ get sessionDirectory() {
187
+ return this.webSessionDirectory;
188
+ }
189
+
190
+ get sessionManager() {
191
+ return this.runtime.session.sessionManager;
192
+ }
193
+
194
+ isIdle() {
195
+ return !this.runtime.session.isStreaming;
196
+ }
197
+
198
+ getActiveTurn() {
199
+ return this.activeTurnFromTrace(this.activePromptTrace);
200
+ }
201
+
202
+ cancelTurn(options: WebTurnCancellationOptions) {
203
+ return this.serializeControllerMutation(() =>
204
+ this.cancelActiveTurn(options),
205
+ );
206
+ }
207
+
208
+ private async cancelActiveTurn(
209
+ options: WebTurnCancellationOptions,
210
+ ): Promise<WebTurnCancellationResult> {
211
+ this.assertActive();
212
+ this.assertWorkspaceSelected();
213
+ const activeSessionId = this.runtime.session.sessionManager.getSessionId();
214
+ if (options.sessionId !== activeSessionId) {
215
+ return { ...options, state: "stale-session" };
216
+ }
217
+ const key = this.turnKey(options);
218
+ if (this.terminalTurnKeys.has(key)) {
219
+ return { ...options, state: "already-settled" };
220
+ }
221
+ const activeTurn = this.getActiveTurn();
222
+ if (
223
+ !activeTurn ||
224
+ activeTurn.commandId !== options.commandId ||
225
+ activeTurn.epoch !== options.epoch
226
+ ) {
227
+ return { ...options, state: "stale-turn" };
228
+ }
229
+ if (this.turnAbortOperations.has(key)) {
230
+ return {
231
+ ...options,
232
+ state: "failed",
233
+ error: "Cancellation is already waiting for Pi to settle this turn",
234
+ };
235
+ }
236
+
237
+ let ownWaiter: ((settlement: TurnSettlement) => void) | undefined;
238
+ const settlement = new Promise<TurnSettlement>((resolveSettlement) => {
239
+ ownWaiter = resolveSettlement;
240
+ const waiters = this.turnSettlementWaiters.get(key) ?? new Set();
241
+ waiters.add(resolveSettlement);
242
+ this.turnSettlementWaiters.set(key, waiters);
243
+ });
244
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
245
+ try {
246
+ const abortOperation = this.runtime.session.abort();
247
+ this.turnAbortOperations.set(key, abortOperation);
248
+ void abortOperation.catch(() => {
249
+ if (this.turnAbortOperations.get(key) === abortOperation) {
250
+ this.turnAbortOperations.delete(key);
251
+ }
252
+ });
253
+ const abortFailure = new Promise<never>((_, reject) => {
254
+ void abortOperation.catch(reject);
255
+ });
256
+ const settlementTimeout = new Promise<never>((_, reject) => {
257
+ timeoutHandle = setTimeout(
258
+ () =>
259
+ reject(
260
+ new Error(
261
+ "Cancellation did not settle within the bounded wait window",
262
+ ),
263
+ ),
264
+ TURN_CANCELLATION_SETTLEMENT_TIMEOUT_MS,
265
+ );
266
+ });
267
+ const terminal = await Promise.race([
268
+ settlement,
269
+ abortFailure,
270
+ settlementTimeout,
271
+ ]);
272
+ return {
273
+ ...options,
274
+ state:
275
+ terminal.outcome === "cancelled"
276
+ ? "accepted"
277
+ : terminal.outcome === "completed"
278
+ ? "already-settled"
279
+ : "failed",
280
+ ...(terminal.outcome === "failed"
281
+ ? { error: "The active turn failed while cancellation was requested" }
282
+ : terminal.outcome === "uncertain"
283
+ ? {
284
+ error:
285
+ "Pi settled without a terminal assistant outcome for this cancellation",
286
+ }
287
+ : {}),
288
+ };
289
+ } catch (error) {
290
+ return { ...options, state: "failed", error: errorText(error) };
291
+ } finally {
292
+ if (timeoutHandle) clearTimeout(timeoutHandle);
293
+ const waiters = this.turnSettlementWaiters.get(key);
294
+ if (waiters && ownWaiter) {
295
+ waiters.delete(ownWaiter);
296
+ if (waiters.size === 0) this.turnSettlementWaiters.delete(key);
297
+ }
298
+ }
299
+ }
300
+
301
+ listModels() {
302
+ const current = this.runtime.session.model;
303
+ const available = [...this.runtime.services.modelRuntime.getAvailableSnapshot()];
304
+ if (
305
+ current &&
306
+ !available.some(
307
+ (model) => model.provider === current.provider && model.id === current.id,
308
+ )
309
+ ) {
310
+ available.unshift(current);
311
+ }
312
+ return available.map((model) => ({
313
+ provider: model.provider,
314
+ id: model.id,
315
+ name: model.name,
316
+ label: model.name || `${model.provider}/${model.id}`,
317
+ current: current?.provider === model.provider && current.id === model.id,
318
+ }));
319
+ }
320
+
321
+ setModel(
322
+ provider: string,
323
+ modelId: string,
324
+ options?: WebModelSelectionOptions,
325
+ ) {
326
+ return this.serializeControllerMutation(() =>
327
+ this.applyModelSelection(provider, modelId, options),
328
+ );
329
+ }
330
+
331
+ private async applyModelSelection(
332
+ provider: string,
333
+ modelId: string,
334
+ options?: WebModelSelectionOptions,
335
+ ) {
336
+ this.assertActive();
337
+ this.assertWorkspaceSelected();
338
+ const agentRuntime = this.runtime;
339
+ if (
340
+ options?.expectedSessionId !== undefined &&
341
+ options.expectedSessionId !==
342
+ agentRuntime.session.sessionManager.getSessionId()
343
+ ) {
344
+ throw new WebRuntimeRequestError(
345
+ "Only the active Web session accepts model selection",
346
+ "SESSION_CONFLICT",
347
+ 409,
348
+ );
349
+ }
350
+ const { modelRuntime } = agentRuntime.services;
351
+ const model = modelRuntime.getModel(provider, modelId);
352
+ if (
353
+ !model ||
354
+ !modelRuntime
355
+ .getAvailableSnapshot()
356
+ .some((item) => item.provider === provider && item.id === modelId)
357
+ ) {
358
+ throw new WebRuntimeRequestError(
359
+ "Model is not available",
360
+ "MODEL_NOT_AVAILABLE",
361
+ 400,
362
+ );
363
+ }
364
+ this.retainRuntimeReference(agentRuntime);
365
+ try {
366
+ await agentRuntime.session.setModel(model);
367
+ const current = agentRuntime.session.model;
368
+ const selected = modelRuntime
369
+ .getAvailableSnapshot()
370
+ .map((item) => ({
371
+ provider: item.provider,
372
+ id: item.id,
373
+ name: item.name,
374
+ label: item.name || `${item.provider}/${item.id}`,
375
+ current:
376
+ current?.provider === item.provider && current.id === item.id,
377
+ }))
378
+ .find((item) => item.current);
379
+ if (!selected) throw new Error("Model selection was not confirmed");
380
+ this.emit("model_select", { provider, modelId });
381
+ return selected;
382
+ } finally {
383
+ this.releaseRuntimeReference(agentRuntime);
384
+ }
385
+ }
386
+
387
+ subscribe(listener: (event: WebRuntimeEvent) => void) {
388
+ this.listeners.add(listener);
389
+ return () => this.listeners.delete(listener);
390
+ }
391
+
392
+ async sendPrompt(content: string, options?: WebPromptOptions) {
393
+ this.assertActive();
394
+ this.assertWorkspaceSelected();
395
+ const agentRuntime = this.runtime;
396
+ const session = agentRuntime.session;
397
+ const sessionId = session.sessionManager.getSessionId();
398
+ if (
399
+ options?.expectedSessionId !== undefined &&
400
+ options.expectedSessionId !== sessionId
401
+ ) {
402
+ throw new WebRuntimeRequestError(
403
+ "Only the active Web session accepts messages",
404
+ "SESSION_CONFLICT",
405
+ 409,
406
+ );
407
+ }
408
+ const previousAdmission = this.promptAdmission;
409
+ let releaseAdmission: () => void = () => undefined;
410
+ this.promptAdmission = new Promise<void>((resolveAdmission) => {
411
+ releaseAdmission = resolveAdmission;
412
+ });
413
+ const startedAt = performance.now();
414
+ const promptTrace: PromptTrace | undefined = options?.commandId
415
+ ? {
416
+ commandId: options.commandId,
417
+ sessionId,
418
+ startedAt,
419
+ started: false,
420
+ queued: false,
421
+ userMessageObserved: false,
422
+ }
423
+ : undefined;
424
+ this.retainRuntimeReference(agentRuntime);
425
+ let resolveRequest: (receipt: WebPromptAdmissionReceipt) => void = () => undefined;
426
+ let rejectRequest: (error: unknown) => void = () => undefined;
427
+ const requestAdmission = new Promise<WebPromptAdmissionReceipt>(
428
+ (resolveRequestAdmission, reject) => {
429
+ resolveRequest = resolveRequestAdmission;
430
+ rejectRequest = reject;
431
+ },
432
+ );
433
+ const operation = (async () => {
434
+ let preflightObserved = false;
435
+ let admitted = false;
436
+ let agentLifecycleStarted = false;
437
+ let queuedForAgent = false;
438
+ let unsubscribePromptLifecycle: (() => void) | undefined;
439
+ try {
440
+ await previousAdmission;
441
+ this.assertActive();
442
+ if (promptTrace && agentRuntime === this.runtime) {
443
+ this.pendingPromptTraces.push(promptTrace);
444
+ this.activePromptTrace ??= this.pendingPromptTraces.shift();
445
+ }
446
+ if (promptTrace) {
447
+ traceWeb("prompt_dispatch_started", {
448
+ commandId: promptTrace.commandId,
449
+ sessionId,
450
+ chars: content.length,
451
+ provider: session.model?.provider,
452
+ modelId: session.model?.id,
453
+ });
454
+ traceWeb("prompt_preflight_started", {
455
+ commandId: promptTrace.commandId,
456
+ sessionId,
457
+ elapsedMs: elapsed(startedAt),
458
+ });
459
+ }
460
+ let followUpMessages = session.getFollowUpMessages().length;
461
+ unsubscribePromptLifecycle = session.subscribe((event) => {
462
+ if (event.type === "agent_start") agentLifecycleStarted = true;
463
+ if (event.type === "queue_update") {
464
+ if (event.followUp.length > followUpMessages) {
465
+ queuedForAgent = true;
466
+ if (promptTrace) promptTrace.queued = true;
467
+ }
468
+ followUpMessages = event.followUp.length;
469
+ }
470
+ });
471
+ await session.prompt(content, {
472
+ ...(session.isStreaming
473
+ ? { streamingBehavior: "followUp" as const }
474
+ : {}),
475
+ source: "rpc",
476
+ preflightResult: (accepted) => {
477
+ preflightObserved = true;
478
+ admitted = accepted;
479
+ releaseAdmission();
480
+ if (promptTrace) {
481
+ traceWeb(
482
+ accepted
483
+ ? "prompt_preflight_accepted"
484
+ : "prompt_preflight_rejected",
485
+ {
486
+ commandId: promptTrace.commandId,
487
+ sessionId,
488
+ elapsedMs: elapsed(startedAt),
489
+ },
490
+ );
491
+ }
492
+ if (accepted) {
493
+ resolveRequest({
494
+ pendingFollowUps: session.getFollowUpMessages().length,
495
+ });
496
+ } else {
497
+ rejectRequest(
498
+ new WebRuntimeRequestError(
499
+ "Prompt was rejected before admission",
500
+ "PROMPT_REJECTED",
501
+ 422,
502
+ ),
503
+ );
504
+ }
505
+ },
506
+ });
507
+ unsubscribePromptLifecycle();
508
+ unsubscribePromptLifecycle = undefined;
509
+ if (!preflightObserved || !admitted) {
510
+ rejectRequest(
511
+ new WebRuntimeRequestError(
512
+ preflightObserved
513
+ ? "Prompt was rejected before admission"
514
+ : "Pi completed the prompt without confirming admission",
515
+ "PROMPT_REJECTED",
516
+ 422,
517
+ ),
518
+ );
519
+ }
520
+ if (
521
+ admitted &&
522
+ options?.commandId &&
523
+ !agentLifecycleStarted &&
524
+ !queuedForAgent
525
+ ) {
526
+ this.emit("prompt_settled", {
527
+ commandId: options.commandId,
528
+ sessionId,
529
+ outcome: "handled",
530
+ });
531
+ }
532
+ if (promptTrace) {
533
+ traceWeb("prompt_operation_settled", {
534
+ commandId: promptTrace.commandId,
535
+ sessionId,
536
+ elapsedMs: elapsed(startedAt),
537
+ });
538
+ promptTrace.started =
539
+ this.activePromptTrace?.commandId === promptTrace.commandId
540
+ ? this.activePromptTrace.started
541
+ : promptTrace.started;
542
+ if (!promptTrace.queued && !promptTrace.started) {
543
+ this.removePromptTrace(promptTrace);
544
+ }
545
+ }
546
+ } catch (error) {
547
+ releaseAdmission();
548
+ if (!admitted) {
549
+ rejectRequest(
550
+ new WebRuntimeRequestError(
551
+ errorText(error),
552
+ "PROMPT_REJECTED",
553
+ 422,
554
+ ),
555
+ );
556
+ } else if (admitted) {
557
+ this.emit("prompt_failed", {
558
+ ...(options?.commandId ? { commandId: options.commandId } : {}),
559
+ sessionId,
560
+ error: errorText(error),
561
+ });
562
+ }
563
+ if (promptTrace) {
564
+ promptTrace.outcome = "failed";
565
+ traceWeb("prompt_operation_failed", {
566
+ commandId: promptTrace.commandId,
567
+ sessionId,
568
+ elapsedMs: elapsed(startedAt),
569
+ error: errorText(error),
570
+ });
571
+ this.removePromptTrace(promptTrace);
572
+ }
573
+ } finally {
574
+ unsubscribePromptLifecycle?.();
575
+ releaseAdmission();
576
+ this.releaseRuntimeReference(agentRuntime);
577
+ }
578
+ })();
579
+ this.promptOperations.add(operation);
580
+ void operation.then(
581
+ () => this.promptOperations.delete(operation),
582
+ () => this.promptOperations.delete(operation),
583
+ );
584
+ return await requestAdmission;
585
+ }
586
+
587
+ newSession(workspacePath: string, options?: WebSessionCreationOptions) {
588
+ return this.serializeControllerMutation(() =>
589
+ this.createNewSession(workspacePath, options),
590
+ );
591
+ }
592
+
593
+ private async createNewSession(
594
+ workspacePath: string,
595
+ options?: WebSessionCreationOptions,
596
+ ) {
597
+ const cwd = await canonicalDirectory(workspacePath);
598
+ this.assertActive();
599
+ const replacement = await PiWebRuntime.createRuntime(
600
+ cwd,
601
+ SessionManager.create(cwd, this.webSessionDirectory),
602
+ this.dispatcherLease,
603
+ );
604
+ await this.activateCandidate(replacement.runtime);
605
+ this.hasSelectedWorkspace = true;
606
+ const sessionPath = this.runtime.session.sessionManager.getSessionFile();
607
+ this.emit("session_switched", {
608
+ ...(options?.commandId ? { commandId: options.commandId } : {}),
609
+ ...(sessionPath ? { sessionPath } : {}),
610
+ });
611
+ return {
612
+ cancelled: false,
613
+ ...(options?.commandId ? { commandId: options.commandId } : {}),
614
+ ...(sessionPath ? { sessionPath } : {}),
615
+ };
616
+ }
617
+
618
+ switchSession(sessionPath: string) {
619
+ return this.serializeControllerMutation(() =>
620
+ this.switchActiveSession(sessionPath),
621
+ );
622
+ }
623
+
624
+ private async switchActiveSession(sessionPath: string) {
625
+ if (this.runtime.session.sessionManager.getSessionFile() === sessionPath) {
626
+ return { cancelled: false };
627
+ }
628
+ const retained = [...this.retainedRuntimes].find(
629
+ (candidate) =>
630
+ candidate.session.sessionManager.getSessionFile() === sessionPath,
631
+ );
632
+ if (retained) {
633
+ await this.promoteRetainedRuntime(retained);
634
+ this.hasSelectedWorkspace = true;
635
+ this.emit("session_switched", { sessionPath });
636
+ return { cancelled: false };
637
+ }
638
+ const sessionManager = SessionManager.open(
639
+ sessionPath,
640
+ this.webSessionDirectory,
641
+ );
642
+ const cwd = await canonicalDirectory(sessionManager.getCwd());
643
+ this.assertActive();
644
+ const replacement = await PiWebRuntime.createRuntime(
645
+ cwd,
646
+ sessionManager,
647
+ this.dispatcherLease,
648
+ );
649
+ await this.activateCandidate(replacement.runtime);
650
+ this.hasSelectedWorkspace = true;
651
+ this.emit("session_switched", { sessionPath });
652
+ return { cancelled: false };
653
+ }
654
+
655
+ dispose() {
656
+ this.disposePromise ??= this.disposeInternal();
657
+ return this.disposePromise;
658
+ }
659
+
660
+ private async disposeInternal() {
661
+ this.disposed = true;
662
+ this.unsubscribeSession?.();
663
+ this.unsubscribeSession = undefined;
664
+ const runtimes = new Set([
665
+ this.runtime,
666
+ ...this.retainedRuntimes,
667
+ ...this.candidateRuntimes,
668
+ ]);
669
+ for (const retained of this.retainedRuntimes) {
670
+ this.retainedSubscriptions.get(retained)?.();
671
+ }
672
+ const failures: unknown[] = [];
673
+ try {
674
+ const aborts = await Promise.allSettled(
675
+ [...runtimes].map((runtime) => runtime.session.abort()),
676
+ );
677
+ failures.push(
678
+ ...aborts
679
+ .filter((result) => result.status === "rejected")
680
+ .map((result) => result.reason),
681
+ );
682
+ await Promise.all([...this.promptOperations]);
683
+ await Promise.all([...this.runtimeOperations]);
684
+ const finalRuntimes = new Set([
685
+ ...runtimes,
686
+ this.runtime,
687
+ ...this.retainedRuntimes,
688
+ ...this.candidateRuntimes,
689
+ ]);
690
+ const lateRuntimes = [...finalRuntimes].filter(
691
+ (runtime) => !runtimes.has(runtime),
692
+ );
693
+ const lateAborts = await Promise.allSettled(
694
+ lateRuntimes.map((runtime) => runtime.session.abort()),
695
+ );
696
+ failures.push(
697
+ ...lateAborts
698
+ .filter((result) => result.status === "rejected")
699
+ .map((result) => result.reason),
700
+ );
701
+ await Promise.allSettled(
702
+ [...finalRuntimes].map((runtime) => this.disposeAgentRuntime(runtime)),
703
+ );
704
+ await Promise.allSettled([...this.runtimeDisposals]);
705
+ if (this.runtimeDisposalFailure !== undefined) {
706
+ failures.push(this.runtimeDisposalFailure);
707
+ this.runtimeDisposalFailure = undefined;
708
+ }
709
+ } finally {
710
+ try {
711
+ await this.dispatcherLease.release();
712
+ } catch (error) {
713
+ failures.push(error);
714
+ }
715
+ try {
716
+ await this.webHostLease.release();
717
+ } catch (error) {
718
+ failures.push(error);
719
+ }
720
+ this.retainedRuntimes.clear();
721
+ this.retainedSubscriptions.clear();
722
+ this.candidateRuntimes.clear();
723
+ this.runtimeOperations.clear();
724
+ this.listeners.clear();
725
+ }
726
+ if (failures.length > 0) {
727
+ throw new AggregateError(failures, "Failed to dispose the Web runtime");
728
+ }
729
+ }
730
+
731
+ private static async createRuntime(
732
+ cwd: string,
733
+ sessionManager: SessionManager,
734
+ dispatcherLease?: HttpDispatcherLease,
735
+ ) {
736
+ const agentDir = getAgentDir();
737
+ let sharedDispatcherLease = dispatcherLease;
738
+ let ownsDispatcherLease = false;
739
+ const trustStore = new ProjectTrustStore(agentDir);
740
+ const createRuntime: CreateAgentSessionRuntimeFactory = async (options) => {
741
+ const projectTrusted =
742
+ !hasTrustRequiringProjectResources(options.cwd) ||
743
+ trustStore.get(options.cwd) === true;
744
+ const settingsManager = SettingsManager.create(
745
+ options.cwd,
746
+ options.agentDir,
747
+ { projectTrusted },
748
+ );
749
+ const httpProxyConfigured = applyHttpProxySettings(
750
+ settingsManager.getGlobalSettings().httpProxy,
751
+ );
752
+ if (!sharedDispatcherLease) {
753
+ sharedDispatcherLease = configureHttpDispatcher(
754
+ settingsManager.getHttpIdleTimeoutMs(),
755
+ );
756
+ ownsDispatcherLease = true;
757
+ }
758
+ const services = await createAgentSessionServices({
759
+ cwd: options.cwd,
760
+ agentDir: options.agentDir,
761
+ settingsManager,
762
+ modelRuntimeSignal: AbortSignal.timeout(STARTUP_TIMEOUT_MS),
763
+ });
764
+ const extensionErrors = services.resourceLoader
765
+ .getExtensions()
766
+ .errors.map(({ path, error }) => `Failed to load extension "${path}": ${error}`);
767
+ const errors = [
768
+ ...services.diagnostics
769
+ .filter((diagnostic) => diagnostic.type === "error")
770
+ .map((diagnostic) => diagnostic.message),
771
+ ...extensionErrors,
772
+ ];
773
+ if (errors.length > 0) throw new Error(errors.join("; "));
774
+ const created = await createAgentSessionFromServices({
775
+ services,
776
+ sessionManager: options.sessionManager,
777
+ sessionStartEvent: options.sessionStartEvent,
778
+ });
779
+ const model = created.session.model;
780
+ traceWeb("provider_config", {
781
+ provider: model?.provider,
782
+ modelId: model?.id,
783
+ api: model?.api,
784
+ baseOrigin: model?.baseUrl ? new URL(model.baseUrl).origin : undefined,
785
+ httpIdleTimeoutMs: sharedDispatcherLease.timeoutMs,
786
+ providerRetry: settingsManager.getProviderRetrySettings(),
787
+ httpProxyConfigured,
788
+ });
789
+ return {
790
+ ...created,
791
+ services,
792
+ diagnostics: services.diagnostics,
793
+ };
794
+ };
795
+ try {
796
+ const runtime = await createAgentSessionRuntime(createRuntime, {
797
+ cwd,
798
+ agentDir,
799
+ sessionManager,
800
+ });
801
+ if (!sharedDispatcherLease) {
802
+ throw new Error("HTTP dispatcher lease was not created");
803
+ }
804
+ return { runtime, dispatcherLease: sharedDispatcherLease };
805
+ } catch (error) {
806
+ if (ownsDispatcherLease) await sharedDispatcherLease?.release();
807
+ throw error;
808
+ }
809
+ }
810
+
811
+ private async startRuntimeSession() {
812
+ const runtime = this.runtime;
813
+ await this.initializeRuntimeSession(runtime);
814
+ this.attachActiveSession(runtime, runtime.session);
815
+ }
816
+
817
+ private async initializeRuntimeSession(runtime: AgentSessionRuntime) {
818
+ await this.bindExtensions(runtime, runtime.session);
819
+ runtime.setRebindSession(async (replacement) => {
820
+ this.assertActiveRuntime(runtime);
821
+ await this.bindExtensions(runtime, replacement);
822
+ this.assertActiveRuntime(runtime);
823
+ this.attachActiveSession(runtime, replacement);
824
+ });
825
+ }
826
+
827
+ private async bindExtensions(
828
+ runtime: AgentSessionRuntime,
829
+ session: AgentSession,
830
+ ) {
831
+ const startedAt = performance.now();
832
+ traceWeb("extensions_bind_started", {
833
+ sessionId: session.sessionManager.getSessionId(),
834
+ cwd: runtime.cwd,
835
+ });
836
+ await session.bindExtensions({ mode: "print" });
837
+ traceWeb("extensions_bind_finished", {
838
+ sessionId: session.sessionManager.getSessionId(),
839
+ elapsedMs: elapsed(startedAt),
840
+ });
841
+ }
842
+
843
+ private attachActiveSession(
844
+ runtime: AgentSessionRuntime,
845
+ session: AgentSession,
846
+ ) {
847
+ this.assertActiveRuntime(runtime);
848
+ const unsubscribe = session.subscribe((event) =>
849
+ this.projectEvent(session, event),
850
+ );
851
+ const previous = this.unsubscribeSession;
852
+ this.unsubscribeSession = unsubscribe;
853
+ previous?.();
854
+ }
855
+
856
+ private projectEvent(session: AgentSession, event: AgentSessionEvent) {
857
+ if (session !== this.runtime.session) return;
858
+ if (event.type === "agent_start" && this.activePromptTrace) {
859
+ this.startPromptTrace(this.activePromptTrace);
860
+ }
861
+ if (event.type === "message_start" && event.message.role === "user") {
862
+ if (!this.activePromptTrace) {
863
+ this.activePromptTrace = this.pendingPromptTraces.shift();
864
+ }
865
+ if (this.activePromptTrace) {
866
+ this.startPromptTrace(this.activePromptTrace);
867
+ this.activePromptTrace.userMessageObserved = true;
868
+ }
869
+ }
870
+ const promptTrace = this.activePromptTrace;
871
+ if (promptTrace) {
872
+ const eventDetail: Record<string, unknown> = {
873
+ commandId: promptTrace.commandId,
874
+ sessionId: promptTrace.sessionId,
875
+ type: event.type,
876
+ elapsedMs: elapsed(promptTrace.startedAt),
877
+ };
878
+ if (event.type === "message_update") {
879
+ eventDetail.contentChars = projectMessage(event.message).content.length;
880
+ }
881
+ if (event.type === "message_start" || event.type === "message_end") {
882
+ eventDetail.role = event.message.role;
883
+ const message = event.message as { stopReason?: unknown; errorMessage?: unknown };
884
+ if (typeof message.stopReason === "string") {
885
+ eventDetail.stopReason = message.stopReason;
886
+ }
887
+ if (typeof message.errorMessage === "string") {
888
+ eventDetail.errorMessage = message.errorMessage;
889
+ }
890
+ }
891
+ if (event.type === "auto_retry_start") {
892
+ eventDetail.attempt = event.attempt;
893
+ eventDetail.maxAttempts = event.maxAttempts;
894
+ eventDetail.delayMs = event.delayMs;
895
+ eventDetail.errorMessage = event.errorMessage;
896
+ }
897
+ if (event.type === "auto_retry_end") {
898
+ eventDetail.attempt = event.attempt;
899
+ eventDetail.success = event.success;
900
+ if (event.finalError) eventDetail.finalError = event.finalError;
901
+ }
902
+ if (event.type === "agent_end") eventDetail.willRetry = event.willRetry;
903
+ traceWeb("agent_event", eventDetail);
904
+ }
905
+ switch (event.type) {
906
+ case "agent_start":
907
+ this.emit(event.type, {
908
+ sessionId: session.sessionManager.getSessionId(),
909
+ ...(this.getActiveTurn()
910
+ ? { activeTurn: this.getActiveTurn() }
911
+ : {}),
912
+ });
913
+ break;
914
+ case "agent_settled":
915
+ // Pi emits this only after the whole agent run (including tool loops
916
+ // and admitted follow-ups) has reached a terminal state. A
917
+ // message_end is only one model response and must not settle a turn.
918
+ if (this.activePromptTrace?.started) {
919
+ this.settlePromptTrace(this.activePromptTrace);
920
+ }
921
+ this.activePromptTrace = undefined;
922
+ this.pendingPromptTraces.length = 0;
923
+ this.emit(event.type, {
924
+ sessionId: session.sessionManager.getSessionId(),
925
+ });
926
+ break;
927
+ case "auto_retry_start":
928
+ this.emit(event.type, {
929
+ attempt: event.attempt,
930
+ maxAttempts: event.maxAttempts,
931
+ delayMs: event.delayMs,
932
+ });
933
+ break;
934
+ case "message_start":
935
+ this.liveMessageKey = `live-${++this.liveMessageSequence}`;
936
+ this.emit(event.type, {
937
+ message: projectMessage(event.message),
938
+ messageKey: this.liveMessageKey,
939
+ });
940
+ break;
941
+ case "message_update":
942
+ case "message_end":
943
+ if (
944
+ event.type === "message_end" &&
945
+ event.message.role === "assistant" &&
946
+ this.activePromptTrace
947
+ ) {
948
+ // Preserve the terminal model result for classification, but defer
949
+ // publication until Pi confirms the entire run is settled.
950
+ const outcome =
951
+ event.message.stopReason === "aborted"
952
+ ? "cancelled"
953
+ : event.message.stopReason === "error"
954
+ ? "failed"
955
+ : event.message.stopReason === "stop" ||
956
+ event.message.stopReason === "length"
957
+ ? "completed"
958
+ : undefined;
959
+ // A later queued continuation must not erase proof that the
960
+ // provider result targeted by Stop was aborted. The control remains
961
+ // owned until agent_settled; this outcome does not claim that every
962
+ // queued follow-up in the same Pi execution was cancelled.
963
+ if (outcome && this.activePromptTrace.outcome !== "cancelled") {
964
+ this.activePromptTrace.outcome = outcome;
965
+ }
966
+ }
967
+ this.emit(event.type, {
968
+ message: projectMessage(event.message),
969
+ ...(this.liveMessageKey ? { messageKey: this.liveMessageKey } : {}),
970
+ });
971
+ if (event.type === "message_end") this.liveMessageKey = undefined;
972
+ break;
973
+ case "tool_execution_start":
974
+ case "tool_execution_end":
975
+ this.emit(event.type, {
976
+ toolName: event.toolName,
977
+ toolCallId: event.toolCallId,
978
+ ...(event.type === "tool_execution_end"
979
+ ? { isError: event.isError }
980
+ : {}),
981
+ });
982
+ break;
983
+ }
984
+ }
985
+
986
+ private emit(type: string, detail?: Record<string, unknown>) {
987
+ for (const listener of this.listeners) listener({ type, detail });
988
+ }
989
+
990
+ private activeTurnFromTrace(trace?: PromptTrace): WebActiveTurn | undefined {
991
+ if (!trace?.started || trace.epoch === undefined) return undefined;
992
+ return {
993
+ sessionId: trace.sessionId,
994
+ commandId: trace.commandId,
995
+ epoch: trace.epoch,
996
+ };
997
+ }
998
+
999
+ private startPromptTrace(trace: PromptTrace) {
1000
+ if (trace.started) return;
1001
+ trace.started = true;
1002
+ trace.epoch = ++this.nextTurnEpoch;
1003
+ const activeTurn = this.activeTurnFromTrace(trace);
1004
+ if (activeTurn) this.emit("turn_started", { ...activeTurn });
1005
+ }
1006
+
1007
+ private settlePromptTrace(trace: PromptTrace) {
1008
+ const activeTurn = this.activeTurnFromTrace(trace);
1009
+ if (!activeTurn) return;
1010
+ const settlement: TurnSettlement = {
1011
+ ...activeTurn,
1012
+ outcome:
1013
+ trace.outcome ?? "uncertain",
1014
+ };
1015
+ const key = this.turnKey(activeTurn);
1016
+ if (this.terminalTurnKeys.has(key)) return;
1017
+ this.terminalTurnKeys.add(key);
1018
+ this.turnAbortOperations.delete(key);
1019
+ while (this.terminalTurnKeys.size > 64) {
1020
+ const oldest = this.terminalTurnKeys.values().next().value;
1021
+ if (typeof oldest === "string") this.terminalTurnKeys.delete(oldest);
1022
+ }
1023
+ this.emit("turn_settled", { ...settlement });
1024
+ for (const resolveSettlement of this.turnSettlementWaiters.get(key) ?? []) {
1025
+ resolveSettlement(settlement);
1026
+ }
1027
+ this.turnSettlementWaiters.delete(key);
1028
+ }
1029
+
1030
+ private turnKey(turn: WebActiveTurn) {
1031
+ return `${turn.sessionId}\u0000${turn.commandId}\u0000${turn.epoch}`;
1032
+ }
1033
+
1034
+ private removePromptTrace(trace: PromptTrace) {
1035
+ const pendingIndex = this.pendingPromptTraces.indexOf(trace);
1036
+ if (pendingIndex !== -1) this.pendingPromptTraces.splice(pendingIndex, 1);
1037
+ if (this.activePromptTrace !== trace) return;
1038
+ // A started trace can only be terminally projected by agent_settled.
1039
+ if (trace.started) return;
1040
+ this.activePromptTrace = this.pendingPromptTraces.shift();
1041
+ }
1042
+
1043
+ private retainRuntimeReference(runtime: AgentSessionRuntime) {
1044
+ this.inFlightRuntimes.set(
1045
+ runtime,
1046
+ (this.inFlightRuntimes.get(runtime) ?? 0) + 1,
1047
+ );
1048
+ }
1049
+
1050
+ private releaseRuntimeReference(runtime: AgentSessionRuntime) {
1051
+ const remaining = (this.inFlightRuntimes.get(runtime) ?? 1) - 1;
1052
+ if (remaining > 0) this.inFlightRuntimes.set(runtime, remaining);
1053
+ else this.inFlightRuntimes.delete(runtime);
1054
+ this.releaseRetainedRuntime(runtime);
1055
+ }
1056
+
1057
+ private trackRuntimeOperation<T>(operation: Promise<T>) {
1058
+ const settlement = operation.then(
1059
+ () => undefined,
1060
+ () => undefined,
1061
+ );
1062
+ this.runtimeOperations.add(settlement);
1063
+ void settlement.then(() => this.runtimeOperations.delete(settlement));
1064
+ return operation;
1065
+ }
1066
+
1067
+ private serializeControllerMutation<T>(operation: () => Promise<T>) {
1068
+ const result = (this.controllerMutation ?? Promise.resolve()).then(() => {
1069
+ this.assertActive();
1070
+ return operation();
1071
+ });
1072
+ this.controllerMutation = result.then(
1073
+ () => undefined,
1074
+ () => undefined,
1075
+ );
1076
+ return this.trackRuntimeOperation(result);
1077
+ }
1078
+
1079
+ private disposeAgentRuntime(runtime: AgentSessionRuntime) {
1080
+ const existing = this.runtimeDisposalPromises.get(runtime);
1081
+ if (existing) return existing;
1082
+ const disposal = Promise.resolve().then(() => runtime.dispose());
1083
+ this.runtimeDisposalPromises.set(runtime, disposal);
1084
+ this.runtimeDisposals.add(disposal);
1085
+ void disposal.catch(() => undefined);
1086
+ void disposal.then(
1087
+ () => this.runtimeDisposals.delete(disposal),
1088
+ (error) => {
1089
+ this.runtimeDisposals.delete(disposal);
1090
+ this.runtimeDisposalFailure ??= error;
1091
+ this.emit("runtime_dispose_failed", { error: errorText(error) });
1092
+ },
1093
+ );
1094
+ return disposal;
1095
+ }
1096
+
1097
+ private retainRuntime(runtime: AgentSessionRuntime) {
1098
+ this.retainedRuntimes.add(runtime);
1099
+ const unsubscribe = runtime.session.subscribe((event) => {
1100
+ if (event.type !== "agent_settled") return;
1101
+ this.emit("session_progress", {
1102
+ sessionId: runtime.session.sessionManager.getSessionId(),
1103
+ });
1104
+ this.releaseRetainedRuntime(runtime);
1105
+ });
1106
+ this.retainedSubscriptions.set(runtime, unsubscribe);
1107
+ this.releaseRetainedRuntime(runtime);
1108
+ }
1109
+
1110
+ private async promoteRetainedRuntime(runtime: AgentSessionRuntime) {
1111
+ const previous = this.runtime;
1112
+ this.retainedSubscriptions.get(runtime)?.();
1113
+ this.retainedSubscriptions.delete(runtime);
1114
+ this.retainedRuntimes.delete(runtime);
1115
+ this.unsubscribeSession?.();
1116
+ this.unsubscribeSession = undefined;
1117
+ this.resetPromptTraces();
1118
+ this.retainRuntime(previous);
1119
+ this.runtime = runtime;
1120
+ this.attachActiveSession(runtime, runtime.session);
1121
+ }
1122
+
1123
+ private releaseRetainedRuntime(runtime: AgentSessionRuntime) {
1124
+ if (!this.retainedRuntimes.has(runtime)) return;
1125
+ if (runtime.session.isStreaming || this.inFlightRuntimes.has(runtime)) return;
1126
+ this.retainedSubscriptions.get(runtime)?.();
1127
+ this.retainedSubscriptions.delete(runtime);
1128
+ this.retainedRuntimes.delete(runtime);
1129
+ void this.disposeAgentRuntime(runtime);
1130
+ }
1131
+
1132
+ private async activateCandidate(runtime: AgentSessionRuntime) {
1133
+ this.candidateRuntimes.add(runtime);
1134
+ try {
1135
+ await this.replaceRuntime(runtime);
1136
+ } finally {
1137
+ this.candidateRuntimes.delete(runtime);
1138
+ }
1139
+ }
1140
+
1141
+ private async replaceRuntime(replacement: AgentSessionRuntime) {
1142
+ const previous = this.runtime;
1143
+ try {
1144
+ this.assertActive();
1145
+ await this.initializeRuntimeSession(replacement);
1146
+ this.assertActive();
1147
+ if (this.runtime !== previous) {
1148
+ throw new Error("The active Web runtime changed during replacement");
1149
+ }
1150
+ } catch (error) {
1151
+ try {
1152
+ await this.disposeAgentRuntime(replacement);
1153
+ } catch (cleanupError) {
1154
+ throw new AggregateError(
1155
+ [error, cleanupError],
1156
+ "Failed to activate and dispose the replacement Web runtime",
1157
+ );
1158
+ }
1159
+ throw error;
1160
+ }
1161
+ this.runtime = replacement;
1162
+ try {
1163
+ this.attachActiveSession(replacement, replacement.session);
1164
+ } catch (error) {
1165
+ this.runtime = previous;
1166
+ try {
1167
+ await this.disposeAgentRuntime(replacement);
1168
+ } catch (cleanupError) {
1169
+ throw new AggregateError(
1170
+ [error, cleanupError],
1171
+ "Failed to attach and dispose the replacement Web runtime",
1172
+ );
1173
+ }
1174
+ throw error;
1175
+ }
1176
+ this.resetPromptTraces();
1177
+ this.retainRuntime(previous);
1178
+ }
1179
+
1180
+ private assertActive() {
1181
+ if (this.disposed) throw new Error("Web runtime is stopped");
1182
+ }
1183
+
1184
+ private assertWorkspaceSelected() {
1185
+ if (this.hasSelectedWorkspace) return;
1186
+ throw new WebRuntimeRequestError(
1187
+ "Choose a workspace before using the Web runtime",
1188
+ "WORKSPACE_REQUIRED",
1189
+ 409,
1190
+ );
1191
+ }
1192
+
1193
+ private assertActiveRuntime(runtime: AgentSessionRuntime) {
1194
+ this.assertActive();
1195
+ if (runtime !== this.runtime) {
1196
+ this.releaseRetainedRuntime(runtime);
1197
+ throw new Error("A retained Web runtime cannot replace its Session");
1198
+ }
1199
+ }
1200
+
1201
+ private resetPromptTraces() {
1202
+ this.activePromptTrace = undefined;
1203
+ this.pendingPromptTraces.length = 0;
1204
+ this.turnAbortOperations.clear();
1205
+ }
1206
+ }