@tinoy/pi-child-request-dump 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tinoy Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @tinoy/pi-child-request-dump
2
+
3
+ Structure-only JSONL record of each outbound provider request in a child session.
4
+
5
+ ```bash
6
+ pi install npm:@tinoy/pi-child-request-dump
7
+ ```
8
+
9
+ ## What it needs at call time
10
+
11
+ A child session (a launch marker) and a writable state directory.
12
+
13
+ ## Registers
14
+
15
+ no tool
16
+
17
+ ## Caveats
18
+
19
+ No caveat rows declared: this unit has no soft dependency on another package in this repository. What it needs beyond its declared dependencies is machine capability, named below, and it refuses by name rather than failing to load.
20
+
21
+ ## Dependencies
22
+
23
+ pi-supplied imports (`@earendil-works/pi-coding-agent` or "none") are peer dependencies with a `*` range and are
24
+ never bundled. This package has no npm dependencies.
25
+
26
+ ## Licence
27
+
28
+ MIT — see the repository [LICENSE](../../LICENSE).
@@ -0,0 +1,281 @@
1
+ /**
2
+ * child-request-dump — structure-only record of every outbound provider request
3
+ * in a CHILD session.
4
+ *
5
+ * A provider that validates tool-call pairing rejects a request when a message
6
+ * with role "tool" answers a call that no preceding assistant message declares.
7
+ * That condition is a property of the message sequence alone, so it is recordable
8
+ * without any message content: the roles in order, the tool-call ids each
9
+ * assistant message declares, and the id each result answers. One JSONL line per
10
+ * request is written, so the last line of a run is the last request it built.
11
+ *
12
+ * Observation point. The row is built from the payload of this handler's turn.
13
+ * Handlers run in extension order, and `orphan-repair.ts` rewrites
14
+ * `payload.messages` later in that order, so a row here is the sequence pi's
15
+ * converter produced, before any repair — which is the sequence that reaches the
16
+ * provider when no repair is loaded.
17
+ *
18
+ * Content is NEVER recorded: no prompt text, no message text, no tool names, no
19
+ * tool arguments. Roles, counts and opaque tool-call ids only.
20
+ *
21
+ * Scope. Nothing is registered unless the process is a child: `PI_SUBAGENT_CHILD=1`
22
+ * for a session hosted by the async runner, `PI_SUBAGENT=1` for a launch through
23
+ * the `pi-subagent` wrapper. Ambient loading into a parent session records
24
+ * nothing. Children load this file through the settings route
25
+ * `subagents.defaultExtensions`.
26
+ *
27
+ * Log: $XDG_STATE_HOME/pi/child-request-dump.jsonl (default
28
+ * ~/.local/state/pi/child-request-dump.jsonl), capped at 256 KiB with one
29
+ * rotated sibling (child-request-dump.1.jsonl). Override with
30
+ * PI_CHILD_REQUEST_DUMP.
31
+ *
32
+ * Every fault is swallowed: a recorder must never break a provider request, and
33
+ * the append is asynchronous so no request waits on the file.
34
+ */
35
+ import { appendFile, mkdirSync, rename, statSync } from "node:fs";
36
+ import { homedir } from "node:os";
37
+ import { dirname, join } from "node:path";
38
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
+
40
+ const CAP_BYTES = 256 * 1024;
41
+
42
+ function logPath(): string {
43
+ if (process.env.PI_CHILD_REQUEST_DUMP) return process.env.PI_CHILD_REQUEST_DUMP;
44
+ const state = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
45
+ return join(state, "pi", "child-request-dump.jsonl");
46
+ }
47
+
48
+ /** The wire shapes differ by provider, so both spellings are read. */
49
+ interface WireMessage {
50
+ role?: unknown;
51
+ tool_call_id?: unknown;
52
+ tool_calls?: unknown;
53
+ toolCalls?: unknown;
54
+ content?: unknown;
55
+ }
56
+
57
+ /** The tool-call ids an assistant message declares. */
58
+ function declaredIds(m: WireMessage): string[] {
59
+ const ids: string[] = [];
60
+ const push = (v: unknown): void => {
61
+ if (typeof v === "string" && v !== "") ids.push(v);
62
+ };
63
+ if (Array.isArray(m.tool_calls)) {
64
+ for (const tc of m.tool_calls) push((tc as { id?: unknown })?.id);
65
+ }
66
+ if (Array.isArray(m.toolCalls)) {
67
+ for (const tc of m.toolCalls) push((tc as { id?: unknown })?.id);
68
+ }
69
+ if (Array.isArray(m.content)) {
70
+ for (const part of m.content as Array<{
71
+ type?: unknown;
72
+ id?: unknown;
73
+ toolCallId?: unknown;
74
+ tool_use_id?: unknown;
75
+ }>) {
76
+ if (part?.type === "toolCall" || part?.type === "tool_use" || part?.type === "function") {
77
+ push(part.id ?? part.toolCallId ?? part.tool_use_id);
78
+ }
79
+ }
80
+ }
81
+ return ids;
82
+ }
83
+
84
+ /** The tool-call id a result message answers, null when it carries none. */
85
+ function answeredId(m: WireMessage): string | null {
86
+ if (typeof m.tool_call_id === "string") return m.tool_call_id;
87
+ if (Array.isArray(m.content)) {
88
+ for (const part of m.content as Array<{
89
+ type?: unknown;
90
+ toolCallId?: unknown;
91
+ tool_use_id?: unknown;
92
+ }>) {
93
+ if (part?.type === "toolResult" || part?.type === "tool_result") {
94
+ const id = part.toolCallId ?? part.tool_use_id;
95
+ if (typeof id === "string") return id;
96
+ }
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** One row entry per message that carries a call or a result. */
103
+ interface Entry {
104
+ i: number;
105
+ r: string;
106
+ /** assistant: how many calls it declares */
107
+ tc?: number;
108
+ /** assistant: the ids it declares */
109
+ ids?: string[];
110
+ /** assistant: indices of the results that answer it */
111
+ paired?: number[];
112
+ /** tool: the id it answers, null when it carries none */
113
+ id?: string | null;
114
+ /** tool: index of the assistant message it follows */
115
+ after?: number;
116
+ /** tool: whether the assistant message it follows declares the answered id */
117
+ ok?: boolean;
118
+ /** tool: whether any preceding assistant message declares the answered id */
119
+ prev?: boolean;
120
+ }
121
+
122
+ interface Shape {
123
+ roles: string[];
124
+ entries: Entry[];
125
+ orphans: Array<{ i: number; id: string | null }>;
126
+ late: Array<{ i: number; id: string | null }>;
127
+ calls: number;
128
+ results: number;
129
+ }
130
+
131
+ /**
132
+ * A result is paired when the assistant message it follows declares the id it
133
+ * answers. The provider's rule is weaker — a result is valid when ANY preceding
134
+ * assistant message declares the id — so two conditions are recorded apart:
135
+ * `orphans` holds results no preceding assistant declares, which is the sequence
136
+ * the provider rejects, and `late` holds results whose call belongs to an earlier
137
+ * assistant than the one they follow, which is an order anomaly the provider
138
+ * still accepts. `after` names the assistant a result follows, so a broken pair
139
+ * also names the message that lost its calls.
140
+ */
141
+ function scanShape(messages: unknown[]): Shape {
142
+ const roles: string[] = [];
143
+ const entries: Entry[] = [];
144
+ const orphans: Array<{ i: number; id: string | null }> = [];
145
+ const late: Array<{ i: number; id: string | null }> = [];
146
+ const byAssistant = new Map<number, number[]>();
147
+ const everDeclared = new Set<string>();
148
+ let lastAssistant = -1;
149
+ let lastIds = new Set<string>();
150
+ let calls = 0;
151
+ let results = 0;
152
+
153
+ for (let i = 0; i < messages.length; i++) {
154
+ const m = (messages[i] ?? {}) as WireMessage;
155
+ const role = typeof m.role === "string" ? m.role : "?";
156
+ roles.push(role);
157
+
158
+ if (role === "assistant") {
159
+ const ids = declaredIds(m);
160
+ lastAssistant = i;
161
+ lastIds = new Set(ids);
162
+ calls += ids.length;
163
+ for (const id of ids) everDeclared.add(id);
164
+ if (ids.length > 0) {
165
+ const paired: number[] = [];
166
+ entries.push({ i, r: role, tc: ids.length, ids, paired });
167
+ byAssistant.set(i, paired);
168
+ }
169
+ continue;
170
+ }
171
+
172
+ if (role === "tool") {
173
+ results += 1;
174
+ const id = answeredId(m);
175
+ const declared = id !== null && everDeclared.has(id);
176
+ const ok = declared && lastIds.has(id);
177
+ entries.push({ i, r: role, id, after: lastAssistant, ok, prev: declared });
178
+ if (!declared) orphans.push({ i, id });
179
+ else if (!ok) late.push({ i, id });
180
+ if (ok) byAssistant.get(lastAssistant)?.push(i);
181
+ }
182
+ }
183
+
184
+ return { roles, entries, orphans, late, calls, results };
185
+ }
186
+
187
+ export default function (pi: ExtensionAPI): void {
188
+ // Child sessions only: the async runner marks its hosted session with
189
+ // PI_SUBAGENT_CHILD, the pi-subagent wrapper exports PI_SUBAGENT.
190
+ if (process.env.PI_SUBAGENT_CHILD !== "1" && process.env.PI_SUBAGENT !== "1") return;
191
+
192
+ const PATH = logPath();
193
+ const ROTATED = `${PATH}.1`;
194
+
195
+ try {
196
+ mkdirSync(dirname(PATH), { recursive: true });
197
+ } catch {
198
+ /* best effort: the append below is guarded regardless */
199
+ }
200
+
201
+ let req = 0;
202
+ let run = 0;
203
+ let bytes = 0;
204
+ let rotating = false;
205
+ try {
206
+ bytes = statSync(PATH).size;
207
+ } catch {
208
+ bytes = 0;
209
+ }
210
+
211
+ // Run-start identity, as recorded by cache-prefix-log: agent_start opens a run,
212
+ // and before_agent_start fires only for a run started by the prompt path.
213
+ let promptHookFired = false;
214
+ let origin: "prompt" | "injected" | null = null;
215
+ pi.on("before_agent_start", () => {
216
+ promptHookFired = true;
217
+ });
218
+ pi.on("agent_start", () => {
219
+ run += 1;
220
+ origin = promptHookFired ? "prompt" : "injected";
221
+ promptHookFired = false;
222
+ });
223
+
224
+ function schedule(line: string): void {
225
+ const len = Buffer.byteLength(line);
226
+ // Hard cap: at or over CAP the file is rotated once, and rows are dropped
227
+ // until the rename lands, so PATH cannot exceed CAP_BYTES under a burst.
228
+ if (bytes + len > CAP_BYTES) {
229
+ if (!rotating) {
230
+ rotating = true;
231
+ bytes = CAP_BYTES;
232
+ rename(PATH, ROTATED, () => {
233
+ rotating = false;
234
+ bytes = 0;
235
+ });
236
+ }
237
+ return;
238
+ }
239
+ bytes += len;
240
+ appendFile(PATH, line, () => {
241
+ /* best effort: a recorder fault must never surface */
242
+ });
243
+ }
244
+
245
+ pi.on("before_provider_request", (event, ctx) => {
246
+ try {
247
+ const payload = (event as { payload?: Record<string, unknown> })?.payload;
248
+ const messages = Array.isArray(payload?.messages) ? (payload.messages as unknown[]) : null;
249
+ if (!messages) return undefined;
250
+
251
+ const shape = scanShape(messages);
252
+ let sess = "";
253
+ try {
254
+ sess = String(ctx?.sessionManager?.getSessionId?.() ?? "").slice(0, 8);
255
+ } catch {
256
+ sess = "";
257
+ }
258
+
259
+ const row: Record<string, unknown> = {
260
+ ts: new Date().toISOString(),
261
+ pid: process.pid,
262
+ sess,
263
+ run,
264
+ req: ++req,
265
+ origin: origin ?? (promptHookFired ? "prompt" : "injected"),
266
+ model: typeof payload?.model === "string" ? payload.model : null,
267
+ n: messages.length,
268
+ calls: shape.calls,
269
+ results: shape.results,
270
+ orphans: shape.orphans,
271
+ late: shape.late,
272
+ roles: shape.roles,
273
+ msgs: shape.entries,
274
+ };
275
+ schedule(`${JSON.stringify(row)}\n`);
276
+ } catch {
277
+ /* a recorder fault must never be the reason a request fails */
278
+ }
279
+ return undefined;
280
+ });
281
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@tinoy/pi-child-request-dump",
3
+ "version": "0.1.0",
4
+ "description": "Structure-only JSONL record of each outbound provider request in a child session.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tinoy1336/pi-extensions.git",
9
+ "directory": "packages/child-request-dump"
10
+ },
11
+ "homepage": "https://github.com/tinoy1336/pi-extensions/tree/main/packages/child-request-dump#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/tinoy1336/pi-extensions/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "child-request-dump.ts",
17
+ "keywords": [
18
+ "pi-package"
19
+ ],
20
+ "files": [
21
+ "child-request-dump.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./child-request-dump.ts"
28
+ ]
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*"
35
+ }
36
+ }