@tt-a1i/openpi 0.6.1 → 0.7.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 (37) hide show
  1. package/THIRD_PARTY_NOTICES.md +242 -0
  2. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  3. package/extensions/ai-providers/cursor/provider.ts +6 -19
  4. package/extensions/file-mutation-display/index.ts +13 -9
  5. package/extensions/shared/agent-transcript.ts +3 -2
  6. package/extensions/web/index.ts +69 -5
  7. package/extensions/workflows/artifacts.ts +362 -23
  8. package/extensions/workflows/dashboard.ts +2 -0
  9. package/package.json +28 -4
  10. package/web/dist/app.js +87 -0
  11. package/web/dist/favicon.svg +9 -0
  12. package/web/dist/index.html +15 -0
  13. package/web/dist/styles.css +3 -0
  14. package/web/host/web-host.ts +6 -9
  15. package/web/ui/index.html +3 -131
  16. package/web/ui/public/favicon.svg +9 -0
  17. package/web/ui/src/app/App.tsx +134 -0
  18. package/web/ui/src/app/providers.tsx +38 -0
  19. package/web/ui/src/components/Markdown.tsx +58 -0
  20. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  21. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  22. package/web/ui/src/features/composer/Composer.tsx +237 -0
  23. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  24. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  25. package/web/ui/src/i18n.ts +159 -0
  26. package/web/ui/src/lib/format.ts +57 -0
  27. package/web/ui/src/main.tsx +16 -0
  28. package/web/ui/src/protocol/client.ts +199 -0
  29. package/web/ui/src/protocol/event-stream.ts +88 -0
  30. package/web/ui/src/store/web-store.ts +926 -0
  31. package/web/ui/src/styles.css +420 -0
  32. package/web/ui/tsconfig.json +12 -0
  33. package/web/ui/vite-env.d.ts +1 -0
  34. package/web/vite.config.mjs +21 -1
  35. package/web/host/static-assets.ts +0 -4
  36. package/web/ui/app.js +0 -1700
  37. package/web/ui/styles.css +0 -680
@@ -29,3 +29,245 @@ The upstream project is distributed under the MIT License. Its notice states
29
29
  OpenPI is distributed under the MIT License; see [`LICENSE`](LICENSE). Portions
30
30
  identified in this notice retain their original copyright notices and license
31
31
  terms. The project-wide MIT license does not replace or remove those notices.
32
+
33
+ ## Web production bundle
34
+
35
+ `web/dist/app.js` and `web/dist/styles.css` contain code from the packages
36
+ listed below. This inventory was taken from the Vite production module graph;
37
+ build-only packages are not included.
38
+
39
+ ### MIT-licensed packages
40
+
41
+ Copyright (c) 2026 Meta Platforms, Inc.:
42
+
43
+ - `@astryxdesign/core@0.5.2`
44
+ - `@astryxdesign/theme-neutral@0.5.2`
45
+
46
+ Copyright (c) Meta Platforms, Inc. and affiliates:
47
+
48
+ - `@stylexjs/stylex@0.19.0`
49
+ - `react@19.2.8`
50
+ - `react-dom@19.2.8`
51
+ - `scheduler@0.27.0`
52
+ - `use-sync-external-store@1.6.0`
53
+
54
+ Copyright (c) 2023 FormatJS:
55
+
56
+ - `@formatjs/fast-memoize@3.1.7`
57
+ - `@formatjs/icu-messageformat-parser@3.5.17`
58
+ - `@formatjs/icu-skeleton-parser@2.1.11`
59
+
60
+ Copyright (c) 2011-present i18next:
61
+
62
+ - `i18next@26.4.1`
63
+
64
+ Copyright (c) 2015-present i18next:
65
+
66
+ - `react-i18next@17.0.13`
67
+
68
+ Copyright (c) 2026 Espen Hovlandsdal <espen@hovlandsdal.com>:
69
+
70
+ - `eventsource-parser@4.1.0`
71
+
72
+ Copyright (c) Espen Hovlandsdal:
73
+
74
+ - `react-markdown@10.1.0`
75
+
76
+ Copyright (c) 2019 Paul Henschel:
77
+
78
+ - `zustand@5.0.15`
79
+
80
+ Copyright (c) Sindre Sorhus <sindresorhus@gmail.com>
81
+ (<https://sindresorhus.com>):
82
+
83
+ - `escape-string-regexp@5.0.0`
84
+ - `is-plain-obj@4.1.0`
85
+
86
+ Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>:
87
+
88
+ - `inline-style-parser@0.2.7`
89
+
90
+ Copyright (c) 2014 Stefan Thomas:
91
+
92
+ - `extend@3.0.2`
93
+
94
+ Copyright (c) 2017 Menglin "Mark" Xu <mark@remarkablemark.org>:
95
+
96
+ - `style-to-object@1.0.14`
97
+
98
+ Copyright (c) 2020 Menglin "Mark" Xu <mark@remarkablemark.org>:
99
+
100
+ - `style-to-js@1.1.21`
101
+
102
+ Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>:
103
+
104
+ - `remark-parse@11.0.0`
105
+
106
+ Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>:
107
+
108
+ - `bail@2.0.2`, `ccount@2.0.1`, `mdast-util-to-string@4.0.0`
109
+ - `unified@11.0.5`, `unist-util-is@6.0.1`, `unist-util-position@5.0.0`
110
+ - `unist-util-visit@5.1.0`, `vfile@6.0.3`
111
+
112
+ Copyright (c) 2015 Titus Wormer <mailto:tituswormer@gmail.com>:
113
+
114
+ - `longest-streak@3.1.0`, `trim-lines@3.0.1`
115
+
116
+ Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>:
117
+
118
+ - `comma-separated-tokens@2.0.3`, `hast-util-whitespace@3.0.0`
119
+ - `mdast-util-to-hast@13.2.1`, `rehype-sanitize@6.0.0`
120
+ - `space-separated-tokens@2.0.2`, `trough@2.2.0`
121
+ - `unist-util-stringify-position@4.0.0`, `unist-util-visit-parents@6.0.2`
122
+
123
+ Copyright (c) 2017 Titus Wormer <tituswormer@gmail.com>:
124
+
125
+ - `mdast-util-newline-to-break@2.0.0`, `remark-breaks@4.0.0`
126
+
127
+ Copyright (c) 2020 Titus Wormer <tituswormer@gmail.com>:
128
+
129
+ - `estree-util-is-identifier-name@3.0.0`
130
+ - `mdast-util-gfm-autolink-literal@2.0.1`
131
+ - `mdast-util-gfm-strikethrough@2.0.0`, `mdast-util-gfm-table@2.0.0`
132
+ - `mdast-util-gfm-task-list-item@2.0.0`, `micromark-extension-gfm@3.0.0`
133
+ - `micromark-extension-gfm-autolink-literal@2.1.0`
134
+ - `micromark-extension-gfm-strikethrough@2.1.0`
135
+ - `micromark-extension-gfm-task-list-item@2.1.0`
136
+
137
+ Copyright (c) 2021 Titus Wormer <tituswormer@gmail.com>:
138
+
139
+ - `micromark-extension-gfm-footnote@2.1.0`
140
+
141
+ Copyright (c) 2023 Titus Wormer <tituswormer@gmail.com>:
142
+
143
+ - `devlop@1.1.0`
144
+
145
+ Copyright (c) Titus Wormer <tituswormer@gmail.com>:
146
+
147
+ - `decode-named-character-reference@1.3.0`, `hast-util-sanitize@5.0.2`
148
+ - `hast-util-to-jsx-runtime@2.3.6`, `markdown-table@3.0.4`
149
+ - `mdast-util-find-and-replace@3.0.2`, `mdast-util-from-markdown@2.0.3`
150
+ - `mdast-util-gfm@3.1.0`, `mdast-util-gfm-footnote@2.1.0`
151
+ - `mdast-util-to-markdown@2.1.2`, `micromark@4.0.2`
152
+ - `micromark-core-commonmark@2.0.3`, `micromark-extension-gfm-table@2.1.1`
153
+ - `micromark-factory-destination@2.0.1`, `micromark-factory-label@2.0.1`
154
+ - `micromark-factory-space@2.0.1`, `micromark-factory-title@2.0.1`
155
+ - `micromark-factory-whitespace@2.0.1`, `micromark-util-character@2.1.1`
156
+ - `micromark-util-chunked@2.0.1`, `micromark-util-classify-character@2.0.1`
157
+ - `micromark-util-combine-extensions@2.0.1`
158
+ - `micromark-util-decode-numeric-character-reference@2.0.2`
159
+ - `micromark-util-decode-string@2.0.1`, `micromark-util-html-tag-name@2.0.1`
160
+ - `micromark-util-normalize-identifier@2.0.1`
161
+ - `micromark-util-resolve-all@2.0.1`, `micromark-util-sanitize-uri@2.0.1`
162
+ - `micromark-util-subtokenize@2.1.0`, `remark-gfm@4.0.1`
163
+ - `remark-rehype@11.1.2`, `vfile-message@4.0.3`
164
+
165
+ Copyright (c) Titus Wormer:
166
+
167
+ - `html-url-attributes@3.0.1`
168
+
169
+ Copyright (c) Titus Wormer <mailto:tituswormer@gmail.com>:
170
+
171
+ - `property-information@7.2.0`
172
+
173
+ `mdast-util-phrasing@4.1.0` retains both of its notices:
174
+
175
+ - Copyright (c) 2017 Titus Wormer <tituswormer@gmail.com>
176
+ - Copyright (c) 2017 Victor Felder <victor@draft.li>
177
+
178
+ The MIT terms for the packages above are:
179
+
180
+ > Permission is hereby granted, free of charge, to any person obtaining a copy
181
+ > of this software and associated documentation files (the "Software"), to deal
182
+ > in the Software without restriction, including without limitation the rights
183
+ > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
184
+ > copies of the Software, and to permit persons to whom the Software is
185
+ > furnished to do so, subject to the following conditions:
186
+ >
187
+ > The above copyright notice and this permission notice shall be included in
188
+ > all copies or substantial portions of the Software.
189
+ >
190
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
191
+ > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
192
+ > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
193
+ > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
194
+ > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
195
+ > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
196
+ > SOFTWARE.
197
+
198
+ ### ISC-licensed packages
199
+
200
+ Copyright (c) 2021, Andrea Giammarchi, @WebReflection:
201
+
202
+ - `@ungap/structured-clone@1.4.0`
203
+
204
+ Copyright (c) 2026 Lucide Icons and Contributors:
205
+
206
+ - `lucide-react@1.39.0`
207
+
208
+ The ISC terms for these packages are:
209
+
210
+ > Permission to use, copy, modify, and/or distribute this software for any
211
+ > purpose with or without fee is hereby granted, provided that the above
212
+ > copyright notice and this permission notice appear in all copies.
213
+ >
214
+ > THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
215
+ > REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
216
+ > AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
217
+ > INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
218
+ > LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
219
+ > OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
220
+ > PERFORMANCE OF THIS SOFTWARE.
221
+
222
+ Some Lucide icons are derived from Feather and retain this additional notice:
223
+
224
+ > Copyright (c) 2013-present Cole Bemis
225
+ >
226
+ > Permission is hereby granted, free of charge, to any person obtaining a copy
227
+ > of this software and associated documentation files (the "Software"), to deal
228
+ > in the Software without restriction, including without limitation the rights
229
+ > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
230
+ > copies of the Software, and to permit persons to whom the Software is
231
+ > furnished to do so, subject to the following conditions:
232
+ >
233
+ > The above copyright notice and this permission notice shall be included in
234
+ > all copies or substantial portions of the Software.
235
+ >
236
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
237
+ > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
238
+ > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
239
+ > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
240
+ > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
241
+ > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
242
+ > SOFTWARE.
243
+
244
+ ### BSD-3-Clause package
245
+
246
+ `intl-messageformat@11.2.14` retains this notice:
247
+
248
+ > Copyright (c) 2023, Oath Inc.
249
+ >
250
+ > Redistribution and use of this software in source and binary forms, with or
251
+ > without modification, are permitted provided that the following conditions
252
+ > are met:
253
+ >
254
+ > - Redistributions of source code must retain the above copyright notice,
255
+ > this list of conditions and the following disclaimer.
256
+ > - Redistributions in binary form must reproduce the above copyright notice,
257
+ > this list of conditions and the following disclaimer in the documentation
258
+ > and/or other materials provided with the distribution.
259
+ > - Neither the name of Oath Inc. nor the names of its contributors may be used
260
+ > to endorse or promote products derived from this software without specific
261
+ > prior written permission.
262
+ >
263
+ > THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
264
+ > AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
265
+ > IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
266
+ > ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
267
+ > LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
268
+ > CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
269
+ > SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
270
+ > INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
271
+ > CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
272
+ > ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
273
+ > POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,76 @@
1
+ /** Incremental Cursor Connect framing; callbacks consume complete frames synchronously. */
2
+ export class ConnectFrameReader {
3
+ private readonly maxBytes: number;
4
+ private readonly header = Buffer.alloc(5);
5
+ private headerBytes = 0;
6
+ private payload: Buffer | undefined;
7
+ private payloadBytes = 0;
8
+
9
+ constructor(maxBytes: number) {
10
+ this.maxBytes = maxBytes;
11
+ }
12
+
13
+ get incomplete() {
14
+ return this.headerBytes !== 0;
15
+ }
16
+
17
+ get awaitingPayload() {
18
+ return this.payload !== undefined;
19
+ }
20
+
21
+ push(chunk: Buffer, onFrame: (flags: number, data: Buffer) => void) {
22
+ let offset = 0;
23
+ while (offset < chunk.length) {
24
+ if (this.payload) {
25
+ const copied = chunk.copy(
26
+ this.payload,
27
+ this.payloadBytes,
28
+ offset,
29
+ offset +
30
+ Math.min(
31
+ chunk.length - offset,
32
+ this.payload.length - this.payloadBytes,
33
+ ),
34
+ );
35
+ offset += copied;
36
+ this.payloadBytes += copied;
37
+ } else {
38
+ if (this.headerBytes < 5) {
39
+ const copied = chunk.copy(
40
+ this.header,
41
+ this.headerBytes,
42
+ offset,
43
+ offset + Math.min(5 - this.headerBytes, chunk.length - offset),
44
+ );
45
+ offset += copied;
46
+ this.headerBytes += copied;
47
+ if (this.headerBytes < 5) return;
48
+ }
49
+ const size = this.header.readUInt32BE(1);
50
+ if (size > this.maxBytes)
51
+ throw new Error(
52
+ `Cursor Connect frame exceeds ${this.maxBytes} bytes`,
53
+ );
54
+ if (chunk.length - offset >= size) {
55
+ const data = chunk.subarray(offset, offset + size);
56
+ const flags = this.header[0]!;
57
+ offset += size;
58
+ this.headerBytes = 0;
59
+ onFrame(flags, data);
60
+ continue;
61
+ }
62
+ this.payload = Buffer.allocUnsafe(size);
63
+ this.payloadBytes = 0;
64
+ continue;
65
+ }
66
+ if (this.payloadBytes === this.payload.length) {
67
+ const data = this.payload;
68
+ const flags = this.header[0]!;
69
+ this.payload = undefined;
70
+ this.payloadBytes = 0;
71
+ this.headerBytes = 0;
72
+ onFrame(flags, data);
73
+ }
74
+ }
75
+ }
76
+ }
@@ -14,6 +14,7 @@ import type {
14
14
  } from "@earendil-works/pi-ai/compat";
15
15
  import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat";
16
16
  import { emptyUsage } from "../usage.ts";
17
+ import { ConnectFrameReader } from "./connect-frame-reader.ts";
17
18
  import {
18
19
  CURSOR_API_URL,
19
20
  CURSOR_CLIENT_VERSION,
@@ -833,7 +834,7 @@ export function streamCursor(
833
834
  h2Request?.close(http2.constants.NGHTTP2_CANCEL);
834
835
  }, requestTimeoutMs);
835
836
  };
836
- let frameBuffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
837
+ const frames = new ConnectFrameReader(MAX_CONNECT_FRAME_BYTES);
837
838
  const processFrame = (flags: number, bytes: Uint8Array) => {
838
839
  if (handingOffTools) return;
839
840
  if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
@@ -1033,23 +1034,9 @@ export function streamCursor(
1033
1034
  );
1034
1035
  };
1035
1036
  const processData = (chunk: Buffer) => {
1036
- frameBuffer =
1037
- frameBuffer.length === 0
1038
- ? chunk
1039
- : Buffer.concat([frameBuffer, chunk]);
1040
- while (frameBuffer.length >= 5) {
1041
- const size = frameBuffer.readUInt32BE(1);
1042
- if (size > MAX_CONNECT_FRAME_BYTES) {
1043
- throw new Error(
1044
- `Cursor Connect frame exceeds ${MAX_CONNECT_FRAME_BYTES} bytes`,
1045
- );
1046
- }
1047
- if (frameBuffer.length < size + 5) return;
1048
- const flags = frameBuffer[0]!;
1049
- const data = frameBuffer.subarray(5, size + 5);
1050
- frameBuffer = frameBuffer.subarray(size + 5);
1051
- processFrame(flags, data);
1052
- }
1037
+ frames.push(chunk, processFrame);
1038
+ // Preserve tool batching while a complete header awaits its body.
1039
+ if (frames.awaitingPayload) return;
1053
1040
  if (pendingCalls.size > 0 && !handingOffTools) {
1054
1041
  if (terminalError) throw terminalError;
1055
1042
  if (options?.signal?.aborted)
@@ -1158,7 +1145,7 @@ export function streamCursor(
1158
1145
  .then(() => {
1159
1146
  if (!responseSeen)
1160
1147
  throw new Error("Cursor response headers were not received");
1161
- if (frameBuffer.length !== 0)
1148
+ if (frames.incomplete)
1162
1149
  throw new Error("Incomplete Cursor Connect frame");
1163
1150
  settle();
1164
1151
  })
@@ -27,15 +27,19 @@ function compact<TParams extends TSchema, TDetails, TState>(
27
27
  export default function fileMutationDisplay(pi: ExtensionAPI) {
28
28
  pi.on("session_start", (_event, ctx) => {
29
29
  const display = loadSetupConfig().ui;
30
- if (ctx.mode === "tui") {
31
- // Ctrl+O remains a temporary override. A new/reloaded session starts from
32
- // the persisted defaults instead of inheriting an old expanded toggle.
33
- ctx.ui.setToolsExpanded(
34
- display.subagentResultDisplay === "full" &&
35
- display.bashToolDisplay === "full" &&
36
- display.fileMutationDisplay === "full",
37
- );
38
- }
30
+ // This extension changes only the interactive TUI projection. Headless
31
+ // sessions must keep Pi's native definitions, especially bash: replacing
32
+ // it here would drop the SettingsManager-provided shellPath and can make
33
+ // Windows resolve the WSL System32 stub instead of the configured shell.
34
+ if (ctx.mode !== "tui") return;
35
+
36
+ // Ctrl+O remains a temporary override. A new/reloaded session starts from
37
+ // the persisted defaults instead of inheriting an old expanded toggle.
38
+ ctx.ui.setToolsExpanded(
39
+ display.subagentResultDisplay === "full" &&
40
+ display.bashToolDisplay === "full" &&
41
+ display.fileMutationDisplay === "full",
42
+ );
39
43
 
40
44
  pi.registerTool(
41
45
  compact(
@@ -409,7 +409,7 @@ function findResult(
409
409
  }
410
410
 
411
411
  /**
412
- * Caches finalized transcript items by identity and width. Live state remains
412
+ * Caches finalized transcript items by identity, width, and cwd. Live state remains
413
413
  * uncached because it changes on every stream tick; callers clear this cache
414
414
  * from their component's invalidate() when Pi changes theme.
415
415
  */
@@ -434,7 +434,8 @@ export class AgentTranscriptRenderer {
434
434
  for (let index = 0; index < document.items.length; index++) {
435
435
  const item = document.items[index];
436
436
  const context = itemContext(document.items, index, liveIds, pairing);
437
- const key = `${width}|${context.token}`;
437
+ // Fallback tool rows relativize paths against the document's cwd.
438
+ const key = JSON.stringify([width, context.token, document.cwd]);
438
439
  const cacheable = !document.toolRenderer || !itemHasTool(item);
439
440
  const cached = cacheable ? this.itemCache.get(item)?.get(key) : undefined;
440
441
  const lines =
@@ -10,12 +10,23 @@ import {
10
10
  PI_CODING_AGENT_ENTRY_ENV,
11
11
  resolvePiCodingAgentEntry,
12
12
  } from "../../web/host/pi-coding-agent-entry.ts";
13
+ import { TerminalTextSanitizer } from "../shared/terminal-text.ts";
13
14
 
14
15
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
16
+ const WEB_ERROR_TAIL_MAX_BYTES = 8 * 1024;
17
+
18
+ interface WebProcessErrorStream {
19
+ on(event: "data", listener: (chunk: Buffer | string) => void): this;
20
+ removeListener(
21
+ event: "data",
22
+ listener: (chunk: Buffer | string) => void,
23
+ ): this;
24
+ }
15
25
 
16
26
  export interface WebProcess {
17
27
  readonly exitCode: number | null;
18
28
  readonly signalCode: NodeJS.Signals | null;
29
+ readonly stderr: WebProcessErrorStream | null;
19
30
  once(event: "error", listener: (error: Error) => void): this;
20
31
  once(
21
32
  event: "close",
@@ -28,7 +39,7 @@ interface SpawnWebOptions {
28
39
  cwd: string;
29
40
  env: NodeJS.ProcessEnv;
30
41
  shell: false;
31
- stdio: "inherit";
42
+ stdio: ["inherit", "inherit", "pipe"];
32
43
  }
33
44
 
34
45
  function webProcessEnvironment(
@@ -51,6 +62,7 @@ function webProcessEnvironment(
51
62
  export interface WebCommandDependencies {
52
63
  entrypoint: string;
53
64
  spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess;
65
+ writeStderr(chunk: Buffer | string): void;
54
66
  clearTerminal(): void;
55
67
  holdParentSigint(): () => void;
56
68
  resolvePiCodingAgentEntry(): string | undefined;
@@ -58,7 +70,12 @@ export interface WebCommandDependencies {
58
70
  }
59
71
 
60
72
  type WebExit =
61
- | { kind: "close"; code: number | null; signal: NodeJS.Signals | null }
73
+ | {
74
+ kind: "close";
75
+ code: number | null;
76
+ signal: NodeJS.Signals | null;
77
+ errorDetail?: string;
78
+ }
62
79
  | { kind: "error"; error: Error };
63
80
 
64
81
  interface ActiveWebProcess {
@@ -71,6 +88,9 @@ const defaultDependencies: WebCommandDependencies = {
71
88
  spawn(command, args, options) {
72
89
  return nodeSpawn(command, args, options);
73
90
  },
91
+ writeStderr(chunk) {
92
+ process.stderr.write(chunk);
93
+ },
74
94
  clearTerminal() {
75
95
  process.stdout.write("\u001b[2J\u001b[H");
76
96
  },
@@ -91,6 +111,31 @@ function delay(milliseconds: number) {
91
111
  });
92
112
  }
93
113
 
114
+ function boundedUtf8Tail(text: string, maxBytes: number) {
115
+ let bytes = 0;
116
+ let start = text.length;
117
+ for (const character of Array.from(text).reverse()) {
118
+ const characterBytes = Buffer.byteLength(character, "utf8");
119
+ if (bytes + characterBytes > maxBytes) break;
120
+ bytes += characterBytes;
121
+ start -= character.length;
122
+ }
123
+ return text.slice(start);
124
+ }
125
+
126
+ function actionableWebError(stderr: string) {
127
+ const lines = stderr
128
+ .split(/\r?\n/u)
129
+ .map((line) => line.trim())
130
+ .filter(Boolean);
131
+ const newestFirst = [...lines].reverse();
132
+ return (
133
+ newestFirst.find((line) =>
134
+ line.includes("Failed to start OpenPI Web Workbench:"),
135
+ ) ?? newestFirst.find((line) => /^(?:Error|Failed):/u.test(line))
136
+ );
137
+ }
138
+
94
139
  async function stopWebProcess(active: ActiveWebProcess, timeoutMs: number) {
95
140
  if (active.child.exitCode !== null || active.child.signalCode !== null)
96
141
  return;
@@ -116,15 +161,26 @@ function runWebInForeground(
116
161
  return ctx.ui.custom<WebExit>((tui, _theme, _keybindings, done) => {
117
162
  let finished = false;
118
163
  let tuiStopped = false;
164
+ let stderrTail = "";
165
+ let childStderr: WebProcessErrorStream | null | undefined;
119
166
  let resolveClosed = () => {};
120
167
  const closed = new Promise<void>((resolve) => {
121
168
  resolveClosed = resolve;
122
169
  });
123
170
  const releaseParentSigint = dependencies.holdParentSigint();
171
+ const sanitizer = new TerminalTextSanitizer();
172
+ const captureStderr = (chunk: Buffer | string) => {
173
+ dependencies.writeStderr(chunk);
174
+ stderrTail = boundedUtf8Tail(
175
+ `${stderrTail}${sanitizer.push(String(chunk))}`,
176
+ WEB_ERROR_TAIL_MAX_BYTES,
177
+ );
178
+ };
124
179
 
125
180
  const finish = (result: WebExit) => {
126
181
  if (finished) return;
127
182
  finished = true;
183
+ childStderr?.removeListener("data", captureStderr);
128
184
  releaseParentSigint();
129
185
  setActive(undefined);
130
186
  resolveClosed();
@@ -147,13 +203,20 @@ function runWebInForeground(
147
203
  cwd: childCwd,
148
204
  env: webProcessEnvironment(childCwd, piCodingAgentEntry),
149
205
  shell: false,
150
- stdio: "inherit",
206
+ stdio: ["inherit", "inherit", "pipe"],
151
207
  },
152
208
  );
209
+ childStderr = child.stderr;
210
+ childStderr?.on("data", captureStderr);
153
211
  setActive({ child, closed });
154
212
  child.once("error", (error) => finish({ kind: "error", error }));
155
213
  child.once("close", (code, signal) =>
156
- finish({ kind: "close", code, signal }),
214
+ finish({
215
+ kind: "close",
216
+ code,
217
+ signal,
218
+ errorDetail: actionableWebError(stderrTail),
219
+ }),
157
220
  );
158
221
  } catch (error) {
159
222
  finish({
@@ -242,7 +305,8 @@ export default function web(
242
305
  }
243
306
  if (result.code !== 0) {
244
307
  ctx.ui.notify(
245
- `OpenPI Web Workbench exited with code ${result.code ?? "unknown"}.`,
308
+ result.errorDetail ??
309
+ `OpenPI Web Workbench exited with code ${result.code ?? "unknown"}.`,
246
310
  "error",
247
311
  );
248
312
  return;