pi-outpost 0.27.1 → 0.28.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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Arm the provider stack probe, or do nothing at all.
3
+ *
4
+ * Passed with `--import`, because the whole module graph is loaded before any of
5
+ * pi-outpost's own code evaluates: a `register()` call from inside `index.ts` would run
6
+ * after the SDK was already parsed, and rewrite nothing. This is the only slot early
7
+ * enough, which is why it is a module of its own rather than a few lines somewhere.
8
+ *
9
+ * Inert unless `PI_OUTPOST_PROVIDER_STACK` is set to something meaning yes. Off, this
10
+ * file costs one environment read and returns; nothing is registered, no module is
11
+ * rewritten, and the process runs exactly as it did before. That is the point of the
12
+ * flag: a diagnostic nobody asked for should not be in the path of everybody's turns.
13
+ *
14
+ * On, it also raises `Error.stackTraceLimit`. V8's default of 10 frames is nothing on a
15
+ * recursion — the repeated frame is the answer, and ten of it says only that something
16
+ * repeated.
17
+ */
18
+ import { register } from "node:module";
19
+ import { MessageChannel } from "node:worker_threads";
20
+
21
+ export const ENV_VAR = "PI_OUTPOST_PROVIDER_STACK";
22
+
23
+ /** How many frames are worth keeping. A recursion shows its cycle long before this. */
24
+ export const STACK_TRACE_LIMIT = 200;
25
+
26
+ /** Whether the flag's value means yes. Unset, empty, `0`, `false` and `no` mean no. */
27
+ export function isEnabled(value) {
28
+ if (typeof value !== "string") return false;
29
+ const normalized = value.trim().toLowerCase();
30
+ if (normalized.length === 0) return false;
31
+ return normalized !== "0" && normalized !== "false" && normalized !== "no" && normalized !== "off";
32
+ }
33
+
34
+ /**
35
+ * Registers the load hook and returns the port arming announcements arrive on.
36
+ *
37
+ * Exported so a test can drive it without a second process, and so the caller decides
38
+ * what to do with the announcements — this module only prints them.
39
+ */
40
+ export function armProviderStackProbe(env = process.env) {
41
+ if (!isEnabled(env[ENV_VAR])) return undefined;
42
+ Error.stackTraceLimit = STACK_TRACE_LIMIT;
43
+ const { port1, port2 } = new MessageChannel();
44
+ port1.on("message", (url) => {
45
+ console.error(`[pi-outpost] provider stack probe armed on ${url}`);
46
+ });
47
+ // Nothing should wait on this port: an armed probe must not hold the event loop open.
48
+ port1.unref();
49
+ register("./providerStackRewrite.mjs", {
50
+ parentURL: import.meta.url,
51
+ data: port2,
52
+ transferList: [port2],
53
+ });
54
+ console.error(
55
+ `[pi-outpost] provider stack probe on (${ENV_VAR}). If no probe reports in by the first` +
56
+ " model call, the SDK's catch has moved and there was nothing to read.",
57
+ );
58
+ return port1;
59
+ }
60
+
61
+ armProviderStackProbe();
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The module-load hook that puts a provider's lost stack back on stderr.
3
+ *
4
+ * Every pi-ai provider's stream loop ends in the same catch:
5
+ *
6
+ * catch (error) { output.errorMessage = formatProviderError(normalizeProviderError(error)); … }
7
+ *
8
+ * The message is kept and the `Error` is dropped, so by the time `assistant_end`
9
+ * crosses the runtime seam there is no `.stack` left anywhere in the process — which is
10
+ * the gap `turnFailureLog.ts` was written around, and why it records a census of the
11
+ * *input* rather than a trace. `normalizeProviderError` returns a flat
12
+ * `{ status, body, message, messageCarriesBody }`; it never carries the stack either.
13
+ * No extension hook reaches this: `after_provider_response` fires before the stream is
14
+ * consumed, and V8 offers no hook at an `Error`'s construction — `prepareStackTrace`
15
+ * runs on the first read of `.stack`, which never happens.
16
+ *
17
+ * So the stack is read here, at module load, before the catch can drop it — and put
18
+ * back where the rest of the pipeline already knows to look, as an assistant-message
19
+ * diagnostic, which is what the upstream fix would be. The source is rewritten **in
20
+ * memory**: nothing under `node_modules` is touched, `npm ci` has nothing to undo, and
21
+ * a machine running without the flag runs the bytes it installed.
22
+ *
23
+ * Matched on the catch's own line rather than on a filename, so it arms every provider
24
+ * that shares it — and arms none if a future SDK rewrites that line. That silence is
25
+ * reported rather than left to be discovered: each arming is announced through the
26
+ * port, and a run that announces nothing is a hook that found nothing.
27
+ *
28
+ * Hooks run off-thread, so `console.error` from here would not interleave with the
29
+ * server's own output; the port is how this side talks back. The injected probe itself
30
+ * runs in the module's own context on the main thread, where stderr is the real one.
31
+ */
32
+
33
+ /** The assignment that drops the error, verbatim from every pi-ai provider. */
34
+ const NEEDLE = "output.errorMessage = formatProviderError(normalizeProviderError(error));";
35
+
36
+ /**
37
+ * Only stack exhaustion. Deliberately the same test as `isStackExhaustion` in
38
+ * `turnFailureLog.ts`: every other provider failure is already legible in the red
39
+ * bubble, and dumping 200 frames for a rate limit would bury the rare event in the
40
+ * common ones.
41
+ *
42
+ * Two destinations, because they answer different questions. stderr is immediate and
43
+ * is what someone watching a terminal sees. The diagnostic is durable: it rides on the
44
+ * assistant message the way `pi-messages` and `openai-codex-responses` already attach
45
+ * theirs, so it reaches `censusOfTurn`, which records `diagnostics` verbatim and says
46
+ * in its own comment that this is the only object in the pipeline that ever carries a
47
+ * stack. The census and the trace then land in the same record instead of having to be
48
+ * correlated by timestamp across two streams.
49
+ *
50
+ * The shape is `createAssistantMessageDiagnostic` + `appendAssistantMessageDiagnostic`
51
+ * from pi-ai's `utils/diagnostics.js`, inlined rather than imported: this text is
52
+ * injected into a module whose imports were resolved before the rewrite, so there is
53
+ * nothing to add an import to. `details.source` says who attached it, so nobody reads
54
+ * it as something the SDK itself produced.
55
+ */
56
+ const PROBE = [
57
+ "try {",
58
+ " if (/maximum call stack size exceeded|call stack size exceeded|stack overflow/i.test(String(error && error.message))) {",
59
+ " const __piCode = error && error.code;",
60
+ " output.diagnostics = [...(output.diagnostics ?? []), {",
61
+ ' type: "provider_transport_failure", timestamp: Date.now(),',
62
+ " error: { name: (error && error.name) || undefined,",
63
+ " message: (error && error.message) || (error && error.name),",
64
+ " stack: error && error.stack,",
65
+ ' code: (typeof __piCode === "string" || typeof __piCode === "number") ? __piCode : undefined },',
66
+ ' details: { source: "pi-outpost provider stack probe" } }];',
67
+ ' console.error("[pi-outpost] provider stack:\\n" + ((error && error.stack) || String(error)));',
68
+ " }",
69
+ "} catch { }",
70
+ ].join("");
71
+
72
+ /** Set by `initialize`, which `module.register` calls with the port it was given. */
73
+ let port;
74
+
75
+ export function initialize(data) {
76
+ port = data;
77
+ }
78
+
79
+ export async function load(url, context, nextLoad) {
80
+ const result = await nextLoad(url, context);
81
+ if (result.source === undefined || result.source === null) return result;
82
+ const source = result.source.toString();
83
+ if (!source.includes(NEEDLE)) return result;
84
+ port?.postMessage(url);
85
+ // Prepended, not replacing: the assignment still runs and the turn still fails the
86
+ // way it did. This hook observes, it does not change what the agent sees.
87
+ return { ...result, source: source.replaceAll(NEEDLE, `${PROBE} ${NEEDLE}`) };
88
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-outpost",
3
- "version": "0.27.1",
3
+ "version": "0.28.1",
4
4
  "type": "module",
5
5
  "description": "A web interface for the pi coding agent: a browser chat UI you run with npx — no clone, no build.",
6
6
  "license": "MIT",
@@ -53,7 +53,7 @@
53
53
  "prepack": "node scripts/build.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "@earendil-works/pi-coding-agent": "^0.86.1",
56
+ "@earendil-works/pi-coding-agent": "^0.87.0",
57
57
  "@fastify/static": "^10.1.2",
58
58
  "@fastify/websocket": "^11.0.2",
59
59
  "fastify": "^5.3.0",