@tt-a1i/openpi 0.4.0 → 0.5.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 (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -0,0 +1,789 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import {
5
+ createServer,
6
+ type IncomingMessage,
7
+ type Server,
8
+ type ServerResponse,
9
+ } from "node:http";
10
+ import { URL } from "node:url";
11
+ import { promisify } from "node:util";
12
+ import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts";
13
+ import { PiWebAdapter } from "../adapter/pi-adapter.ts";
14
+ import {
15
+ jsonByteLength,
16
+ WEB_MAX_EVENT_BYTES,
17
+ WEB_MAX_EVENTS,
18
+ WEB_MAX_SNAPSHOT_BYTES,
19
+ WEB_PROTOCOL_VERSION,
20
+ type WebEvent,
21
+ type WebSnapshot,
22
+ } from "../protocol/types.ts";
23
+ import {
24
+ WebRuntimeRequestError,
25
+ type WebRuntimeController,
26
+ } from "../runtime/types.ts";
27
+ import { elapsed, traceWeb } from "../trace.ts";
28
+ import { MARKED_BROWSER_URL } from "./static-assets.ts";
29
+
30
+ const HOST = "127.0.0.1";
31
+ const UI_ROOT = new URL("../ui/", import.meta.url);
32
+ const MAX_COMMAND_BYTES = 16 * 1024;
33
+ const MAX_SSE_CLIENTS = 8;
34
+ const MAX_SSE_BUFFER_BYTES = 256 * 1024;
35
+ const MAX_SSE_REPLAY_BYTES = MAX_SSE_BUFFER_BYTES;
36
+ const SERVER_CLOSE_DRAIN_MS = 500;
37
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
38
+ const execFileAsync = promisify(execFile);
39
+
40
+ export interface WebHostOptions {
41
+ runtime: WebRuntimeController;
42
+ onEvent?: (type: string, detail?: Record<string, unknown>) => void;
43
+ port?: number;
44
+ token?: string;
45
+ allowedOrigins?: readonly string[];
46
+ directoryChooser?: (signal: AbortSignal) => Promise<string | undefined>;
47
+ shutdownTimeoutMs?: number;
48
+ }
49
+
50
+ export class WebHost {
51
+ private readonly server: Server;
52
+ private readonly token: Buffer;
53
+ private readonly adapter: PiWebAdapter;
54
+ private readonly clients = new Set<ServerResponse>();
55
+ private readonly events: WebEvent[] = [];
56
+ private sequence = 0;
57
+ private port = 0;
58
+ private readonly runtime: WebRuntimeController;
59
+ private readonly requestedPort: number;
60
+ private readonly onEvent?: WebHostOptions["onEvent"];
61
+ private readonly allowedOrigins: ReadonlySet<string>;
62
+ private readonly directoryChooser: NonNullable<
63
+ WebHostOptions["directoryChooser"]
64
+ >;
65
+ private readonly shutdownTimeoutMs: number;
66
+ private readonly unsubscribeCapabilities: () => void;
67
+ private readonly unsubscribeRuntime: () => void;
68
+ private readonly chooserAbort = new AbortController();
69
+ private readonly leaseSensitiveRequests = new Set<Promise<void>>();
70
+ private readonly leaseSensitiveMessages = new Set<IncomingMessage>();
71
+ private stopping = false;
72
+ private stopPromise?: Promise<void>;
73
+
74
+ constructor(options: WebHostOptions) {
75
+ this.runtime = options.runtime;
76
+ this.requestedPort = options.port ?? 0;
77
+ this.token = options.token
78
+ ? Buffer.from(options.token, "hex")
79
+ : randomBytes(32);
80
+ if (this.token.length !== 32)
81
+ throw new Error("Web host token must be 64 hexadecimal characters");
82
+ this.allowedOrigins = new Set(options.allowedOrigins ?? []);
83
+ this.directoryChooser =
84
+ options.directoryChooser ?? (() => this.chooseDirectory());
85
+ this.shutdownTimeoutMs =
86
+ options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
87
+ if (
88
+ !Number.isSafeInteger(this.shutdownTimeoutMs) ||
89
+ this.shutdownTimeoutMs <= 0
90
+ ) {
91
+ throw new Error("Web host shutdown timeout must be a positive integer");
92
+ }
93
+ this.adapter = new PiWebAdapter(options.runtime);
94
+ this.onEvent = options.onEvent;
95
+ this.unsubscribeCapabilities = subscribeWebCapabilities((scope) => {
96
+ if (scope === this.runtime.sessionManager) this.publish("runtime_changed");
97
+ });
98
+ this.unsubscribeRuntime = this.runtime.subscribe(({ type, detail }) =>
99
+ this.publish(type, detail),
100
+ );
101
+ this.server = createServer((request, response) => {
102
+ const leaseSensitive = this.isLeaseSensitiveMutation(request);
103
+ if (this.stopping && leaseSensitive) {
104
+ request.resume();
105
+ response.setHeader("Connection", "close");
106
+ this.json(response, 503, {
107
+ code: "HOST_STOPPING",
108
+ error: "Web host is stopping",
109
+ });
110
+ response.once("finish", () => request.socket.destroy());
111
+ return;
112
+ }
113
+ const operation = this.dispatchRequest(request, response);
114
+ if (leaseSensitive) {
115
+ this.leaseSensitiveRequests.add(operation);
116
+ this.leaseSensitiveMessages.add(request);
117
+ void operation.then(
118
+ () => {
119
+ this.leaseSensitiveRequests.delete(operation);
120
+ this.leaseSensitiveMessages.delete(request);
121
+ },
122
+ () => {
123
+ this.leaseSensitiveRequests.delete(operation);
124
+ this.leaseSensitiveMessages.delete(request);
125
+ },
126
+ );
127
+ }
128
+ void operation;
129
+ });
130
+ }
131
+
132
+ async start() {
133
+ await this.adapter.initialize();
134
+ await new Promise<void>((resolve, reject) => {
135
+ this.server.once("error", reject);
136
+ this.server.listen(this.requestedPort, HOST, () => resolve());
137
+ });
138
+ const address = this.server.address();
139
+ if (!address || typeof address === "string")
140
+ throw new Error("Web host did not expose a TCP port");
141
+ this.port = address.port;
142
+ this.publish("web_host_started", {
143
+ port: this.port,
144
+ ...(this.runtime.workspaceSelected === true
145
+ ? { cwd: this.runtime.cwd }
146
+ : {}),
147
+ mode: "local-workbench",
148
+ });
149
+ }
150
+
151
+ get origin() {
152
+ return `http://${HOST}:${this.port}`;
153
+ }
154
+
155
+ get url() {
156
+ return `${this.origin}/#token=${this.token.toString("hex")}`;
157
+ }
158
+
159
+ publish(type: string, detail?: Record<string, unknown>) {
160
+ let event: WebEvent = {
161
+ protocolVersion: WEB_PROTOCOL_VERSION,
162
+ sequence: ++this.sequence,
163
+ type,
164
+ timestamp: new Date().toISOString(),
165
+ ...(detail ? { detail } : {}),
166
+ };
167
+ let serialized = JSON.stringify(event);
168
+ if (Buffer.byteLength(serialized) > WEB_MAX_EVENT_BYTES) {
169
+ event = {
170
+ protocolVersion: WEB_PROTOCOL_VERSION,
171
+ sequence: event.sequence,
172
+ type: "state_invalidated",
173
+ timestamp: event.timestamp,
174
+ detail: { reason: "event_too_large", originalType: type },
175
+ };
176
+ serialized = JSON.stringify(event);
177
+ }
178
+ this.events.push(event);
179
+ if (this.events.length > WEB_MAX_EVENTS) this.events.shift();
180
+ const record = `id: ${event.sequence}\ndata: ${serialized}\n\n`;
181
+ for (const client of this.clients) {
182
+ if (
183
+ client.destroyed ||
184
+ client.writableEnded ||
185
+ client.writableLength > MAX_SSE_BUFFER_BYTES ||
186
+ !client.write(record)
187
+ ) {
188
+ this.clients.delete(client);
189
+ client.destroy();
190
+ }
191
+ }
192
+ this.onEvent?.(event.type, event.detail);
193
+ traceWeb("sse_event", {
194
+ type: event.type,
195
+ sequence: event.sequence,
196
+ detailKeys: event.detail ? Object.keys(event.detail) : [],
197
+ });
198
+ }
199
+
200
+ stop() {
201
+ if (this.stopPromise) return this.stopPromise;
202
+ this.stopping = true;
203
+ this.stopPromise = (async () => {
204
+ this.unsubscribeCapabilities();
205
+ this.unsubscribeRuntime();
206
+ this.chooserAbort.abort();
207
+ for (const client of this.clients) client.end();
208
+ this.clients.clear();
209
+ const closeServer = this.server.listening
210
+ ? new Promise<void>((resolve) => {
211
+ const forceClose = setTimeout(
212
+ () => {
213
+ for (const request of this.leaseSensitiveMessages) {
214
+ request.destroy();
215
+ }
216
+ this.server.closeAllConnections();
217
+ },
218
+ SERVER_CLOSE_DRAIN_MS,
219
+ );
220
+ forceClose.unref();
221
+ this.server.close(() => {
222
+ clearTimeout(forceClose);
223
+ resolve();
224
+ });
225
+ this.server.closeIdleConnections();
226
+ })
227
+ : Promise.resolve();
228
+ const disposeRuntime = (async () => {
229
+ await this.drainLeaseSensitiveRequests();
230
+ await this.runtime.dispose();
231
+ })();
232
+ const cleanup = Promise.all([disposeRuntime, closeServer]).then(
233
+ () => undefined,
234
+ );
235
+ void cleanup.catch(() => undefined);
236
+ await new Promise<void>((resolve, reject) => {
237
+ const timeout = setTimeout(
238
+ () =>
239
+ reject(
240
+ new Error(
241
+ `Web runtime cleanup did not settle within ${this.shutdownTimeoutMs} ms; cleanup state is uncertain`,
242
+ ),
243
+ ),
244
+ this.shutdownTimeoutMs,
245
+ );
246
+ void cleanup.then(
247
+ () => {
248
+ clearTimeout(timeout);
249
+ resolve();
250
+ },
251
+ (error) => {
252
+ clearTimeout(timeout);
253
+ reject(error);
254
+ },
255
+ );
256
+ });
257
+ })();
258
+ return this.stopPromise;
259
+ }
260
+
261
+ private async dispatchRequest(
262
+ request: IncomingMessage,
263
+ response: ServerResponse,
264
+ ) {
265
+ try {
266
+ await this.handle(request, response);
267
+ } catch (error) {
268
+ if (response.destroyed || response.writableEnded) return;
269
+ this.json(response, 500, {
270
+ error: error instanceof Error ? error.message : "request failed",
271
+ });
272
+ }
273
+ }
274
+
275
+ private isLeaseSensitiveMutation(request: IncomingMessage) {
276
+ if (request.method === "GET" || request.method === "HEAD") return false;
277
+ const pathname = new URL(request.url ?? "/", `http://${HOST}`).pathname;
278
+ if (pathname === "/api/prompt") return false;
279
+ return pathname.startsWith("/api/workspaces") ||
280
+ pathname.startsWith("/api/sessions") ||
281
+ pathname === "/api/model";
282
+ }
283
+
284
+ private async drainLeaseSensitiveRequests() {
285
+ while (this.leaseSensitiveRequests.size > 0) {
286
+ await Promise.allSettled([...this.leaseSensitiveRequests]);
287
+ }
288
+ }
289
+
290
+ private async handle(request: IncomingMessage, response: ServerResponse) {
291
+ const url = new URL(request.url ?? "/", `http://${HOST}`);
292
+ const expectedHost = `${HOST}:${this.port}`;
293
+ if (
294
+ request.headers.host !== expectedHost ||
295
+ (request.headers.origin &&
296
+ request.headers.origin !== `http://${expectedHost}` &&
297
+ !this.allowedOrigins.has(request.headers.origin))
298
+ ) {
299
+ this.json(response, 403, { error: "invalid host or origin" });
300
+ return;
301
+ }
302
+ if (url.pathname === "/" && request.method === "GET") {
303
+ const body = await readFile(new URL("index.html", UI_ROOT));
304
+ response.writeHead(200, {
305
+ "Content-Type": "text/html; charset=utf-8",
306
+ "Cache-Control": "no-store",
307
+ "Content-Security-Policy":
308
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
309
+ "Referrer-Policy": "no-referrer",
310
+ });
311
+ response.end(body);
312
+ return;
313
+ }
314
+ if (
315
+ url.pathname === "/styles.css" ||
316
+ url.pathname === "/app.js" ||
317
+ url.pathname === "/marked.js"
318
+ ) {
319
+ if (request.method !== "GET")
320
+ return this.json(response, 405, {
321
+ error: "static assets accept GET only",
322
+ });
323
+ const file = url.pathname.slice(1);
324
+ const body = await readFile(
325
+ file === "marked.js"
326
+ ? MARKED_BROWSER_URL
327
+ : new URL(file, UI_ROOT),
328
+ );
329
+ response.writeHead(200, {
330
+ "Content-Type": file.endsWith(".css")
331
+ ? "text/css; charset=utf-8"
332
+ : "text/javascript; charset=utf-8",
333
+ "Cache-Control": "no-store",
334
+ });
335
+ response.end(body);
336
+ return;
337
+ }
338
+ if (!this.authorized(request))
339
+ return this.json(response, 401, { error: "invalid or missing token" });
340
+ if (
341
+ url.pathname === "/api/workspaces/select" &&
342
+ request.method === "POST"
343
+ ) {
344
+ const path = await this.directoryChooser(this.chooserAbort.signal);
345
+ if (!path) return this.json(response, 200, { cancelled: true });
346
+ const importedPath = await this.adapter.importWorkspace(path);
347
+ this.publish("workspace_imported", { path: importedPath });
348
+ return this.json(response, 201, { path: importedPath });
349
+ }
350
+ if (url.pathname === "/api/workspaces" && request.method === "POST") {
351
+ const body = await this.readJson(request);
352
+ if (typeof body.path !== "string" || body.path.trim().length === 0) {
353
+ return this.json(response, 400, {
354
+ error: "workspace path is required",
355
+ });
356
+ }
357
+ const path = await this.adapter.importWorkspace(body.path);
358
+ this.publish("workspace_imported", { path });
359
+ return this.json(response, 201, { path });
360
+ }
361
+ if (url.pathname === "/api/workspaces" && request.method === "PATCH") {
362
+ const body = await this.readJson(request);
363
+ if (typeof body.path !== "string" || typeof body.name !== "string") {
364
+ return this.json(response, 400, {
365
+ error: "workspace path and name are required",
366
+ });
367
+ }
368
+ const name = await this.adapter.renameWorkspace(body.path, body.name);
369
+ this.publish("workspace_renamed", { path: body.path, name });
370
+ return this.json(response, 200, { path: body.path, name });
371
+ }
372
+ if (url.pathname === "/api/workspaces" && request.method === "DELETE") {
373
+ const path = url.searchParams.get("path");
374
+ if (!path)
375
+ return this.json(response, 400, {
376
+ error: "workspace path is required",
377
+ });
378
+ await this.adapter.removeWorkspace(path);
379
+ this.publish("workspace_removed", { path });
380
+ return this.json(response, 200, { path, removed: true });
381
+ }
382
+ if (url.pathname === "/api/sessions" && request.method === "POST") {
383
+ const body = await this.readJson(request);
384
+ if (
385
+ typeof body.workspacePath !== "string" ||
386
+ typeof body.commandId !== "string" ||
387
+ body.commandId.length === 0 ||
388
+ body.commandId.length > 128
389
+ ) {
390
+ return this.json(response, 400, {
391
+ error: "workspace path and bounded commandId are required",
392
+ });
393
+ }
394
+ const workspacePath = await this.adapter.requireWorkspace(
395
+ body.workspacePath,
396
+ );
397
+ const result = await this.runtime.newSession(workspacePath, {
398
+ commandId: body.commandId,
399
+ });
400
+ this.publish("session_created", {
401
+ workspacePath,
402
+ commandId: body.commandId,
403
+ ...(result.sessionPath ? { sessionPath: result.sessionPath } : {}),
404
+ });
405
+ return this.json(response, 201, {
406
+ ...result,
407
+ commandId: body.commandId,
408
+ });
409
+ }
410
+ if (url.pathname === "/api/sessions" && request.method === "PATCH") {
411
+ const body = await this.readJson(request);
412
+ if (typeof body.path !== "string" || typeof body.name !== "string") {
413
+ return this.json(response, 400, {
414
+ error: "session path and name are required",
415
+ });
416
+ }
417
+ const name = await this.adapter.renameSession(body.path, body.name);
418
+ this.publish("session_renamed", { sessionPath: body.path, name });
419
+ return this.json(response, 200, { path: body.path, name });
420
+ }
421
+ if (url.pathname === "/api/sessions/archive" && request.method === "POST") {
422
+ const path = url.searchParams.get("path");
423
+ if (!path) return this.json(response, 400, { error: "session path is required" });
424
+ await this.adapter.archiveSession(path);
425
+ this.publish("session_archived", { sessionPath: path });
426
+ return this.json(response, 200, { path, archived: true });
427
+ }
428
+ if (url.pathname === "/api/sessions/select" && request.method === "POST") {
429
+ const body = await this.readJson(request);
430
+ if (typeof body.path !== "string" || body.path.trim().length === 0) {
431
+ return this.json(response, 400, { error: "session path is required" });
432
+ }
433
+ const session = await this.adapter.requireSession(body.path);
434
+ const result =
435
+ session.id === this.runtime.sessionManager.getSessionId()
436
+ ? { cancelled: false }
437
+ : await this.runtime.switchSession(session.path);
438
+ this.publish("session_selected", { sessionPath: session.path });
439
+ return this.json(response, 200, result);
440
+ }
441
+ if (url.pathname === "/api/model" && request.method === "POST") {
442
+ const body = await this.readJson(request);
443
+ if (
444
+ typeof body.provider !== "string" ||
445
+ typeof body.modelId !== "string" ||
446
+ typeof body.sessionId !== "string"
447
+ ) {
448
+ return this.json(response, 400, {
449
+ error: "provider, modelId, and sessionId are required",
450
+ });
451
+ }
452
+ if (this.runtime.workspaceSelected !== true) {
453
+ return this.json(response, 409, {
454
+ code: "WORKSPACE_REQUIRED",
455
+ error: "Choose a workspace before using the Web runtime",
456
+ });
457
+ }
458
+ try {
459
+ const model = await this.runtime.setModel(body.provider, body.modelId, {
460
+ expectedSessionId: body.sessionId,
461
+ });
462
+ this.publish("model_selected", {
463
+ provider: model.provider,
464
+ modelId: model.id,
465
+ });
466
+ return this.json(response, 200, model);
467
+ } catch (error) {
468
+ const failure = this.runtimeRequestFailure(
469
+ error,
470
+ "MODEL_SELECTION_FAILED",
471
+ "model selection failed",
472
+ );
473
+ return this.json(response, failure.status, {
474
+ code: failure.code,
475
+ error: failure.error,
476
+ });
477
+ }
478
+ }
479
+ if (url.pathname === "/api/prompt" && request.method === "POST") {
480
+ const requestStarted = performance.now();
481
+ const body = await this.readJson(request);
482
+ const content =
483
+ typeof body.content === "string" ? body.content.trim() : "";
484
+ if (!content || content.length > 12_000) {
485
+ return this.json(response, 400, {
486
+ error: "prompt must be 1-12000 characters",
487
+ });
488
+ }
489
+ if (this.runtime.workspaceSelected !== true) {
490
+ return this.json(response, 409, {
491
+ code: "WORKSPACE_REQUIRED",
492
+ error: "Choose a workspace before using the Web runtime",
493
+ });
494
+ }
495
+ if (
496
+ typeof body.sessionId !== "string" ||
497
+ body.sessionId !== this.runtime.sessionManager.getSessionId()
498
+ ) {
499
+ return this.json(response, 409, {
500
+ code: "SESSION_CONFLICT",
501
+ error: "Only the active Web session accepts messages",
502
+ });
503
+ }
504
+ const commandId = randomUUID();
505
+ traceWeb("prompt_received", {
506
+ commandId,
507
+ sessionId: body.sessionId,
508
+ chars: content.length,
509
+ });
510
+ try {
511
+ await this.runtime.sendPrompt(content, {
512
+ commandId,
513
+ expectedSessionId: body.sessionId,
514
+ });
515
+ traceWeb("prompt_admission_finished", {
516
+ commandId,
517
+ elapsedMs: elapsed(requestStarted),
518
+ });
519
+ } catch (error) {
520
+ const failure = this.runtimeRequestFailure(error);
521
+ traceWeb("prompt_admission_failed", {
522
+ commandId,
523
+ elapsedMs: elapsed(requestStarted),
524
+ error: failure.error,
525
+ });
526
+ return this.json(response, failure.status, {
527
+ code: failure.code,
528
+ error: failure.error,
529
+ });
530
+ }
531
+ this.publish("prompt_accepted", { commandId, sessionId: body.sessionId });
532
+ traceWeb("prompt_response_sent", {
533
+ commandId,
534
+ sessionId: body.sessionId,
535
+ elapsedMs: elapsed(requestStarted),
536
+ });
537
+ return this.json(response, 202, {
538
+ id: commandId,
539
+ accepted: true,
540
+ state: "accepted",
541
+ cursor: this.sequence,
542
+ });
543
+ }
544
+ if (request.method !== "GET") {
545
+ return this.json(response, 405, { error: "method not allowed" });
546
+ }
547
+ if (url.pathname === "/events") return this.eventsStream(request, response);
548
+ if (url.pathname === "/api/sessions") {
549
+ const projection = await this.adapter.listSessionProjection();
550
+ return this.json(response, 200, {
551
+ sessions: projection.sessions,
552
+ truncation: {
553
+ truncated: projection.omitted > 0,
554
+ sessionsOmitted: projection.omitted,
555
+ },
556
+ });
557
+ }
558
+ if (url.pathname === "/api/models")
559
+ return this.json(response, 200, { models: this.runtime.listModels() });
560
+ if (url.pathname === "/api/snapshot") {
561
+ const cursor = this.sequence;
562
+ const projection = await this.adapter.getSnapshot(
563
+ url.searchParams.get("path") ?? undefined,
564
+ );
565
+ const snapshot: WebSnapshot = {
566
+ protocolVersion: WEB_PROTOCOL_VERSION,
567
+ generatedAt: new Date().toISOString(),
568
+ cursor,
569
+ ...projection,
570
+ };
571
+ let finalBytes = jsonByteLength(snapshot);
572
+ while (snapshot.truncation.bytes !== finalBytes) {
573
+ snapshot.truncation.bytes = finalBytes;
574
+ finalBytes = jsonByteLength(snapshot);
575
+ }
576
+ return this.json(response, 200, snapshot);
577
+ }
578
+ if (url.pathname === "/api/session") {
579
+ const path = url.searchParams.get("path");
580
+ if (!path)
581
+ return this.json(response, 400, { error: "session path is required" });
582
+ const session = await this.adapter.getSession(path);
583
+ return session
584
+ ? this.json(response, 200, { session })
585
+ : this.json(response, 404, {
586
+ error: "session is not in the current workspace",
587
+ });
588
+ }
589
+ this.json(response, 404, { error: "not found" });
590
+ }
591
+
592
+ private async chooseDirectory() {
593
+ try {
594
+ if (process.platform === "darwin") {
595
+ const { stdout } = await execFileAsync(
596
+ "osascript",
597
+ [
598
+ "-e",
599
+ 'tell application "System Events" to activate',
600
+ "-e",
601
+ 'POSIX path of (choose folder with prompt "Choose a workspace")',
602
+ ],
603
+ { signal: this.chooserAbort.signal },
604
+ );
605
+ return stdout.trim() || undefined;
606
+ }
607
+ if (process.platform === "win32") {
608
+ const { stdout } = await execFileAsync(
609
+ "powershell.exe",
610
+ [
611
+ "-NoProfile",
612
+ "-Command",
613
+ "$dialog = New-Object -ComObject Shell.Application; $folder = $dialog.BrowseForFolder(0, 'Choose a workspace', 0); if ($folder) { $folder.Self.Path }",
614
+ ],
615
+ { signal: this.chooserAbort.signal },
616
+ );
617
+ return stdout.trim() || undefined;
618
+ }
619
+ const { stdout } = await execFileAsync(
620
+ "zenity",
621
+ ["--file-selection", "--directory", "--title=Choose a workspace"],
622
+ { signal: this.chooserAbort.signal },
623
+ );
624
+ return stdout.trim() || undefined;
625
+ } catch {
626
+ return undefined;
627
+ }
628
+ }
629
+
630
+ private async readJson(request: IncomingMessage) {
631
+ const chunks: Buffer[] = [];
632
+ let bytes = 0;
633
+ for await (const chunk of request) {
634
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
635
+ bytes += buffer.length;
636
+ if (bytes > MAX_COMMAND_BYTES)
637
+ throw new Error("request body is too large");
638
+ chunks.push(buffer);
639
+ }
640
+ try {
641
+ const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
642
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
643
+ throw new Error("request body must be an object");
644
+ }
645
+ return value as Record<string, unknown>;
646
+ } catch (error) {
647
+ throw new Error(
648
+ error instanceof SyntaxError
649
+ ? "request body is invalid JSON"
650
+ : String(error),
651
+ );
652
+ }
653
+ }
654
+
655
+ private authorized(request: IncomingMessage) {
656
+ const value = request.headers.authorization?.replace(/^Bearer\s+/i, "");
657
+ if (!value || !/^[0-9a-f]{64}$/i.test(value)) return false;
658
+ const candidate = Buffer.from(value, "hex");
659
+ return (
660
+ candidate.length === this.token.length &&
661
+ timingSafeEqual(candidate, this.token)
662
+ );
663
+ }
664
+
665
+ private eventsStream(request: IncomingMessage, response: ServerResponse) {
666
+ const url = new URL(request.url ?? "/events", `http://${HOST}`);
667
+ const queryCursor = this.parseCursor(url.searchParams.get("cursor"));
668
+ const headerValue = request.headers["last-event-id"];
669
+ const headerCursor = this.parseCursor(
670
+ Array.isArray(headerValue) ? headerValue[0] : headerValue,
671
+ );
672
+ if (queryCursor.invalid || headerCursor.invalid) {
673
+ return this.json(response, 400, {
674
+ code: "INVALID_CURSOR",
675
+ error: "cursor must be a non-negative integer",
676
+ });
677
+ }
678
+ if (
679
+ queryCursor.value !== undefined &&
680
+ headerCursor.value !== undefined &&
681
+ queryCursor.value !== headerCursor.value
682
+ ) {
683
+ return this.json(response, 400, {
684
+ code: "CURSOR_MISMATCH",
685
+ error: "cursor and Last-Event-ID must match",
686
+ });
687
+ }
688
+ const cursor = queryCursor.value ?? headerCursor.value;
689
+ const oldestCursor = this.events[0]?.sequence
690
+ ? this.events[0].sequence - 1
691
+ : this.sequence;
692
+ if (
693
+ cursor === undefined ||
694
+ cursor < oldestCursor ||
695
+ cursor > this.sequence
696
+ ) {
697
+ return this.json(response, 409, {
698
+ code: "RESYNC_REQUIRED",
699
+ error: "event history is not available for this cursor",
700
+ cursor: this.sequence,
701
+ oldestCursor,
702
+ });
703
+ }
704
+ if (this.clients.size >= MAX_SSE_CLIENTS) {
705
+ return this.json(response, 503, {
706
+ code: "SSE_CLIENT_LIMIT",
707
+ error: "too many event clients",
708
+ });
709
+ }
710
+ const replay = this.events
711
+ .filter((event) => event.sequence > cursor)
712
+ .map(
713
+ (event) =>
714
+ `id: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`,
715
+ );
716
+ const replayBytes = replay.reduce(
717
+ (bytes, record) => bytes + Buffer.byteLength(record),
718
+ Buffer.byteLength(": connected\n\n"),
719
+ );
720
+ if (replayBytes > MAX_SSE_REPLAY_BYTES) {
721
+ return this.json(response, 409, {
722
+ code: "RESYNC_REQUIRED",
723
+ error: "event replay exceeds the bounded transport budget",
724
+ cursor: this.sequence,
725
+ oldestCursor,
726
+ });
727
+ }
728
+ response.writeHead(200, {
729
+ "Content-Type": "text/event-stream; charset=utf-8",
730
+ "Cache-Control": "no-cache",
731
+ Connection: "keep-alive",
732
+ });
733
+ response.write(": connected\n\n");
734
+ // The complete replay is bounded before headers. A false return only means
735
+ // Node buffered the write; finishing this synchronous replay preserves
736
+ // ordering without treating normal backpressure as a broken client.
737
+ for (const record of replay) response.write(record);
738
+ this.clients.add(response);
739
+ response.on("close", () => this.clients.delete(response));
740
+ }
741
+
742
+ private parseCursor(value: string | undefined | null) {
743
+ if (value === undefined || value === null || value === "") {
744
+ return { invalid: false, value: undefined };
745
+ }
746
+ if (!/^\d+$/.test(value)) return { invalid: true, value: undefined };
747
+ const parsed = Number(value);
748
+ return Number.isSafeInteger(parsed)
749
+ ? { invalid: false, value: parsed }
750
+ : { invalid: true, value: undefined };
751
+ }
752
+
753
+ private runtimeRequestFailure(
754
+ error: unknown,
755
+ fallbackCode = "PROMPT_ADMISSION_FAILED",
756
+ fallbackMessage = "prompt admission failed",
757
+ ) {
758
+ if (error instanceof WebRuntimeRequestError) {
759
+ return {
760
+ status: error.statusCode,
761
+ code: error.code,
762
+ error: error.message,
763
+ };
764
+ }
765
+ return {
766
+ status: 500,
767
+ code: fallbackCode,
768
+ error: error instanceof Error ? error.message : fallbackMessage,
769
+ };
770
+ }
771
+
772
+ private json(response: ServerResponse, status: number, value: unknown) {
773
+ let body = JSON.stringify(value);
774
+ if (Buffer.byteLength(body) > WEB_MAX_SNAPSHOT_BYTES) {
775
+ status = 500;
776
+ body = JSON.stringify({
777
+ code: "RESPONSE_TOO_LARGE",
778
+ error: "response exceeded the Web protocol byte limit",
779
+ maxBytes: WEB_MAX_SNAPSHOT_BYTES,
780
+ });
781
+ }
782
+ response.writeHead(status, {
783
+ "Content-Type": "application/json; charset=utf-8",
784
+ "Cache-Control": "no-store",
785
+ "Content-Length": Buffer.byteLength(body),
786
+ });
787
+ response.end(body);
788
+ }
789
+ }