@monotykamary/pi-ledger 0.3.0 → 0.4.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/extensions/pi-ledger/__tests__/helpers.ts +8 -1
- package/extensions/pi-ledger/__tests__/in-process-session-observer.test.ts +555 -0
- package/extensions/pi-ledger/__tests__/nested-agent-telemetry.test.ts +867 -0
- package/extensions/pi-ledger/__tests__/subprocess-observer.test.ts +364 -0
- package/extensions/pi-ledger/in-process-session-observer.ts +890 -0
- package/extensions/pi-ledger/index.ts +3 -0
- package/extensions/pi-ledger/nested-agent-telemetry.ts +1271 -0
- package/extensions/pi-ledger/subprocess-observer.ts +694 -0
- package/package.json +1 -1
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import * as diagnosticsChannel from 'node:diagnostics_channel';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
|
+
|
|
5
|
+
export type PiSubprocessMode = 'rpc' | 'json';
|
|
6
|
+
|
|
7
|
+
export const SUBPROCESS_OBSERVER_LIMITS = Object.freeze({
|
|
8
|
+
maxLineBytes: 1_048_576,
|
|
9
|
+
maxMetadataBytes: 512,
|
|
10
|
+
maxActiveTools: 256,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export interface SubprocessModelRef {
|
|
14
|
+
readonly provider?: string;
|
|
15
|
+
readonly modelId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SubprocessAssistantUsage {
|
|
19
|
+
readonly input: number;
|
|
20
|
+
readonly output: number;
|
|
21
|
+
readonly cacheRead: number;
|
|
22
|
+
readonly cacheWrite: number;
|
|
23
|
+
readonly reasoning?: number;
|
|
24
|
+
readonly totalTokens: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ProcessRef {
|
|
28
|
+
readonly pid?: number;
|
|
29
|
+
readonly mode: PiSubprocessMode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubprocessAssistantUsageEvent extends ProcessRef {
|
|
33
|
+
readonly type: 'assistant_usage';
|
|
34
|
+
readonly timestamp: number;
|
|
35
|
+
readonly model?: SubprocessModelRef;
|
|
36
|
+
readonly responseId?: string;
|
|
37
|
+
readonly usage: SubprocessAssistantUsage;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubprocessToolIntervalEvent extends ProcessRef {
|
|
41
|
+
readonly type: 'tool_interval';
|
|
42
|
+
readonly model?: SubprocessModelRef;
|
|
43
|
+
readonly responseId?: string;
|
|
44
|
+
readonly startedAt: number;
|
|
45
|
+
readonly endedAt: number;
|
|
46
|
+
readonly durationMs: number;
|
|
47
|
+
readonly toolCalls: readonly { readonly toolCallId: string; readonly toolName?: string }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SubprocessLifecycleEvent extends ProcessRef {
|
|
51
|
+
readonly type: 'lifecycle';
|
|
52
|
+
readonly phase: 'spawn' | 'close';
|
|
53
|
+
readonly timestamp: number;
|
|
54
|
+
readonly code?: number | null;
|
|
55
|
+
readonly signal?: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SubprocessMalformedLineEvent extends ProcessRef {
|
|
59
|
+
readonly type: 'malformed_line';
|
|
60
|
+
readonly reason: 'invalid_json' | 'line_too_long';
|
|
61
|
+
readonly lineBytes: number;
|
|
62
|
+
readonly timestamp: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SubprocessTelemetryEvent =
|
|
66
|
+
| SubprocessAssistantUsageEvent
|
|
67
|
+
| SubprocessToolIntervalEvent
|
|
68
|
+
| SubprocessLifecycleEvent
|
|
69
|
+
| SubprocessMalformedLineEvent;
|
|
70
|
+
|
|
71
|
+
export type SubprocessObserverErrorPhase = 'install' | 'attach' | 'stream' | 'callback' | 'cleanup';
|
|
72
|
+
|
|
73
|
+
export interface SubprocessTelemetryObserverOptions {
|
|
74
|
+
readonly maxLineBytes?: number;
|
|
75
|
+
readonly onAssistantUsage?: (event: SubprocessAssistantUsageEvent) => unknown;
|
|
76
|
+
readonly onToolInterval?: (event: SubprocessToolIntervalEvent) => unknown;
|
|
77
|
+
readonly onLifecycle?: (event: SubprocessLifecycleEvent) => unknown;
|
|
78
|
+
readonly onMalformedLine?: (event: SubprocessMalformedLineEvent) => unknown;
|
|
79
|
+
readonly onError?: (error: { phase: SubprocessObserverErrorPhase; error: unknown }) => unknown;
|
|
80
|
+
readonly dependencies?: {
|
|
81
|
+
readonly channel?: DiagnosticsChannelLike | null;
|
|
82
|
+
readonly now?: () => number;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DiagnosticsChannelLike {
|
|
87
|
+
subscribe(listener: DiagnosticListener): unknown;
|
|
88
|
+
unsubscribe(listener: DiagnosticListener): unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SubprocessTelemetryObserverHandle {
|
|
92
|
+
readonly installed: boolean;
|
|
93
|
+
readonly incompatibility?: 'diagnostics_channel';
|
|
94
|
+
unsubscribe(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type DiagnosticListener = (message: unknown, name?: string | symbol) => void;
|
|
98
|
+
type Listener = (...args: unknown[]) => void;
|
|
99
|
+
type Emit = (this: unknown, ...args: unknown[]) => unknown;
|
|
100
|
+
|
|
101
|
+
const STREAM_PATCH_SYMBOL = Symbol.for('@monotykamary/pi-ledger/subprocess-stdout-emit-patch/v1');
|
|
102
|
+
const STREAM_PATCH_BRAND = '@monotykamary/pi-ledger/subprocess-stdout-emit-patch/v1';
|
|
103
|
+
|
|
104
|
+
interface StreamLike {
|
|
105
|
+
emit: Emit;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface StreamEmitPatch {
|
|
109
|
+
readonly brand: typeof STREAM_PATCH_BRAND;
|
|
110
|
+
readonly stream: StreamLike;
|
|
111
|
+
readonly wrapper: Emit;
|
|
112
|
+
readonly previous: Emit;
|
|
113
|
+
readonly previousDescriptor?: PropertyDescriptor;
|
|
114
|
+
active: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ChildLike {
|
|
118
|
+
pid?: number;
|
|
119
|
+
spawnfile?: string | null;
|
|
120
|
+
spawnargs?: readonly string[];
|
|
121
|
+
stdout?: StreamLike | null;
|
|
122
|
+
once(event: string, listener: Listener): unknown;
|
|
123
|
+
off?(event: string, listener: Listener): unknown;
|
|
124
|
+
removeListener?(event: string, listener: Listener): unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function executableName(value: string): string {
|
|
128
|
+
return basename(value.replaceAll('\\', '/'))
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/\.(?:exe|cmd|bat)$/i, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function piScript(value: string): 'cli' | 'rpc-entry' | undefined {
|
|
134
|
+
const path = value.replaceAll('\\', '/').toLowerCase();
|
|
135
|
+
if (!path.includes('pi-coding-agent/') && !path.includes('/node_modules/.bin/pi')) return;
|
|
136
|
+
if (/(?:^|\/)rpc-entry\.(?:js|mjs|cjs|ts)$/.test(path)) return 'rpc-entry';
|
|
137
|
+
if (/(?:^|\/)cli\.(?:js|mjs|cjs|ts)$/.test(path)) return 'cli';
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Conservatively identify Pi invocations that produce JSONL event streams. */
|
|
142
|
+
export function classifyPiSubprocess(
|
|
143
|
+
spawnfile: unknown,
|
|
144
|
+
spawnargs: unknown
|
|
145
|
+
): PiSubprocessMode | undefined {
|
|
146
|
+
try {
|
|
147
|
+
if (typeof spawnfile !== 'string' || !Array.isArray(spawnargs)) return;
|
|
148
|
+
const args = spawnargs.filter((arg): arg is string => typeof arg === 'string');
|
|
149
|
+
if (args.length !== spawnargs.length) return;
|
|
150
|
+
let mode: PiSubprocessMode | undefined;
|
|
151
|
+
for (let index = 0; index < args.length; index++) {
|
|
152
|
+
const arg = args[index]!.toLowerCase();
|
|
153
|
+
const next = args[index + 1]?.toLowerCase();
|
|
154
|
+
if (arg === '--mode' && (next === 'rpc' || next === 'json')) mode = next;
|
|
155
|
+
if (arg === '--mode=rpc') mode = 'rpc';
|
|
156
|
+
if (arg === '--mode=json') mode = 'json';
|
|
157
|
+
}
|
|
158
|
+
if (executableName(spawnfile) === 'pi') return mode;
|
|
159
|
+
const scripts = [spawnfile, ...args].map(piScript);
|
|
160
|
+
if (scripts.includes('rpc-entry')) return mode ?? 'rpc';
|
|
161
|
+
if (!mode) return;
|
|
162
|
+
const launcher = executableName(spawnfile);
|
|
163
|
+
if (
|
|
164
|
+
!['node', 'nodejs', 'bun', 'deno', 'env', 'npx', 'npm', 'pnpm', 'yarn', 'bunx'].includes(
|
|
165
|
+
launcher
|
|
166
|
+
)
|
|
167
|
+
) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const hasPi = args.some(
|
|
171
|
+
(arg) =>
|
|
172
|
+
executableName(arg) === 'pi' ||
|
|
173
|
+
arg.toLowerCase() === '@earendil-works/pi-coding-agent' ||
|
|
174
|
+
piScript(arg) === 'cli'
|
|
175
|
+
);
|
|
176
|
+
return hasPi ? mode : undefined;
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
183
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
184
|
+
? (value as Record<string, unknown>)
|
|
185
|
+
: undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function streamEmitPatch(value: unknown): StreamEmitPatch | undefined {
|
|
189
|
+
if (typeof value !== 'function') return undefined;
|
|
190
|
+
try {
|
|
191
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, STREAM_PATCH_SYMBOL);
|
|
192
|
+
const candidate = descriptor && 'value' in descriptor ? record(descriptor.value) : undefined;
|
|
193
|
+
if (
|
|
194
|
+
candidate?.brand !== STREAM_PATCH_BRAND ||
|
|
195
|
+
candidate.wrapper !== value ||
|
|
196
|
+
typeof candidate.previous !== 'function' ||
|
|
197
|
+
typeof candidate.active !== 'boolean'
|
|
198
|
+
) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return candidate as unknown as StreamEmitPatch;
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function peelInactiveStreamPatches(
|
|
208
|
+
stream: StreamLike,
|
|
209
|
+
onError: SubprocessTelemetryObserverOptions['onError']
|
|
210
|
+
): void {
|
|
211
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
212
|
+
let current: unknown;
|
|
213
|
+
try {
|
|
214
|
+
current = Reflect.get(stream, 'emit');
|
|
215
|
+
} catch (error) {
|
|
216
|
+
report(onError, 'cleanup', error);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const patch = streamEmitPatch(current);
|
|
220
|
+
if (!patch || patch.stream !== stream || patch.active) return;
|
|
221
|
+
try {
|
|
222
|
+
if (patch.previousDescriptor) {
|
|
223
|
+
Object.defineProperty(stream, 'emit', patch.previousDescriptor);
|
|
224
|
+
} else if (!Reflect.deleteProperty(stream, 'emit')) {
|
|
225
|
+
throw new Error('Unable to restore the inherited stdout emit method.');
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
report(onError, 'cleanup', error);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
report(onError, 'cleanup', new Error('Too many nested stdout emit observer patches.'));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function nonNegative(value: unknown): number {
|
|
236
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function metadataString(value: unknown): string | undefined {
|
|
240
|
+
return typeof value === 'string' &&
|
|
241
|
+
value.length > 0 &&
|
|
242
|
+
Buffer.byteLength(value, 'utf8') <= SUBPROCESS_OBSERVER_LIMITS.maxMetadataBytes
|
|
243
|
+
? value
|
|
244
|
+
: undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface ParserSink {
|
|
248
|
+
event(event: SubprocessTelemetryEvent): void;
|
|
249
|
+
now(): number;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
class JsonlParser {
|
|
253
|
+
private decoder = new StringDecoder('utf8');
|
|
254
|
+
private text = '';
|
|
255
|
+
private bytes = 0;
|
|
256
|
+
private overflow = false;
|
|
257
|
+
private ended = false;
|
|
258
|
+
private readonly active = new Map<string, string | undefined>();
|
|
259
|
+
private readonly union = new Map<string, string | undefined>();
|
|
260
|
+
private unionStart?: number;
|
|
261
|
+
private unionModel?: SubprocessModelRef;
|
|
262
|
+
private unionResponseId?: string;
|
|
263
|
+
private lastModel?: SubprocessModelRef;
|
|
264
|
+
private lastResponseId?: string;
|
|
265
|
+
|
|
266
|
+
constructor(
|
|
267
|
+
private readonly ref: ProcessRef,
|
|
268
|
+
private readonly maxLineBytes: number,
|
|
269
|
+
private readonly sink: ParserSink
|
|
270
|
+
) {}
|
|
271
|
+
|
|
272
|
+
push(chunk: unknown): void {
|
|
273
|
+
if (this.ended) return;
|
|
274
|
+
if (typeof chunk === 'string') this.pushString(chunk);
|
|
275
|
+
else if (chunk instanceof Uint8Array) {
|
|
276
|
+
this.pushBuffer(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
finish(): void {
|
|
281
|
+
if (this.ended) return;
|
|
282
|
+
this.ended = true;
|
|
283
|
+
if (this.bytes || this.text || this.overflow) this.line();
|
|
284
|
+
else this.decoder.end();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
dispose(): void {
|
|
288
|
+
this.ended = true;
|
|
289
|
+
this.text = '';
|
|
290
|
+
this.decoder.end();
|
|
291
|
+
this.active.clear();
|
|
292
|
+
this.union.clear();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private addBytes(count: number): void {
|
|
296
|
+
this.bytes = Math.min(Number.MAX_SAFE_INTEGER, this.bytes + count);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private pushBuffer(chunk: Buffer): void {
|
|
300
|
+
let start = 0;
|
|
301
|
+
for (;;) {
|
|
302
|
+
const newline = chunk.indexOf(10, start);
|
|
303
|
+
const end = newline < 0 ? chunk.length : newline;
|
|
304
|
+
const part = chunk.subarray(start, end);
|
|
305
|
+
const before = this.bytes;
|
|
306
|
+
this.addBytes(part.length);
|
|
307
|
+
if (!this.overflow && this.bytes <= this.maxLineBytes) this.text += this.decoder.write(part);
|
|
308
|
+
else if (!this.overflow) {
|
|
309
|
+
const allowed = Math.max(0, this.maxLineBytes - before);
|
|
310
|
+
if (allowed) this.decoder.write(part.subarray(0, allowed));
|
|
311
|
+
this.decoder.end();
|
|
312
|
+
this.decoder = new StringDecoder('utf8');
|
|
313
|
+
this.text = '';
|
|
314
|
+
this.overflow = true;
|
|
315
|
+
}
|
|
316
|
+
if (newline < 0) return;
|
|
317
|
+
this.line();
|
|
318
|
+
start = newline + 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private pushString(chunk: string): void {
|
|
323
|
+
if (!this.overflow) this.text += this.decoder.end();
|
|
324
|
+
else this.decoder.end();
|
|
325
|
+
this.decoder = new StringDecoder('utf8');
|
|
326
|
+
let start = 0;
|
|
327
|
+
for (;;) {
|
|
328
|
+
const newline = chunk.indexOf('\n', start);
|
|
329
|
+
const end = newline < 0 ? chunk.length : newline;
|
|
330
|
+
const part = chunk.slice(start, end);
|
|
331
|
+
this.addBytes(Buffer.byteLength(part, 'utf8'));
|
|
332
|
+
if (!this.overflow && this.bytes <= this.maxLineBytes) this.text += part;
|
|
333
|
+
else if (!this.overflow) {
|
|
334
|
+
this.text = '';
|
|
335
|
+
this.overflow = true;
|
|
336
|
+
}
|
|
337
|
+
if (newline < 0) return;
|
|
338
|
+
this.line();
|
|
339
|
+
start = newline + 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private line(): void {
|
|
344
|
+
const bytes = this.bytes;
|
|
345
|
+
const tooLong = this.overflow;
|
|
346
|
+
const line = tooLong ? '' : `${this.text}${this.decoder.end()}`.replace(/\r$/, '');
|
|
347
|
+
this.decoder = new StringDecoder('utf8');
|
|
348
|
+
this.text = '';
|
|
349
|
+
this.bytes = 0;
|
|
350
|
+
this.overflow = false;
|
|
351
|
+
if (tooLong) {
|
|
352
|
+
this.sink.event({
|
|
353
|
+
type: 'malformed_line',
|
|
354
|
+
...this.ref,
|
|
355
|
+
reason: 'line_too_long',
|
|
356
|
+
lineBytes: bytes,
|
|
357
|
+
timestamp: this.sink.now(),
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
let parsed: unknown;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(line) as unknown;
|
|
364
|
+
} catch {
|
|
365
|
+
this.sink.event({
|
|
366
|
+
type: 'malformed_line',
|
|
367
|
+
...this.ref,
|
|
368
|
+
reason: 'invalid_json',
|
|
369
|
+
lineBytes: bytes,
|
|
370
|
+
timestamp: this.sink.now(),
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this.handle(record(parsed));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private handle(event: Record<string, unknown> | undefined): void {
|
|
378
|
+
if (event?.type === 'message_end') this.assistant(event);
|
|
379
|
+
else if (event?.type === 'tool_execution_start') this.toolStart(event);
|
|
380
|
+
else if (event?.type === 'tool_execution_end') this.toolEnd(event);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private assistant(event: Record<string, unknown>): void {
|
|
384
|
+
const message = record(event.message);
|
|
385
|
+
if (!message || message.role !== 'assistant') return;
|
|
386
|
+
const provider = metadataString(message.provider);
|
|
387
|
+
const modelId = metadataString(message.model);
|
|
388
|
+
const model =
|
|
389
|
+
provider || modelId
|
|
390
|
+
? { ...(provider ? { provider } : {}), ...(modelId ? { modelId } : {}) }
|
|
391
|
+
: undefined;
|
|
392
|
+
const responseId = metadataString(message.responseId);
|
|
393
|
+
this.lastModel = model;
|
|
394
|
+
this.lastResponseId = responseId;
|
|
395
|
+
const source = record(message.usage);
|
|
396
|
+
if (!source) return;
|
|
397
|
+
const input = nonNegative(source.input);
|
|
398
|
+
const output = nonNegative(source.output);
|
|
399
|
+
const cacheRead = nonNegative(source.cacheRead);
|
|
400
|
+
const cacheWrite = nonNegative(source.cacheWrite);
|
|
401
|
+
const reasoning =
|
|
402
|
+
typeof source.reasoning === 'number' && Number.isFinite(source.reasoning)
|
|
403
|
+
? Math.max(0, source.reasoning)
|
|
404
|
+
: undefined;
|
|
405
|
+
const total =
|
|
406
|
+
typeof source.totalTokens === 'number' && Number.isFinite(source.totalTokens)
|
|
407
|
+
? Math.max(0, source.totalTokens)
|
|
408
|
+
: input + output + cacheRead + cacheWrite;
|
|
409
|
+
const usage: SubprocessAssistantUsage = {
|
|
410
|
+
input,
|
|
411
|
+
output,
|
|
412
|
+
cacheRead,
|
|
413
|
+
cacheWrite,
|
|
414
|
+
...(reasoning !== undefined ? { reasoning } : {}),
|
|
415
|
+
totalTokens: total,
|
|
416
|
+
};
|
|
417
|
+
const timestamp =
|
|
418
|
+
typeof message.timestamp === 'number' && Number.isFinite(message.timestamp)
|
|
419
|
+
? Math.max(0, message.timestamp)
|
|
420
|
+
: this.sink.now();
|
|
421
|
+
this.sink.event({
|
|
422
|
+
type: 'assistant_usage',
|
|
423
|
+
...this.ref,
|
|
424
|
+
timestamp,
|
|
425
|
+
...(model ? { model } : {}),
|
|
426
|
+
...(responseId ? { responseId } : {}),
|
|
427
|
+
usage,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private toolStart(event: Record<string, unknown>): void {
|
|
432
|
+
const id = metadataString(event.toolCallId);
|
|
433
|
+
if (!id || this.active.has(id) || this.active.size >= SUBPROCESS_OBSERVER_LIMITS.maxActiveTools)
|
|
434
|
+
return;
|
|
435
|
+
const name = metadataString(event.toolName);
|
|
436
|
+
if (this.active.size === 0) {
|
|
437
|
+
this.unionStart = this.sink.now();
|
|
438
|
+
this.union.clear();
|
|
439
|
+
this.unionModel = this.lastModel;
|
|
440
|
+
this.unionResponseId = this.lastResponseId;
|
|
441
|
+
}
|
|
442
|
+
this.active.set(id, name);
|
|
443
|
+
this.union.set(id, name);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private toolEnd(event: Record<string, unknown>): void {
|
|
447
|
+
const id = metadataString(event.toolCallId);
|
|
448
|
+
if (!id || !this.active.delete(id) || this.active.size) return;
|
|
449
|
+
const startedAt = this.unionStart ?? this.sink.now();
|
|
450
|
+
const endedAt = Math.max(startedAt, this.sink.now());
|
|
451
|
+
const durationMs = endedAt - startedAt;
|
|
452
|
+
const toolCalls = [...this.union].map(([toolCallId, toolName]) => ({
|
|
453
|
+
toolCallId,
|
|
454
|
+
...(toolName ? { toolName } : {}),
|
|
455
|
+
}));
|
|
456
|
+
this.sink.event({
|
|
457
|
+
type: 'tool_interval',
|
|
458
|
+
...this.ref,
|
|
459
|
+
...(this.unionModel ? { model: this.unionModel } : {}),
|
|
460
|
+
...(this.unionResponseId ? { responseId: this.unionResponseId } : {}),
|
|
461
|
+
startedAt,
|
|
462
|
+
endedAt,
|
|
463
|
+
durationMs,
|
|
464
|
+
toolCalls,
|
|
465
|
+
});
|
|
466
|
+
this.unionStart = undefined;
|
|
467
|
+
this.unionModel = undefined;
|
|
468
|
+
this.unionResponseId = undefined;
|
|
469
|
+
this.union.clear();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function report(
|
|
474
|
+
callback: SubprocessTelemetryObserverOptions['onError'],
|
|
475
|
+
phase: SubprocessObserverErrorPhase,
|
|
476
|
+
error: unknown
|
|
477
|
+
): void {
|
|
478
|
+
try {
|
|
479
|
+
const result = callback?.({ phase, error });
|
|
480
|
+
if (result instanceof Promise) void result.catch(() => undefined);
|
|
481
|
+
} catch {
|
|
482
|
+
// Observability is never load-bearing for a child process.
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function remove(child: ChildLike, event: string, listener: Listener): void {
|
|
487
|
+
try {
|
|
488
|
+
const method = child.off ?? child.removeListener;
|
|
489
|
+
if (method) Reflect.apply(method, child, [event, listener]);
|
|
490
|
+
} catch {
|
|
491
|
+
// Best-effort cleanup for feature-compatible ChildProcess implementations.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Observe future Pi RPC/JSON children without consuming or flowing stdout. */
|
|
496
|
+
export function installSubprocessTelemetryObserver(
|
|
497
|
+
options: SubprocessTelemetryObserverOptions = {}
|
|
498
|
+
): SubprocessTelemetryObserverHandle {
|
|
499
|
+
const nowSource = options.dependencies?.now ?? Date.now;
|
|
500
|
+
const now = (): number => {
|
|
501
|
+
try {
|
|
502
|
+
const value = nowSource();
|
|
503
|
+
if (Number.isFinite(value)) return value;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
report(options.onError, 'stream', error);
|
|
506
|
+
}
|
|
507
|
+
return Date.now();
|
|
508
|
+
};
|
|
509
|
+
const requestedLimit = options.maxLineBytes;
|
|
510
|
+
const maxLineBytes =
|
|
511
|
+
Number.isSafeInteger(requestedLimit) && requestedLimit! > 0
|
|
512
|
+
? Math.min(requestedLimit!, 16_777_216)
|
|
513
|
+
: SUBPROCESS_OBSERVER_LIMITS.maxLineBytes;
|
|
514
|
+
let channel: DiagnosticsChannelLike | null = null;
|
|
515
|
+
try {
|
|
516
|
+
channel =
|
|
517
|
+
options.dependencies && 'channel' in options.dependencies
|
|
518
|
+
? (options.dependencies.channel ?? null)
|
|
519
|
+
: ((diagnosticsChannel.channel?.('child_process') as DiagnosticsChannelLike | undefined) ??
|
|
520
|
+
null);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
report(options.onError, 'install', error);
|
|
523
|
+
}
|
|
524
|
+
if (!channel?.subscribe || !channel.unsubscribe) {
|
|
525
|
+
return { installed: false, incompatibility: 'diagnostics_channel', unsubscribe() {} };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const cleanups = new Set<() => void>();
|
|
529
|
+
const dispatch = (event: SubprocessTelemetryEvent): void => {
|
|
530
|
+
const callback =
|
|
531
|
+
event.type === 'assistant_usage'
|
|
532
|
+
? options.onAssistantUsage
|
|
533
|
+
: event.type === 'tool_interval'
|
|
534
|
+
? options.onToolInterval
|
|
535
|
+
: event.type === 'lifecycle'
|
|
536
|
+
? options.onLifecycle
|
|
537
|
+
: options.onMalformedLine;
|
|
538
|
+
try {
|
|
539
|
+
const result = callback?.(event as never);
|
|
540
|
+
if (result instanceof Promise)
|
|
541
|
+
void result.catch((error) => report(options.onError, 'callback', error));
|
|
542
|
+
} catch (error) {
|
|
543
|
+
report(options.onError, 'callback', error);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const observe = (child: ChildLike): void => {
|
|
548
|
+
let done = false;
|
|
549
|
+
let parser: JsonlParser | undefined;
|
|
550
|
+
let ref: ProcessRef | undefined;
|
|
551
|
+
let restore = (): void => undefined;
|
|
552
|
+
const cleanup = (): void => {
|
|
553
|
+
if (done) return;
|
|
554
|
+
done = true;
|
|
555
|
+
remove(child, 'spawn', spawned);
|
|
556
|
+
remove(child, 'error', failed);
|
|
557
|
+
remove(child, 'close', closed);
|
|
558
|
+
parser?.dispose();
|
|
559
|
+
restore();
|
|
560
|
+
cleanups.delete(cleanup);
|
|
561
|
+
};
|
|
562
|
+
const failed: Listener = () => cleanup();
|
|
563
|
+
const closed: Listener = (code, signal) => {
|
|
564
|
+
try {
|
|
565
|
+
parser?.finish();
|
|
566
|
+
if (ref) {
|
|
567
|
+
dispatch({
|
|
568
|
+
type: 'lifecycle',
|
|
569
|
+
...ref,
|
|
570
|
+
phase: 'close',
|
|
571
|
+
timestamp: now(),
|
|
572
|
+
...(typeof code === 'number' || code === null ? { code } : {}),
|
|
573
|
+
...(typeof signal === 'string' || signal === null ? { signal } : {}),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
} catch (error) {
|
|
577
|
+
report(options.onError, 'stream', error);
|
|
578
|
+
} finally {
|
|
579
|
+
cleanup();
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
const spawned: Listener = () => {
|
|
583
|
+
try {
|
|
584
|
+
remove(child, 'error', failed);
|
|
585
|
+
const mode = classifyPiSubprocess(child.spawnfile, child.spawnargs);
|
|
586
|
+
if (!mode) return cleanup();
|
|
587
|
+
const pid = Number.isSafeInteger(child.pid) && child.pid! >= 0 ? child.pid : undefined;
|
|
588
|
+
ref = { mode, ...(pid !== undefined ? { pid } : {}) };
|
|
589
|
+
dispatch({
|
|
590
|
+
type: 'lifecycle',
|
|
591
|
+
...ref,
|
|
592
|
+
phase: 'spawn',
|
|
593
|
+
timestamp: now(),
|
|
594
|
+
});
|
|
595
|
+
if (done) return;
|
|
596
|
+
const stdout = child.stdout;
|
|
597
|
+
const previous = stdout?.emit;
|
|
598
|
+
if (stdout && typeof previous === 'function') {
|
|
599
|
+
parser = new JsonlParser(ref, maxLineBytes, { event: dispatch, now });
|
|
600
|
+
const previousDescriptor = Object.getOwnPropertyDescriptor(stdout, 'emit');
|
|
601
|
+
let patch: StreamEmitPatch;
|
|
602
|
+
const wrapper: Emit = function (this: unknown, ...args: unknown[]): unknown {
|
|
603
|
+
if (patch.active && this === stdout) {
|
|
604
|
+
try {
|
|
605
|
+
if (args[0] === 'data') parser?.push(args[1]);
|
|
606
|
+
else if (args[0] === 'end') parser?.finish();
|
|
607
|
+
else if (args[0] === 'close') {
|
|
608
|
+
parser?.finish();
|
|
609
|
+
restore();
|
|
610
|
+
}
|
|
611
|
+
} catch (error) {
|
|
612
|
+
report(options.onError, 'stream', error);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return Reflect.apply(previous, this, args);
|
|
616
|
+
};
|
|
617
|
+
patch = {
|
|
618
|
+
brand: STREAM_PATCH_BRAND,
|
|
619
|
+
stream: stdout,
|
|
620
|
+
wrapper,
|
|
621
|
+
previous,
|
|
622
|
+
...(previousDescriptor ? { previousDescriptor } : {}),
|
|
623
|
+
active: true,
|
|
624
|
+
};
|
|
625
|
+
Object.defineProperty(wrapper, STREAM_PATCH_SYMBOL, {
|
|
626
|
+
value: patch,
|
|
627
|
+
enumerable: false,
|
|
628
|
+
configurable: false,
|
|
629
|
+
writable: false,
|
|
630
|
+
});
|
|
631
|
+
Object.defineProperty(stdout, 'emit', {
|
|
632
|
+
value: wrapper,
|
|
633
|
+
writable: previousDescriptor?.writable ?? true,
|
|
634
|
+
enumerable: previousDescriptor?.enumerable ?? false,
|
|
635
|
+
configurable: previousDescriptor?.configurable ?? true,
|
|
636
|
+
});
|
|
637
|
+
restore = (): void => {
|
|
638
|
+
if (!patch.active) return;
|
|
639
|
+
patch.active = false;
|
|
640
|
+
peelInactiveStreamPatches(stdout, options.onError);
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
Reflect.apply(child.once, child, ['close', closed]);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
report(options.onError, 'attach', error);
|
|
646
|
+
cleanup();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
cleanups.add(cleanup);
|
|
650
|
+
try {
|
|
651
|
+
Reflect.apply(child.once, child, ['error', failed]);
|
|
652
|
+
Reflect.apply(child.once, child, ['spawn', spawned]);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
report(options.onError, 'attach', error);
|
|
655
|
+
cleanup();
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const listener: DiagnosticListener = (message) => {
|
|
660
|
+
try {
|
|
661
|
+
const child = record(message)?.process;
|
|
662
|
+
if (
|
|
663
|
+
child &&
|
|
664
|
+
(typeof child === 'object' || typeof child === 'function') &&
|
|
665
|
+
typeof Reflect.get(child, 'once') === 'function'
|
|
666
|
+
) {
|
|
667
|
+
observe(child as ChildLike);
|
|
668
|
+
}
|
|
669
|
+
} catch (error) {
|
|
670
|
+
report(options.onError, 'attach', error);
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
try {
|
|
674
|
+
channel.subscribe(listener);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
report(options.onError, 'install', error);
|
|
677
|
+
return { installed: false, incompatibility: 'diagnostics_channel', unsubscribe() {} };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
let unsubscribed = false;
|
|
681
|
+
return {
|
|
682
|
+
installed: true,
|
|
683
|
+
unsubscribe(): void {
|
|
684
|
+
if (unsubscribed) return;
|
|
685
|
+
unsubscribed = true;
|
|
686
|
+
try {
|
|
687
|
+
channel!.unsubscribe(listener);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
report(options.onError, 'cleanup', error);
|
|
690
|
+
}
|
|
691
|
+
for (const cleanup of [...cleanups]) cleanup();
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-ledger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Billing engine for the serverless agency — a pi extension that meters agentic dev work like cloud compute and invoices it like a timesheet.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|