@ap3x/observe 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/dist/index.js ADDED
@@ -0,0 +1,913 @@
1
+ // src/envelope.ts
2
+ import { AsyncLocalStorage } from "async_hooks";
3
+ import { uuidv7 } from "@ap3x/agent-core";
4
+ var contextStorage = new AsyncLocalStorage();
5
+ function newTraceContext() {
6
+ return { traceId: uuidv7(), spanId: uuidv7() };
7
+ }
8
+ function childContext(parent) {
9
+ const base = parent ?? contextStorage.getStore();
10
+ if (!base) return newTraceContext();
11
+ return { traceId: base.traceId, spanId: uuidv7(), parentSpanId: base.spanId };
12
+ }
13
+ function currentTraceContext() {
14
+ return contextStorage.getStore();
15
+ }
16
+ function runInTrace(ctx, fn) {
17
+ return contextStorage.run(ctx, fn);
18
+ }
19
+ function withSpan(fn) {
20
+ return contextStorage.run(childContext(), fn);
21
+ }
22
+
23
+ // src/pipeline.ts
24
+ var MAX_TRACKED_TRACES = 1e4;
25
+ var TracePipeline = class {
26
+ #sinks;
27
+ #seqByTrace = /* @__PURE__ */ new Map();
28
+ #dropped = 0;
29
+ #now;
30
+ #root;
31
+ constructor(options) {
32
+ this.#sinks = new Set(options?.sinks ?? []);
33
+ this.#now = options?.now ?? Date.now;
34
+ }
35
+ get fallbackRoot() {
36
+ this.#root ??= newTraceContext();
37
+ return this.#root;
38
+ }
39
+ get dropped() {
40
+ return this.#dropped;
41
+ }
42
+ /** Forget a finished trace's seq counter (footer written, run over). */
43
+ releaseTrace(traceId) {
44
+ this.#seqByTrace.delete(traceId);
45
+ }
46
+ addSink(sink) {
47
+ this.#sinks.add(sink);
48
+ return () => this.#sinks.delete(sink);
49
+ }
50
+ emit(input, ctx) {
51
+ const context = ctx ?? currentTraceContext() ?? this.fallbackRoot;
52
+ const seq = (this.#seqByTrace.get(context.traceId) ?? 0) + 1;
53
+ this.#seqByTrace.set(context.traceId, seq);
54
+ if (this.#seqByTrace.size > MAX_TRACKED_TRACES) {
55
+ const oldest = this.#seqByTrace.keys().next().value;
56
+ if (oldest !== void 0 && oldest !== context.traceId) this.#seqByTrace.delete(oldest);
57
+ }
58
+ const event = {
59
+ traceId: context.traceId,
60
+ spanId: context.spanId,
61
+ ...context.parentSpanId ? { parentSpanId: context.parentSpanId } : {},
62
+ seq,
63
+ ts: this.#now(),
64
+ source: input.source,
65
+ name: input.name,
66
+ ...input.agent ? { agent: input.agent } : {},
67
+ ...input.usage ? { usage: input.usage } : {},
68
+ ...input.data !== void 0 ? { data: input.data } : {}
69
+ };
70
+ for (const sink of this.#sinks) {
71
+ try {
72
+ sink.onEvent(event);
73
+ } catch {
74
+ this.#dropped += 1;
75
+ }
76
+ }
77
+ return event;
78
+ }
79
+ };
80
+
81
+ // src/trace-writer.ts
82
+ import { appendFileSync, mkdirSync } from "fs";
83
+ import { homedir } from "os";
84
+ import { join } from "path";
85
+ var TraceWriter = class {
86
+ #dir;
87
+ #now;
88
+ #open = /* @__PURE__ */ new Map();
89
+ /** Trace IDs whose header write failed permanently — never retried. */
90
+ #failed = /* @__PURE__ */ new Set();
91
+ #dropped = 0;
92
+ #dirReady = false;
93
+ constructor(options) {
94
+ this.#dir = options?.dir ?? join(homedir(), ".ap3x", "traces");
95
+ this.#now = options?.now ?? Date.now;
96
+ }
97
+ /** Aggregate dropped-event count across all traces. */
98
+ get dropped() {
99
+ return this.#dropped;
100
+ }
101
+ onEvent(event) {
102
+ const trace = this.#openTrace(event.traceId);
103
+ if (!trace) return;
104
+ let line;
105
+ try {
106
+ line = JSON.stringify(event);
107
+ } catch (error) {
108
+ this.#dropped += 1;
109
+ trace.dropped += 1;
110
+ if (!trace.warned) {
111
+ trace.warned = true;
112
+ console.error(`trace writer: cannot serialize event for ${trace.file}: ${String(error)}`);
113
+ }
114
+ return;
115
+ }
116
+ this.#append(trace, line);
117
+ }
118
+ close(traceId, outcome) {
119
+ const trace = this.#open.get(traceId);
120
+ if (!trace) return;
121
+ this.#append(
122
+ trace,
123
+ JSON.stringify({
124
+ type: "trace_end",
125
+ traceId,
126
+ // Per-trace dropped count, not the writer-global aggregate.
127
+ dropped: trace.dropped,
128
+ ...outcome ? { outcome } : {},
129
+ endedAt: this.#now()
130
+ })
131
+ );
132
+ this.#open.delete(traceId);
133
+ }
134
+ closeAll(outcome) {
135
+ for (const traceId of [...this.#open.keys()]) this.close(traceId, outcome);
136
+ }
137
+ #openTrace(traceId) {
138
+ const existing = this.#open.get(traceId);
139
+ if (existing) return existing;
140
+ if (this.#failed.has(traceId)) {
141
+ this.#dropped += 1;
142
+ return void 0;
143
+ }
144
+ const trace = {
145
+ file: join(this.#dir, `${traceId}.jsonl`),
146
+ warned: false,
147
+ dropped: 0
148
+ };
149
+ try {
150
+ if (!this.#dirReady) {
151
+ mkdirSync(this.#dir, { recursive: true });
152
+ this.#dirReady = true;
153
+ }
154
+ appendFileSync(
155
+ trace.file,
156
+ `${JSON.stringify({ type: "trace", version: 1, traceId, startedAt: this.#now() })}
157
+ `
158
+ );
159
+ } catch (error) {
160
+ this.#dropped += 1;
161
+ this.#failed.add(traceId);
162
+ console.error(`trace writer: cannot open ${trace.file}: ${String(error)}`);
163
+ return void 0;
164
+ }
165
+ this.#open.set(traceId, trace);
166
+ return trace;
167
+ }
168
+ #append(trace, line) {
169
+ try {
170
+ appendFileSync(trace.file, `${line}
171
+ `);
172
+ } catch (error) {
173
+ this.#dropped += 1;
174
+ trace.dropped += 1;
175
+ if (!trace.warned) {
176
+ trace.warned = true;
177
+ console.error(`trace writer: write failed for ${trace.file}: ${String(error)}`);
178
+ }
179
+ }
180
+ }
181
+ };
182
+
183
+ // src/trace-file.ts
184
+ function parseTraceFile(content) {
185
+ const parsed = { events: [], malformedLines: 0 };
186
+ for (const line of content.split("\n")) {
187
+ const trimmed = line.trim();
188
+ if (!trimmed) continue;
189
+ let raw;
190
+ try {
191
+ raw = JSON.parse(trimmed);
192
+ } catch {
193
+ parsed.malformedLines += 1;
194
+ continue;
195
+ }
196
+ if (typeof raw !== "object" || raw === null) {
197
+ parsed.malformedLines += 1;
198
+ continue;
199
+ }
200
+ const row = raw;
201
+ if (row.type === "trace") parsed.header = row;
202
+ else if (row.type === "trace_end") parsed.footer = row;
203
+ else if (typeof row.seq === "number" && typeof row.name === "string") {
204
+ parsed.events.push(row);
205
+ } else parsed.malformedLines += 1;
206
+ }
207
+ return parsed;
208
+ }
209
+ function summarizeTrace(parsed) {
210
+ const summary = {
211
+ traceId: parsed.header?.traceId ?? parsed.events[0]?.traceId ?? "unknown",
212
+ dropped: parsed.footer?.dropped ?? 0,
213
+ events: parsed.events.length,
214
+ malformedLines: parsed.malformedLines,
215
+ turns: 0,
216
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
217
+ toolCalls: 0,
218
+ toolFailures: 0,
219
+ tools: {},
220
+ hookRuns: 0,
221
+ hookErrors: 0,
222
+ agents: []
223
+ };
224
+ if (parsed.header) summary.startedAt = parsed.header.startedAt;
225
+ if (parsed.footer) summary.endedAt = parsed.footer.endedAt;
226
+ if (parsed.footer?.outcome !== void 0) summary.outcome = parsed.footer.outcome;
227
+ const agents = /* @__PURE__ */ new Set();
228
+ let costTotal;
229
+ for (const event of parsed.events) {
230
+ if (event.agent?.name) agents.add(event.agent.name);
231
+ if (event.name === "turn_end") {
232
+ summary.turns += 1;
233
+ if (event.usage) {
234
+ summary.usage.input += event.usage.input;
235
+ summary.usage.output += event.usage.output;
236
+ summary.usage.cacheRead += event.usage.cacheRead;
237
+ summary.usage.cacheWrite += event.usage.cacheWrite;
238
+ const total = event.usage.cost?.total;
239
+ if (typeof total === "number") costTotal = (costTotal ?? 0) + total;
240
+ }
241
+ } else if (event.name === "tool_execution_end") {
242
+ summary.toolCalls += 1;
243
+ const data = event.data ?? {};
244
+ const name = data.toolName ?? "unknown";
245
+ const stats = summary.tools[name] ?? { calls: 0, failures: 0 };
246
+ stats.calls += 1;
247
+ if (data.isError === true) {
248
+ stats.failures += 1;
249
+ summary.toolFailures += 1;
250
+ }
251
+ summary.tools[name] = stats;
252
+ } else if (event.source === "hook") {
253
+ if (event.name === "hook_run") summary.hookRuns += 1;
254
+ else if (event.name === "hook_error") summary.hookErrors += 1;
255
+ }
256
+ }
257
+ summary.agents = [...agents];
258
+ const end = summary.endedAt ?? parsed.events.at(-1)?.ts;
259
+ if (summary.startedAt !== void 0 && end !== void 0) {
260
+ summary.endedAt = end;
261
+ summary.durationMs = end - summary.startedAt;
262
+ }
263
+ if (costTotal !== void 0) summary.usage.costTotal = costTotal;
264
+ return summary;
265
+ }
266
+
267
+ // src/queued-sink.ts
268
+ var QueuedSink = class {
269
+ #inner;
270
+ #queue = [];
271
+ #max;
272
+ #scheduled = false;
273
+ #dropped = 0;
274
+ constructor(inner, options) {
275
+ this.#inner = inner;
276
+ this.#max = options?.maxQueue ?? 1024;
277
+ }
278
+ get dropped() {
279
+ return this.#dropped;
280
+ }
281
+ onEvent(event) {
282
+ if (this.#queue.length >= this.#max) {
283
+ this.#dropped += 1;
284
+ return;
285
+ }
286
+ this.#queue.push(event);
287
+ if (!this.#scheduled) {
288
+ this.#scheduled = true;
289
+ setImmediate(() => this.#drain());
290
+ }
291
+ }
292
+ #drain() {
293
+ this.#scheduled = false;
294
+ const batch = this.#queue.splice(0);
295
+ for (const event of batch) {
296
+ try {
297
+ this.#inner.onEvent(event);
298
+ } catch {
299
+ this.#dropped += 1;
300
+ }
301
+ }
302
+ }
303
+ /** Drain everything queued right now (shutdown, tests). */
304
+ flush() {
305
+ this.#drain();
306
+ }
307
+ };
308
+
309
+ // src/taps/agent.ts
310
+ var ARGS_CAP = 4096;
311
+ function truncate(text) {
312
+ return text.length > ARGS_CAP ? `${text.slice(0, ARGS_CAP)}\u2026[truncated]` : text;
313
+ }
314
+ function safeSerializeArgs(args) {
315
+ try {
316
+ return truncate(JSON.stringify(args));
317
+ } catch {
318
+ return "[unserializable]";
319
+ }
320
+ }
321
+ function mapEvent(e) {
322
+ switch (e.type) {
323
+ case "agent_start":
324
+ return { source: "agent", name: "agent_start" };
325
+ case "agent_end":
326
+ return { source: "agent", name: "agent_end", data: { messageCount: e.messages.length } };
327
+ case "turn_start":
328
+ return { source: "agent", name: "turn_start" };
329
+ case "turn_end":
330
+ return {
331
+ source: "agent",
332
+ name: "turn_end",
333
+ usage: e.message.usage,
334
+ data: { stopReason: e.message.stopReason, toolResults: e.toolResults.length }
335
+ };
336
+ case "message_start":
337
+ case "message_end":
338
+ return { source: "agent", name: e.type, data: { role: e.message.role } };
339
+ case "compaction":
340
+ return {
341
+ source: "agent",
342
+ name: "compaction",
343
+ data: { tokensBefore: e.tokensBefore, summaryChars: e.summary.length }
344
+ };
345
+ case "tool_execution_start":
346
+ return {
347
+ source: "tool",
348
+ name: "tool_execution_start",
349
+ data: {
350
+ toolCallId: e.toolCallId,
351
+ toolName: e.toolName,
352
+ args: safeSerializeArgs(e.args)
353
+ }
354
+ };
355
+ case "tool_execution_end":
356
+ return {
357
+ source: "tool",
358
+ name: "tool_execution_end",
359
+ data: { toolCallId: e.toolCallId, toolName: e.toolName, isError: e.isError }
360
+ };
361
+ default:
362
+ return void 0;
363
+ }
364
+ }
365
+ function observeAgent(source, pipeline, options) {
366
+ const attachCtx = options?.ctx ?? currentTraceContext();
367
+ return source.subscribe((event) => {
368
+ try {
369
+ const input = mapEvent(event);
370
+ if (!input) return;
371
+ if (options?.agent) input.agent = options.agent;
372
+ pipeline.emit(input, options?.ctx ?? currentTraceContext() ?? attachCtx);
373
+ } catch {
374
+ try {
375
+ options?.onDrop?.();
376
+ } catch {
377
+ }
378
+ }
379
+ });
380
+ }
381
+
382
+ // src/taps/swarm.ts
383
+ import { onSwarmEvent, setDelegationSpanWrapper } from "@ap3x/swarms";
384
+ function observeSwarms(pipeline, options) {
385
+ return onSwarmEvent((event) => {
386
+ try {
387
+ const agentName = "agentName" in event ? event.agentName : void 0;
388
+ pipeline.emit({
389
+ source: "swarm",
390
+ name: event.type,
391
+ ...agentName ? { agent: { name: agentName } } : {},
392
+ data: event
393
+ });
394
+ } catch {
395
+ try {
396
+ options?.onDrop?.();
397
+ } catch {
398
+ }
399
+ }
400
+ });
401
+ }
402
+ function enableDelegationSpans() {
403
+ setDelegationSpanWrapper((fn) => runInTrace(childContext(), fn));
404
+ return () => setDelegationSpanWrapper(void 0);
405
+ }
406
+
407
+ // src/hooks/types.ts
408
+ var HOOK_EVENT_NAMES = [
409
+ "PreToolUse",
410
+ "PostToolUse",
411
+ "RunStart",
412
+ "TurnStart",
413
+ "Stop",
414
+ "PreCompact",
415
+ "SubagentStart",
416
+ "SubagentStop"
417
+ ];
418
+ function defineHook(hook) {
419
+ return hook;
420
+ }
421
+
422
+ // src/hooks/engine.ts
423
+ var GATING_EVENTS = /* @__PURE__ */ new Set([
424
+ "PreToolUse",
425
+ "SubagentStart",
426
+ "PreCompact",
427
+ "Stop"
428
+ ]);
429
+ var DEFAULT_GATING_TIMEOUT_MS = 1e4;
430
+ var DEFAULT_HOOK_TIMEOUT_MS = 3e4;
431
+ var HookTimeoutError = class extends Error {
432
+ constructor(timeoutMs) {
433
+ super(`hook timed out after ${timeoutMs}ms`);
434
+ this.timeoutMs = timeoutMs;
435
+ this.name = "HookTimeoutError";
436
+ }
437
+ timeoutMs;
438
+ };
439
+ function escapeRegExp(text) {
440
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
441
+ }
442
+ function globMatch(pattern, value) {
443
+ const rx = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`);
444
+ return rx.test(value);
445
+ }
446
+ async function withTimeout(fn, ms) {
447
+ let timer;
448
+ try {
449
+ return await Promise.race([
450
+ Promise.resolve().then(fn),
451
+ new Promise((_, reject) => {
452
+ timer = setTimeout(() => reject(new HookTimeoutError(ms)), ms);
453
+ })
454
+ ]);
455
+ } finally {
456
+ if (timer) clearTimeout(timer);
457
+ }
458
+ }
459
+ var HookEngine = class {
460
+ #hooks = [];
461
+ #pipeline;
462
+ constructor(options) {
463
+ this.#pipeline = options?.pipeline;
464
+ }
465
+ register(hook) {
466
+ const stored = hook;
467
+ this.#hooks.push(stored);
468
+ return () => {
469
+ const i = this.#hooks.indexOf(stored);
470
+ if (i >= 0) this.#hooks.splice(i, 1);
471
+ };
472
+ }
473
+ list() {
474
+ return this.#hooks;
475
+ }
476
+ /** Cheap pre-check so bridges can skip work when nothing listens. */
477
+ hasHooks(event) {
478
+ return this.#hooks.some((h) => h.event === event);
479
+ }
480
+ async run(event, payload, matchValue) {
481
+ const decisions = [];
482
+ for (const [index, hook] of this.#hooks.entries()) {
483
+ if (hook.event !== event) continue;
484
+ if (hook.matcher !== void 0 && matchValue !== void 0) {
485
+ if (!globMatch(hook.matcher, matchValue)) continue;
486
+ }
487
+ const timeout = hook.timeout ?? (GATING_EVENTS.has(event) ? DEFAULT_GATING_TIMEOUT_MS : DEFAULT_HOOK_TIMEOUT_MS);
488
+ const label = hook.name ?? `${event}#${index}`;
489
+ const startedAt = Date.now();
490
+ try {
491
+ const handler = hook.handler;
492
+ const decision = await withTimeout(() => handler(payload), timeout);
493
+ this.#pipeline?.emit({
494
+ source: "hook",
495
+ name: "hook_run",
496
+ data: {
497
+ hook: label,
498
+ event,
499
+ durationMs: Date.now() - startedAt,
500
+ decided: decision !== void 0
501
+ }
502
+ });
503
+ if (decision !== void 0) decisions.push(decision);
504
+ } catch (error) {
505
+ const failMode = hook.failMode ?? "open";
506
+ const message = error instanceof Error ? error.message : String(error);
507
+ this.#pipeline?.emit({
508
+ source: "hook",
509
+ name: "hook_error",
510
+ data: {
511
+ hook: label,
512
+ event,
513
+ durationMs: Date.now() - startedAt,
514
+ failMode,
515
+ error: message
516
+ }
517
+ });
518
+ if (failMode === "closed") {
519
+ return {
520
+ decisions,
521
+ failedClosed: true,
522
+ failReason: `hook "${label}" failed (failMode closed): ${message}`
523
+ };
524
+ }
525
+ }
526
+ }
527
+ return { decisions, failedClosed: false };
528
+ }
529
+ };
530
+
531
+ // src/hooks/agent-bridge.ts
532
+ function userMessage(content) {
533
+ return { role: "user", content, timestamp: Date.now() };
534
+ }
535
+ function createHookAgentHooks(engine) {
536
+ let firstConsult = true;
537
+ let turn = 0;
538
+ return {
539
+ async getSteeringMessages() {
540
+ const messages = [];
541
+ if (firstConsult) {
542
+ firstConsult = false;
543
+ const run = await engine.run("RunStart", { turn: 0 });
544
+ for (const d of run.decisions) if (d.messages) messages.push(...d.messages);
545
+ }
546
+ const t = await engine.run("TurnStart", { turn });
547
+ turn += 1;
548
+ for (const d of t.decisions) if (d.messages) messages.push(...d.messages);
549
+ return messages;
550
+ },
551
+ async beforeToolCall(toolName, args, toolCallId) {
552
+ const out = await engine.run("PreToolUse", { toolName, toolCallId, args }, toolName);
553
+ if (out.failedClosed) {
554
+ return { block: true, reason: out.failReason ?? "hook failed (failMode closed)" };
555
+ }
556
+ let updated;
557
+ for (const d of out.decisions) {
558
+ if (d.block) return { block: true, ...d.reason ? { reason: d.reason } : {} };
559
+ if (d.updatedArgs) updated = { ...updated ?? args, ...d.updatedArgs };
560
+ }
561
+ return updated ? { updatedArgs: updated } : void 0;
562
+ },
563
+ async afterToolCall(toolName, result, toolCallId, context) {
564
+ const out = await engine.run(
565
+ "PostToolUse",
566
+ {
567
+ toolName,
568
+ toolCallId,
569
+ args: context?.args ?? {},
570
+ result,
571
+ isError: result.isError ?? false
572
+ },
573
+ toolName
574
+ );
575
+ if (out.failedClosed) {
576
+ return {
577
+ content: [{ type: "text", text: out.failReason ?? "hook failed (failMode closed)" }],
578
+ isError: true
579
+ };
580
+ }
581
+ let patch;
582
+ for (const d of out.decisions) {
583
+ if (d.override) patch = { ...patch, ...d.override };
584
+ }
585
+ return patch;
586
+ },
587
+ async getFollowUpMessages() {
588
+ const out = await engine.run("Stop", { turn });
589
+ if (!out.failedClosed) {
590
+ for (const d of out.decisions) {
591
+ if (d.continue && d.messages?.length) return d.messages;
592
+ }
593
+ }
594
+ firstConsult = true;
595
+ turn = 0;
596
+ return [];
597
+ },
598
+ async beforeCompaction() {
599
+ const out = await engine.run("PreCompact", {});
600
+ if (out.failedClosed) return { defer: true };
601
+ return out.decisions.some((d) => d.defer) ? { defer: true } : void 0;
602
+ },
603
+ reset() {
604
+ firstConsult = true;
605
+ turn = 0;
606
+ }
607
+ };
608
+ }
609
+
610
+ // src/hooks/swarm-bridge.ts
611
+ import { onSwarmEvent as onSwarmEvent2, setDelegationGate } from "@ap3x/swarms";
612
+ function attachSwarmHooks(engine) {
613
+ setDelegationGate(async (request) => {
614
+ const out = await engine.run(
615
+ "SubagentStart",
616
+ {
617
+ ...request.agentName !== void 0 ? { agentName: request.agentName } : {},
618
+ mechanism: request.mechanism,
619
+ depth: request.depth,
620
+ task: request.task
621
+ },
622
+ // "" (not undefined) on purpose: a name matcher expresses "agents named
623
+ // X", so it never matches an anonymous delegation — hooks that must see
624
+ // every delegation omit the matcher. (All current emitters pass a name.)
625
+ request.agentName ?? ""
626
+ );
627
+ if (out.failedClosed) {
628
+ return { block: true, reason: out.failReason ?? "hook failed (failMode closed)" };
629
+ }
630
+ for (const d of out.decisions) {
631
+ if (d.block) return { block: true, ...d.reason ? { reason: d.reason } : {} };
632
+ }
633
+ return void 0;
634
+ });
635
+ const offEvents = onSwarmEvent2((event) => {
636
+ if (event.type !== "member_run_end") return;
637
+ void engine.run(
638
+ "SubagentStop",
639
+ {
640
+ agentName: event.agentName,
641
+ mechanism: event.mechanism,
642
+ depth: event.depth,
643
+ durationMs: event.durationMs,
644
+ isError: event.isError
645
+ },
646
+ event.agentName
647
+ );
648
+ });
649
+ return () => {
650
+ setDelegationGate(void 0);
651
+ offEvents();
652
+ };
653
+ }
654
+
655
+ // src/hooks/shell.ts
656
+ import { spawn } from "child_process";
657
+ var HookExecutionError = class extends Error {
658
+ constructor(message) {
659
+ super(message);
660
+ this.name = "HookExecutionError";
661
+ }
662
+ };
663
+ function runShellHook(command, payloadJson, timeoutMs) {
664
+ return new Promise((resolve) => {
665
+ const child = spawn(command, { shell: true, windowsHide: true });
666
+ let stdout = "";
667
+ let stderr = "";
668
+ let timedOut = false;
669
+ const timer = setTimeout(() => {
670
+ timedOut = true;
671
+ child.kill();
672
+ child.stdout.destroy();
673
+ child.stderr.destroy();
674
+ child.stdin.destroy();
675
+ resolve({ exitCode: null, stdout, stderr, timedOut });
676
+ }, timeoutMs);
677
+ child.stdout.on("data", (chunk) => {
678
+ stdout += chunk;
679
+ });
680
+ child.stderr.on("data", (chunk) => {
681
+ stderr += chunk;
682
+ });
683
+ child.on("error", (error) => {
684
+ clearTimeout(timer);
685
+ resolve({ exitCode: null, stdout, stderr: stderr || error.message, timedOut });
686
+ });
687
+ child.on("close", (code) => {
688
+ clearTimeout(timer);
689
+ resolve({ exitCode: code, stdout, stderr, timedOut });
690
+ });
691
+ child.stdin.on("error", () => {
692
+ });
693
+ child.stdout.on("error", () => {
694
+ });
695
+ child.stderr.on("error", () => {
696
+ });
697
+ child.stdin.end(payloadJson);
698
+ });
699
+ }
700
+ function parseStdout(stdout) {
701
+ const trimmed = stdout.trim();
702
+ if (!trimmed) return void 0;
703
+ try {
704
+ const parsed = JSON.parse(trimmed);
705
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
706
+ } catch {
707
+ return void 0;
708
+ }
709
+ }
710
+ function isPlainObject(value) {
711
+ return typeof value === "object" && value !== null && !Array.isArray(value);
712
+ }
713
+ function stringField(json, key) {
714
+ const value = json[key];
715
+ return typeof value === "string" ? value : void 0;
716
+ }
717
+ function boolField(json, key) {
718
+ return json[key] === true;
719
+ }
720
+ function overrideField(json) {
721
+ const value = json.override;
722
+ if (!isPlainObject(value)) return void 0;
723
+ if ("content" in value) {
724
+ if (!Array.isArray(value.content)) return void 0;
725
+ if (!value.content.every((el) => isPlainObject(el) && typeof el.type === "string")) {
726
+ return void 0;
727
+ }
728
+ }
729
+ return value;
730
+ }
731
+ function shellDecision(event, result) {
732
+ if (result.timedOut) {
733
+ throw new HookExecutionError(`shell hook timed out (killed): ${result.stderr.trim()}`.trim());
734
+ }
735
+ if (result.exitCode === 2) {
736
+ const reason = result.stderr.trim() || "blocked by hook";
737
+ switch (event) {
738
+ case "PreToolUse":
739
+ case "SubagentStart":
740
+ return { block: true, reason };
741
+ case "PreCompact":
742
+ return { defer: true };
743
+ case "Stop":
744
+ return { continue: true, messages: [userMessage(reason)] };
745
+ default:
746
+ throw new HookExecutionError(`shell hook exited 2 at non-gating event ${event}: ${reason}`);
747
+ }
748
+ }
749
+ if (result.exitCode !== 0) {
750
+ throw new HookExecutionError(
751
+ `shell hook exited ${result.exitCode ?? "without a code"}: ${result.stderr.trim()}`
752
+ );
753
+ }
754
+ const json = parseStdout(result.stdout);
755
+ if (!json) return void 0;
756
+ switch (event) {
757
+ case "PreToolUse": {
758
+ const reason = stringField(json, "reason");
759
+ if (json.decision === "block" || json.updatedArgs) {
760
+ return {
761
+ ...json.decision === "block" ? { block: true } : {},
762
+ ...reason ? { reason } : {},
763
+ ...json.updatedArgs ? { updatedArgs: json.updatedArgs } : {}
764
+ };
765
+ }
766
+ return void 0;
767
+ }
768
+ case "PostToolUse": {
769
+ const override = overrideField(json);
770
+ return override ? { override } : void 0;
771
+ }
772
+ case "RunStart":
773
+ case "TurnStart": {
774
+ const message = stringField(json, "message");
775
+ return message ? { messages: [userMessage(message)] } : void 0;
776
+ }
777
+ case "Stop": {
778
+ const message = stringField(json, "message");
779
+ return boolField(json, "continue") && message ? { continue: true, messages: [userMessage(message)] } : void 0;
780
+ }
781
+ case "PreCompact":
782
+ return boolField(json, "defer") ? { defer: true } : void 0;
783
+ case "SubagentStart": {
784
+ const reason = stringField(json, "reason");
785
+ return json.decision === "block" ? { block: true, ...reason ? { reason } : {} } : void 0;
786
+ }
787
+ default:
788
+ return void 0;
789
+ }
790
+ }
791
+
792
+ // src/hooks/config.ts
793
+ import { readFileSync } from "fs";
794
+ import { Type, validateToolArguments } from "@ap3x/ai";
795
+ var THookEntry = Type.Object({
796
+ event: Type.Union(HOOK_EVENT_NAMES.map((n) => Type.Literal(n))),
797
+ matcher: Type.Optional(Type.String()),
798
+ command: Type.String({ minLength: 1 }),
799
+ timeout: Type.Optional(Type.Number({ minimum: 1 })),
800
+ failMode: Type.Optional(Type.Union([Type.Literal("open"), Type.Literal("closed")])),
801
+ name: Type.Optional(Type.String())
802
+ });
803
+ var THooksFile = Type.Object({
804
+ hooks: Type.Array(THookEntry)
805
+ });
806
+ var HookConfigError = class extends Error {
807
+ constructor(file, detail) {
808
+ super(`invalid hook config ${file}: ${detail}`);
809
+ this.file = file;
810
+ this.name = "HookConfigError";
811
+ }
812
+ file;
813
+ };
814
+ function validateShape(file, candidate) {
815
+ const syntheticTool = {
816
+ name: "hooks_config",
817
+ description: "",
818
+ parameters: THooksFile
819
+ };
820
+ try {
821
+ return validateToolArguments(syntheticTool, {
822
+ type: "toolCall",
823
+ id: "hooks-config",
824
+ name: "hooks_config",
825
+ arguments: candidate
826
+ });
827
+ } catch (error) {
828
+ throw new HookConfigError(file, error instanceof Error ? error.message : String(error));
829
+ }
830
+ }
831
+ function parseHooksFile(file, content) {
832
+ let raw;
833
+ try {
834
+ raw = JSON.parse(content);
835
+ } catch (error) {
836
+ throw new HookConfigError(
837
+ file,
838
+ `not valid JSON (${error instanceof Error ? error.message : String(error)})`
839
+ );
840
+ }
841
+ return validateShape(file, raw).hooks;
842
+ }
843
+ function loadHooksFiles(paths) {
844
+ const loaded = [];
845
+ for (const file of paths) {
846
+ let content;
847
+ try {
848
+ content = readFileSync(file, "utf-8");
849
+ } catch (error) {
850
+ if (error.code === "ENOENT") continue;
851
+ throw new HookConfigError(
852
+ file,
853
+ `cannot read: ${error instanceof Error ? error.message : String(error)}`
854
+ );
855
+ }
856
+ loaded.push({ file, entries: parseHooksFile(file, content) });
857
+ }
858
+ return loaded;
859
+ }
860
+ var GATING = /* @__PURE__ */ new Set([
861
+ "PreToolUse",
862
+ "SubagentStart",
863
+ "PreCompact",
864
+ "Stop"
865
+ ]);
866
+ function hooksFromEntries(entries) {
867
+ return entries.map((entry) => {
868
+ const timeout = entry.timeout ?? (GATING.has(entry.event) ? DEFAULT_GATING_TIMEOUT_MS : DEFAULT_HOOK_TIMEOUT_MS);
869
+ return {
870
+ event: entry.event,
871
+ ...entry.matcher !== void 0 ? { matcher: entry.matcher } : {},
872
+ timeout,
873
+ ...entry.failMode !== void 0 ? { failMode: entry.failMode } : {},
874
+ name: entry.name ?? entry.command,
875
+ handler: async (payload) => {
876
+ const result = await runShellHook(entry.command, JSON.stringify(payload), timeout);
877
+ return shellDecision(entry.event, result);
878
+ }
879
+ };
880
+ });
881
+ }
882
+ export {
883
+ DEFAULT_GATING_TIMEOUT_MS,
884
+ DEFAULT_HOOK_TIMEOUT_MS,
885
+ HOOK_EVENT_NAMES,
886
+ HookConfigError,
887
+ HookEngine,
888
+ HookExecutionError,
889
+ HookTimeoutError,
890
+ QueuedSink,
891
+ TracePipeline,
892
+ TraceWriter,
893
+ attachSwarmHooks,
894
+ childContext,
895
+ createHookAgentHooks,
896
+ currentTraceContext,
897
+ defineHook,
898
+ enableDelegationSpans,
899
+ globMatch,
900
+ hooksFromEntries,
901
+ loadHooksFiles,
902
+ newTraceContext,
903
+ observeAgent,
904
+ observeSwarms,
905
+ parseHooksFile,
906
+ parseTraceFile,
907
+ runInTrace,
908
+ runShellHook,
909
+ shellDecision,
910
+ summarizeTrace,
911
+ userMessage,
912
+ withSpan
913
+ };