@tt-a1i/openpi 0.5.0 → 0.6.1

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 (80) hide show
  1. package/README.md +30 -20
  2. package/SETUP.md +10 -4
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +65 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +105 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1271 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1181 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1431 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -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 +8 -1
  31. package/extensions/background-terminals/src/manager.ts +3 -5
  32. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  33. package/extensions/cron/index.ts +68 -27
  34. package/extensions/cron/schedule.ts +5 -1
  35. package/extensions/model-info/cache-diagnostics.ts +220 -0
  36. package/extensions/model-info/index.ts +45 -1
  37. package/extensions/plan-mode/index.ts +75 -4
  38. package/extensions/setup/index.ts +15 -3
  39. package/extensions/shared/child-session.ts +39 -5
  40. package/extensions/shared/completion-inbox.ts +193 -0
  41. package/extensions/shared/setup-config.ts +10 -1
  42. package/extensions/shared/structured-output.ts +154 -0
  43. package/extensions/subagents/index.ts +64 -7
  44. package/extensions/subagents/src/agent-types.ts +5 -17
  45. package/extensions/subagents/src/backends/pi.ts +130 -48
  46. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  47. package/extensions/subagents/src/domain.ts +16 -1
  48. package/extensions/subagents/src/manager.ts +7 -71
  49. package/extensions/subagents/src/prompt.ts +19 -5
  50. package/extensions/subagents/src/result-artifact.ts +32 -0
  51. package/extensions/subagents/src/result-delivery.ts +33 -14
  52. package/extensions/subagents/src/runtime.ts +10 -3
  53. package/extensions/ui-customization/footer.ts +16 -5
  54. package/extensions/user-input-fold/index.ts +42 -6
  55. package/extensions/web/index.ts +25 -2
  56. package/extensions/workflows/acceptance.ts +43 -19
  57. package/extensions/workflows/completion-projection.ts +3 -1
  58. package/extensions/workflows/dashboard.ts +147 -21
  59. package/extensions/workflows/index.ts +75 -20
  60. package/extensions/workflows/model.ts +5 -1
  61. package/extensions/workflows/progress-projection.ts +7 -1
  62. package/extensions/workflows/prompt.ts +4 -10
  63. package/extensions/workflows/result-delivery.ts +96 -22
  64. package/extensions/workflows/retention.ts +6 -0
  65. package/extensions/workflows/runner.ts +11 -233
  66. package/extensions/workflows/sandbox.ts +4 -0
  67. package/package.json +7 -7
  68. package/skills/subagents/REFERENCE.md +9 -9
  69. package/skills/subagents/SKILL.md +2 -1
  70. package/skills/workflows/REFERENCE.md +5 -3
  71. package/skills/workflows/SKILL.md +1 -1
  72. package/web/adapter/pi-adapter.ts +3 -0
  73. package/web/host/pi-coding-agent-entry.ts +162 -0
  74. package/web/host/web-host.ts +330 -50
  75. package/web/protocol/types.ts +5 -0
  76. package/web/runtime/pi-runtime.ts +240 -25
  77. package/web/runtime/types.ts +32 -1
  78. package/web/ui/app.js +343 -41
  79. package/web/ui/index.html +3 -0
  80. package/web/ui/styles.css +119 -37
@@ -0,0 +1,162 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
6
+ export const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY";
7
+ const PACKAGE_ROOT_SEARCH_DEPTH = 10;
8
+
9
+ type PackageManifest = {
10
+ name?: unknown;
11
+ main?: unknown;
12
+ exports?: Record<string, { import?: unknown } | string>;
13
+ };
14
+
15
+ export function findPackageRoot(realPath: string, packageName: string) {
16
+ let dir = dirname(realPath);
17
+ for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) {
18
+ const manifestPath = join(dir, "package.json");
19
+ if (existsSync(manifestPath)) {
20
+ const manifest = readManifest(manifestPath);
21
+ if (manifest?.name === packageName) return dir;
22
+ }
23
+ const parent = dirname(dir);
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ function readManifest(manifestPath: string) {
31
+ try {
32
+ return JSON.parse(readFileSync(manifestPath, "utf8")) as PackageManifest;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function officialEntry(root: string | undefined) {
39
+ if (!root) return undefined;
40
+ const manifest = readManifest(join(root, "package.json"));
41
+ const target = manifest?.exports?.["."];
42
+ const relative =
43
+ typeof target === "string"
44
+ ? target
45
+ : typeof target?.import === "string"
46
+ ? target.import
47
+ : typeof manifest?.main === "string"
48
+ ? manifest.main
49
+ : "dist/index.js";
50
+ const entry = join(root, relative);
51
+ try {
52
+ return existsSync(entry) && statSync(entry).isFile()
53
+ ? realpathSync(entry)
54
+ : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function walkFromFile(file: string) {
61
+ try {
62
+ const real = realpathSync(file);
63
+ if (!statSync(real).isFile()) return undefined;
64
+ return officialEntry(findPackageRoot(real, PI_CODING_AGENT_PACKAGE));
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ function fileFromUrl(fromUrl: string) {
71
+ return fromUrl.startsWith("file:") ? fileURLToPath(fromUrl) : fromUrl;
72
+ }
73
+
74
+ function nearestPackageRoot(file: string) {
75
+ let dir = dirname(file);
76
+ for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) {
77
+ if (existsSync(join(dir, "package.json"))) return dir;
78
+ const parent = dirname(dir);
79
+ if (parent === dir) break;
80
+ dir = parent;
81
+ }
82
+ }
83
+
84
+ function peerAt(nodeModules: string) {
85
+ const root = join(nodeModules, ...PI_CODING_AGENT_PACKAGE.split("/"));
86
+ const manifest = readManifest(join(root, "package.json"));
87
+ return manifest?.name === PI_CODING_AGENT_PACKAGE
88
+ ? officialEntry(root)
89
+ : undefined;
90
+ }
91
+
92
+ function resolveFromInstall(fromUrl: string) {
93
+ let start = fileFromUrl(fromUrl);
94
+ try {
95
+ start = realpathSync(start);
96
+ } catch {
97
+ // Keep the unresolved path when the caller file is a test stub.
98
+ }
99
+ const packageRoot = nearestPackageRoot(start);
100
+ if (!packageRoot) return undefined;
101
+
102
+ const nested = peerAt(join(packageRoot, "node_modules"));
103
+ if (nested) return nested;
104
+
105
+ const parent = dirname(packageRoot);
106
+ const grandparent = dirname(parent);
107
+ const hoistedModules =
108
+ basename(parent).startsWith("@") && basename(grandparent) === "node_modules"
109
+ ? grandparent
110
+ : basename(parent) === "node_modules"
111
+ ? parent
112
+ : undefined;
113
+ return hoistedModules ? peerAt(hoistedModules) : undefined;
114
+ }
115
+
116
+ export function validatePiCodingAgentEntry(candidate: string | undefined) {
117
+ if (!candidate) return undefined;
118
+ return walkFromFile(candidate);
119
+ }
120
+
121
+ export function missingPiCodingAgentDiagnostic() {
122
+ return [
123
+ `OpenPI Web could not resolve ${PI_CODING_AGENT_PACKAGE} for this process.`,
124
+ "Host resolution uses only the current process argv identity and fail-closes if that path is not the official package.",
125
+ `${PI_CODING_AGENT_ENTRY_ENV} is an explicit standalone handoff, not a host fallback.`,
126
+ `Standalone openpi web uses that handoff when valid, then the installed nested or hoisted peer (npm install ${PI_CODING_AGENT_PACKAGE}).`,
127
+ "From a running Pi session use /web, which hands over the host Pi.",
128
+ "Supported package install is `pi install npm:@tt-a1i/openpi`.",
129
+ ].join(" ");
130
+ }
131
+
132
+ export function resolvePiCodingAgentEntry(options?: {
133
+ source?: "host" | "standalone";
134
+ env?: NodeJS.ProcessEnv;
135
+ argv1?: string | undefined;
136
+ fromUrl?: string;
137
+ }) {
138
+ const source = options?.source ?? "host";
139
+ if (source === "standalone") {
140
+ const env = options?.env ?? process.env;
141
+ const handed = validatePiCodingAgentEntry(env[PI_CODING_AGENT_ENTRY_ENV]);
142
+ if (handed) return handed;
143
+ return resolveFromInstall(options?.fromUrl ?? import.meta.url);
144
+ }
145
+
146
+ const argv1 = options?.argv1 === undefined ? process.argv[1] : options.argv1;
147
+ return argv1 ? walkFromFile(argv1) : undefined;
148
+ }
149
+
150
+ export function resolveStandaloneJitiAliases(options?: {
151
+ env?: NodeJS.ProcessEnv;
152
+ argv1?: string | undefined;
153
+ fromUrl?: string;
154
+ }) {
155
+ const fromUrl = options?.fromUrl ?? import.meta.url;
156
+ const entry = resolvePiCodingAgentEntry({
157
+ ...options,
158
+ fromUrl,
159
+ source: "standalone",
160
+ });
161
+ return entry ? { [PI_CODING_AGENT_PACKAGE]: entry } : {};
162
+ }
@@ -9,7 +9,11 @@ import {
9
9
  } from "node:http";
10
10
  import { URL } from "node:url";
11
11
  import { promisify } from "node:util";
12
- import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts";
12
+ import {
13
+ subscribeWebCapabilities,
14
+ webCapabilitySnapshot,
15
+ } from "../../extensions/shared/web-observer-registry.ts";
16
+ import { loadSetupConfig } from "../../extensions/shared/setup-config.ts";
13
17
  import { PiWebAdapter } from "../adapter/pi-adapter.ts";
14
18
  import {
15
19
  jsonByteLength,
@@ -33,10 +37,47 @@ const MAX_COMMAND_BYTES = 16 * 1024;
33
37
  const MAX_SSE_CLIENTS = 8;
34
38
  const MAX_SSE_BUFFER_BYTES = 256 * 1024;
35
39
  const MAX_SSE_REPLAY_BYTES = MAX_SSE_BUFFER_BYTES;
40
+ const DEFAULT_SSE_HEARTBEAT_MS = 15_000;
36
41
  const SERVER_CLOSE_DRAIN_MS = 500;
37
42
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
43
+ const MAX_PROMPT_ADMISSIONS = 128;
38
44
  const execFileAsync = promisify(execFile);
39
45
 
46
+ type PromptAdmissionResponse = {
47
+ readonly status: number;
48
+ readonly body: Record<string, unknown>;
49
+ };
50
+
51
+ type PromptAdmission = {
52
+ readonly sessionId: string;
53
+ readonly content: string;
54
+ readonly completion: Promise<PromptAdmissionResponse>;
55
+ result?: PromptAdmissionResponse;
56
+ };
57
+
58
+ type WebRequestErrorCode =
59
+ | "INVALID_REQUEST_BODY"
60
+ | "REQUEST_BODY_TOO_LARGE";
61
+
62
+ class WebRequestError extends Error {
63
+ readonly code: WebRequestErrorCode;
64
+ readonly statusCode: 400 | 413;
65
+ readonly maxBytes?: number;
66
+
67
+ constructor(
68
+ message: string,
69
+ code: WebRequestErrorCode,
70
+ statusCode: 400 | 413,
71
+ maxBytes?: number,
72
+ ) {
73
+ super(message);
74
+ this.name = "WebRequestError";
75
+ this.code = code;
76
+ this.statusCode = statusCode;
77
+ this.maxBytes = maxBytes;
78
+ }
79
+ }
80
+
40
81
  export interface WebHostOptions {
41
82
  runtime: WebRuntimeController;
42
83
  onEvent?: (type: string, detail?: Record<string, unknown>) => void;
@@ -45,6 +86,7 @@ export interface WebHostOptions {
45
86
  allowedOrigins?: readonly string[];
46
87
  directoryChooser?: (signal: AbortSignal) => Promise<string | undefined>;
47
88
  shutdownTimeoutMs?: number;
89
+ sseHeartbeatMs?: number;
48
90
  }
49
91
 
50
92
  export class WebHost {
@@ -52,6 +94,10 @@ export class WebHost {
52
94
  private readonly token: Buffer;
53
95
  private readonly adapter: PiWebAdapter;
54
96
  private readonly clients = new Set<ServerResponse>();
97
+ private readonly clientHeartbeats = new Map<
98
+ ServerResponse,
99
+ ReturnType<typeof setInterval>
100
+ >();
55
101
  private readonly events: WebEvent[] = [];
56
102
  private sequence = 0;
57
103
  private port = 0;
@@ -63,11 +109,16 @@ export class WebHost {
63
109
  WebHostOptions["directoryChooser"]
64
110
  >;
65
111
  private readonly shutdownTimeoutMs: number;
112
+ private readonly sseHeartbeatMs: number;
66
113
  private readonly unsubscribeCapabilities: () => void;
67
114
  private readonly unsubscribeRuntime: () => void;
68
115
  private readonly chooserAbort = new AbortController();
69
116
  private readonly leaseSensitiveRequests = new Set<Promise<void>>();
70
117
  private readonly leaseSensitiveMessages = new Set<IncomingMessage>();
118
+ private readonly promptAdmissions = new Map<
119
+ string,
120
+ PromptAdmission
121
+ >();
71
122
  private stopping = false;
72
123
  private stopPromise?: Promise<void>;
73
124
 
@@ -90,6 +141,14 @@ export class WebHost {
90
141
  ) {
91
142
  throw new Error("Web host shutdown timeout must be a positive integer");
92
143
  }
144
+ this.sseHeartbeatMs =
145
+ options.sseHeartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS;
146
+ if (
147
+ !Number.isSafeInteger(this.sseHeartbeatMs) ||
148
+ this.sseHeartbeatMs <= 0
149
+ ) {
150
+ throw new Error("SSE heartbeat interval must be a positive integer");
151
+ }
93
152
  this.adapter = new PiWebAdapter(options.runtime);
94
153
  this.onEvent = options.onEvent;
95
154
  this.unsubscribeCapabilities = subscribeWebCapabilities((scope) => {
@@ -185,8 +244,7 @@ export class WebHost {
185
244
  client.writableLength > MAX_SSE_BUFFER_BYTES ||
186
245
  !client.write(record)
187
246
  ) {
188
- this.clients.delete(client);
189
- client.destroy();
247
+ this.removeSseClient(client, "destroy");
190
248
  }
191
249
  }
192
250
  this.onEvent?.(event.type, event.detail);
@@ -204,8 +262,7 @@ export class WebHost {
204
262
  this.unsubscribeCapabilities();
205
263
  this.unsubscribeRuntime();
206
264
  this.chooserAbort.abort();
207
- for (const client of this.clients) client.end();
208
- this.clients.clear();
265
+ for (const client of [...this.clients]) this.removeSseClient(client, "end");
209
266
  const closeServer = this.server.listening
210
267
  ? new Promise<void>((resolve) => {
211
268
  const forceClose = setTimeout(
@@ -266,6 +323,15 @@ export class WebHost {
266
323
  await this.handle(request, response);
267
324
  } catch (error) {
268
325
  if (response.destroyed || response.writableEnded) return;
326
+ if (error instanceof WebRequestError) {
327
+ return this.json(response, error.statusCode, {
328
+ code: error.code,
329
+ error: error.message,
330
+ ...(error.maxBytes === undefined
331
+ ? {}
332
+ : { maxBytes: error.maxBytes }),
333
+ });
334
+ }
269
335
  this.json(response, 500, {
270
336
  error: error instanceof Error ? error.message : "request failed",
271
337
  });
@@ -276,6 +342,7 @@ export class WebHost {
276
342
  if (request.method === "GET" || request.method === "HEAD") return false;
277
343
  const pathname = new URL(request.url ?? "/", `http://${HOST}`).pathname;
278
344
  if (pathname === "/api/prompt") return false;
345
+ if (pathname === "/api/turns/cancel") return true;
279
346
  return pathname.startsWith("/api/workspaces") ||
280
347
  pathname.startsWith("/api/sessions") ||
281
348
  pathname === "/api/model";
@@ -486,6 +553,51 @@ export class WebHost {
486
553
  error: "prompt must be 1-12000 characters",
487
554
  });
488
555
  }
556
+ const commandId =
557
+ typeof body.commandId === "string" && body.commandId.length > 0
558
+ ? body.commandId
559
+ : randomUUID();
560
+ if (commandId.length > 128) {
561
+ return this.json(response, 400, {
562
+ error: "commandId must be at most 128 characters",
563
+ });
564
+ }
565
+ if (body.retry !== undefined && typeof body.retry !== "boolean") {
566
+ return this.json(response, 400, {
567
+ error: "retry must be a boolean when provided",
568
+ });
569
+ }
570
+ if (typeof body.sessionId !== "string") {
571
+ return this.json(response, 400, {
572
+ error: "sessionId is required",
573
+ });
574
+ }
575
+ const existing = this.promptAdmissions.get(commandId);
576
+ if (existing) {
577
+ if (
578
+ existing.sessionId !== body.sessionId ||
579
+ existing.content !== content
580
+ ) {
581
+ return this.json(response, 409, {
582
+ code: "COMMAND_CONFLICT",
583
+ error: "commandId is already bound to a different prompt",
584
+ });
585
+ }
586
+ const result = await existing.completion;
587
+ traceWeb("prompt_admission_replayed", {
588
+ commandId,
589
+ sessionId: body.sessionId,
590
+ status: result.status,
591
+ elapsedMs: elapsed(requestStarted),
592
+ });
593
+ return this.json(response, result.status, result.body);
594
+ }
595
+ if (body.retry === true) {
596
+ return this.json(response, 409, {
597
+ code: "COMMAND_ADMISSION_UNKNOWN",
598
+ error: "previous prompt admission is unknown; refresh canonical state before sending a new request",
599
+ });
600
+ }
489
601
  if (this.runtime.workspaceSelected !== true) {
490
602
  return this.json(response, 409, {
491
603
  code: "WORKSPACE_REQUIRED",
@@ -493,7 +605,6 @@ export class WebHost {
493
605
  });
494
606
  }
495
607
  if (
496
- typeof body.sessionId !== "string" ||
497
608
  body.sessionId !== this.runtime.sessionManager.getSessionId()
498
609
  ) {
499
610
  return this.json(response, 409, {
@@ -501,43 +612,61 @@ export class WebHost {
501
612
  error: "Only the active Web session accepts messages",
502
613
  });
503
614
  }
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,
615
+ if (!this.makePromptAdmissionSpace()) {
616
+ return this.json(response, 503, {
617
+ code: "PROMPT_ADMISSION_CAPACITY",
618
+ error: "prompt admission capacity is full; wait for a pending admission to settle",
514
619
  });
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,
620
+ }
621
+ const admission = this.beginPromptAdmission(
622
+ commandId,
623
+ body.sessionId,
624
+ content,
625
+ );
626
+ const result = await admission.completion;
627
+ return this.json(response, result.status, result.body);
628
+ }
629
+ if (url.pathname === "/api/turns/cancel" && request.method === "POST") {
630
+ const body = await this.readJson(request);
631
+ if (
632
+ typeof body.sessionId !== "string" ||
633
+ body.sessionId.length === 0 ||
634
+ body.sessionId.length > 128 ||
635
+ typeof body.commandId !== "string" ||
636
+ body.commandId.length === 0 ||
637
+ body.commandId.length > 128 ||
638
+ typeof body.epoch !== "number" ||
639
+ !Number.isSafeInteger(body.epoch) ||
640
+ body.epoch <= 0
641
+ ) {
642
+ return this.json(response, 400, {
643
+ code: "INVALID_TURN",
644
+ error: "bounded sessionId, commandId, and positive turn epoch are required",
525
645
  });
526
- return this.json(response, failure.status, {
527
- code: failure.code,
528
- error: failure.error,
646
+ }
647
+ if (this.runtime.workspaceSelected !== true) {
648
+ return this.json(response, 409, {
649
+ code: "WORKSPACE_REQUIRED",
650
+ error: "Choose a workspace before using the Web runtime",
529
651
  });
530
652
  }
531
- this.publish("prompt_accepted", { commandId, sessionId: body.sessionId });
532
- traceWeb("prompt_response_sent", {
533
- commandId,
653
+ const result = await this.runtime.cancelTurn({
534
654
  sessionId: body.sessionId,
535
- elapsedMs: elapsed(requestStarted),
655
+ commandId: body.commandId,
656
+ epoch: body.epoch,
536
657
  });
537
- return this.json(response, 202, {
538
- id: commandId,
539
- accepted: true,
540
- state: "accepted",
658
+ traceWeb("turn_cancel_receipt", { ...result });
659
+ const status =
660
+ result.state === "accepted"
661
+ ? 202
662
+ : result.state === "already-settled"
663
+ ? 200
664
+ : result.state === "failed"
665
+ ? 500
666
+ : 409;
667
+ return this.json(response, status, {
668
+ ...result,
669
+ accepted: result.state === "accepted",
541
670
  cursor: this.sequence,
542
671
  });
543
672
  }
@@ -557,6 +686,19 @@ export class WebHost {
557
686
  }
558
687
  if (url.pathname === "/api/models")
559
688
  return this.json(response, 200, { models: this.runtime.listModels() });
689
+ if (url.pathname === "/api/capabilities")
690
+ return this.json(response, 200, {
691
+ sessionId: this.runtime.sessionManager.getSessionId(),
692
+ capabilities: webCapabilitySnapshot(this.runtime.sessionManager),
693
+ });
694
+ if (url.pathname === "/api/diagnostics")
695
+ return this.json(response, 200, {
696
+ node: process.version,
697
+ cwd: this.runtime.cwd,
698
+ sessionId: this.runtime.sessionManager.getSessionId(),
699
+ workspaceSelected: this.runtime.workspaceSelected,
700
+ models: this.runtime.listModels().filter((model) => model.current),
701
+ });
560
702
  if (url.pathname === "/api/snapshot") {
561
703
  const cursor = this.sequence;
562
704
  const projection = await this.adapter.getSnapshot(
@@ -566,6 +708,7 @@ export class WebHost {
566
708
  protocolVersion: WEB_PROTOCOL_VERSION,
567
709
  generatedAt: new Date().toISOString(),
568
710
  cursor,
711
+ preferences: { theme: loadSetupConfig().ui.webTheme },
569
712
  ...projection,
570
713
  };
571
714
  let finalBytes = jsonByteLength(snapshot);
@@ -627,29 +770,142 @@ export class WebHost {
627
770
  }
628
771
  }
629
772
 
773
+ private makePromptAdmissionSpace() {
774
+ while (this.promptAdmissions.size >= MAX_PROMPT_ADMISSIONS) {
775
+ const settled = [...this.promptAdmissions.entries()].find(
776
+ ([, admission]) => admission.result !== undefined,
777
+ );
778
+ if (!settled) return false;
779
+ this.promptAdmissions.delete(settled[0]);
780
+ }
781
+ return true;
782
+ }
783
+
784
+ private beginPromptAdmission(
785
+ commandId: string,
786
+ sessionId: string,
787
+ content: string,
788
+ ) {
789
+ let settle!: (result: PromptAdmissionResponse) => void;
790
+ const admission: PromptAdmission = {
791
+ sessionId,
792
+ content,
793
+ completion: new Promise<PromptAdmissionResponse>((resolve) => {
794
+ settle = resolve;
795
+ }),
796
+ };
797
+ // Store before dispatch: a client retry can only replay this record.
798
+ this.promptAdmissions.set(commandId, admission);
799
+ try {
800
+ traceWeb("prompt_received", {
801
+ commandId,
802
+ sessionId,
803
+ chars: content.length,
804
+ });
805
+ } catch {}
806
+ void Promise.resolve()
807
+ .then(() =>
808
+ this.runtime.sendPrompt(content, {
809
+ commandId,
810
+ expectedSessionId: sessionId,
811
+ }),
812
+ )
813
+ .then(
814
+ (receipt) => {
815
+ const result: PromptAdmissionResponse = {
816
+ status: 202,
817
+ body: {
818
+ id: commandId,
819
+ accepted: true,
820
+ state: "accepted",
821
+ pendingFollowUps: receipt.pendingFollowUps,
822
+ cursor: this.sequence,
823
+ },
824
+ };
825
+ try {
826
+ this.publish("prompt_accepted", {
827
+ commandId,
828
+ sessionId,
829
+ pendingFollowUps: receipt.pendingFollowUps,
830
+ });
831
+ result.body.cursor = this.sequence;
832
+ } catch {}
833
+ return result;
834
+ },
835
+ (error) => {
836
+ const failure = this.runtimeRequestFailure(error);
837
+ return {
838
+ status: failure.status,
839
+ body: { code: failure.code, error: failure.error },
840
+ };
841
+ },
842
+ )
843
+ .then((result: PromptAdmissionResponse) => {
844
+ admission.result = result;
845
+ settle(result);
846
+ try {
847
+ traceWeb(
848
+ result.status === 202
849
+ ? "prompt_admission_finished"
850
+ : "prompt_admission_failed",
851
+ {
852
+ commandId,
853
+ sessionId,
854
+ status: result.status,
855
+ ...(typeof result.body.error === "string"
856
+ ? { error: result.body.error }
857
+ : {}),
858
+ },
859
+ );
860
+ } catch {}
861
+ })
862
+ .catch((error) => {
863
+ if (admission.result) return;
864
+ const failure = this.runtimeRequestFailure(error);
865
+ const result: PromptAdmissionResponse = {
866
+ status: failure.status,
867
+ body: { code: failure.code, error: failure.error },
868
+ };
869
+ admission.result = result;
870
+ settle(result);
871
+ });
872
+ return admission;
873
+ }
874
+
630
875
  private async readJson(request: IncomingMessage) {
631
876
  const chunks: Buffer[] = [];
632
877
  let bytes = 0;
633
878
  for await (const chunk of request) {
634
879
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
635
880
  bytes += buffer.length;
636
- if (bytes > MAX_COMMAND_BYTES)
637
- throw new Error("request body is too large");
881
+ if (bytes > MAX_COMMAND_BYTES) {
882
+ throw new WebRequestError(
883
+ "request body is too large",
884
+ "REQUEST_BODY_TOO_LARGE",
885
+ 413,
886
+ MAX_COMMAND_BYTES,
887
+ );
888
+ }
638
889
  chunks.push(buffer);
639
890
  }
891
+ let value: unknown;
640
892
  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),
893
+ value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
894
+ } catch {
895
+ throw new WebRequestError(
896
+ "request body is invalid JSON",
897
+ "INVALID_REQUEST_BODY",
898
+ 400,
651
899
  );
652
900
  }
901
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
902
+ throw new WebRequestError(
903
+ "request body must be an object",
904
+ "INVALID_REQUEST_BODY",
905
+ 400,
906
+ );
907
+ }
908
+ return value as Record<string, unknown>;
653
909
  }
654
910
 
655
911
  private authorized(request: IncomingMessage) {
@@ -736,7 +992,31 @@ export class WebHost {
736
992
  // ordering without treating normal backpressure as a broken client.
737
993
  for (const record of replay) response.write(record);
738
994
  this.clients.add(response);
739
- response.on("close", () => this.clients.delete(response));
995
+ const heartbeat = setInterval(() => {
996
+ if (
997
+ response.destroyed ||
998
+ response.writableEnded ||
999
+ response.writableLength > MAX_SSE_BUFFER_BYTES ||
1000
+ !response.write(": heartbeat\n\n")
1001
+ ) {
1002
+ this.removeSseClient(response, "destroy");
1003
+ }
1004
+ }, this.sseHeartbeatMs);
1005
+ heartbeat.unref();
1006
+ this.clientHeartbeats.set(response, heartbeat);
1007
+ response.on("close", () => this.removeSseClient(response));
1008
+ }
1009
+
1010
+ private removeSseClient(
1011
+ response: ServerResponse,
1012
+ close?: "destroy" | "end",
1013
+ ) {
1014
+ this.clients.delete(response);
1015
+ const heartbeat = this.clientHeartbeats.get(response);
1016
+ if (heartbeat) clearInterval(heartbeat);
1017
+ this.clientHeartbeats.delete(response);
1018
+ if (close === "destroy" && !response.destroyed) response.destroy();
1019
+ else if (close === "end" && !response.writableEnded) response.end();
740
1020
  }
741
1021
 
742
1022
  private parseCursor(value: string | undefined | null) {
@@ -1,5 +1,6 @@
1
1
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import type { WebCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
3
+ import type { WebActiveTurn } from "../runtime/types.ts";
3
4
 
4
5
  export const WEB_PROTOCOL_VERSION = 1;
5
6
  export const WEB_MAX_EVENTS = 200;
@@ -105,6 +106,9 @@ export interface WebSnapshot {
105
106
  protocolVersion: typeof WEB_PROTOCOL_VERSION;
106
107
  generatedAt: string;
107
108
  cursor: number;
109
+ preferences: {
110
+ theme: "system" | "light" | "dark";
111
+ };
108
112
  /** Absent until the browser selects or creates a real Web Session. */
109
113
  currentSessionId?: string;
110
114
  workspaces: WebWorkspaceSummary[];
@@ -113,6 +117,7 @@ export interface WebSnapshot {
113
117
  models: WebModelSummary[];
114
118
  runtime: {
115
119
  status: "idle" | "running" | "unknown";
120
+ activeTurn?: WebActiveTurn;
116
121
  capabilities: WebCapabilitySnapshot;
117
122
  };
118
123
  truncation: WebSnapshotTruncation;