@danypops/pi-web-spider 0.10.4 → 0.10.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-web-spider",
3
- "version": "0.10.4",
3
+ "version": "0.10.8",
4
4
  "description": "Pi extension: web_fetch, web_crawl, web_search \u2014 structured scraping for AI agents",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -25,9 +25,15 @@
25
25
  },
26
26
  "devDependencies": {
27
27
  "@biomejs/biome": "^2.4.15",
28
+ "@earendil-works/pi-coding-agent": "^0.80.10",
28
29
  "@types/node": "^22.19.19",
29
30
  "jiti": "^2.0.0",
30
31
  "typescript": "^5.9.3",
31
32
  "vitest": "^3.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/DanyPops/web-spider.git",
37
+ "directory": "packages/pi-extension"
32
38
  }
33
39
  }
@@ -29,7 +29,7 @@ import {
29
29
  import {
30
30
  createExtensionHarness,
31
31
  type ExtensionHarness,
32
- } from "@earendil-works/pi-coding-agent/testing"
32
+ } from "./harness/index.ts"
33
33
 
34
34
  const __dirname = dirname(fileURLToPath(import.meta.url))
35
35
  const FIXTURES = join(__dirname, "../../web-spider/fixtures")
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, it, expect, beforeAll, afterAll } from "vitest"
7
- import { createExtensionHarness, type ExtensionHarness } from "@earendil-works/pi-coding-agent/testing"
7
+ import { createExtensionHarness, type ExtensionHarness } from "./harness/index.ts"
8
8
  import piFactory from "../src/index.js"
9
9
 
10
10
  let h: ExtensionHarness
@@ -0,0 +1,554 @@
1
+ /**
2
+ * ExtensionHarness — lightweight test host for Pi extensions.
3
+ *
4
+ * VENDORED from the pi-mono fork (src/testing/extension-harness.ts) so the
5
+ * tests no longer depend on a locally-linked pi fork. Imports point at the
6
+ * published @earendil-works/pi-coding-agent package types.
7
+ *
8
+ * Implements ExtensionAPI and ExtensionContext as minimal in-memory stubs so
9
+ * extension authors can unit-test hooks and tools without booting AgentSession,
10
+ * a real LLM, or any I/O. Analogous to ESLint's RuleTester: the host ships
11
+ * the testing primitives so every extension author does not have to hand-roll
12
+ * their own mock.
13
+ *
14
+ * Usage:
15
+ *
16
+ * import { createExtensionHarness } from "./harness/index.ts";
17
+ *
18
+ * const h = createExtensionHarness(myExtensionFactory, { cwd: "/tmp/workspace" });
19
+ * await h.boot();
20
+ *
21
+ * // Fire a lifecycle event and inspect the result
22
+ * const result = await h.emit("before_agent_start", { prompt: "fix the bug" });
23
+ *
24
+ * // Invoke a registered tool directly
25
+ * const out = await h.invokeTool("my_tool", { file: "src/foo.ts" });
26
+ *
27
+ * // Inspect observable state
28
+ * expect(h.activeTools).toContain("my_tool");
29
+ * expect(h.notifications[0]).toEqual({ message: "connected", type: "info" });
30
+ *
31
+ * await h.shutdown();
32
+ */
33
+
34
+ import type { ExecOptions, ExecResult } from "@earendil-works/pi-coding-agent";
35
+ import { JITI_NATIVE_MODULES } from "./jiti-native-modules.ts";
36
+ import type {
37
+ ExtensionAPI,
38
+ ExtensionContext,
39
+ ExtensionFactory,
40
+ ExtensionHandler,
41
+ ToolDefinition,
42
+ } from "@earendil-works/pi-coding-agent";
43
+
44
+ // ── Jiti loader ───────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Load a Pi extension the same way Pi's Bun binary loads it: via jiti with
48
+ * tryNative:false + the production nativeModules list.
49
+ *
50
+ * Pass `nativeModules: JITI_NATIVE_MODULES` (the default) to exercise the
51
+ * exact same resolver config as the production loader. This surfaces
52
+ * Bun-native resolver failures — e.g. "Cannot find package X from jsdom" —
53
+ * in unit tests before the extension ever reaches a real Pi session.
54
+ *
55
+ * Pass `nativeModules: []` for a lighter load that skips native resolution
56
+ * (equivalent to the old behaviour, useful for extensions that don't use jsdom).
57
+ *
58
+ * @param extensionPath Absolute path to the extension entry point (index.ts).
59
+ * @param nativeModules Packages to load natively. Defaults to JITI_NATIVE_MODULES
60
+ * (the production list). Import and pass this from
61
+ * "@earendil-works/pi-coding-agent" to stay in sync.
62
+ */
63
+ export async function loadExtensionViaJiti(
64
+ extensionPath: string,
65
+ { nativeModules = JITI_NATIVE_MODULES }: { nativeModules?: string[] } = {},
66
+ ): Promise<ExtensionFactory> {
67
+ const { createJiti } = (await import("jiti")) as typeof import("jiti");
68
+ const jiti = createJiti(`file://${extensionPath}`, { moduleCache: false, tryNative: false, nativeModules });
69
+ const factory = await jiti.import(extensionPath, { default: true });
70
+ if (typeof factory !== "function") {
71
+ throw new Error(
72
+ `Extension at ${extensionPath} did not export a default function (got ${typeof factory}). ` +
73
+ "This is the jiti/Bun CJS interop failure \u2014 check that the extension uses dynamic import() " +
74
+ "for ESM-only packages instead of top-level import.",
75
+ );
76
+ }
77
+ return factory as ExtensionFactory;
78
+ }
79
+
80
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
81
+ // ReadonlySessionManager is not exported publicly; the harness only needs a stub.
82
+ type ReadonlySessionManager = unknown;
83
+
84
+ // ── Public types ──────────────────────────────────────────────────────────────
85
+
86
+ /** A notification recorded by ui.notify(). */
87
+ export interface HarnessNotification {
88
+ message: string;
89
+ type: "info" | "warning" | "error";
90
+ }
91
+
92
+ /** A user message recorded by sendUserMessage(). */
93
+ export interface HarnessUserMessage {
94
+ content: string;
95
+ options?: { deliverAs?: "steer" | "followUp" };
96
+ }
97
+
98
+ /** A registered tool (the raw ToolDefinition + a convenience invocation helper). */
99
+ export interface HarnessTool {
100
+ name: string;
101
+ definition: ToolDefinition<any, any, any>;
102
+ }
103
+
104
+ /** Options for createExtensionHarness(). */
105
+ export interface ExtensionHarnessOptions {
106
+ /** Working directory passed to ExtensionContext.cwd. Default: process.cwd(). */
107
+ cwd?: string;
108
+ /**
109
+ * Environment variables to inject into process.env during boot().
110
+ * Restored after boot() completes.
111
+ */
112
+ env?: Record<string, string | undefined>;
113
+ /**
114
+ * Stub for pi.exec(). When provided, replaces the real child-process spawn
115
+ * so extensions that shell out can be tested without a real filesystem or
116
+ * git repo. When omitted, pi.exec() returns { stdout: "", stderr: "", code: 0, killed: false }.
117
+ *
118
+ * @example
119
+ * exec: async (cmd, args) => {
120
+ * if (cmd === "git" && args[0] === "status") {
121
+ * return { stdout: " M src/foo.ts\n", stderr: "", code: 0, killed: false };
122
+ * }
123
+ * return { stdout: "", stderr: "", code: 0, killed: false };
124
+ * }
125
+ */
126
+ exec?: (cmd: string, args: string[], options?: ExecOptions) => Promise<ExecResult>;
127
+ }
128
+
129
+ /**
130
+ * A raw write that an extension made directly to stdout or stderr.
131
+ * Either is a TUI corruption — extensions must use ctx.ui.notify() instead.
132
+ */
133
+ export interface HarnessLeak {
134
+ /** Which stream was written to. */
135
+ stream: "stdout" | "stderr";
136
+ /** The raw string content that was written. */
137
+ content: string;
138
+ }
139
+
140
+ /** The harness returned by createExtensionHarness(). */
141
+ export interface ExtensionHarness {
142
+ // ── Observable state ────────────────────────────────────────────────────
143
+
144
+ /** All notifications fired via ctx.ui.notify(). Appended in call order. */
145
+ readonly notifications: HarnessNotification[];
146
+
147
+ /** All messages sent via pi.sendUserMessage(). */
148
+ readonly userMessages: HarnessUserMessage[];
149
+
150
+ /** Tools registered via pi.registerTool(), keyed by name. */
151
+ readonly tools: Map<string, HarnessTool>;
152
+
153
+ /** Current active tool names (last value set by pi.setActiveTools()). */
154
+ readonly activeTools: string[];
155
+
156
+ /** Commands registered via pi.registerCommand(). */
157
+ readonly commands: string[];
158
+
159
+ /** Session name set via pi.setSessionName(). */
160
+ readonly sessionName: string | undefined;
161
+
162
+ /**
163
+ * Raw writes made directly to process.stdout during this session.
164
+ * Non-empty means the extension is leaking into the TUI stream.
165
+ * Extensions must use ctx.ui.notify() — never process.stdout.write or console.*.
166
+ */
167
+ readonly stdoutLeaks: HarnessLeak[];
168
+
169
+ /**
170
+ * Raw writes made directly to process.stderr during this session.
171
+ * Same contract as stdoutLeaks.
172
+ */
173
+ readonly stderrLeaks: HarnessLeak[];
174
+
175
+ /**
176
+ * All stdout + stderr leaks combined.
177
+ * Standard assertion: expect(h.leaks).toHaveLength(0)
178
+ */
179
+ readonly leaks: HarnessLeak[];
180
+
181
+ // ── Lifecycle ───────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Boot the extension: apply env overrides, fire "session_start", restore env.
185
+ * Must be called before emit() or invokeTool().
186
+ */
187
+ boot(): Promise<void>;
188
+
189
+ /** Fire "session_shutdown" and clear internal state. */
190
+ shutdown(): Promise<void>;
191
+
192
+ // ── Event simulation ────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Fire an event to all registered handlers for that event type.
196
+ * Returns the last non-undefined result, mirroring Pi's own runner behaviour.
197
+ *
198
+ * Provide only the fields your extension actually reads — the harness fills
199
+ * the rest with safe defaults. Events do not need to be fully typed at the
200
+ * call site.
201
+ */
202
+ emit<R = unknown>(event: string, payload?: Record<string, unknown>): Promise<R | undefined>;
203
+
204
+ // ── Tool invocation ─────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Invoke a registered tool by name, bypassing the LLM entirely.
208
+ * Throws if the tool is not registered.
209
+ */
210
+ invokeTool(name: string, args: Record<string, unknown>): Promise<unknown>;
211
+
212
+ /**
213
+ * Invoke a registered command handler by name.
214
+ * Throws if the command is not registered or has no handler.
215
+ */
216
+ invokeCommand(name: string, args?: string): Promise<void>;
217
+
218
+ // ── Context access ──────────────────────────────────────────────────────
219
+
220
+ /**
221
+ * The ExtensionContext stub passed to all event handlers.
222
+ * Useful for inspecting or overriding stub behaviour in advanced scenarios.
223
+ */
224
+ readonly ctx: ExtensionContext;
225
+ }
226
+
227
+ // ── Implementation ────────────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Create a lightweight test harness for a Pi extension factory.
231
+ *
232
+ * The factory is called immediately (synchronously) with a stub ExtensionAPI,
233
+ * registering hooks and tools. boot() then fires "session_start".
234
+ */
235
+ export function createExtensionHarness(
236
+ factory: ExtensionFactory,
237
+ options: ExtensionHarnessOptions = {},
238
+ ): ExtensionHarness {
239
+ const cwd = options.cwd ?? process.cwd();
240
+
241
+ // ── Mutable state ────────────────────────────────────────────────────────
242
+
243
+ const notifications: HarnessNotification[] = [];
244
+ const userMessages: HarnessUserMessage[] = [];
245
+ const tools = new Map<string, HarnessTool>();
246
+ const commands: string[] = [];
247
+ const commandHandlers = new Map<string, (...args: any[]) => any>();
248
+ const handlers = new Map<string, Array<ExtensionHandler<any, any>>>();
249
+ let activeTools: string[] = [];
250
+ let sessionName: string | undefined;
251
+
252
+ // ── ExtensionContext stub ────────────────────────────────────────────────
253
+
254
+ const sessionManagerStub = {
255
+ getCwd: () => cwd,
256
+ getSessionDir: () => "",
257
+ getSessionId: () => "harness-session",
258
+ getSessionFile: () => undefined,
259
+ getLeafId: () => null,
260
+ getLeafEntry: () => undefined,
261
+ getEntry: () => undefined,
262
+ getLabel: () => undefined,
263
+ getBranch: () => [],
264
+ getHeader: () => null,
265
+ getEntries: () => [],
266
+ getTree: () => [],
267
+ getSessionName: () => sessionName,
268
+ } as unknown as ReadonlySessionManager;
269
+
270
+ const modelRegistryStub = {} as ModelRegistry;
271
+
272
+ const ui = {
273
+ notify(message: string, type: "info" | "warning" | "error" = "info") {
274
+ notifications.push({ message, type });
275
+ },
276
+ select: async () => undefined,
277
+ confirm: async () => false,
278
+ input: async () => undefined,
279
+ onTerminalInput: () => () => {},
280
+ setStatus: () => {},
281
+ setWorkingMessage: () => {},
282
+ setWorkingVisible: () => {},
283
+ setWorkingIndicator: () => {},
284
+ setHiddenThinkingLabel: () => {},
285
+ setWidget: () => {},
286
+ setFooter: () => {},
287
+ setHeader: () => {},
288
+ setTitle: () => {},
289
+ custom: async () => undefined as any,
290
+ pasteToEditor: () => {},
291
+ setEditorText: () => {},
292
+ getEditorText: () => "",
293
+ editor: async () => undefined,
294
+ addAutocompleteProvider: () => {},
295
+ setEditorComponent: () => {},
296
+ getEditorComponent: () => undefined,
297
+ theme: {} as any,
298
+ } as unknown as ExtensionContext["ui"];
299
+
300
+ const ctx: ExtensionContext = {
301
+ cwd,
302
+ hasUI: false,
303
+ sessionManager: sessionManagerStub,
304
+ modelRegistry: modelRegistryStub,
305
+ model: undefined,
306
+ signal: undefined,
307
+ isIdle: () => true,
308
+ abort: () => {},
309
+ hasPendingMessages: () => false,
310
+ shutdown: () => {},
311
+ getContextUsage: () => undefined,
312
+ compact: () => {},
313
+ getSystemPrompt: () => "",
314
+ ui,
315
+ };
316
+
317
+ // ── ExtensionAPI stub ────────────────────────────────────────────────────
318
+
319
+ function addHandler(event: string, handler: ExtensionHandler<any, any>) {
320
+ const list = handlers.get(event) ?? [];
321
+ list.push(handler);
322
+ handlers.set(event, list);
323
+ }
324
+
325
+ const api: ExtensionAPI = {
326
+ on(event: string, handler: ExtensionHandler<any, any>) {
327
+ addHandler(event, handler);
328
+ },
329
+
330
+ registerTool(tool: ToolDefinition<any, any, any>) {
331
+ tools.set(tool.name, { name: tool.name, definition: tool });
332
+ },
333
+
334
+ registerCommand(name: string, definition?: { handler?: (...args: any[]) => any }) {
335
+ commands.push(name);
336
+ if (definition?.handler) {
337
+ commandHandlers.set(name, definition.handler);
338
+ }
339
+ },
340
+
341
+ registerShortcut: () => {},
342
+ registerFlag: () => {},
343
+ getFlag: () => undefined,
344
+ registerMessageRenderer: () => {},
345
+
346
+ sendMessage: () => {},
347
+
348
+ sendUserMessage(content: string | unknown[], opts?: { deliverAs?: "steer" | "followUp" }) {
349
+ const text = typeof content === "string" ? content : JSON.stringify(content);
350
+ userMessages.push({ content: text, options: opts });
351
+ },
352
+
353
+ appendEntry: () => {},
354
+
355
+ setSessionName(name: string) {
356
+ sessionName = name;
357
+ },
358
+
359
+ getSessionName: () => sessionName,
360
+ setLabel: () => {},
361
+ exec: options.exec ?? (async () => ({ stdout: "", stderr: "", code: 0, killed: false })),
362
+
363
+ getActiveTools: () => [...activeTools],
364
+ getAllTools: () => [],
365
+
366
+ setActiveTools(names: string[]) {
367
+ activeTools = [...names];
368
+ },
369
+
370
+ getCommands: () => [],
371
+ setModel: async () => false,
372
+ getThinkingLevel: () => "off" as any,
373
+ setThinkingLevel: () => {},
374
+ registerProvider: () => {},
375
+ refreshTools: () => {},
376
+ } as unknown as ExtensionAPI;
377
+
378
+ // Call the factory now so sync extensions register tools immediately.
379
+ // The returned promise (if any) is awaited in boot() so async extensions
380
+ // (which use dynamic import inside the factory) are also fully initialised
381
+ // before session_start fires.
382
+ const factoryResult = factory(api);
383
+
384
+ // ── Harness implementation ───────────────────────────────────────────────
385
+
386
+ async function emit<R>(event: string, payload: Record<string, unknown> = {}): Promise<R | undefined> {
387
+ const list = handlers.get(event) ?? [];
388
+ let result: R | undefined;
389
+ for (const h of list) {
390
+ const r = await h({ type: event, ...payload } as any, ctx);
391
+ if (r !== undefined) result = r as R;
392
+ }
393
+ return result;
394
+ }
395
+
396
+ // ── Stdout / stderr leak detection ────────────────────────────────────
397
+ //
398
+ // During the session (boot → shutdown), process.stdout.write and
399
+ // process.stderr.write are wrapped. Any write from extension code is
400
+ // recorded as a leak and swallowed — it must not corrupt the TUI stream.
401
+ // The original write functions are restored in shutdown().
402
+
403
+ const stdoutLeaks: HarnessLeak[] = [];
404
+ const stderrLeaks: HarnessLeak[] = [];
405
+
406
+ let _originalStdoutWrite: typeof process.stdout.write | null = null;
407
+ let _originalStderrWrite: typeof process.stderr.write | null = null;
408
+ const _originalConsoleMethods: Partial<Record<string, (...args: unknown[]) => void>> = {};
409
+
410
+ function makeStreamInterceptor(leaks: HarnessLeak[], stream: "stdout" | "stderr") {
411
+ return (raw: string | Uint8Array, _encodingOrCb?: unknown, _cb?: unknown): boolean => {
412
+ const content = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf-8");
413
+ leaks.push({ stream, content });
414
+ return true; // swallow — do not forward to the real TUI stream
415
+ };
416
+ }
417
+
418
+ function makeConsoleInterceptor(leaks: HarnessLeak[], stream: "stdout" | "stderr", methodName: string) {
419
+ return (...args: unknown[]): void => {
420
+ const content = `[console.${methodName}] ${args.map(String).join(" ")}`;
421
+ leaks.push({ stream, content });
422
+ };
423
+ }
424
+
425
+ function installLeakDetectors(): void {
426
+ // Intercept process.stdout/stderr.write (direct writes).
427
+ _originalStdoutWrite = process.stdout.write.bind(process.stdout);
428
+ _originalStderrWrite = process.stderr.write.bind(process.stderr);
429
+ process.stdout.write = makeStreamInterceptor(stdoutLeaks, "stdout") as unknown as typeof process.stdout.write;
430
+ process.stderr.write = makeStreamInterceptor(stderrLeaks, "stderr") as unknown as typeof process.stderr.write;
431
+
432
+ // Also intercept console.* — test runners (vitest, jest) replace the
433
+ // console object before process.stdout.write, so console.log does NOT
434
+ // reach our stream interceptor above in test environments.
435
+ const stdoutMethods = ["log", "info", "debug", "dir", "table", "time", "timeEnd", "count"] as const;
436
+ const stderrMethods = ["error", "warn", "trace"] as const;
437
+ for (const m of stdoutMethods) {
438
+ if (typeof (console as any)[m] === "function") {
439
+ _originalConsoleMethods[m] = (console as any)[m].bind(console);
440
+ (console as any)[m] = makeConsoleInterceptor(stdoutLeaks, "stdout", m);
441
+ }
442
+ }
443
+ for (const m of stderrMethods) {
444
+ if (typeof (console as any)[m] === "function") {
445
+ _originalConsoleMethods[m] = (console as any)[m].bind(console);
446
+ (console as any)[m] = makeConsoleInterceptor(stderrLeaks, "stderr", m);
447
+ }
448
+ }
449
+ }
450
+
451
+ function removeLeakDetectors(): void {
452
+ if (_originalStdoutWrite) {
453
+ process.stdout.write = _originalStdoutWrite;
454
+ _originalStdoutWrite = null;
455
+ }
456
+ if (_originalStderrWrite) {
457
+ process.stderr.write = _originalStderrWrite;
458
+ _originalStderrWrite = null;
459
+ }
460
+ for (const [m, fn] of Object.entries(_originalConsoleMethods)) {
461
+ if (fn) (console as any)[m] = fn;
462
+ }
463
+ for (const k of Object.keys(_originalConsoleMethods)) {
464
+ delete _originalConsoleMethods[k];
465
+ }
466
+ }
467
+
468
+ // Saved env values — restored in shutdown()
469
+ const savedEnv: Record<string, string | undefined> = {};
470
+
471
+ async function boot(): Promise<void> {
472
+ // Install stdout/stderr interceptors before the factory and session_start
473
+ // fire so any leak during initialisation is also captured.
474
+ installLeakDetectors();
475
+ // Apply env overrides for the duration of the session (restored in shutdown).
476
+ for (const [k, v] of Object.entries(options.env ?? {})) {
477
+ savedEnv[k] = process.env[k];
478
+ if (v === undefined) delete process.env[k];
479
+ else process.env[k] = v;
480
+ }
481
+ // Await any pending async factory work (e.g. dynamic imports) before
482
+ // firing session_start. For sync factories factoryResult is undefined.
483
+ await factoryResult;
484
+ await emit("session_start", { reason: "startup" });
485
+ }
486
+
487
+ async function shutdown(): Promise<void> {
488
+ await emit("session_shutdown", {});
489
+ // Restore stdout/stderr before env so shutdown handlers see the real streams.
490
+ removeLeakDetectors();
491
+ // Restore env after session_shutdown so handlers can still read it.
492
+ for (const [k, v] of Object.entries(savedEnv)) {
493
+ if (v === undefined) delete process.env[k];
494
+ else process.env[k] = v;
495
+ }
496
+ // Clear observable state so the harness is ready for re-use or GC.
497
+ notifications.length = 0;
498
+ userMessages.length = 0;
499
+ tools.clear();
500
+ commands.length = 0;
501
+ activeTools = [];
502
+ sessionName = undefined;
503
+ stdoutLeaks.length = 0;
504
+ stderrLeaks.length = 0;
505
+ }
506
+
507
+ async function invokeTool(name: string, args: Record<string, unknown>): Promise<unknown> {
508
+ const tool = tools.get(name);
509
+ if (!tool) throw new Error(`Tool "${name}" is not registered`);
510
+ return tool.definition.execute("harness-call-id", args, new AbortController().signal, () => {}, ctx as any);
511
+ }
512
+
513
+ async function invokeCommand(name: string, args?: string): Promise<void> {
514
+ const handler = commandHandlers.get(name);
515
+ if (!handler) throw new Error(`Command "${name}" is not registered or has no handler`);
516
+ await handler(args ?? "", ctx);
517
+ }
518
+
519
+ return {
520
+ get notifications() {
521
+ return notifications;
522
+ },
523
+ get userMessages() {
524
+ return userMessages;
525
+ },
526
+ get tools() {
527
+ return tools;
528
+ },
529
+ get activeTools() {
530
+ return [...activeTools];
531
+ },
532
+ get commands() {
533
+ return commands;
534
+ },
535
+ get sessionName() {
536
+ return sessionName;
537
+ },
538
+ get stdoutLeaks() {
539
+ return stdoutLeaks;
540
+ },
541
+ get stderrLeaks() {
542
+ return stderrLeaks;
543
+ },
544
+ get leaks() {
545
+ return [...stdoutLeaks, ...stderrLeaks];
546
+ },
547
+ boot,
548
+ shutdown,
549
+ emit,
550
+ invokeTool,
551
+ invokeCommand,
552
+ ctx,
553
+ };
554
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ createExtensionHarness,
3
+ loadExtensionViaJiti,
4
+ type ExtensionHarness,
5
+ type ExtensionHarnessOptions,
6
+ type HarnessLeak,
7
+ type HarnessNotification,
8
+ type HarnessTool,
9
+ type HarnessUserMessage,
10
+ } from "./extension-harness.ts";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Vendored from pi's extension loader (pi-mono fork, core/extensions/loader.ts).
3
+ * Pure-CJS packages whose module-level Maps must live in the global V8 realm,
4
+ * not jiti's transform scope. Kept in sync manually — small and stable.
5
+ */
6
+ export const JITI_NATIVE_MODULES: string[] = [
7
+ "jsdom",
8
+ "lru-cache",
9
+ "@asamuzakjp/css-color",
10
+ "css-tree",
11
+ "@asamuzakjp/dom-selector",
12
+ "nwsapi",
13
+ ];
@@ -22,7 +22,7 @@ import {
22
22
  createExtensionHarness,
23
23
  loadExtensionViaJiti,
24
24
  type ExtensionHarness,
25
- } from "@earendil-works/pi-coding-agent/testing"
25
+ } from "./harness/index.ts"
26
26
  import { dirname, join } from "node:path"
27
27
  import { fileURLToPath } from "node:url"
28
28
 
package/test/load.test.ts CHANGED
@@ -13,7 +13,7 @@ import { describe, expect, it } from "vitest"
13
13
  import {
14
14
  createExtensionHarness,
15
15
  loadExtensionViaJiti,
16
- } from "@earendil-works/pi-coding-agent/testing"
16
+ } from "./harness/index.ts"
17
17
 
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url))
19
19
  const EXTENSION_PATH = join(__dirname, "../src/index.ts")
@@ -39,7 +39,7 @@ import {
39
39
  import {
40
40
  createExtensionHarness,
41
41
  type ExtensionHarness,
42
- } from "@earendil-works/pi-coding-agent/testing"
42
+ } from "./harness/index.ts"
43
43
  import type { HttpRequest, HttpResponse } from "@danypops/web-spider"
44
44
 
45
45
  const __dirname = dirname(fileURLToPath(import.meta.url))