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
|
+
}
|
package/dist/sea-prep.blob
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-outpost",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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",
|