From 4438f303c7dcda6a483c9fb2c9f6458aa4775f2d Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 7 Jun 2026 09:30:44 +0300 Subject: [PATCH 01/40] Extract three module globals into RuntimeSingletons container for atomic test swap --- notebook/store.ts | 30 ++++++++-------- runtime-singletons.ts | 79 +++++++++++++++++++++++++++++++++++++++++++ spawn/renderer.ts | 36 +++++++++++++++----- 3 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 runtime-singletons.ts diff --git a/notebook/store.ts b/notebook/store.ts index f9b048b..04d7e3b 100644 --- a/notebook/store.ts +++ b/notebook/store.ts @@ -10,39 +10,37 @@ import { DEFAULT_MAX_LINES, truncateHead, } from "@earendil-works/pi-coding-agent"; -import { AsyncLocalStorage } from "node:async_hooks"; import type { AgenticodingState } from "../state.js"; - -/** - * Module-level write lock state. - * - * Concurrent callers serialize by chaining on the prior promise. Reentrancy is - * tracked per async call chain so a nested saveNotebookPage fails explicitly - * without rejecting unrelated concurrent writers that happen to overlap. - */ -let writeLock: Promise = Promise.resolve(); -const writeContext = new AsyncLocalStorage(); +import { createWriteLock, __setSingletons, getSingletons } from "../runtime-singletons.js"; /** Reset write lock state. Only for test cleanup after concurrent runs. */ export function resetNotebookWriteLock(): void { - writeLock = Promise.resolve(); + __setSingletons( + { ...getSingletons(), writeLock: createWriteLock() }, + { forceWriteLock: true }, + ); } async function withWriteLock(fn: () => Promise): Promise { - if (writeContext.getStore()) { + const s = getSingletons(); + const lock = s.writeLock; + if (s.writeContext.getStore()) { throw new Error( "Notebook write lock is not reentrant — saveNotebookPage called from within its own critical section.", ); } let release: () => void; - const prev = writeLock; - writeLock = new Promise((resolve) => { + const prev = lock.tail; + const next = new Promise((resolve) => { release = resolve; }); + lock.pending += 1; + lock.tail = next; await prev; try { - return await writeContext.run(true, fn); + return await s.writeContext.run(true, fn); } finally { + lock.pending -= 1; release!(); } } diff --git a/runtime-singletons.ts b/runtime-singletons.ts new file mode 100644 index 0000000..14087e9 --- /dev/null +++ b/runtime-singletons.ts @@ -0,0 +1,79 @@ +/** + * Shared singleton container for the agenticoding extension. + * + * Allows tests to replace all module-level singletons (write lock, frame + * scheduler, etc.) with one atomic swap via __setSingletons(), instead of + * patching each singleton individually per test. + * + * In production the frame scheduler is registered by spawn/renderer.ts at + * module import time. In tests, createTestHarness() provides a fresh + * container that tests own and dispose. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +// ── Types ───────────────────────────────────────────────────────────── + +/** Minimal frame scheduler interface that the container understands. */ +export interface RuntimeFrameScheduler { + markDirty(component: unknown): void; + cancelDirty(component: unknown): void; + flushNow(): void; + clear(): void; +} + +export interface RuntimeWriteLock { + pending: number; + tail: Promise; +} + +export interface RuntimeSingletons { + writeLock: RuntimeWriteLock; + writeContext: AsyncLocalStorage; + frameScheduler: RuntimeFrameScheduler; +} + +export function createWriteLock(): RuntimeWriteLock { + return { + pending: 0, + tail: Promise.resolve(), + }; +} + +// ── Pre‑init defaults (overwritten by spawn/renderer.ts at import time) ── + +let current: RuntimeSingletons = { + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: { + markDirty: () => {}, + cancelDirty: () => {}, + flushNow: () => {}, + clear: () => {}, + }, +}; + +// ── Public API ──────────────────────────────────────────────────────── + +/** Atomically replace all singletons. Test‑only — use __ naming convention. */ +export function __setSingletons( + s: RuntimeSingletons, + options?: { forceWriteLock?: boolean }, +): void { + if (!options?.forceWriteLock && current.writeLock.pending > 0) { + console.warn( + "[runtime-singletons] writeLock has %d pending operation(s) — " + + "preserving existing lock chain to avoid breaking in-flight writes. " + + "Use { forceWriteLock: true } to override.", + current.writeLock.pending, + ); + current = { ...s, writeLock: current.writeLock }; + return; + } + current = s; +} + +/** Read the current singleton container. */ +export function getSingletons(): RuntimeSingletons { + return current; +} diff --git a/spawn/renderer.ts b/spawn/renderer.ts index 00e92e7..af4da83 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -32,6 +32,10 @@ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; import { Container, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { TUI } from "@earendil-works/pi-tui"; import type { AgenticodingState } from "../state.js"; +import { + __setSingletons, + getSingletons, +} from "../runtime-singletons.js"; import { getLastAssistantText, type SpawnOutcome, @@ -249,7 +253,7 @@ interface SpawnFrameTarget { * streaming events (50-100+/sec) do not trigger an equal number of heavy * component mutations. */ -class SpawnFrameScheduler { +export class SpawnFrameScheduler { private readonly frameMs: number; private dirtyComponents = new Set(); private frameTimer: ReturnType | null = null; @@ -316,8 +320,20 @@ class SpawnFrameScheduler { } } -/** Module-level singleton shared by all NestedAgentSessionComponent instances. */ +/** + * Module-level singleton shared by all NestedAgentSessionComponent instances. + * + * Registered into the RuntimeSingletons container at module evaluation time. + * Test harnesses overwrite this with a fresh SpawnFrameScheduler via + * createTestHarness(). ESM guarantees all static imports resolve before any + * module body runs, so the harness always wins. + * + * IMPORTANT: never use dynamic import() to load this module *after* a + * createTestHarness() call, or the production scheduler will overwrite the + * test one. + */ const spawnFrameScheduler = new SpawnFrameScheduler(); +__setSingletons({ ...getSingletons(), frameScheduler: spawnFrameScheduler }); // ── NestedAgentSessionComponent ─────────────────────────────────────── @@ -396,7 +412,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.renderQueued = false; this.queuedRenderToken = undefined; this.renderScheduleToken++; - spawnFrameScheduler.cancelDirty(this); + getSingletons().frameScheduler.cancelDirty(this); } /** @@ -409,7 +425,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (this.renderQueued) return; this.renderQueued = true; this.queuedRenderToken = ++this.renderScheduleToken; - spawnFrameScheduler.markDirty(this); + getSingletons().frameScheduler.markDirty(this); } /** @@ -555,7 +571,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget dispose(): void { this.unsubscribe?.(); this.unsubscribe = undefined; - spawnFrameScheduler.cancelDirty(this); + getSingletons().frameScheduler.cancelDirty(this); this.clearPendingState(); // Snapshot fields before clearing: if session.abort() triggers re-entrant // dispose, the nulled-out fields prevent double-abort. @@ -731,7 +747,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (!this.session) return; // Flush any pending state first so accumulated updates don't double-apply - spawnFrameScheduler.cancelDirty(this); + getSingletons().frameScheduler.cancelDirty(this); this.clearPendingState(); this.clear(); @@ -1209,16 +1225,20 @@ export { NestedAgentSessionComponent, renderSpawnCall, renderSpawnResult }; * Synchronously flush all pending spawn frame work. * Exported for tests. Not needed in production — the frame timer handles * everything automatically. + * + * Delegate through getSingletons() so that test harness swaps are respected. */ export function flushSpawnFrameScheduler(): void { - spawnFrameScheduler.flushNow(); + getSingletons().frameScheduler.flushNow(); } /** * Reset the frame scheduler, discarding any pending dirty markers. * Exported for tests. In production the scheduler lifecycle is tied to * component dispose(), so this is never needed. + * + * Delegate through getSingletons() so that test harness swaps are respected. */ export function resetSpawnFrameScheduler(): void { - spawnFrameScheduler.clear(); + getSingletons().frameScheduler.clear(); } From ec4c41fd9216959a5201ff886bf31aa07a9e927c Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 7 Jun 2026 09:30:46 +0300 Subject: [PATCH 02/40] Create centralized test harness with singleton isolation and capture --- tests/test-utils.ts | 88 ++++++++++ tests/unit/helpers.ts | 238 ++++++++++++++++++++++++++ tests/unit/runtime-singletons.test.ts | 81 +++++++++ 3 files changed, 407 insertions(+) create mode 100644 tests/test-utils.ts create mode 100644 tests/unit/helpers.ts create mode 100644 tests/unit/runtime-singletons.test.ts diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 0000000..28efb68 --- /dev/null +++ b/tests/test-utils.ts @@ -0,0 +1,88 @@ +/** + * Central test harness for the agenticoding extension. + * + * Every non-E2E test that touches module-level singletons starts with + * `const h = createTestHarness()` and ends with `h.teardown()`. One call + * replaces the singleton container atomically and captures console output — + * no per-test patches. + * + * Usage: + * + * const h = createTestHarness(); + * // test body — use h.warnings + * h.teardown(); + * + * With beforeEach/afterEach: + * + * describe("spawn", () => { + * let h: TestHarness; + * beforeEach(() => { h = createTestHarness(); }); + * afterEach(() => { h.teardown(); }); + * }); + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { + __setSingletons, + createWriteLock, + getSingletons, + type RuntimeSingletons, +} from "../runtime-singletons.js"; +import { SpawnFrameScheduler } from "../spawn/renderer.js"; + +// ── Types ───────────────────────────────────────────────────────────── + +export interface TestHarness { + /** Captured console.warn and console.error calls. */ + warnings: Array<{ level: string; args: unknown[] }>; + /** Restore console, clear scheduler, reset write lock. */ + teardown: () => void; +} + +// ── Factory ─────────────────────────────────────────────────────────── + +/** + * Create a fresh test harness. Every test that needs isolation calls this. + * + * CRITICAL: ESM static imports resolve before any module body runs. This means + * spawn/renderer.ts registers the production frame scheduler at import time, + * and createTestHarness() (called in beforeEach) always wins because tests + * import this module after production modules. Never use dynamic import() to + * load spawn/renderer.ts after createTestHarness() — the production scheduler + * would overwrite the test one. + */ +export function createTestHarness(): TestHarness { + const previousSingletons = getSingletons(); + const singletons: RuntimeSingletons = { + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: new SpawnFrameScheduler(), + }; + const warnings: Array<{ level: string; args: unknown[] }> = []; + const originalWarn = console.warn; + const originalError = console.error; + + // Atomic swap: replace the production singleton container (write lock, + // context, frame scheduler) in one call. + __setSingletons(singletons); + + // Capture console output for assertions without noisy passing-test output. + console.warn = (...args: unknown[]) => { + warnings.push({ level: "warn", args }); + }; + console.error = (...args: unknown[]) => { + warnings.push({ level: "error", args }); + }; + + return { + warnings, + teardown: () => { + // Restore singletons first so the harness scheduler is current. + // Then clear it to release any dirty components before disposal. + __setSingletons(previousSingletons); + singletons.frameScheduler.clear(); + console.warn = originalWarn; + console.error = originalError; + }, + }; +} diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts new file mode 100644 index 0000000..db04423 --- /dev/null +++ b/tests/unit/helpers.ts @@ -0,0 +1,238 @@ +// ── Shared test helpers ────────────────────────────────────────── +// Imported by other test files via `./helpers.js` +// Includes createTestPI(), test utilities, theme constants, etc. + +import type { Theme } from "@earendil-works/pi-coding-agent"; +import assert from "node:assert/strict"; + +export const theme = { + fg: (_name: string, text: string) => text, + bold: (text: string) => text, +} as unknown as Theme; + +export const ansiTheme = { + fg: (_name: string, text: string) => `\u001b[38;5;245m${text}\u001b[39m`, + bg: (_name: string, text: string) => `\u001b[48;5;236m${text}\u001b[49m`, + bold: (text: string) => text, +} as unknown as Theme; + +export function createRenderContext(overrides: Record = {}): Record { + return { + expanded: false, + showImages: true, + toolCallId: "tool-call-1", + lastComponent: undefined, + invalidate: () => {}, + ...overrides, + }; +} + +export function createSession(messages: any[]) { + return { + messages, + subscribe: () => () => {}, + getToolDefinition: () => undefined, + sessionManager: { getCwd: () => process.cwd() }, + abort: async () => {}, + } as unknown as import("@earendil-works/pi-coding-agent").AgentSession; +} + +export function createSubscribableSession(messages: any[] = []) { + let handler: ((event: any) => void) | undefined; + return { + session: { + messages, + subscribe: (cb: (event: any) => void) => { + handler = cb; + return () => { handler = undefined; }; + }, + getToolDefinition: () => undefined, + sessionManager: { getCwd: () => process.cwd() }, + abort: async () => {}, + } as unknown as import("@earendil-works/pi-coding-agent").AgentSession, + emit: (event: any) => handler?.(event), + }; +} + +export function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, ""); +} + +export function getRenderedLine(lines: string[], match: (plain: string) => boolean): string { + const line = lines.find(candidate => match(stripAnsi(candidate))); + assert.ok(line); + return line; +} + +export function getLineContaining(lines: string[], text: string): string { + const line = lines.find(candidate => candidate.includes(text)); + assert.ok(line); + return line; +} + +export function assertShellBackgroundPreserved(line: string): void { + assert.equal(line.includes("\u001b[0m"), false); + assert.match(line, /\u001b\[48;/); +} + +export function createDeferred() { + let resolve!: () => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +type Handler = (args: any, ctx: any) => any; + +export function createTestPI() { + const _handlers = new Map(); + const _tools = new Map(); + const _commands = new Map(); + const _activeTools: string[] = []; + const _allToolNames: string[] = []; + const _toolSources = new Map(); + const _sentUserMessages: Array<{ content: string; options: any }> = []; + const _appendedEntries: Array<{ customType: string; data: any }> = []; + + const obj = { + registerCommand: (name: string, def: any) => { _commands.set(name, def); }, + registerTool: (def: any) => { _tools.set(def.name, def); }, + on: (event: string, handler: any) => { + const h = _handlers.get(event) ?? []; + h.push(handler); + _handlers.set(event, h); + }, + getActiveTools: () => [..._activeTools], + getAllTools: () => + (_allToolNames.length ? _allToolNames : [..._activeTools]).map((name) => ({ + name, + description: "", + parameters: {}, + sourceInfo: { + path: `<${_toolSources.get(name) ?? "builtin"}:${name}>`, + source: _toolSources.get(name) ?? "builtin", + scope: "temporary", + origin: "top-level", + }, + })), + getThinkingLevel: () => "medium" as const, + setThinkingLevel: () => {}, + sendUserMessage: (content: string, options?: any) => { + _sentUserMessages.push({ content, options }); + }, + appendEntry: (customType: string, data: any) => { + _appendedEntries.push({ customType, data }); + }, + setActiveTools: (tools: string[]) => { + _activeTools.length = 0; + _activeTools.push(...tools); + for (const tool of tools) { + if (!_toolSources.has(tool)) _toolSources.set(tool, "builtin"); + } + }, + setToolSource: (name: string, source: string) => { + _toolSources.set(name, source); + }, + setAllTools: (tools: string[]) => { + _allToolNames.length = 0; + _allToolNames.push(...tools); + for (const tool of tools) { + if (!_toolSources.has(tool)) _toolSources.set(tool, "builtin"); + } + }, + sendMessage: () => Promise.resolve(), + setSessionName: () => {}, + getSessionName: () => undefined, + exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + getCommands: () => [], + setModel: () => Promise.resolve(true), + registerProvider: () => {}, + registerShortcut: () => {}, + registerFlag: () => {}, + getFlag: () => undefined, + registerMessageRenderer: () => {}, + setLabel: () => {}, + setEditorText: () => {}, + get commands() { return _commands; }, + get tools() { return _tools; }, + get handlers() { return _handlers; }, + get activeTools() { return _activeTools; }, + set activeTools(tools: string[]) { + _activeTools.length = 0; + _activeTools.push(...tools); + }, + get sentUserMessages() { return _sentUserMessages; }, + get appendedEntries() { return _appendedEntries; }, + get allToolNames() { return _allToolNames; }, + get toolSources() { return _toolSources; }, + }; + return obj; +} + +// ── ExtensionAPI compile-time check ────────────────────────────── +// If ExtensionAPI adds new required members, this fails at compile +// time — forcing the test PI factory to be updated in sync. +type _TestPICoversExtensionAPI = typeof createTestPI extends () => import("@earendil-works/pi-coding-agent").ExtensionAPI ? true : never; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _testPIVerified: _TestPICoversExtensionAPI = true; + +export const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +export function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") { + return { + role: "assistant", + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: EMPTY_USAGE, + stopReason, + timestamp: Date.now(), + }; +} + +export function createTestAssistantStream(message: any): any { + return { + async *[Symbol.asyncIterator]() { + yield { type: "done", reason: message.stopReason, message }; + }, + result: async () => message, + }; +} + +export function messageText(message: any): string { + return (message.content ?? []) + .map((block: any) => block.type === "text" ? block.text : JSON.stringify(block)) + .join("\n"); +} + +// ── TUI context factory ─────────────────────────────────────────────── + +export function makeTUICtx( + overrides: Partial<{ + percent: number | null; + hasUI: boolean; + record: { statuses: Map; widgets: Map }; + }> = {}, +): any { + const record = overrides.record ?? { statuses: new Map(), widgets: new Map() }; + const hasUI = overrides.hasUI ?? true; + const percent = overrides.percent !== undefined ? overrides.percent : null; + return { + hasUI, + ui: { + theme: { + fg: (name: string, text: string) => `[${name}:${text}]`, + }, + setStatus: (key: string, status: string | undefined) => { record.statuses.set(key, status); }, + setWidget: (key: string, content: string[] | undefined) => { record.widgets.set(key, content); }, + }, + getContextUsage: () => (percent !== null ? { percent } : null), + }; +} diff --git a/tests/unit/runtime-singletons.test.ts b/tests/unit/runtime-singletons.test.ts new file mode 100644 index 0000000..091925a --- /dev/null +++ b/tests/unit/runtime-singletons.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { createTestHarness } from "../test-utils.js"; +import { + __setSingletons, + createWriteLock, + getSingletons, +} from "../../runtime-singletons.js"; + +test("createTestHarness swaps singleton state atomically and restores it on teardown", () => { + const before = getSingletons(); + const h = createTestHarness(); + const during = getSingletons(); + + assert.notEqual(during, before); + assert.notEqual(during.writeContext, before.writeContext); + assert.notEqual(during.frameScheduler, before.frameScheduler); + + h.teardown(); + assert.equal(getSingletons(), before); +}); + +test("__setSingletons warns when preserving in-flight write lock", () => { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (msg: string) => { + warnings.push(msg); + }; + try { + const s = getSingletons(); + s.writeLock.pending = 1; // simulate in-flight write + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: s.frameScheduler, + }); + assert.ok(warnings.length > 0); + assert.match(warnings[0], /pending/); + } finally { + getSingletons().writeLock.pending = 0; // clean up + __setSingletons(getSingletons(), { forceWriteLock: true }); // restore clean state + console.warn = originalWarn; + } +}); + +test("write lock serializes concurrent writers and completes all", async () => { + const h = createTestHarness(); + const s = getSingletons(); + + const order: number[] = []; + const writers = Array.from({ length: 5 }, (_, i) => + (async () => { + // Grab the current tail promise before acquiring the lock + const prev = s.writeLock.tail; + // Simulate acquiring the lock by chaining onto the tail + let release: () => void; + const next = new Promise((resolve) => { release = resolve; }); + s.writeLock.pending += 1; + s.writeLock.tail = next; + await prev; + order.push(i); + s.writeLock.pending -= 1; + release!(); + })(), + ); + + await Promise.all(writers); + + // All writers completed in some order + assert.equal(order.length, 5); + assert.ok(order.includes(0)); + assert.ok(order.includes(4)); + + // Order is deterministic (no concurrent completion — serialized by lock) + // If lock works, order is a strict permutation of [0,1,2,3,4] + const sorted = [...order].sort((a, b) => a - b); + assert.deepEqual(order, sorted, "lock must serialize writers — no concurrent completion"); + + h.teardown(); +}); From 54b8a3c8a5bc2796ed7c722ff4152aae03997900 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 7 Jun 2026 09:30:48 +0300 Subject: [PATCH 03/40] Fragment monolithic test into per-module unit suites with snapshots --- tests/__snapshots__/indicator-30.txt | 1 + tests/__snapshots__/indicator-50.txt | 1 + tests/__snapshots__/indicator-70.txt | 1 + .../nested-collapsed-running.txt | 2 + .../nested-collapsed-success.txt | 3 + tests/__snapshots__/nested-expanded.txt | 6 + tests/__snapshots__/spawn-call-collapsed.txt | 4 + tests/__snapshots__/spawn-call-long.txt | 7 + tests/__snapshots__/spawn-result-aborted.txt | 5 + tests/__snapshots__/spawn-result-error.txt | 5 + tests/__snapshots__/spawn-result-success.txt | 5 + tests/unit/fixtures/register-loader-entry.mjs | 2 + tests/unit/handoff.test.ts | 229 ++ tests/unit/notebook.test.ts | 593 +++++ tests/unit/register-loader.test.ts | 33 + tests/unit/render-snapshots.test.ts | 302 +++ tests/unit/spawn-render.test.ts | 298 +++ .../unit/spawn.test.ts | 1921 ++--------------- tests/unit/state-invariants.test.ts | 331 +++ tests/unit/system-prompt.test.ts | 67 + tests/unit/topic.test.ts | 70 + tests/unit/tui-indicators.test.ts | 101 + tests/unit/watchdog.test.ts | 158 ++ 23 files changed, 2350 insertions(+), 1795 deletions(-) create mode 100644 tests/__snapshots__/indicator-30.txt create mode 100644 tests/__snapshots__/indicator-50.txt create mode 100644 tests/__snapshots__/indicator-70.txt create mode 100644 tests/__snapshots__/nested-collapsed-running.txt create mode 100644 tests/__snapshots__/nested-collapsed-success.txt create mode 100644 tests/__snapshots__/nested-expanded.txt create mode 100644 tests/__snapshots__/spawn-call-collapsed.txt create mode 100644 tests/__snapshots__/spawn-call-long.txt create mode 100644 tests/__snapshots__/spawn-result-aborted.txt create mode 100644 tests/__snapshots__/spawn-result-error.txt create mode 100644 tests/__snapshots__/spawn-result-success.txt create mode 100644 tests/unit/fixtures/register-loader-entry.mjs create mode 100644 tests/unit/handoff.test.ts create mode 100644 tests/unit/notebook.test.ts create mode 100644 tests/unit/register-loader.test.ts create mode 100644 tests/unit/render-snapshots.test.ts create mode 100644 tests/unit/spawn-render.test.ts rename agenticoding.test.ts => tests/unit/spawn.test.ts (52%) create mode 100644 tests/unit/state-invariants.test.ts create mode 100644 tests/unit/system-prompt.test.ts create mode 100644 tests/unit/topic.test.ts create mode 100644 tests/unit/tui-indicators.test.ts create mode 100644 tests/unit/watchdog.test.ts diff --git a/tests/__snapshots__/indicator-30.txt b/tests/__snapshots__/indicator-30.txt new file mode 100644 index 0000000..ed914e8 --- /dev/null +++ b/tests/__snapshots__/indicator-30.txt @@ -0,0 +1 @@ +[dim:ctx ][accent:30%] \ No newline at end of file diff --git a/tests/__snapshots__/indicator-50.txt b/tests/__snapshots__/indicator-50.txt new file mode 100644 index 0000000..d794902 --- /dev/null +++ b/tests/__snapshots__/indicator-50.txt @@ -0,0 +1 @@ +[dim:ctx ][warning:50%] \ No newline at end of file diff --git a/tests/__snapshots__/indicator-70.txt b/tests/__snapshots__/indicator-70.txt new file mode 100644 index 0000000..d7401e1 --- /dev/null +++ b/tests/__snapshots__/indicator-70.txt @@ -0,0 +1 @@ +[dim:ctx ][error:70%] \ No newline at end of file diff --git a/tests/__snapshots__/nested-collapsed-running.txt b/tests/__snapshots__/nested-collapsed-running.txt new file mode 100644 index 0000000..88b674e --- /dev/null +++ b/tests/__snapshots__/nested-collapsed-running.txt @@ -0,0 +1,2 @@ + gpt-4o • medium + ⏳ initializing… \ No newline at end of file diff --git a/tests/__snapshots__/nested-collapsed-success.txt b/tests/__snapshots__/nested-collapsed-success.txt new file mode 100644 index 0000000..445ae6f --- /dev/null +++ b/tests/__snapshots__/nested-collapsed-success.txt @@ -0,0 +1,3 @@ + ✅ gpt-4o • high + Analysis complete. The optimal solution is to use a cache layer with TTL of... + tok 200/150 · 4t · $0.0450 \ No newline at end of file diff --git a/tests/__snapshots__/nested-expanded.txt b/tests/__snapshots__/nested-expanded.txt new file mode 100644 index 0000000..343f835 --- /dev/null +++ b/tests/__snapshots__/nested-expanded.txt @@ -0,0 +1,6 @@ + + ✅ gpt-4o • medium + ]133;A + Here is the implementation plan. Create data access layer, add caching + ]133;B]133;C middleware, wire up the controller. + \ No newline at end of file diff --git a/tests/__snapshots__/spawn-call-collapsed.txt b/tests/__snapshots__/spawn-call-collapsed.txt new file mode 100644 index 0000000..cea6994 --- /dev/null +++ b/tests/__snapshots__/spawn-call-collapsed.txt @@ -0,0 +1,4 @@ + + spawn child [medium] + Research the rate limits for the OpenAI API and document the results. + \ No newline at end of file diff --git a/tests/__snapshots__/spawn-call-long.txt b/tests/__snapshots__/spawn-call-long.txt new file mode 100644 index 0000000..fe95eb9 --- /dev/null +++ b/tests/__snapshots__/spawn-call-long.txt @@ -0,0 +1,7 @@ + + spawn child [high] + Line 1: Initialize the project structure + Line 2: Set up TypeScript configuration + Line 3: Create the main entry point + ... (4 more lines, to expand) + \ No newline at end of file diff --git a/tests/__snapshots__/spawn-result-aborted.txt b/tests/__snapshots__/spawn-result-aborted.txt new file mode 100644 index 0000000..0bf8457 --- /dev/null +++ b/tests/__snapshots__/spawn-result-aborted.txt @@ -0,0 +1,5 @@ + + ✗ gpt-4o-mini • low + 💬 aborted + Operation cancelled by user request. + \ No newline at end of file diff --git a/tests/__snapshots__/spawn-result-error.txt b/tests/__snapshots__/spawn-result-error.txt new file mode 100644 index 0000000..c55831b --- /dev/null +++ b/tests/__snapshots__/spawn-result-error.txt @@ -0,0 +1,5 @@ + + ⚠ gpt-4o • high + 💬 error + Failed to connect to API: rate limit exceeded. Retry after 60 seconds. + \ No newline at end of file diff --git a/tests/__snapshots__/spawn-result-success.txt b/tests/__snapshots__/spawn-result-success.txt new file mode 100644 index 0000000..19d8b7c --- /dev/null +++ b/tests/__snapshots__/spawn-result-success.txt @@ -0,0 +1,5 @@ + + ✅ gpt-4o • medium + 💬 done + Task completed successfully. All tests pass and documentation is updated. + \ No newline at end of file diff --git a/tests/unit/fixtures/register-loader-entry.mjs b/tests/unit/fixtures/register-loader-entry.mjs new file mode 100644 index 0000000..fded0a2 --- /dev/null +++ b/tests/unit/fixtures/register-loader-entry.mjs @@ -0,0 +1,2 @@ +import "../../../state.js"; +console.log("ok"); diff --git a/tests/unit/handoff.test.ts b/tests/unit/handoff.test.ts new file mode 100644 index 0000000..90b5084 --- /dev/null +++ b/tests/unit/handoff.test.ts @@ -0,0 +1,229 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { registerHandoffCommand } from "../../handoff/command.js"; +import { registerHandoffTool } from "../../handoff/tool.js"; +import { registerHandoffCompaction } from "../../handoff/compact.js"; +import registerAgenticoding from "../../index.js"; +import { STATUS_KEY_HANDOFF, WIDGET_KEY_WARNING, updateIndicators } from "../../tui.js"; +import { createTestPI, makeTUICtx } from "./helpers.js"; + +test("/handoff sends the direction back through the LLM without opening the editor", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { notify: (_message: string) => {} }, + }); + + assert.deepEqual(state.pendingRequestedHandoff, { + direction: "implement auth", + enforcementAttempts: 0, + toolCalled: false, + }); + assert.deepEqual(pi.sentUserMessages, [ + { + content: + "Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.", + options: undefined, + }, + ]); +}); + +test("/handoff requires a direction", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + + const notifications: string[] = []; + await pi.commands.get("handoff")!.handler(" ", { + hasUI: true, + isIdle: () => true, + ui: { notify: (message: string) => notifications.push(message) }, + }); + + assert.deepEqual(notifications, ["Usage: /handoff "]); + assert.deepEqual(pi.sentUserMessages, []); +}); + +test("handoff tool triggers compaction and resumes with the compacted task", async () => { + const pi = createTestPI(); + const state = createState(); + state.notebookPages.set("auth-refresh", "sensitive notebook body"); + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerHandoffTool(pi as any, state); + + let compactOptions: any; + const result = await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue auth-refresh" }, + undefined, + undefined, + { + compact: (options: any) => { + compactOptions = options; + }, + }, + ); + + assert.equal(state.pendingHandoff?.source, "tool"); + // Structural: verify the task contains the handoff template structure, + // not exact phrasing (template wording may evolve). + assert.match(state.pendingHandoff?.task ?? "", /## Handoff/); + assert.match(state.pendingHandoff?.task ?? "", /notebook/i); + assert.match(state.pendingHandoff?.task ?? "", /task|context|situational/i); + // The user's task content is the actual contract — keep exact match. + assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); + assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); + assert.equal(state.pendingRequestedHandoff?.toolCalled, true); + assert.equal(typeof compactOptions?.onComplete, "function"); + assert.equal(result.content[0].text, "Handoff started."); + assert.equal(result.terminate, true); + + compactOptions.onComplete({}); + assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); +}); + +test("handoff compaction replaces old context with the queued task", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingHandoff = { task: "Goal: continue", source: "tool" }; + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; + state.activeNotebookTopic = "oauth"; + state.activeNotebookTopicSource = "human"; + registerHandoffCompaction(pi as any, state); + + const [handler] = pi.handlers.get("session_before_compact")!; + const result = await handler( + { + preparation: { tokensBefore: 123 }, + branchEntries: [{ id: "leaf-1" }], + }, + {}, + ); + + assert.equal(state.pendingHandoff, null); + assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.activeNotebookTopic, null); + assert.equal(state.activeNotebookTopicSource, null); + assert.equal(result.compaction.summary, "Goal: continue"); + assert.equal(result.compaction.tokensBefore, 123); + assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut"); + assert.deepEqual(result.compaction.details, { handoff: true, task: "Goal: continue" }); +}); + +test("/handoff sets the handoff status indicator", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + const statuses = new Map(); + + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + }); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); +}); + +test("handoff compaction clears the handoff status indicator", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingHandoff = { task: "Goal: continue", source: "tool" }; + registerHandoffCompaction(pi as any, state); + const statuses = new Map(); + const [handler] = pi.handlers.get("session_before_compact")!; + + await handler( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, + { hasUI: true, ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } } }, + ); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("handoff compaction error clears pending state and status", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerHandoffTool(pi as any, state); + let compactOptions: any; + const statuses = new Map(); + + await pi.tools.get("handoff").execute( + "1", + { task: "Goal: continue" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + compactOptions.onError({}); + + assert.equal(state.pendingHandoff, null); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("turn_end fallback clears stale requested handoff status", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const statuses = new Map(); + await pi.commands.get("handoff")!.handler("implement auth", { + hasUI: true, + isIdle: () => true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: () => {}, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + }, + }); + + const [turnEnd] = pi.handlers.get("turn_end")!; + await turnEnd({}, { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + setWidget: () => {}, + }, + getContextUsage: () => null, + }); + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); +}); + +test("session_start new clears stale handoff status and warning widget", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const statuses = new Map([[STATUS_KEY_HANDOFF, "stale"]]); + const widgets = new Map([[WIDGET_KEY_WARNING, ["stale"]]]); + const sessionStartHandlers = pi.handlers.get("session_start")!; + const ctx = { + hasUI: true, + ui: { + theme: { fg: (_name: string, text: string) => text }, + setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, + setWidget: (key: string, value: string[] | undefined) => { widgets.set(key, value); }, + }, + sessionManager: { getBranch: () => [] }, + getContextUsage: () => null, + }; + for (const sessionStart of sessionStartHandlers) { + await sessionStart({ reason: "new" }, ctx); + } + + assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); +}); diff --git a/tests/unit/notebook.test.ts b/tests/unit/notebook.test.ts new file mode 100644 index 0000000..4eb6cd4 --- /dev/null +++ b/tests/unit/notebook.test.ts @@ -0,0 +1,593 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { Text } from "@earendil-works/pi-tui"; +import { createState, resetState } from "../../state.js"; +import { registerNotebookRehydration } from "../../notebook/rehydration.js"; +import { saveNotebookPage, resetNotebookWriteLock } from "../../notebook/store.js"; +import { createNotebookToolDefinitions } from "../../notebook/tools.js"; +import { __setSingletons, createWriteLock, getSingletons } from "../../runtime-singletons.js"; +import registerAgenticoding from "../../index.js"; +import { STATUS_KEY_TOPIC, WIDGET_KEY_WARNING } from "../../tui.js"; +import { createTestPI, makeTUICtx, createDeferred, theme, stripAnsi } from "./helpers.js"; + +// ── Notebook rehydration tests ──────────────────────────────────────── + +test("notebook rehydration rebuilds the latest epoch and enables notebook tools", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "ledger-entry", data: { epoch: 1, name: "old", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "new" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "newer" } }, + ], + }, + }, + ); + + assert.equal(state.epoch, 2); + assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "newer"]]); + assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); +}); + + +test("notebook rehydration rebuilds from the latest persisted epoch and avoids duplicate active tools", async () => { + const pi = createTestPI(); + pi.activeTools = ["read", "notebook_read", "notebook_index"]; + const state = createState(); + state.epoch = 7; + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 7, name: "keep", content: "fresh" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "future", content: "latest" } }, + ], + }, + }, + ); + + assert.equal(state.epoch, 8); + assert.deepEqual(Array.from(state.notebookPages.entries()), [["future", "latest"]]); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); +}); + + +test("notebook rehydration clears stale in-memory notebook state when persisted history is empty", async () => { + const pi = createTestPI(); + const state = createState(); + state.epoch = 7; + state.notebookPages.set("stale", "stale body"); + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [], + }, + }, + ); + + assert.equal(state.epoch, 0); + assert.deepEqual(Array.from(state.notebookPages.entries()), []); + assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); +}); + + +test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { + const pi = createTestPI(); + pi.activeTools = ["read", "notebook_read"]; + registerAgenticoding(pi as any); + + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute( + "seed", + { name: "stale-page", content: "stale body" }, + undefined, + undefined, + makeTUICtx({ hasUI: false }), + ); + + const sessionStartHandlers = pi.handlers.get("session_start")!; + const ctx = { + hasUI: false, + getContextUsage: () => null, + sessionManager: { + getBranch: () => [ + { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } }, + { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } }, + ], + }, + }; + for (const sessionStart of sessionStartHandlers) { + await sessionStart({ reason: "resume" }, ctx as any); + } + + const notebookIndex = pi.tools.get("notebook_index"); + const notebookRead = pi.tools.get("notebook_read"); + const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any); + assert.deepEqual(indexResult.details.entries, ["keep"]); + + const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any); + assert.equal(readResult.details.found, true); + assert.equal(readResult.details.body, "newer"); + assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); +}); + +// ── Notebook tool contract tests ────────────────────────────────────── + +test("notebook tools add/get/list return stable contract details", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); + + const addResult = await notebookWrite.execute("1", { name: "entry-a", content: "first line\nsecond line" }, undefined, undefined, {} as any); + assert.deepEqual(addResult.details, { entries: ["entry-a"], preview: "first line" }); + assert.equal(state.notebookPages.get("entry-a"), "first line\nsecond line"); + assert.equal(pi.appendedEntries.length, 1); + assert.equal(pi.appendedEntries[0].customType, "notebook-entry"); + assert.equal(pi.appendedEntries[0].data.name, "entry-a"); + + const getResult = await notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any); + assert.equal(getResult.details.found, true); + assert.deepEqual(getResult.details.entries, ["entry-a"]); + assert.match(getResult.content[0].text, /--- entry-a ---/); + assert.match(getResult.content[0].text, /second line/); + + const listResult = await notebookIndex.execute("3", {}, undefined, undefined, {} as any); + assert.deepEqual(listResult.details, { entries: ["entry-a"] }); + assert.match(listResult.content[0].text, /entry-a: first line/); +}); + +test("child notebook tools reject stale access after reset", async () => { + const pi = createTestPI(); + const state = createState(); + state.notebookPages.set("entry-a", "alpha"); + let stale = false; + const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state, { isStale: () => stale }); + + stale = true; + await assert.rejects( + () => notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any), + /invalidated by reset/i, + ); + await assert.rejects( + () => notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any), + /invalidated by reset/i, + ); + await assert.rejects( + () => notebookIndex.execute("3", {}, undefined, undefined, {} as any), + /invalidated by reset/i, + ); + assert.equal(state.notebookPages.get("entry-a"), "alpha"); + assert.equal(pi.appendedEntries.length, 0); +}); + +test("child notebook_write succeeds while child session is fresh", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite] = createNotebookToolDefinitions(pi as any, state, { isStale: () => false }); + + const result = await notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any); + assert.deepEqual(result.details, { entries: ["entry-a"], preview: "alpha" }); + assert.equal(state.notebookPages.get("entry-a"), "alpha"); + assert.equal(pi.appendedEntries.length, 1); +}); + +test("notebook_read reports not found with current page names", async () => { + const pi = createTestPI(); + const state = createState(); + state.notebookPages.set("entry-a", "alpha"); + state.notebookPages.set("entry-b", "beta"); + const [, notebookRead] = createNotebookToolDefinitions(pi as any, state); + + const result = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); + assert.deepEqual(result.details, { entries: ["entry-a", "entry-b"], found: false }); + assert.match(result.content[0].text, /Notebook page "missing" not found\./); + assert.match(result.content[0].text, /Notebook Pages:\n/); + assert.match(result.content[0].text, /entry-a: alpha/); + assert.match(result.content[0].text, /entry-b: beta/); +}); + +test("notebook tools show empty-state placeholders", async () => { + const pi = createTestPI(); + const state = createState(); + const [, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); + + const missing = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); + assert.deepEqual(missing.details, { entries: [], found: false }); + assert.match(missing.content[0].text, /Notebook Pages:\n\(empty\)/); + + const list = await notebookIndex.execute("2", {}, undefined, undefined, {} as any); + assert.deepEqual(list.details, { entries: [] }); + assert.match(list.content[0].text, /Notebook Pages:\n\(empty\)/); +}); + +test("notebook_write pushes onUpdate and refreshes UI indicators", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite] = createNotebookToolDefinitions(pi as any, state); + const record = { statuses: new Map(), widgets: new Map() }; + let update: any; + + const result = await notebookWrite.execute( + "1", + { name: "entry-a", content: "first line\nsecond line" }, + undefined, + (payload: any) => { update = payload; }, + makeTUICtx({ percent: 42, record }), + ); + + assert.equal(update.content[0].text, 'Saved "entry-a": first line'); + assert.deepEqual(update.details, { entries: ["entry-a"], preview: "first line" }); + assert.equal(record.statuses.get("agenticoding-notebook"), "📒 1"); + assert.deepEqual(result.details, { entries: ["entry-a"], preview: "first line" }); +}); + +test("notebook tool renderers expose stable call/result summaries", async () => { + const pi = createTestPI(); + const state = createState(); + const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); + + const addCall = notebookWrite.renderCall!({ name: "entry-a", content: "first line\nsecond line" }, theme, {} as any) as Text; + assert.match(stripAnsi(addCall.render(120).join("\n")), /notebook_write "entry-a": first line/); + + const addResult = notebookWrite.renderResult!( + { content: [{ type: "text", text: "" }], details: { entries: ["entry-a"], preview: "first line" } }, + { expanded: true }, + theme, + { args: { name: "entry-a", content: "first line\nsecond line" } } as any, + ) as Text; + assert.match(stripAnsi(addResult.render(120).join("\n")), /Saved "entry-a": first line/); + assert.match(stripAnsi(addResult.render(120).join("\n")), /entry-a/); + + const getResult = notebookRead.renderResult!( + { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "body" } }, + { expanded: true }, + theme, + { args: { name: "entry-a" } } as any, + ) as Text; + assert.match(stripAnsi(getResult.render(120).join("\n")), /"entry-a"/); + assert.match(stripAnsi(getResult.render(120).join("\n")), /body/); + + const getResultWithDelimiters = notebookRead.renderResult!( + { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "line 1\n---\nline 2" } }, + { expanded: true }, + theme, + { args: { name: "entry-a" } } as any, + ) as Text; + assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 1/); + assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 2/); + + const listResult = notebookIndex.renderResult!( + { content: [{ type: "text", text: "" }], details: { entries: ["entry-a", "entry-b"] } }, + { expanded: true }, + theme, + {} as any, + ) as Text; + assert.match(stripAnsi(listResult.render(120).join("\n")), /2 pages/); + assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-a/); + assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-b/); +}); + +// ── Notebook command / overlay tests ────────────────────────────────── + +test("/notebook exits cleanly when headless", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + + await assert.doesNotReject(() => pi.commands.get("notebook")!.handler("", { hasUI: false })); +}); + + +test("/notebook notifies with info on first set and warning on boundary change", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: Array<{ message: string; level: string }> = []; + const statuses = new Map(); + const widgets = new Map(); + const ctx = { + hasUI: true, + getContextUsage: () => ({ percent: 20 }), + ui: { + theme: { fg: (_name: string, text: string) => text }, + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + setStatus: (key: string, status: string | undefined) => { statuses.set(key, status); }, + setWidget: (key: string, content: string[] | undefined) => { widgets.set(key, content); }, + }, + }; + + await pi.commands.get("notebook")!.handler("oauth", ctx as any); + await pi.commands.get("notebook")!.handler("billing", ctx as any); + + assert.deepEqual(notifications[0], { message: "Active notebook topic: oauth", level: "info" }); + assert.match(notifications[1].message, /Active notebook topic changed: oauth → billing/); + assert.equal(notifications[1].level, "warning"); + assert.equal(statuses.get(STATUS_KEY_TOPIC), "🧭 billing"); + assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); +}); + +test("/notebook empty overlay renders empty state and closes on input", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + let overlay: any; + let doneCalls = 0; + + await pi.commands.get("notebook")!.handler("", { + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); + }, + }, + }); + + const lines = stripAnsi(overlay.render(120).join("\n")); + assert.match(lines, /Notebook \(0 pages\)/); + assert.match(lines, /\(empty\) — use notebook_write to create pages/); + overlay.handleInput("x"); + assert.equal(doneCalls, 1); +}); + +test("/notebook selection previews the chosen entry", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notifications: string[] = []; + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute("1", { name: "alpha", content: "body line\nsecond line" }, undefined, undefined, makeTUICtx()); + let overlay: any; + let doneCalls = 0; + + await pi.commands.get("notebook")!.handler("", { + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); + }, + notify: (message: string) => { notifications.push(message); }, + }, + }); + + // First Enter selects the entry — shows body inline, done() not yet called + overlay.handleInput("\r"); + assert.equal(doneCalls, 0, "body shown inline, overlay stays open"); + const bodyLines = stripAnsi(overlay.render(120).join("\n")); + assert.match(bodyLines, /body line/); + assert.match(bodyLines, /alpha/); + + // Second keypress closes the overlay + overlay.handleInput("\r"); + assert.equal(doneCalls, 1); +}); + +test("/notebook overlay sorts entries consistently", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute("1", { name: "zeta", content: "last" }, undefined, undefined, makeTUICtx()); + await notebookWrite.execute("2", { name: "alpha", content: "first" }, undefined, undefined, makeTUICtx()); + let overlay: any; + + await pi.commands.get("notebook")!.handler("", { + hasUI: true, + ui: { + theme, + custom: async (build: any) => { + overlay = build({ requestRender: () => {} }, theme, {}, () => {}); + }, + notify: () => {}, + }, + }); + + const lines = stripAnsi(overlay.render(120).join("\n")); + assert.ok(lines.indexOf("alpha") < lines.indexOf("zeta"), lines); +}); + +// ── saveNotebookPage tests ──────────────────────────────────────────── + +test("saveNotebookPage serializes concurrent writes and preserves completion order", async () => { + const pi = createTestPI(); + const state = createState(); + const firstGate = createDeferred(); + const order: string[] = []; + + const first = saveNotebookPage(pi as any, state, "entry-a", "first", async () => { + order.push("first:start"); + await firstGate.promise; + order.push("first:end"); + }); + const second = saveNotebookPage(pi as any, state, "entry-a", "second", async () => { + order.push("second:start"); + }); + + await Promise.resolve(); + assert.deepEqual(order, ["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + + assert.deepEqual(order, ["first:start", "first:end", "second:start"]); + assert.equal(state.notebookPages.get("entry-a"), "second"); + assert.deepEqual(pi.appendedEntries.map((entry) => entry.data.content), ["first", "second"]); +}); + +test("saveNotebookPage keeps write order across runtime singleton swaps", async () => { + const pi = createTestPI(); + const state = createState(); + const previousSingletons = getSingletons(); + const firstGate = createDeferred(); + const order: string[] = []; + + try { + const first = saveNotebookPage(pi as any, state, "entry-a", "first", async () => { + order.push("first:start"); + await firstGate.promise; + order.push("first:end"); + }); + await Promise.resolve(); + + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: getSingletons().frameScheduler, + }); + const second = saveNotebookPage(pi as any, state, "entry-a", "second", async () => { + order.push("second:start"); + }); + + await Promise.resolve(); + assert.deepEqual(order, ["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + + assert.deepEqual(order, ["first:start", "first:end", "second:start"]); + assert.equal(state.notebookPages.get("entry-a"), "second"); + } finally { + firstGate.resolve(); + resetNotebookWriteLock(); + __setSingletons(previousSingletons, { forceWriteLock: true }); + } +}); + +test("saveNotebookPage rejects true reentrancy explicitly", async () => { + const pi = createTestPI(); + const state = createState(); + + await assert.rejects( + () => saveNotebookPage(pi as any, state, "outer", "outer", async () => { + await saveNotebookPage(pi as any, state, "inner", "inner"); + }), + /not reentrant/i, + ); + assert.equal(state.notebookPages.size, 0); +}); + +test("saveNotebookPage releases the lock when assertWritable throws", async () => { + const pi = createTestPI(); + const state = createState(); + + await assert.rejects( + () => saveNotebookPage(pi as any, state, "broken", "value", async () => { + throw new Error("blocked"); + }), + /blocked/, + ); + await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); + assert.equal(state.notebookPages.get("fresh"), "value"); +}); + +test("resetNotebookWriteLock clears abandoned lock state for later writes", async () => { + const pi = createTestPI(); + const state = createState(); + const gate = createDeferred(); + void saveNotebookPage(pi as any, state, "stuck", "value", async () => { + await gate.promise; + }); + await Promise.resolve(); + resetNotebookWriteLock(); + + await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); + assert.equal(state.notebookPages.get("fresh"), "value"); + gate.resolve(); +}); + + +test("saveNotebookPage truncates oversized content before persisting", async () => { + const pi = createTestPI(); + const state = createState(); + const content = "first line\n" + "detail\n".repeat(3000); + + const result = await saveNotebookPage(pi as any, state, "large-page", content); + const persisted = pi.appendedEntries[0].data.content; + + assert.ok(persisted.length < content.length, "oversized notebook content should be truncated"); + assert.equal(state.notebookPages.get("large-page"), persisted); + assert.equal(result.preview, "first line"); + assert.match(persisted, /^first line/m); +}); + + +test("resetState clears epoch and the next notebook write starts a fresh generation", async () => { + const pi = createTestPI(); + const state = createState(); + const originalNow = Date.now; + + try { + Date.now = () => 1000; + await saveNotebookPage(pi as any, state, "entry-a", "first"); + await saveNotebookPage(pi as any, state, "entry-b", "second"); + assert.equal(state.epoch, 1000); + assert.equal(pi.appendedEntries[0].data.epoch, 1000); + assert.equal(pi.appendedEntries[1].data.epoch, 1000); + + resetState(state); + assert.equal(state.epoch, 0); + + Date.now = () => 2000; + await saveNotebookPage(pi as any, state, "entry-c", "third"); + assert.equal(state.epoch, 2000); + assert.equal(pi.appendedEntries[2].data.epoch, 2000); + } finally { + Date.now = originalNow; + } +}); + +// ── Notebook tool definition metadata tests ─────────────────────────── + +test("notebook tool definitions include prompt hints when withPromptHints is true", () => { + const pi = createTestPI(); + const state = createState(); + const tools = createNotebookToolDefinitions(pi as any, state, { withPromptHints: true }); + + for (const tool of tools) { + assert.ok(typeof tool.promptSnippet === "string", `${tool.name} should have promptSnippet when withPromptHints=true`); + assert.ok(Array.isArray(tool.promptGuidelines), `${tool.name} should have promptGuidelines when withPromptHints=true`); + } + const notebookWrite = tools.find(t => t.name === "notebook_write")!; + const notebookRead = tools.find(t => t.name === "notebook_read")!; + const notebookIndex = tools.find(t => t.name === "notebook_index")!; + + // Structural invariants: all guidelines exist and are non-trivial + for (const tool of tools) { + assert.ok(tool.promptGuidelines!.length >= 2, `${tool.name} should have at least 2 promptGuidelines`); + assert.ok(tool.promptGuidelines!.every((g: string) => g.length > 10), `${tool.name} each guideline should be non-trivial`); + } + + // Conceptual: notebook_write is future-context oriented + const writeGuidelines = notebookWrite.promptGuidelines!.join(" "); + assert.match(writeGuidelines, /subject-oriented pages/i); + assert.match(writeGuidelines, /fresh context/i); + assert.match(writeGuidelines, /belongs in handoff/i); + + // Conceptual: descriptions mention the notebook-page metaphor + assert.match(notebookWrite.description, /page|future contexts/i); + assert.match(notebookRead.description, /notebook page|page/i); + assert.match(notebookIndex.description, /notebook index|index/i); +}); + +test("notebook tool definitions omit prompt hints by default", () => { + const pi = createTestPI(); + const state = createState(); + const tools = createNotebookToolDefinitions(pi as any, state); + + for (const tool of tools) { + assert.equal(tool.promptSnippet, undefined, `${tool.name} should not have promptSnippet by default`); + assert.equal(tool.promptGuidelines, undefined, `${tool.name} should not have promptGuidelines by default`); + } +}); diff --git a/tests/unit/register-loader.test.ts b/tests/unit/register-loader.test.ts new file mode 100644 index 0000000..7f94130 --- /dev/null +++ b/tests/unit/register-loader.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "..", ".."); +const REGISTER_LOADER = pathToFileURL(resolve(ROOT, "register-loader.mjs")).href; +const ENTRY = fileURLToPath(new URL("./fixtures/register-loader-entry.mjs", import.meta.url)); + +test("register-loader resolves test-loader relative to itself instead of cwd", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-")); + + try { + const result = spawnSync( + process.execPath, + ["--import", REGISTER_LOADER, ENTRY], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /ok/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/render-snapshots.test.ts b/tests/unit/render-snapshots.test.ts new file mode 100644 index 0000000..d45b9fa --- /dev/null +++ b/tests/unit/render-snapshots.test.ts @@ -0,0 +1,302 @@ +/** + * Snapshot tests for TUI render output. + * + * Creates golden files in tests/__snapshots__/ for every render variant. + * Use UPDATE_SNAPSHOTS=1 to create/update golden files. + * + * No MockPi needed — uses real Theme, real TUI components via the harness. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { createState, type AgenticodingState } from "../../state.js"; +import { + renderSpawnCall, + renderSpawnResult, +} from "../../spawn/renderer.js"; +import { updateIndicators } from "../../tui.js"; +import { createTestHarness } from "../test-utils.js"; +import { createSession, makeTUICtx } from "./helpers.js"; + +// ── Paths ───────────────────────────────────────────────────────────── + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SNAPSHOT_DIR = join(__dirname, "..", "__snapshots__"); + +// ── Render test backend ─────────────────────────────────────────────── + +class RenderTestBackend { + lines: string[] = []; + render(component: { render(w: number): string[] }, width = 80): this { + this.lines = component.render(width); + return this; + } + toSnapshot(): string { + return this.lines.join("\n"); + } +} + +// ── Theme: identity (no styling tokens, clean golden files) ─────────── + +const theme: Theme = { + fg: (_name: string, text: string) => text, + bold: (text: string) => text, +} as unknown as Theme; + +// ── Snapshot helpers ────────────────────────────────────────────────── + +function ensureSnapshotDir(): void { + if (!existsSync(SNAPSHOT_DIR)) { + mkdirSync(SNAPSHOT_DIR, { recursive: true }); + } +} + +function matchSnapshot(name: string, actual: string): void { + ensureSnapshotDir(); + const file = join(SNAPSHOT_DIR, `${name}.txt`); + if (process.env.UPDATE_SNAPSHOTS) { + writeFileSync(file, actual); + return; + } + if (!existsSync(file)) { + assert.fail(`Snapshot ${name} is missing. Re-run with UPDATE_SNAPSHOTS=1 to create it.`); + } + const expected = readFileSync(file, "utf-8"); + assert.equal(actual, expected, `Snapshot ${name} does not match`); +} + +function withHarness(run: (h: { state: AgenticodingState } & ReturnType) => void): void { + const h = { state: createState(), ...createTestHarness() }; + try { + run(h); + } finally { + h.teardown(); + } +} + +// ── Snapshot width ──────────────────────────────────────────────────── + +const SNAP_WIDTH = 80; + +// ═══════════════════════════════════════════════════════════════════════ +// 1–2: Spawn call (renderSpawnCall) +// ═══════════════════════════════════════════════════════════════════════ + +test("spawn call collapsed matches snapshot", () => { + const component = renderSpawnCall( + { prompt: "Research the rate limits for the OpenAI API and document the results.", thinking: "medium" }, + theme, + { expanded: false }, + ); + + const rtb = new RenderTestBackend().render(component, SNAP_WIDTH); + matchSnapshot("spawn-call-collapsed", rtb.toSnapshot()); +}); + +test("spawn call long prompt matches snapshot", () => { + const prompt = [ + "Line 1: Initialize the project structure", + "Line 2: Set up TypeScript configuration", + "Line 3: Create the main entry point", + "Line 4: Add test infrastructure", + "Line 5: Configure CI/CD pipeline", + "Line 6: Add documentation", + "Line 7: Final review and cleanup", + ].join("\n"); + + const component = renderSpawnCall( + { prompt, thinking: "high" }, + theme, + { expanded: false }, + ); + + const rtb = new RenderTestBackend().render(component, SNAP_WIDTH); + matchSnapshot("spawn-call-long", rtb.toSnapshot()); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// 3–5: Spawn result (renderSpawnResult, static Text path, no child session) +// ═══════════════════════════════════════════════════════════════════════ + +test("spawn result success matches snapshot", () => withHarness((h) => { + const component = renderSpawnResult( + { + content: [{ type: "text", text: "Task completed successfully. All tests pass and documentation is updated." }], + details: { + model: "gpt-4o", + thinking: "medium", + outcome: "success" as const, + stats: { inputTokens: 150, outputTokens: 75, turns: 3, cost: 0.023 }, + }, + }, + false, + theme, + { toolCallId: "tc-1", invalidate: () => {}, showImages: false, lastComponent: undefined }, + h.state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("spawn-result-success", rtb.toSnapshot()); +})); + +test("spawn result error matches snapshot", () => withHarness((h) => { + const component = renderSpawnResult( + { + content: [{ type: "text", text: "Failed to connect to API: rate limit exceeded. Retry after 60 seconds." }], + details: { + model: "gpt-4o", + thinking: "high", + outcome: "error" as const, + stats: { inputTokens: 42, outputTokens: 0, turns: 1, cost: 0.0042 }, + }, + }, + false, + theme, + { toolCallId: "tc-2", invalidate: () => {}, showImages: false, lastComponent: undefined }, + h.state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("spawn-result-error", rtb.toSnapshot()); +})); + +test("spawn result aborted matches snapshot", () => withHarness((h) => { + const component = renderSpawnResult( + { + content: [{ type: "text", text: "Operation cancelled by user request." }], + details: { + model: "gpt-4o-mini", + thinking: "low", + outcome: "aborted" as const, + stats: { inputTokens: 10, outputTokens: 0, turns: 0, cost: 0.0005 }, + }, + }, + false, + theme, + { toolCallId: "tc-3", invalidate: () => {}, showImages: false, lastComponent: undefined }, + h.state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("spawn-result-aborted", rtb.toSnapshot()); +})); + +// ═══════════════════════════════════════════════════════════════════════ +// 6–8: NestedAgentSessionComponent (via renderSpawnResult with child session) +// ═══════════════════════════════════════════════════════════════════════ + +test("nested collapsed running matches snapshot", () => withHarness((h) => { + const session = createSession([]); + h.state.childSessions.set("tc-nested-1", session); + + const component = renderSpawnResult( + { + content: [{ type: "text", text: "" }], + details: { model: "gpt-4o", thinking: "medium" }, + }, + false, + theme, + { toolCallId: "tc-nested-1", invalidate: () => {}, showImages: false, lastComponent: undefined }, + h.state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("nested-collapsed-running", rtb.toSnapshot()); +})); + +test("nested collapsed success matches snapshot", () => withHarness((h) => { + const session = createSession([ + { + role: "assistant", + content: [{ type: "text", text: "Analysis complete. The optimal solution is to use a cache layer with TTL of 300s." }], + }, + ]); + h.state.childSessions.set("tc-nested-2", session); + + const component = renderSpawnResult( + { + content: [{ type: "text", text: "" }], + details: { + model: "gpt-4o", + thinking: "high", + outcome: "success" as const, + stats: { inputTokens: 200, outputTokens: 150, turns: 4, cost: 0.045 }, + }, + }, + false, + theme, + { toolCallId: "tc-nested-2", invalidate: () => {}, showImages: false, lastComponent: undefined }, + h.state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("nested-collapsed-success", rtb.toSnapshot()); +})); + +test("nested expanded matches snapshot", () => withHarness((h) => { + const session = createSession([ + { + role: "assistant", + content: [{ type: "text", text: "Here is the implementation plan. Create data access layer, add caching middleware, wire up the controller." }], + }, + ]); + h.state.childSessions.set("tc-nested-3", session); + + const component = renderSpawnResult( + { + content: [{ type: "text", text: "" }], + details: { + model: "gpt-4o", + thinking: "medium", + outcome: "success" as const, + stats: { inputTokens: 100, outputTokens: 50, turns: 2, cost: 0.012 }, + }, + }, + true, + theme, + { toolCallId: "tc-nested-3", invalidate: () => {}, showImages: false, lastComponent: undefined }, + h.state, + ); + + const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); + matchSnapshot("nested-expanded", rtb.toSnapshot()); +})); + +// ═══════════════════════════════════════════════════════════════════════ +// 9–11: Context indicator snapshots +// ═══════════════════════════════════════════════════════════════════════ + +test("context indicator at 30% matches snapshot", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + const status = record.statuses.get("agenticoding-ctx") ?? ""; + matchSnapshot("indicator-30", status); +}); + +test("context indicator at 50% matches snapshot", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 50, record }); + + updateIndicators(ctx, state); + const status = record.statuses.get("agenticoding-ctx") ?? ""; + matchSnapshot("indicator-50", status); +}); + +test("context indicator at 70% matches snapshot", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 70, record }); + + updateIndicators(ctx, state); + const status = record.statuses.get("agenticoding-ctx") ?? ""; + matchSnapshot("indicator-70", status); +}); diff --git a/tests/unit/spawn-render.test.ts b/tests/unit/spawn-render.test.ts new file mode 100644 index 0000000..c4c4eba --- /dev/null +++ b/tests/unit/spawn-render.test.ts @@ -0,0 +1,298 @@ +/** + * Render-focused tests for the spawn module. + * + * Extracted from spawn.test.ts to keep focused suites. These tests + * verify visual rendering of spawn results — collapsed/expanded + * output, theme application, truncation display, and render caching. + * + * Execution and lifecycle tests remain in spawn.test.ts. + */ + +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { createState } from "../../state.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { renderSpawnResult } from "../../spawn/renderer.js"; +import { + createTestPI, + theme, + ansiTheme, + createRenderContext, + createSession, + stripAnsi, + getRenderedLine, + getLineContaining, + assertShellBackgroundPreserved, +} from "./helpers.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("collapsed nested spawn render shows preview and stats", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix\nseven" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { + model: "mock-model", + thinking: "medium", + truncated: true, + stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, + }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("mock-model • medium"))); + assert.ok(lines.some((l: string) => l.includes("one"))); + assert.ok(lines.some((l: string) => l.includes("five"))); + assert.ok(lines.some((l: string) => l.includes("... 2 more lines"))); + assert.ok(lines.some((l: string) => l.includes("tok 12/34"))); + assert.ok(lines.some((l: string) => l.includes("trunc"))); +}); + +test("collapsed nested spawn render keeps all text blocks from the last assistant message", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("first"))); + assert.ok(lines.some((l: string) => l.includes("second"))); +}); + +test("collapsed nested spawn truncation preserves shell background across preview and stats lines", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "Research the nudge on toggle off TODO from the readonly mode plan." }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { + model: "mock-model", + thinking: "medium", + truncated: true, + stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, + }, + }, + { expanded: false }, + ansiTheme, + createRenderContext(), + ) as any; + + const lines = component.render(24); + const previewLine = getRenderedLine(lines, plain => plain.includes("Research")); + const statsLine = getRenderedLine(lines, plain => plain.includes("tok 12/34")); + assertShellBackgroundPreserved(previewLine); + assertShellBackgroundPreserved(statsLine); + assert.match(stripAnsi(statsLine), /tok 12\/34/); +}); + +test("collapsed nested spawn keeps truncated stats line calm", () => { + const markerTheme = { + fg: (name: string, text: string) => `<${name}>${text}`, + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme; + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "short preview" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { + model: "mock-model", + thinking: "medium", + truncated: true, + stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, + }, + }, + { expanded: false }, + markerTheme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + const statsLine = getLineContaining(lines, "tok 12/34"); + assert.match(statsLine, /.*tok 12\/34.*trunc.*<\/dim>/); + assert.equal(statsLine.includes(""), false); +}); + +test("nested spawn render is safe without details", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }] }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("hello"))); +}); + +test("expanded nested spawn header stays within width after indent", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "model-name", thinking: "medium", truncated: false }, + }, + { expanded: true }, + theme, + createRenderContext({ expanded: true }), + ) as any; + + const lines = component.render(24); + const headerLine = lines.find((line: string) => line.includes("model-name")) ?? ""; + assert.ok(headerLine.startsWith(" ")); + assert.ok(stripAnsi(headerLine).length <= 24); +}); + +test("nested spawn render cache preserves stable output for identical params", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + const first = component.render(120); + const second = component.render(120); + assert.deepEqual(second, first); +}); + +test("nested spawn clears cached render when showImages changes", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }, { type: "image", data: "iVBOR", mimeType: "image/png" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: true }, + theme, + createRenderContext({ expanded: true, showImages: true }), + ) as any; + const linesWithImages = component.render(120); + + const sameComponent = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: true }, + theme, + createRenderContext({ expanded: true, showImages: false, lastComponent: component }), + ) as any; + const linesWithoutImages = sameComponent.render(120); + + assert.equal(sameComponent, component); + assert.ok(Array.isArray(linesWithImages)); + assert.ok(Array.isArray(linesWithoutImages)); + assert.equal((sameComponent as any).cachedShowImages, false); +}); + +test("nested spawn rerenders when stats become unavailable", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const session = createSession([ + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + state.childSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false }, + }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + const before = component.render(120); + assert.equal(before.some((l: string) => l.includes("stats unavailable")), false); + + const sameComponent = childSpawnTool.renderResult( + { + content: [{ type: "text", text: "ignored" }], + details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true }, + }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component }), + ) as any; + const after = sameComponent.render(120); + + assert.equal(sameComponent, component); + assert.ok(after.some((l: string) => l.includes("stats unavailable"))); + assert.equal(after.some((l: string) => l.includes("initializing")), false); +}); diff --git a/agenticoding.test.ts b/tests/unit/spawn.test.ts similarity index 52% rename from agenticoding.test.ts rename to tests/unit/spawn.test.ts index 5468314..aca3811 100644 --- a/agenticoding.test.ts +++ b/tests/unit/spawn.test.ts @@ -1,937 +1,36 @@ -import test, { after } from "node:test"; +import test, { afterEach, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { AuthStorage, ModelRegistry, type Theme } from "@earendil-works/pi-coding-agent"; -import { Text } from "@earendil-works/pi-tui"; -import { registerHandoffCommand } from "./handoff/command.js"; -import { registerHandoffTool } from "./handoff/tool.js"; -import { registerHandoffCompaction } from "./handoff/compact.js"; -import { buildNudge, registerWatchdog } from "./watchdog.js"; -import { createState, resetState } from "./state.js"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createState, resetState } from "../../state.js"; import { buildChildToolNames, createChildTools, executeSpawn, registerSpawnTool, -} from "./spawn/index.js"; -import { renderSpawnResult, flushSpawnFrameScheduler, resetSpawnFrameScheduler } from "./spawn/renderer.js"; -import { registerNotebookRehydration } from "./notebook/rehydration.js"; -import { clearActiveNotebookTopic, setActiveNotebookTopic } from "./notebook/topic.js"; -import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; -import { saveNotebookPage, resetNotebookWriteLock } from "./notebook/store.js"; -import { createNotebookToolDefinitions } from "./notebook/tools.js"; -import registerAgenticoding from "./index.js"; -import { CONTEXT_PRIMER } from "./system-prompt.js"; -import { STATUS_KEY_HANDOFF, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators } from "./tui.js"; - -// Safety net: reset module-level mutable state after all tests. -// Individual tests should also call reset*() at the start for explicit isolation. -after(() => { - resetNotebookWriteLock(); - resetSpawnFrameScheduler(); -}); - -type Handler = (args: any, ctx: any) => any; - -const theme = { - fg: (_name: string, text: string) => text, - bold: (text: string) => text, -} as unknown as Theme; - -const ansiTheme = { - fg: (_name: string, text: string) => `\u001b[38;5;245m${text}\u001b[39m`, - bg: (_name: string, text: string) => `\u001b[48;5;236m${text}\u001b[49m`, - bold: (text: string) => text, -} as unknown as Theme; - -function createRenderContext(overrides: Record = {}): Record { - return { - expanded: false, - showImages: true, - toolCallId: "tool-call-1", - lastComponent: undefined, - invalidate: () => {}, - ...overrides, - }; -} - -function createSession(messages: any[]) { - return { - messages, - subscribe: () => () => {}, - getToolDefinition: () => undefined, - sessionManager: { getCwd: () => process.cwd() }, - abort: async () => {}, - } as unknown as import("@earendil-works/pi-coding-agent").AgentSession; -} - -function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, ""); -} - -function getRenderedLine(lines: string[], match: (plain: string) => boolean): string { - const line = lines.find(candidate => match(stripAnsi(candidate))); - assert.ok(line); - return line; -} +} from "../../spawn/index.js"; +import { renderSpawnResult, flushSpawnFrameScheduler } from "../../spawn/renderer.js"; +import { createTestPI, theme, ansiTheme, createRenderContext, createSession, createSubscribableSession, stripAnsi, getRenderedLine, getLineContaining, assertShellBackgroundPreserved, createDeferred, createTestAssistantMessage, createTestAssistantStream, messageText, makeTUICtx } from "./helpers.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; -function getLineContaining(lines: string[], text: string): string { - const line = lines.find(candidate => candidate.includes(text)); - assert.ok(line); - return line; -} - -function assertShellBackgroundPreserved(line: string): void { - assert.equal(line.includes("\u001b[0m"), false); - assert.match(line, /\u001b\[48;/); -} +let h: TestHarness; -function createDeferred() { - let resolve!: () => void; - const promise = new Promise((r) => { resolve = r; }); - return { promise, resolve }; -} - -function createChildSpawnTool(state: any): any { - const pi = new MockPi(); +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); registerSpawnTool(pi as any, state); return pi.tools.get("spawn"); } -class MockPi { - commands = new Map(); - tools = new Map(); - handlers = new Map(); - activeTools: string[] = []; - allToolNames: string[] | undefined; - toolSources = new Map(); - sentUserMessages: Array<{ content: string; options: any }> = []; - appendedEntries: Array<{ customType: string; data: any }> = []; - - registerCommand(name: string, definition: { description?: string; handler: Handler }) { - this.commands.set(name, definition); - } - - registerTool(definition: any) { - this.tools.set(definition.name, definition); - } - - on(event: string, handler: Handler) { - const handlers = this.handlers.get(event) ?? []; - handlers.push(handler); - this.handlers.set(event, handlers); - } - - getActiveTools() { - return [...this.activeTools]; - } - - setActiveTools(tools: string[]) { - this.activeTools = [...tools]; - for (const tool of tools) { - if (!this.toolSources.has(tool)) { - this.toolSources.set(tool, "builtin"); - } - } - } - - setToolSource(name: string, source: string) { - this.toolSources.set(name, source); - } - - setAllTools(tools: string[]) { - this.allToolNames = [...tools]; - for (const tool of tools) { - if (!this.toolSources.has(tool)) { - this.toolSources.set(tool, "builtin"); - } - } - } - - getAllTools() { - return (this.allToolNames ?? this.activeTools).map((name) => ({ - name, - description: "", - parameters: {}, - sourceInfo: { - path: `<${this.toolSources.get(name) ?? "builtin"}:${name}>`, - source: this.toolSources.get(name) ?? "builtin", - scope: "temporary", - origin: "top-level", - }, - })); - } - - getThinkingLevel() { - return "medium"; - } - - sendUserMessage(content: string, options?: any) { - this.sentUserMessages.push({ content, options }); - } - - appendEntry(customType: string, data: any) { - this.appendedEntries.push({ customType, data }); - } -} - -const EMPTY_USAGE = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - -function createTestAssistantMessage(model: any, content: any[], stopReason = "stop") { - return { - role: "assistant", - content, - api: model.api, - provider: model.provider, - model: model.id, - usage: EMPTY_USAGE, - stopReason, - timestamp: Date.now(), - }; -} - -function createTestAssistantStream(message: any): any { - return { - async *[Symbol.asyncIterator]() { - yield { type: "done", reason: message.stopReason, message }; - }, - result: async () => message, - }; -} - -function messageText(message: any): string { - return (message.content ?? []) - .map((block: any) => block.type === "text" ? block.text : JSON.stringify(block)) - .join("\n"); -} - -// ── TUI indicator tests ─────────────────────────────────────────────── - -function makeTUICtx( - overrides: Partial<{ - percent: number | null; - hasUI: boolean; - record: { statuses: Map; widgets: Map }; - }> = {}, -): any { - const record = overrides.record ?? { statuses: new Map(), widgets: new Map() }; - const hasUI = overrides.hasUI ?? true; - const percent = overrides.percent !== undefined ? overrides.percent : null; - return { - hasUI, - ui: { - theme: { - fg: (name: string, text: string) => `[${name}:${text}]`, - }, - setStatus: (key: string, status: string | undefined) => { record.statuses.set(key, status); }, - setWidget: (key: string, content: string[] | undefined) => { record.widgets.set(key, content); }, - }, - getContextUsage: () => (percent !== null ? { percent } : null), - }; -} - -test("updateIndicators sets context usage status with correct color tone", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 42, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[accent:42%]"), "42% should use accent tone"); - assert.equal(record.widgets.get("agenticoding-warning"), undefined, "42% is below 70 — no warning widget"); -}); - -test("updateIndicators uses error tone at 70%+ context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 85, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[error:85%]"), "85% should use error tone"); - const w = record.widgets.get("agenticoding-warning"); - assert.ok(w?.[0]?.includes("85%"), "warning widget shown at 85%"); -}); - -test("updateIndicators uses warning tone at 50-69% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 55, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[warning:55%]"), "55% should use warning tone"); -}); - -test("updateIndicators uses accent tone at 30-49% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 30, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("[accent:30%]"), "30% should use accent tone"); +beforeEach(() => { + h = createTestHarness(); }); -test("updateIndicators handles null context usage", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: null, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-ctx"); - assert.ok(s?.includes("--%"), "null usage shows --%"); -}); - -test("updateIndicators no-ops when ctx.hasUI is false", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ hasUI: false, record }); - - updateIndicators(ctx, state); - assert.equal(record.statuses.size, 0, "no-op should not call any setStatus"); - assert.equal(record.widgets.size, 0, "no-op should not call any setWidget"); -}); - -test("updateIndicators shows notebook page count in status", () => { - const state = createState(); - state.notebookPages.set("entry-1", "first entry"); - state.notebookPages.set("entry-2", "second entry"); - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: null, record }); - - updateIndicators(ctx, state); - const s = record.statuses.get("agenticoding-notebook"); - assert.ok(s?.includes("2"), "notebook page count should be 2"); -}); - -test("updateIndicators shows active notebook topic when set", () => { - const state = createState(); - state.activeNotebookTopic = "oauth"; - const record = { statuses: new Map(), widgets: new Map() }; - const ctx = makeTUICtx({ percent: 30, record }); - - updateIndicators(ctx, state); - assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); -}); - -test("updateIndicators hides widget below 70% context", () => { - const state = createState(); - const record = { statuses: new Map(), widgets: new Map() }; - // Pre-set a widget to verify it gets cleared - record.widgets.set("agenticoding-warning", ["existing"]); - const ctx = makeTUICtx({ percent: 30, record }); - - updateIndicators(ctx, state); - assert.equal(record.widgets.get("agenticoding-warning"), undefined, "warning widget should be cleared below 70%"); +afterEach(() => { + h.teardown(); }); -// ── Handoff tests ───────────────────────────────────────────────────── - -test("/handoff sends the direction back through the LLM without opening the editor", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); - - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { notify: (_message: string) => {} }, - }); - - assert.deepEqual(state.pendingRequestedHandoff, { - direction: "implement auth", - enforcementAttempts: 0, - toolCalled: false, - }); - assert.deepEqual(pi.sentUserMessages, [ - { - content: - "Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.", - options: undefined, - }, - ]); -}); - -test("/handoff requires a direction", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); - - const notifications: string[] = []; - await pi.commands.get("handoff")!.handler(" ", { - hasUI: true, - isIdle: () => true, - ui: { notify: (message: string) => notifications.push(message) }, - }); - - assert.deepEqual(notifications, ["Usage: /handoff "]); - assert.deepEqual(pi.sentUserMessages, []); -}); - -test("handoff tool triggers compaction and resumes with the compacted task", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("auth-refresh", "sensitive notebook body"); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerHandoffTool(pi as any, state); - - let compactOptions: any; - const result = await pi.tools.get("handoff").execute( - "1", - { task: "Goal: continue auth-refresh" }, - undefined, - undefined, - { - compact: (options: any) => { - compactOptions = options; - }, - }, - ); - - assert.equal(state.pendingHandoff?.source, "tool"); - assert.match(state.pendingHandoff?.task ?? "", /## Handoff — Continue Previous Work/); - assert.match(state.pendingHandoff?.task ?? "", /Notebook pages hold durable grounding knowledge/); - assert.match(state.pendingHandoff?.task ?? "", /distilled next task and immediate situational context/); - assert.match(state.pendingHandoff?.task ?? "", /Goal: continue auth-refresh/); - assert.doesNotMatch(state.pendingHandoff?.task ?? "", /sensitive notebook body/); - assert.equal(state.pendingRequestedHandoff?.toolCalled, true); - assert.equal(typeof compactOptions?.onComplete, "function"); - assert.equal(result.content[0].text, "Handoff started."); - assert.equal(result.terminate, true); - - compactOptions.onComplete({}); - assert.deepEqual(pi.sentUserMessages, [{ content: "Proceed.", options: undefined }]); -}); - -test("handoff compaction replaces old context with the queued task", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; - state.activeNotebookTopic = "oauth"; - state.activeNotebookTopicSource = "human"; - registerHandoffCompaction(pi as any, state); - - const [handler] = pi.handlers.get("session_before_compact")!; - const result = await handler( - { - preparation: { tokensBefore: 123 }, - branchEntries: [{ id: "leaf-1" }], - }, - {}, - ); - - assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff, null); - assert.equal(state.activeNotebookTopic, null); - assert.equal(state.activeNotebookTopicSource, null); - assert.equal(result.compaction.summary, "Goal: continue"); - assert.equal(result.compaction.tokensBefore, 123); - assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut"); - assert.deepEqual(result.compaction.details, { handoff: true, task: "Goal: continue" }); -}); - -test("/handoff sets the handoff status indicator", async () => { - const pi = new MockPi(); - const state = createState(); - registerHandoffCommand(pi as any, state); - const statuses = new Map(); - - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - notify: () => {}, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - }, - }); - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); -}); - -test("handoff compaction clears the handoff status indicator", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - registerHandoffCompaction(pi as any, state); - const statuses = new Map(); - const [handler] = pi.handlers.get("session_before_compact")!; - - await handler( - { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, - { hasUI: true, ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } } }, - ); - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); -}); - -test("handoff compaction error clears pending state and status", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerHandoffTool(pi as any, state); - let compactOptions: any; - const statuses = new Map(); - - await pi.tools.get("handoff").execute( - "1", - { task: "Goal: continue" }, - undefined, - undefined, - { - hasUI: true, - ui: { setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); } }, - compact: (options: any) => { compactOptions = options; }, - }, - ); - compactOptions.onError({}); - - assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff?.toolCalled, false); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); -}); - -test("turn_end fallback clears stale requested handoff status", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const statuses = new Map(); - await pi.commands.get("handoff")!.handler("implement auth", { - hasUI: true, - isIdle: () => true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - notify: () => {}, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - }, - }); - - const [turnEnd] = pi.handlers.get("turn_end")!; - await turnEnd({}, { - hasUI: true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - setWidget: () => {}, - }, - getContextUsage: () => null, - }); - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); -}); - -test("session_start new clears stale handoff status and warning widget", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const statuses = new Map([[STATUS_KEY_HANDOFF, "stale"]]); - const widgets = new Map([[WIDGET_KEY_WARNING, ["stale"]]]); - const sessionStartHandlers = pi.handlers.get("session_start")!; - const ctx = { - hasUI: true, - ui: { - theme: { fg: (_name: string, text: string) => text }, - setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); }, - setWidget: (key: string, value: string[] | undefined) => { widgets.set(key, value); }, - }, - sessionManager: { getBranch: () => [] }, - getContextUsage: () => null, - }; - for (const sessionStart of sessionStartHandlers) { - await sessionStart({ reason: "new" }, ctx); - } - - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); - assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); -}); - -test("watchdog records context usage without user notifications", async () => { - const pi = new MockPi(); - const state = createState(); - registerWatchdog(pi as any, state); - const [handler] = pi.handlers.get("agent_end")!; - - const notifications: string[] = []; - await handler( - {}, - { - hasUI: true, - ui: { notify: (message: string) => notifications.push(message) }, - getContextUsage: () => ({ percent: 70 }), - }, - ); - - assert.equal(state.lastContextPercent, 70); - assert.deepEqual(notifications, []); -}); - -test("context injects watchdog reminder before each LLM call", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { - getContextUsage: () => ({ percent: 70 }), - }, - ); - - assert.equal(result.messages.length, 2); - assert.deepEqual(result.messages[0], { role: "user", content: "hi", timestamp: 1 }); - assert.equal(result.messages[1].role, "custom"); - assert.equal(result.messages[1].customType, "agenticoding-watchdog"); - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Context at 70%/); - assert.match(result.messages[1].content, /Active notebook topic: oauth/); - assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i); - assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); -}); - -test("context injects a boundary nudge below 30% after an explicit topic change", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); - - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 20 }) }, - ); - - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/); -}); - - -test("context injects a no-topic nudge when context is high", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - - const result = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 70 }) }, - ); - - assert.equal(result.messages.length, 2); - assert.equal(result.messages[1].role, "custom"); - assert.equal(result.messages[1].customType, "agenticoding-watchdog"); - assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /No active notebook topic is set/); - assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); -}); - - -test("context consumes a boundary hint after the first injected nudge", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("context")!; - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); - - const first = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - { getContextUsage: () => ({ percent: 20 }) }, - ); - assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/); - - const second = await handler( - { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, - { getContextUsage: () => ({ percent: 20 }) }, - ); - assert.equal(second, undefined); -}); - - -test("buildNudge handles null percent and boundary hints before topic guidance", () => { - const boundary = buildNudge( - { - activeNotebookTopic: "oauth", - pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, - }, - null, - ); - assert.match(boundary, /Notebook topic changed from oauth to billing/); - assert.doesNotMatch(boundary, /Active notebook topic: oauth/); - - const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null }, null); - assert.match(noTopic, /Topic-aware context reminder/); - assert.match(noTopic, /No active notebook topic is set/); -}); - -test("watchdog stays advisory when a requested handoff is not completed", async () => { - const pi = new MockPi(); - const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; - registerWatchdog(pi as any, state); - const [handler] = pi.handlers.get("agent_end")!; - - const notifications: string[] = []; - await handler( - {}, - { - hasUI: true, - ui: { - notify: (message: string) => notifications.push(message), - setStatus: () => {}, - }, - getContextUsage: () => ({ percent: 20 }), - }, - ); - - assert.equal(state.pendingRequestedHandoff, null); - assert.deepEqual(notifications, []); - assert.deepEqual(pi.sentUserMessages, []); -}); - -test("collapsed nested spawn render shows preview and stats", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix\nseven" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { - model: "mock-model", - thinking: "medium", - truncated: true, - stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, - }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("mock-model • medium"))); - assert.ok(lines.some((l: string) => l.includes("one"))); - assert.ok(lines.some((l: string) => l.includes("five"))); - assert.ok(lines.some((l: string) => l.includes("... 2 more lines"))); - assert.ok(lines.some((l: string) => l.includes("tok 12/34"))); - assert.ok(lines.some((l: string) => l.includes("trunc"))); -}); - -test("collapsed nested spawn render keeps all text blocks from the last assistant message", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "first" }, { type: "text", text: "second" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("first"))); - assert.ok(lines.some((l: string) => l.includes("second"))); -}); - -test("collapsed nested spawn truncation preserves shell background across preview and stats lines", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "Research the nudge on toggle off TODO from the readonly mode plan." }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { - model: "mock-model", - thinking: "medium", - truncated: true, - stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, - }, - }, - { expanded: false }, - ansiTheme, - createRenderContext(), - ) as any; - - const lines = component.render(24); - const previewLine = getRenderedLine(lines, plain => plain.includes("Research")); - const statsLine = getRenderedLine(lines, plain => plain.includes("tok 12/34")); - assertShellBackgroundPreserved(previewLine); - assertShellBackgroundPreserved(statsLine); - assert.match(stripAnsi(statsLine), /tok 12\/34/); -}); - -test("collapsed nested spawn keeps truncated stats line calm", () => { - const markerTheme = { - fg: (name: string, text: string) => `<${name}>${text}`, - bg: (_name: string, text: string) => text, - bold: (text: string) => text, - } as unknown as Theme; - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "short preview" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { - model: "mock-model", - thinking: "medium", - truncated: true, - stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 }, - }, - }, - { expanded: false }, - markerTheme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - const statsLine = getLineContaining(lines, "tok 12/34"); - assert.match(statsLine, /.*tok 12\/34.*trunc.*<\/dim>/); - assert.equal(statsLine.includes(""), false); -}); - -test("nested spawn render is safe without details", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }] }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("hello"))); -}); - -test("expanded nested spawn header stays within width after indent", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "model-name", thinking: "medium", truncated: false }, - }, - { expanded: true }, - theme, - createRenderContext({ expanded: true }), - ) as any; - - const lines = component.render(24); - const headerLine = lines.find((line: string) => line.includes("model-name")) ?? ""; - assert.ok(headerLine.startsWith(" ")); - assert.ok(stripAnsi(headerLine).length <= 24); -}); - -test("nested spawn clears cached render when showImages changes", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }, { type: "image", data: "iVBOR", mimeType: "image/png" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: true }, - theme, - createRenderContext({ expanded: true, showImages: true }), - ) as any; - const linesWithImages = component.render(120); - - const sameComponent = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: true }, - theme, - createRenderContext({ expanded: true, showImages: false, lastComponent: component }), - ) as any; - const linesWithoutImages = sameComponent.render(120); - - assert.equal(sameComponent, component); - // Both render calls produce valid output — cache invalidation is verified - // implicitly because the second output reflects the showImages change - // rather than returning stale cached content from the first call. - assert.ok(Array.isArray(linesWithImages)); - assert.ok(Array.isArray(linesWithoutImages)); -}); - -test("nested spawn rerenders when stats become unavailable", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const session = createSession([ - { role: "assistant", content: [{ type: "text", text: "hello" }] }, - ]); - state.childSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false }, - }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - const before = component.render(120); - assert.equal(before.some((l: string) => l.includes("stats unavailable")), false); - - const sameComponent = childSpawnTool.renderResult( - { - content: [{ type: "text", text: "ignored" }], - details: { model: "mock-model", thinking: "medium", truncated: false, outcome: "success", statsUnavailable: true }, - }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component }), - ) as any; - const after = sameComponent.render(120); - - assert.equal(sameComponent, component); - assert.ok(after.some((l: string) => l.includes("stats unavailable"))); - assert.equal(after.some((l: string) => l.includes("initializing")), false); -}); test("agentic e2e spawn child can use active registered non-builtin tool", async () => { const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-")); @@ -1011,7 +110,7 @@ export default function(pi) { const model = parentRegistry.find("openai", "agentic-e2e-model"); assert.ok(model); - const pi = new MockPi(); + const pi = createTestPI(); pi.setToolSource("agentic_e2e_probe", "project"); pi.setActiveTools(["read", "agentic_e2e_probe", "spawn"]); pi.setAllTools(["read", "agentic_e2e_probe", "spawn"]); @@ -1048,7 +147,7 @@ export default function(pi) { }); test("spawn execute passes broad active registered tool formula to child session", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setToolSource("project_search", "project"); pi.setToolSource("inactive_registered", "extension"); pi.setActiveTools(["read", "bash", "spawn", "handoff", "project_search", "phantom_tool"]); @@ -1090,7 +189,7 @@ test("spawn execute passes broad active registered tool formula to child session }); test("spawn execute builds prompt with notebook pages and task", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); state.notebookPages.set("entry-a", "preview line\nfull body"); @@ -1126,7 +225,7 @@ test("spawn execute builds prompt with notebook pages and task", async () => { test("spawn renderResult falls back to static text when no live session is stored", () => { const state = createState(); - const pi = new MockPi(); + const pi = createTestPI(); registerSpawnTool(pi as any, state); const result = pi.tools.get("spawn").renderResult( @@ -1146,7 +245,7 @@ test("spawn renderResult falls back to static text when no live session is store test("spawn renderResult distinguishes aborted and error outcomes", () => { const state = createState(); - const pi = new MockPi(); + const pi = createTestPI(); registerSpawnTool(pi as any, state); const aborted = pi.tools.get("spawn").renderResult( @@ -1177,7 +276,7 @@ test("spawn renderResult distinguishes aborted and error outcomes", () => { }); test("spawn execute returns result and stats", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1226,17 +325,10 @@ test("spawn execute returns result and stats", async () => { }); test("spawn execute marks stats unavailable when stats collection throws", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => { - warnings.push(args); - }; - - try { const mockFactory = async () => { const session = { messages: [] as any[], @@ -1262,16 +354,14 @@ test("spawn execute marks stats unavailable when stats collection throws", async assert.equal(result.details.stats, undefined); assert.equal(result.details.statsUnavailable, true); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][1]), /stats failed/); - assert.equal(warnings[0][2], "spawn-1"); - } finally { - console.warn = originalWarn; - } + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[1]), /stats failed/); + assert.equal(h.warnings[0].args[2], "spawn-1"); + }); test("spawn execute throws when child produces no output", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1294,7 +384,7 @@ test("spawn execute throws when child produces no output", async () => { }); test("spawn execute clears childSessions when prompt throws", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1320,7 +410,7 @@ test("spawn execute clears childSessions when prompt throws", async () => { }); test("spawn execute clears childSessions after successful completion when unrendered", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1350,7 +440,7 @@ test("spawn execute clears childSessions after successful completion when unrend }); test("spawn execute fails explicitly without a configured model", async () => { - const pi = new MockPi(); + const pi = createTestPI(); const state = createState(); registerSpawnTool(pi as any, state); await assert.rejects( @@ -1361,7 +451,7 @@ test("spawn execute fails explicitly without a configured model", async () => { test("child tool names inherit active registered builtins and exclude recursive controls", () => { const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); + const childTools = createChildTools(createTestPI() as any, state); assert.equal(childTools.some(t => t.name === "spawn"), false); const childToolNames = buildChildToolNames( ["read", "bash", "spawn", "handoff", "future_tool"], @@ -1387,7 +477,7 @@ test("spawn renderResult transfers session ownership out of shared state", () => ]); state.childSessions.set("tool-call-1", session); - const pi = new MockPi(); + const pi = createTestPI(); registerSpawnTool(pi as any, state); const component = pi.tools.get("spawn").renderResult( @@ -1409,7 +499,7 @@ test("spawn renderResult reuses lastComponent", () => { ]); state.childSessions.set("tool-call-1", session); - const pi = new MockPi(); + const pi = createTestPI(); registerSpawnTool(pi as any, state); const first = pi.tools.get("spawn").renderResult( @@ -1446,7 +536,7 @@ test("resetState aborts and clears child session registries", () => { test("resetState aborts a claimed child session after render ownership transfer", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); let abortCalls = 0; const session = { ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), @@ -1475,7 +565,7 @@ test("resetState aborts a claimed child session after render ownership transfer" }); test("executeSpawn suppresses stale child sessions after resetState during async setup", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1528,7 +618,7 @@ test("executeSpawn suppresses stale child sessions after resetState during async test("child tool names inherit active registered MCP extension tools", () => { const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); + const childTools = createChildTools(createTestPI() as any, state); const toolNames = buildChildToolNames( ["read", "chunkhound_code_research", "mcp_status"], @@ -1546,7 +636,7 @@ test("child tool names inherit active registered MCP extension tools", () => { test("child tool names inherit active registered project package and local extension tools", () => { const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); + const childTools = createChildTools(createTestPI() as any, state); const toolNames = buildChildToolNames( ["project_search", "package_lint", "local_helper"], @@ -1565,7 +655,7 @@ test("child tool names inherit active registered project package and local exten test("child tool names exclude inactive registered and active phantom tools", () => { const state = createState(); - const childTools = createChildTools(new MockPi() as any, state); + const childTools = createChildTools(createTestPI() as any, state); const toolNames = buildChildToolNames( ["read", "active_phantom"], @@ -1586,36 +676,15 @@ test("child tool names exclude inactive registered and active phantom tools", () assert.equal(toolNames.includes("spawn"), false); }); -function createSubscribableSession(messages: any[] = []) { - let handler: ((event: any) => void) | undefined; - return { - session: { - messages, - subscribe: (cb: (event: any) => void) => { - handler = cb; - return () => { handler = undefined; }; - }, - getToolDefinition: () => undefined, - sessionManager: { getCwd: () => process.cwd() }, - abort: async () => {}, - } as unknown as import("@earendil-works/pi-coding-agent").AgentSession, - emit: (event: any) => handler?.(event), - }; -} + test("nested spawn live action tracks tool execution events", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); - // Mock console.warn to suppress any expected-but-harmless warnings - // (e.g., streaming component errors in headless test env). - const originalWarn = console.warn; - console.warn = () => {}; - - try { const component = childSpawnTool.renderResult( { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, { expanded: false }, @@ -1642,47 +711,38 @@ test("nested spawn live action tracks tool execution events", () => { emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); lines = component.render(120); assert.ok(lines.some((l: string) => l.includes("[bash]")), `expected tool live action, got: ${lines.join("\n")}`); - } finally { - console.warn = originalWarn; - } + }); test("nested spawn handleEvent recovers from malformed events", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - - try { - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; - // Emit a malformed event that will throw inside handleEvent - emit({ type: "message_start", message: null }); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][1]), /message_start/); + // Emit a malformed event that will throw inside handleEvent + emit({ type: "message_start", message: null }); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[1]), /message_start/); + + // Subsequent valid events still process + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`); - // Subsequent valid events still process - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`); - } finally { - console.warn = originalWarn; - } }); test("nested spawn message_end with aborted stopReason clears pending tools", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -1705,7 +765,7 @@ test("nested spawn message_end with aborted stopReason clears pending tools", () test("nested spawn dispose stops event processing", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -1728,7 +788,7 @@ test("nested spawn dispose stops event processing", () => { test("nested spawn dispose aborts a claimed live child session", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); let abortCalls = 0; const session = { ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), @@ -1756,7 +816,7 @@ test("nested spawn dispose aborts a claimed live child session", () => { }); test("spawn execute short-circuits when signal is already aborted", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1799,7 +859,7 @@ test("spawn execute short-circuits when signal is already aborted", async () => }); test("spawn execute truncates very long child output", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1834,7 +894,7 @@ test("spawn execute truncates very long child output", async () => { }); test("spawn execute truncates child output by byte limit", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); const longText = "🙂".repeat(20_000); @@ -1868,7 +928,7 @@ test("spawn execute truncates child output by byte limit", async () => { }); test("spawn execute tells children when no notebook pages exist", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); let promptText = ""; @@ -1902,7 +962,7 @@ test("spawn execute tells children when no notebook pages exist", async () => { }); test("executeSpawn → onUpdate → renderResult chains session ownership", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -1964,7 +1024,7 @@ test("executeSpawn → onUpdate → renderResult chains session ownership", asyn test("spawn render shows success state when stats are unavailable", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const session = createSession([ { role: "assistant", content: [{ type: "text", text: "final summary" }] }, ]); @@ -1987,7 +1047,7 @@ test("spawn render shows success state when stats are unavailable", () => { }); test("spawn execute aborts child session when signal fires during execution", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -2036,7 +1096,7 @@ test("spawn execute aborts child session when signal fires during execution", as test("spawn renderCall shows prompt preview and thinking level", () => { const state = createState(); - const pi = new MockPi(); + const pi = createTestPI(); registerSpawnTool(pi as any, state); const tool = pi.tools.get("spawn"); @@ -2065,518 +1125,9 @@ test("spawn renderCall shows prompt preview and thinking level", () => { }); - -test("notebook rehydration rebuilds the latest epoch and enables notebook tools", async () => { - const pi = new MockPi(); - const state = createState(); - registerNotebookRehydration(pi as any, state); - const [handler] = pi.handlers.get("session_start")!; - - await handler( - {}, - { - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "ledger-entry", data: { epoch: 1, name: "old", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "new" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 2, name: "keep", content: "newer" } }, - ], - }, - }, - ); - - assert.equal(state.epoch, 2); - assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "newer"]]); - assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); -}); - - -test("notebook rehydration rebuilds from the latest persisted epoch and avoids duplicate active tools", async () => { - const pi = new MockPi(); - pi.activeTools = ["read", "notebook_read", "notebook_index"]; - const state = createState(); - state.epoch = 7; - registerNotebookRehydration(pi as any, state); - const [handler] = pi.handlers.get("session_start")!; - - await handler( - {}, - { - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 7, name: "keep", content: "fresh" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "future", content: "latest" } }, - ], - }, - }, - ); - - assert.equal(state.epoch, 8); - assert.deepEqual(Array.from(state.notebookPages.entries()), [["future", "latest"]]); - assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); -}); - - -test("notebook rehydration clears stale in-memory notebook state when persisted history is empty", async () => { - const pi = new MockPi(); - const state = createState(); - state.epoch = 7; - state.notebookPages.set("stale", "stale body"); - registerNotebookRehydration(pi as any, state); - const [handler] = pi.handlers.get("session_start")!; - - await handler( - {}, - { - sessionManager: { - getBranch: () => [], - }, - }, - ); - - assert.equal(state.epoch, 0); - assert.deepEqual(Array.from(state.notebookPages.entries()), []); - assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); -}); - - -test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - pi.activeTools = ["read", "notebook_read"]; - registerAgenticoding(pi as any); - - try { - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute( - "seed", - { name: "stale-page", content: "stale body" }, - undefined, - undefined, - makeTUICtx({ hasUI: false }), - ); - - const sessionStartHandlers = pi.handlers.get("session_start")!; - const ctx = { - hasUI: false, - getContextUsage: () => null, - sessionManager: { - getBranch: () => [ - { type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } }, - { type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } }, - ], - }, - }; - for (const sessionStart of sessionStartHandlers) { - await sessionStart({ reason: "resume" }, ctx as any); - } - - const notebookIndex = pi.tools.get("notebook_index"); - const notebookRead = pi.tools.get("notebook_read"); - const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any); - assert.deepEqual(indexResult.details.entries, ["keep"]); - - const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any); - assert.equal(readResult.details.found, true); - assert.equal(readResult.details.body, "newer"); - assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]); - } finally { - resetNotebookWriteLock(); - } -}); - -test("notebook tools add/get/list return stable contract details", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); - - const addResult = await notebookWrite.execute("1", { name: "entry-a", content: "first line\nsecond line" }, undefined, undefined, {} as any); - assert.deepEqual(addResult.details, { entries: ["entry-a"], preview: "first line" }); - assert.equal(state.notebookPages.get("entry-a"), "first line\nsecond line"); - assert.equal(pi.appendedEntries.length, 1); - assert.equal(pi.appendedEntries[0].customType, "notebook-entry"); - assert.equal(pi.appendedEntries[0].data.name, "entry-a"); - - const getResult = await notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any); - assert.equal(getResult.details.found, true); - assert.deepEqual(getResult.details.entries, ["entry-a"]); - assert.match(getResult.content[0].text, /--- entry-a ---/); - assert.match(getResult.content[0].text, /second line/); - - const listResult = await notebookIndex.execute("3", {}, undefined, undefined, {} as any); - assert.deepEqual(listResult.details, { entries: ["entry-a"] }); - assert.match(listResult.content[0].text, /entry-a: first line/); -}); - -test("child notebook tools reject stale access after reset", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("entry-a", "alpha"); - let stale = false; - const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state, { isStale: () => stale }); - - stale = true; - await assert.rejects( - () => notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any), - /invalidated by reset/i, - ); - await assert.rejects( - () => notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any), - /invalidated by reset/i, - ); - await assert.rejects( - () => notebookIndex.execute("3", {}, undefined, undefined, {} as any), - /invalidated by reset/i, - ); - assert.equal(state.notebookPages.get("entry-a"), "alpha"); - assert.equal(pi.appendedEntries.length, 0); -}); - -test("child notebook_write succeeds while child session is fresh", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite] = createNotebookToolDefinitions(pi as any, state, { isStale: () => false }); - - const result = await notebookWrite.execute("1", { name: "entry-a", content: "alpha" }, undefined, undefined, {} as any); - assert.deepEqual(result.details, { entries: ["entry-a"], preview: "alpha" }); - assert.equal(state.notebookPages.get("entry-a"), "alpha"); - assert.equal(pi.appendedEntries.length, 1); -}); - -test("notebook_read reports not found with current page names", async () => { - const pi = new MockPi(); - const state = createState(); - state.notebookPages.set("entry-a", "alpha"); - state.notebookPages.set("entry-b", "beta"); - const [, notebookRead] = createNotebookToolDefinitions(pi as any, state); - - const result = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); - assert.deepEqual(result.details, { entries: ["entry-a", "entry-b"], found: false }); - assert.match(result.content[0].text, /Notebook page "missing" not found\./); - assert.match(result.content[0].text, /Notebook Pages:\n/); - assert.match(result.content[0].text, /entry-a: alpha/); - assert.match(result.content[0].text, /entry-b: beta/); -}); - -test("notebook tools show empty-state placeholders", async () => { - const pi = new MockPi(); - const state = createState(); - const [, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); - - const missing = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); - assert.deepEqual(missing.details, { entries: [], found: false }); - assert.match(missing.content[0].text, /Notebook Pages:\n\(empty\)/); - - const list = await notebookIndex.execute("2", {}, undefined, undefined, {} as any); - assert.deepEqual(list.details, { entries: [] }); - assert.match(list.content[0].text, /Notebook Pages:\n\(empty\)/); -}); - -test("notebook_write pushes onUpdate and refreshes UI indicators", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite] = createNotebookToolDefinitions(pi as any, state); - const record = { statuses: new Map(), widgets: new Map() }; - let update: any; - - const result = await notebookWrite.execute( - "1", - { name: "entry-a", content: "first line\nsecond line" }, - undefined, - (payload: any) => { update = payload; }, - makeTUICtx({ percent: 42, record }), - ); - - assert.equal(update.content[0].text, 'Saved "entry-a": first line'); - assert.deepEqual(update.details, { entries: ["entry-a"], preview: "first line" }); - assert.equal(record.statuses.get("agenticoding-notebook"), "📒 1"); - assert.deepEqual(result.details, { entries: ["entry-a"], preview: "first line" }); -}); - -test("notebook tool renderers expose stable call/result summaries", async () => { - const pi = new MockPi(); - const state = createState(); - const [notebookWrite, notebookRead, notebookIndex] = createNotebookToolDefinitions(pi as any, state); - - const addCall = notebookWrite.renderCall!({ name: "entry-a", content: "first line\nsecond line" }, theme, {} as any) as Text; - assert.match(stripAnsi(addCall.render(120).join("\n")), /notebook_write "entry-a": first line/); - - const addResult = notebookWrite.renderResult!( - { content: [{ type: "text", text: "" }], details: { entries: ["entry-a"], preview: "first line" } }, - { expanded: true }, - theme, - { args: { name: "entry-a", content: "first line\nsecond line" } } as any, - ) as Text; - assert.match(stripAnsi(addResult.render(120).join("\n")), /Saved "entry-a": first line/); - assert.match(stripAnsi(addResult.render(120).join("\n")), /entry-a/); - - const getResult = notebookRead.renderResult!( - { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "body" } }, - { expanded: true }, - theme, - { args: { name: "entry-a" } } as any, - ) as Text; - assert.match(stripAnsi(getResult.render(120).join("\n")), /"entry-a"/); - assert.match(stripAnsi(getResult.render(120).join("\n")), /body/); - - const getResultWithDelimiters = notebookRead.renderResult!( - { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "line 1\n---\nline 2" } }, - { expanded: true }, - theme, - { args: { name: "entry-a" } } as any, - ) as Text; - assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 1/); - assert.match(stripAnsi(getResultWithDelimiters.render(120).join("\n")), /line 2/); - - const listResult = notebookIndex.renderResult!( - { content: [{ type: "text", text: "" }], details: { entries: ["entry-a", "entry-b"] } }, - { expanded: true }, - theme, - {} as any, - ) as Text; - assert.match(stripAnsi(listResult.render(120).join("\n")), /2 pages/); - assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-a/); - assert.match(stripAnsi(listResult.render(120).join("\n")), /entry-b/); -}); - -test("/notebook exits cleanly when headless", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - - await assert.doesNotReject(() => pi.commands.get("notebook")!.handler("", { hasUI: false })); -}); - - -test("/notebook notifies with info on first set and warning on boundary change", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const notifications: Array<{ message: string; level: string }> = []; - const statuses = new Map(); - const widgets = new Map(); - const ctx = { - hasUI: true, - getContextUsage: () => ({ percent: 20 }), - ui: { - theme: { fg: (_name: string, text: string) => text }, - notify: (message: string, level: string) => { notifications.push({ message, level }); }, - setStatus: (key: string, status: string | undefined) => { statuses.set(key, status); }, - setWidget: (key: string, content: string[] | undefined) => { widgets.set(key, content); }, - }, - }; - - await pi.commands.get("notebook")!.handler("oauth", ctx as any); - await pi.commands.get("notebook")!.handler("billing", ctx as any); - - assert.deepEqual(notifications[0], { message: "Active notebook topic: oauth", level: "info" }); - assert.match(notifications[1].message, /Active notebook topic changed: oauth → billing/); - assert.equal(notifications[1].level, "warning"); - assert.equal(statuses.get(STATUS_KEY_TOPIC), "🧭 billing"); - assert.equal(widgets.get(WIDGET_KEY_WARNING), undefined); -}); - -test("/notebook empty overlay renders empty state and closes on input", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - let overlay: any; - let doneCalls = 0; - - await pi.commands.get("notebook")!.handler("", { - hasUI: true, - ui: { - theme, - custom: async (build: any) => { - overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); - }, - }, - }); - - const lines = stripAnsi(overlay.render(120).join("\n")); - assert.match(lines, /Notebook \(0 pages\)/); - assert.match(lines, /\(empty\) — use notebook_write to create pages/); - overlay.handleInput("x"); - assert.equal(doneCalls, 1); -}); - -test("/notebook selection previews the chosen entry", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const notifications: string[] = []; - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute("1", { name: "alpha", content: "body line\nsecond line" }, undefined, undefined, makeTUICtx()); - let overlay: any; - let doneCalls = 0; - - await pi.commands.get("notebook")!.handler("", { - hasUI: true, - ui: { - theme, - custom: async (build: any) => { - overlay = build({ requestRender: () => {} }, theme, {}, () => { doneCalls++; }); - }, - notify: (message: string) => { notifications.push(message); }, - }, - }); - - // First Enter selects the entry — shows body inline, done() not yet called - overlay.handleInput("\r"); - assert.equal(doneCalls, 0, "body shown inline, overlay stays open"); - const bodyLines = stripAnsi(overlay.render(120).join("\n")); - assert.match(bodyLines, /body line/); - assert.match(bodyLines, /alpha/); - - // Second keypress closes the overlay - overlay.handleInput("\r"); - assert.equal(doneCalls, 1); -}); - -test("/notebook overlay sorts entries consistently", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute("1", { name: "zeta", content: "last" }, undefined, undefined, makeTUICtx()); - await notebookWrite.execute("2", { name: "alpha", content: "first" }, undefined, undefined, makeTUICtx()); - let overlay: any; - - await pi.commands.get("notebook")!.handler("", { - hasUI: true, - ui: { - theme, - custom: async (build: any) => { - overlay = build({ requestRender: () => {} }, theme, {}, () => {}); - }, - notify: () => {}, - }, - }); - - const lines = stripAnsi(overlay.render(120).join("\n")); - assert.ok(lines.indexOf("alpha") < lines.indexOf("zeta"), lines); -}); - -test("saveNotebookPage serializes concurrent writes and preserves completion order", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const firstGate = createDeferred(); - const order: string[] = []; - - const first = saveNotebookPage(pi as any, state, "entry-a", "first", async () => { - order.push("first:start"); - await firstGate.promise; - order.push("first:end"); - }); - const second = saveNotebookPage(pi as any, state, "entry-a", "second", async () => { - order.push("second:start"); - }); - - await Promise.resolve(); - assert.deepEqual(order, ["first:start"]); - firstGate.resolve(); - await Promise.all([first, second]); - - assert.deepEqual(order, ["first:start", "first:end", "second:start"]); - assert.equal(state.notebookPages.get("entry-a"), "second"); - assert.deepEqual(pi.appendedEntries.map((entry) => entry.data.content), ["first", "second"]); - resetNotebookWriteLock(); -}); - -test("saveNotebookPage rejects true reentrancy explicitly", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - - await assert.rejects( - () => saveNotebookPage(pi as any, state, "outer", "outer", async () => { - await saveNotebookPage(pi as any, state, "inner", "inner"); - }), - /not reentrant/i, - ); - assert.equal(state.notebookPages.size, 0); - resetNotebookWriteLock(); -}); - -test("saveNotebookPage releases the lock when assertWritable throws", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - - await assert.rejects( - () => saveNotebookPage(pi as any, state, "broken", "value", async () => { - throw new Error("blocked"); - }), - /blocked/, - ); - await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); - assert.equal(state.notebookPages.get("fresh"), "value"); - resetNotebookWriteLock(); -}); - -test("resetNotebookWriteLock clears abandoned lock state for later writes", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const gate = createDeferred(); - void saveNotebookPage(pi as any, state, "stuck", "value", async () => { - await gate.promise; - }); - await Promise.resolve(); - resetNotebookWriteLock(); - - await assert.doesNotReject(() => saveNotebookPage(pi as any, state, "fresh", "value")); - assert.equal(state.notebookPages.get("fresh"), "value"); - gate.resolve(); - resetNotebookWriteLock(); -}); - - -test("saveNotebookPage truncates oversized content before persisting", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const content = "first line\n" + "detail\n".repeat(3000); - - const result = await saveNotebookPage(pi as any, state, "large-page", content); - const persisted = pi.appendedEntries[0].data.content; - - assert.ok(persisted.length < content.length, "oversized notebook content should be truncated"); - assert.equal(state.notebookPages.get("large-page"), persisted); - assert.equal(result.preview, "first line"); - assert.match(persisted, /^first line/m); - resetNotebookWriteLock(); -}); - - -test("resetState clears epoch and the next notebook write starts a fresh generation", async () => { - resetNotebookWriteLock(); - const pi = new MockPi(); - const state = createState(); - const originalNow = Date.now; - - try { - Date.now = () => 1000; - await saveNotebookPage(pi as any, state, "entry-a", "first"); - await saveNotebookPage(pi as any, state, "entry-b", "second"); - assert.equal(state.epoch, 1000); - assert.equal(pi.appendedEntries[0].data.epoch, 1000); - assert.equal(pi.appendedEntries[1].data.epoch, 1000); - - resetState(state); - assert.equal(state.epoch, 0); - - Date.now = () => 2000; - await saveNotebookPage(pi as any, state, "entry-c", "third"); - assert.equal(state.epoch, 2000); - assert.equal(pi.appendedEntries[2].data.epoch, 2000); - } finally { - Date.now = originalNow; - resetNotebookWriteLock(); - } -}); - test("nested spawn invalidate rebuilds from the attached session transcript", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const session = createSession([ { role: "assistant", content: [{ type: "text", text: "before" }] }, ]); @@ -2603,7 +1154,7 @@ test("nested spawn invalidate rebuilds from the attached session transcript", () test("nested spawn attachSession rebuilds after appended session messages", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); state.childSessions.set("tool-call-1", createSession([ { role: "assistant", content: [{ type: "text", text: "before" }] }, ])); @@ -2636,7 +1187,7 @@ test("nested spawn attachSession rebuilds after appended session messages", () = test("nested spawn attachSession rebuilds after replacing session transcript structure", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); state.childSessions.set("tool-call-1", createSession([ { role: "assistant", content: [{ type: "text", text: "before" }] }, ])); @@ -2670,7 +1221,7 @@ test("nested spawn attachSession rebuilds after replacing session transcript str test("nested spawn rebuildFromSession quietly tolerates missing tool definitions", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const session = { messages: [{ role: "assistant", @@ -2685,11 +1236,6 @@ test("nested spawn rebuildFromSession quietly tolerates missing tool definitions } as any; state.childSessions.set("tool-call-1", session); - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - - try { const component = childSpawnTool.renderResult( { content: [], details: { model: "m", thinking: "low", truncated: false, outcome: "error" } }, { expanded: false }, @@ -2701,15 +1247,13 @@ test("nested spawn rebuildFromSession quietly tolerates missing tool definitions assert.ok(lines.some((l: string) => l.includes("⚠ m • low"))); assert.ok(lines.some((l: string) => l.includes("error"))); assert.equal(state.childSessions.has("tool-call-1"), false); - assert.deepEqual(warnings, []); - } finally { - console.warn = originalWarn; - } + assert.equal(h.warnings.length, 0); + }); test("nested spawn attachSession recovers from subscribe throwing", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); // Session whose subscribe() throws const throwingSession = { @@ -2721,11 +1265,6 @@ test("nested spawn attachSession recovers from subscribe throwing", () => { } as any; state.childSessions.set("tool-call-1", throwingSession); - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - - try { const component = childSpawnTool.renderResult( { content: [], details: { model: "m", thinking: "low", truncated: false } }, { expanded: false }, @@ -2735,20 +1274,18 @@ test("nested spawn attachSession recovers from subscribe throwing", () => { // Should not crash, session attached, ownership transferred assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][0]), /Failed to subscribe/); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[0]), /Failed to subscribe/); // Should still render from session messages despite subscribe failure const lines = component.render(120); assert.ok(lines.some((l: string) => l.includes("hello"))); - } finally { - console.warn = originalWarn; - } + }); test("nested spawn rapid events collapse to last state", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -2779,14 +1316,16 @@ test("nested spawn rapid events collapse to last state", () => { assert.ok(finalLines.some((l: string) => l.includes("✓"))); }); -// Narrow test: verifies the pendingToolCallCreations accumulation layer keeps the -// last streamed args, overwriting on each message_update. The monkey-patch on -// createToolComponent captures args before component creation. If the private -// method is renamed, update the spy target. +// Verifies pendingToolCallCreations accumulation: the last streamed args +// overwrite on each message_update before the first frame flush. +// +// Uses a spy on createToolComponent because ToolExecutionComponent (from +// pi-tui) cannot be constructed in the unit test environment. The spy wraps +// the real method to capture args while preserving the original behavior +// (which will gracefully return undefined when construction fails). test("nested spawn uses the latest streamed tool-call args before first frame flush", () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -2797,10 +1336,13 @@ test("nested spawn uses the latest streamed tool-call args before first frame fl theme, createRenderContext(), ) as any; + + // Spy on createToolComponent to capture args while preserving original behavior let createdArgs: any; - component.createToolComponent = (_toolName: string, _toolCallId: string, args: any) => { + const original = component.createToolComponent.bind(component); + component.createToolComponent = (toolName: string, toolCallId: string, args: any) => { createdArgs = args; - return undefined; + return original(toolName, toolCallId, args); }; emit({ type: "message_start", message: { role: "assistant", content: [] } }); @@ -2818,9 +1360,8 @@ test("nested spawn uses the latest streamed tool-call args before first frame fl }); test("nested spawn coalesces same-turn child events into one parent invalidate", async () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -2847,7 +1388,7 @@ test("nested spawn coalesces same-turn child events into one parent invalidate", test("nested spawn ignores child renderer invalidations during parent rebuild", async () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session } = createSubscribableSession([]); (session as any).getToolDefinition = (toolName: string) => toolName === "reentrant" ? { @@ -2885,9 +1426,8 @@ test("nested spawn ignores child renderer invalidations during parent rebuild", }); test("nested spawn shared scheduler calls each distinct invalidate once per frame", async () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const first = createSubscribableSession([]); const second = createSubscribableSession([]); state.childSessions.set("tool-call-1", first.session); @@ -2925,9 +1465,8 @@ test("nested spawn shared scheduler calls each distinct invalidate once per fram }); test("nested spawn shared scheduler still coalesces duplicate invalidate callbacks", async () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const first = createSubscribableSession([]); const second = createSubscribableSession([]); state.childSessions.set("tool-call-1", first.session); @@ -2958,7 +1497,7 @@ test("nested spawn shared scheduler still coalesces duplicate invalidate callbac test("nested spawn renders state changes across frame boundaries", async () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -2985,7 +1524,7 @@ test("nested spawn renders state changes across frame boundaries", async () => { test("nested spawn dispose cancels pending and further invalidates after cleanup", async () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3015,9 +1554,8 @@ test("nested spawn dispose cancels pending and further invalidates after cleanup }); test("nested spawn reattach resets render guard for the new session", async () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const first = createSubscribableSession([]); state.childSessions.set("tool-call-1", first.session); state.liveChildSessions.set("tool-call-1", first.session); @@ -3054,7 +1592,7 @@ test("nested spawn reattach resets render guard for the new session", async () = test("nested spawn recovers batching state after event handler error", async () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3066,31 +1604,24 @@ test("nested spawn recovers batching state after event handler error", async () createRenderContext(), ) as any; - const warnings: any[] = []; - const originalWarn = console.warn; - console.warn = (...args: any[]) => warnings.push(args); - try { - // Bad event triggers an error in handleMessageStart (null message) - // catch block must call resetRenderBatching() so the flag resets - emit({ type: "message_start", message: null } as any); + // Bad event triggers an error in handleMessageStart (null message) + // catch block must call resetRenderBatching() so the flag resets + emit({ type: "message_start", message: null } as any); + + // Good event after error — should still schedule and render + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), + "error recovery should allow subsequent events to render"); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[0]), /Event handler error/); - // Good event after error — should still schedule and render - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), - "error recovery should allow subsequent events to render"); - assert.equal(warnings.length, 1); - assert.match(String(warnings[0][0]), /Event handler error/); - } finally { - console.warn = originalWarn; - } }); test("nested spawn processes stale-state events without invalidating the parent", async () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3128,9 +1659,8 @@ test("nested spawn processes stale-state events without invalidating the parent" }); test("nested spawn cancels a queued parent invalidate when the session becomes stale before flush", async () => { - resetSpawnFrameScheduler(); const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3154,7 +1684,7 @@ test("nested spawn cancels a queued parent invalidate when the session becomes s test("nested spawn dispose then reattach streams new session events", async () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const first = createSubscribableSession([]); state.childSessions.set("tool-call-1", first.session); state.liveChildSessions.set("tool-call-1", first.session); @@ -3196,7 +1726,7 @@ test("nested spawn dispose then reattach streams new session events", async () = test("nested spawn drops late events after live registry deletion", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3220,7 +1750,7 @@ test("nested spawn drops late events after live registry deletion", () => { test("nested spawn drops events after resetState bumps child epoch", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3244,7 +1774,7 @@ test("nested spawn drops events after resetState bumps child epoch", () => { test("nested spawn drops events when session is replaced in live state", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3269,7 +1799,7 @@ test("nested spawn drops events when session is replaced in live state", () => { test("nested spawn completed-session deletion stays stale even if the toolCallId is later reused", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3298,7 +1828,7 @@ test("nested spawn completed-session deletion stays stale even if the toolCallId }); test("concurrent spawn executions produce independent results", async () => { - const pi = new MockPi(); + const pi = createTestPI(); const state = createState(); let resolveA!: () => void; @@ -3361,207 +1891,8 @@ test("concurrent spawn executions produce independent results", async () => { assert.equal(state.childSessions.has("spawn-B"), false); }); -test("nested spawn render cache preserves stable output for identical params", () => { - const state = createState(); - const childSpawnTool = createChildSpawnTool(state); - const { session } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "hello" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const first = component.render(120); - const second = component.render(120); - assert.deepEqual(second, first); - - const wide = component.render(200); - assert.ok(Array.isArray(wide)); - assert.ok(wide.some((l: string) => l.includes("hello") || l.includes("m • low"))); -}); - -test("notebook tool definitions include prompt hints when withPromptHints is true", () => { - const pi = new MockPi(); - const state = createState(); - const tools = createNotebookToolDefinitions(pi as any, state, { withPromptHints: true }); - - for (const tool of tools) { - assert.ok(typeof tool.promptSnippet === "string", `${tool.name} should have promptSnippet when withPromptHints=true`); - assert.ok(Array.isArray(tool.promptGuidelines), `${tool.name} should have promptGuidelines when withPromptHints=true`); - } - const notebookWrite = tools.find(t => t.name === "notebook_write")!; - const notebookRead = tools.find(t => t.name === "notebook_read")!; - const notebookIndex = tools.find(t => t.name === "notebook_index")!; - - // Structural invariants: all guidelines exist and are non-trivial - for (const tool of tools) { - assert.ok(tool.promptGuidelines!.length >= 2, `${tool.name} should have at least 2 promptGuidelines`); - assert.ok(tool.promptGuidelines!.every((g: string) => g.length > 10), `${tool.name} each guideline should be non-trivial`); - } - - // Conceptual: notebook_write is future-context oriented - const writeGuidelines = notebookWrite.promptGuidelines!.join(" "); - assert.match(writeGuidelines, /subject-oriented pages/i); - assert.match(writeGuidelines, /fresh context/i); - assert.match(writeGuidelines, /belongs in handoff/i); - - // Conceptual: descriptions mention the notebook-page metaphor - assert.match(notebookWrite.description, /page|future contexts/i); - assert.match(notebookRead.description, /notebook page|page/i); - assert.match(notebookIndex.description, /notebook index|index/i); -}); - -test("topic helpers manage the active notebook topic lifecycle", () => { - const state = createState(); - const first = setActiveNotebookTopic(state, "OAuth", "agent"); - assert.deepEqual(first, { - changed: true, - previous: null, - current: "oauth", - boundaryHint: null, - }); - const second = setActiveNotebookTopic(state, "Billing", "human"); - assert.equal(second.boundaryHint?.from, "oauth"); - assert.equal(second.boundaryHint?.to, "billing"); - clearActiveNotebookTopic(state); - assert.equal(state.activeNotebookTopic, null); - assert.equal(state.activeNotebookTopicSource, null); - assert.equal(state.pendingTopicBoundaryHint, null); -}); - -test("notebook_topic_set establishes a fresh topic, is idempotent, and refuses overrides", async () => { - const pi = new MockPi(); - const state = createState(); - registerNotebookTopicTool(pi as any, state); - - const tool = pi.tools.get("notebook_topic_set"); - const first = await tool.execute("1", { topic: "OAuth" }); - assert.equal(first.details.topic, "oauth"); - assert.equal(state.activeNotebookTopic, "oauth"); - assert.equal(state.activeNotebookTopicSource, "agent"); - - const second = await tool.execute("2", { topic: "oauth" }); - assert.equal(second.details.changed, false); - assert.equal(second.details.source, "agent"); - assert.match(second.content[0].text, /already set to "oauth"/i); - - await assert.rejects(() => tool.execute("3", { topic: "billing" }), /already exists/); -}); - - -test("notebook_topic_set preserves human authority, stays idempotent for equal topics, and rejects empty normalized topics", async () => { - const pi = new MockPi(); - const state = createState(); - registerNotebookTopicTool(pi as any, state); - const tool = pi.tools.get("notebook_topic_set"); - - setActiveNotebookTopic(state, "oauth", "human"); - const same = await tool.execute("1", { topic: "OAuth" }); - assert.equal(same.details.changed, false); - assert.equal(same.details.source, "human"); - assert.match(same.content[0].text, /already set to "oauth"/i); - await assert.rejects( - () => tool.execute("2", { topic: "billing" }), - /human-set notebook topic is authoritative/i, - ); - - const freshPi = new MockPi(); - const freshState = createState(); - registerNotebookTopicTool(freshPi as any, freshState); - const freshTool = freshPi.tools.get("notebook_topic_set"); - await assert.rejects( - () => freshTool.execute("3", { topic: "@@@" }), - /notebook topic cannot be empty/i, - ); -}); - -test("buildNudge no longer emits the old percent-only handoff text", () => { - const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46); - assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i); - assert.match(old, /Active notebook topic: oauth/); - assert.match(old, /prefer spawn/i); -}); - - -test("CONTEXT_PRIMER states the notebook, topic, and handoff contracts", () => { - assert.doesNotMatch(CONTEXT_PRIMER, /ledger/i, - "CONTEXT_PRIMER should contain zero stale ledger references after the rename"); - - const notebookParts = CONTEXT_PRIMER.split("### Notebook"); - const topicParts = CONTEXT_PRIMER.split("### Active notebook topic"); - const handoffParts = CONTEXT_PRIMER.split("### Handoff"); - const rulesParts = CONTEXT_PRIMER.split("### Rules"); - assert.equal(notebookParts.length, 2); - assert.equal(topicParts.length, 2); - assert.equal(handoffParts.length, 2); - assert.equal(rulesParts.length, 2); - - const notebookSection = notebookParts[1].split("### Active notebook topic")[0]; - const topicSection = topicParts[1].split("### Handoff")[0]; - const handoffSection = handoffParts[1].split("### Rules")[0]; - const rulesSection = rulesParts[1]; - - assert.match(notebookSection, /notebook_index/); - assert.match(notebookSection, /notebook_read/); - assert.match(notebookSection, /future contexts/i); - assert.match(topicSection, /semantic frame/i); - assert.match(topicSection, /prefer spawn/i); - assert.match(topicSection, /prefer handoff/i); - assert.match(handoffSection, /handoff/i); - assert.match(handoffSection, /notebook/i); - assert.match(rulesSection, /one subject, thread, or subsystem/i); -}); - - -test("before_agent_start injects notebook contracts plus live topic and page data", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); - const notebookWrite = pi.tools.get("notebook_write"); - await notebookWrite.execute("1", { name: "alpha", content: "first line\nsecond line" }, undefined, undefined, makeTUICtx()); - - const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); - - assert.match(result.systemPrompt, /Base system prompt\./); - assert.match(result.systemPrompt, /## Context management/); - assert.match(result.systemPrompt, /## Active Notebook Topic/); - assert.match(result.systemPrompt, /Current topic: `oauth`/); - assert.match(result.systemPrompt, /## Active Notebook Pages/); - assert.match(result.systemPrompt, /notebook_read/); - assert.match(result.systemPrompt, /Reference pages by name/i); - assert.match(result.systemPrompt, /alpha: first line/); -}); - - -test("before_agent_start injects no-topic guidance when the topic is unset", async () => { - const pi = new MockPi(); - registerAgenticoding(pi as any); - const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); - - assert.match(result.systemPrompt, /## Active Notebook Topic/); - assert.match(result.systemPrompt, /No active notebook topic is set\./); - assert.match(result.systemPrompt, /notebook_topic_set/); -}); - -test("notebook tool definitions omit prompt hints by default", () => { - const pi = new MockPi(); - const state = createState(); - const tools = createNotebookToolDefinitions(pi as any, state); - - for (const tool of tools) { - assert.equal(tool.promptSnippet, undefined, `${tool.name} should not have promptSnippet by default`); - assert.equal(tool.promptGuidelines, undefined, `${tool.name} should not have promptGuidelines by default`); - } -}); - test("spawn tool definitions include prompt hints when registered", () => { - const pi = new MockPi(); + const pi = createTestPI(); const state = createState(); registerSpawnTool(pi as any, state); @@ -3576,7 +1907,7 @@ test("spawn tool definitions include prompt hints when registered", () => { }); test("executeSpawn detects stale session before session creation", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -3626,7 +1957,7 @@ test("executeSpawn detects stale session before session creation", async () => { }); test("executeSpawn aborts stale child when resetState fires during prompt", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -3680,7 +2011,7 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn test("handleEvent gracefully degrades with null message events", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const { session, emit } = createSubscribableSession([]); state.childSessions.set("tool-call-1", session); state.liveChildSessions.set("tool-call-1", session); @@ -3704,7 +2035,7 @@ test("handleEvent gracefully degrades with null message events", () => { }); test("truncateText respects line limit before byte limit", async () => { - const pi = new MockPi(); + const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); @@ -3740,7 +2071,7 @@ test("truncateText respects line limit before byte limit", async () => { test("nested spawn setExpanded and setShowImages no-op when value matches", () => { const state = createState(); - const childSpawnTool = createChildSpawnTool(state); + const childSpawnTool = makeChildSpawnTool(state); const session = createSession([ { role: "assistant", content: [{ type: "text", text: "hello" }] }, ]); @@ -3801,7 +2132,7 @@ test("renderSpawnResult handles result with no details field", () => { }); test("registerSpawnTool registers a tool with correct name and metadata", () => { - const pi = new MockPi(); + const pi = createTestPI(); const state = createState(); registerSpawnTool(pi as any, state); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts new file mode 100644 index 0000000..4b77360 --- /dev/null +++ b/tests/unit/state-invariants.test.ts @@ -0,0 +1,331 @@ +/** + * Property-based state invariant tests using fast-check. + * + * Generates random sequences of state operations and asserts invariants + * that must hold after every operation on a pure AgenticodingState. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fc from "fast-check"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createState, resetState, abortAndClearChildSessions } from "../../state.js"; +import type { AgenticodingState } from "../../state.js"; +import { + setActiveNotebookTopic, + clearActiveNotebookTopic, +} from "../../notebook/topic.js"; +import { saveNotebookPage } from "../../notebook/store.js"; + +// ── Mock ExtensionAPI ───────────────────────────────────────────────── + +const mockPi = { appendEntry: () => {} } as unknown as ExtensionAPI; + +// ── Action types ────────────────────────────────────────────────────── + +type StateAction = + | { type: "reset" } + | { type: "setTopic"; name: string } + | { type: "clearTopic" } + | { type: "savePage"; name: string } + | { type: "addChildSession"; id: string } + | { type: "abortChildren" }; + +/** Generator for valid normalized topic names (non-empty after normalizeNotebookTopic). */ +const arbTopicName = fc + .stringMatching(/^[a-zA-Z][a-zA-Z0-9 _-]{0,19}$/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + +/** Generator for valid notebook page names (kebab-case). */ +const arbPageName = fc.stringMatching(/^[a-z][a-z0-9-]{0,19}$/); + +/** Generator for session IDs (simulating toolCallId format). */ +const arbSessionId = fc + .stringMatching(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/) + .filter((s) => s.length > 0); + +// ── Apply ───────────────────────────────────────────────────────────── + +async function apply( + state: AgenticodingState, + action: StateAction, +): Promise { + switch (action.type) { + case "reset": + resetState(state); + break; + case "setTopic": + setActiveNotebookTopic(state, action.name, "agent"); + break; + case "clearTopic": + clearActiveNotebookTopic(state); + break; + case "savePage": + await saveNotebookPage(mockPi, state, action.name, "content-" + action.name); + break; + case "addChildSession": + state.childSessions.set(action.id, { abort: () => Promise.resolve() } as any); + state.liveChildSessions.set(action.id, { abort: () => Promise.resolve() } as any); + break; + case "abortChildren": + abortAndClearChildSessions(state); + break; + } +} + +// ── Invariant helpers ───────────────────────────────────────────────── + +function assertTopicSourceCoupling(state: AgenticodingState): void { + const msg = `topic=${state.activeNotebookTopic} source=${state.activeNotebookTopicSource}`; + if (state.activeNotebookTopic === null) { + assert.equal(state.activeNotebookTopicSource, null, `topic null → source null: ${msg}`); + } else { + assert.notEqual(state.activeNotebookTopicSource, null, `topic set → source set: ${msg}`); + } + // Bidirectional: source null → topic null too + if (state.activeNotebookTopicSource === null) { + assert.equal(state.activeNotebookTopic, null, `source null → topic null: ${msg}`); + } +} + +function assertChildSessionContainment(state: AgenticodingState): void { + for (const key of state.childSessions.keys()) { + assert.ok( + state.liveChildSessions.has(key), + `childSessions key "${key}" must be in liveChildSessions`, + ); + } +} + +function assertResetClears(state: AgenticodingState): void { + assert.equal(state.notebookPages.size, 0, "notebookPages must be empty after reset"); + assert.equal(state.childSessions.size, 0, "childSessions must be empty after reset"); + assert.equal(state.liveChildSessions.size, 0, "liveChildSessions must be empty after reset"); + assert.equal(state.epoch, 0, "epoch must be 0 after reset"); + assert.equal(state.activeNotebookTopic, null, "topic must be null after reset"); + assert.equal(state.activeNotebookTopicSource, null, "topic source must be null after reset"); + assert.equal(state.pendingHandoff, null, "pendingHandoff must be null after reset"); + assert.equal(state.pendingRequestedHandoff, null, "pendingRequestedHandoff must be null after reset"); + assert.equal(state.pendingTopicBoundaryHint, null, "pendingTopicBoundaryHint must be null after reset"); +} + +// ── Properties ──────────────────────────────────────────────────────── + +test("Property 1: Topic-source coupling invariant", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + for (const action of actions) { + await apply(state, action); + assertTopicSourceCoupling(state); + } + }, + ), + { numRuns: 100 }, + ); +}); + +test("Property 2: Child session containment invariant", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + for (const action of actions) { + await apply(state, action); + assertChildSessionContainment(state); + } + }, + ), + { numRuns: 100 }, + ); +}); + +test("Property 3: childSessionEpoch only changes on reset", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + let expectedChildEpoch: number | null = null; + let lastActionType: string | null = null; + + for (const action of actions) { + const prevChildEpoch = state.childSessionEpoch; + await apply(state, action); + + if (action.type === "reset") { + // After reset, childSessionEpoch should have incremented + if (expectedChildEpoch === null) { + expectedChildEpoch = prevChildEpoch + 1; + } else { + expectedChildEpoch = state.childSessionEpoch; + } + assert.equal( + state.childSessionEpoch, + expectedChildEpoch, + `childSessionEpoch must be ${expectedChildEpoch} after reset (prev=${prevChildEpoch})`, + ); + expectedChildEpoch = state.childSessionEpoch; + } else { + // Non-reset: childSessionEpoch must be unchanged + assert.equal( + state.childSessionEpoch, + prevChildEpoch, + `childSessionEpoch must not change on ${action.type} action`, + ); + expectedChildEpoch = state.childSessionEpoch; + } + lastActionType = action.type; + } + }, + ), + { numRuns: 100 }, + ); +}); + +test("Property 4: Reset clears all state fields", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + + for (const action of actions) { + await apply(state, action); + + // After every reset, assert full clear + if (action.type === "reset") { + assertResetClears(state); + } + } + + // Also test explicitly: create fresh state, perform some work, then reset + const s2 = createState(); + setActiveNotebookTopic(s2, "test-topic", "agent"); + await saveNotebookPage(mockPi, s2, "my-page", "some content"); + resetState(s2); + assertResetClears(s2); + }, + ), + { numRuns: 100 }, + ); +}); + +test("Property 5: Epoch monotonicity — non-zero after savePage", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.constant({ type: "clearTopic" } as StateAction), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + + assert.equal(state.epoch, 0, "epoch must be 0 on fresh state"); + + for (const action of actions) { + const prevEpoch = state.epoch; + await apply(state, action); + + if (action.type === "savePage") { + // After first savePage, epoch transitions from 0 to Date.now() (> 0) + // After subsequent saves, epoch is unchanged (set once) + assert.ok( + state.epoch > 0, + `epoch must be > 0 after savePage, got ${state.epoch}`, + ); + if (prevEpoch === 0) { + // First write: epoch transitions from 0 to Date.now() + assert.ok( + state.epoch >= Date.now() - 5000, + `epoch ${state.epoch} should be recent Date.now()`, + ); + } else { + // Subsequent writes: epoch unchanged + assert.equal(state.epoch, prevEpoch, "epoch must not change on subsequent savePage"); + } + } else if (action.type === "reset") { + // Reset sets epoch to 0 + assert.equal(state.epoch, 0, "epoch must be 0 after reset"); + } else { + // Non-save, non-reset: epoch unchanged + assert.equal(state.epoch, prevEpoch, "epoch unchanged on non-save/non-reset actions"); + } + } + }, + ), + { numRuns: 100 }, + ); +}); + +test("Property 6: childSessionEpoch monotonicity (never decreases)", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + ), + { maxLength: 30 }, + ), + async (actions) => { + const state = createState(); + let maxSeenEpoch = 0; + + for (const action of actions) { + await apply(state, action); + assert.ok( + state.childSessionEpoch >= maxSeenEpoch, + `childSessionEpoch must never decrease: was ${maxSeenEpoch}, got ${state.childSessionEpoch}`, + ); + maxSeenEpoch = Math.max(maxSeenEpoch, state.childSessionEpoch); + } + }, + ), + { numRuns: 100 }, + ); +}); diff --git a/tests/unit/system-prompt.test.ts b/tests/unit/system-prompt.test.ts new file mode 100644 index 0000000..45e6a89 --- /dev/null +++ b/tests/unit/system-prompt.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CONTEXT_PRIMER } from "../../system-prompt.js"; +import registerAgenticoding from "../../index.js"; +import { createTestPI, makeTUICtx } from "./helpers.js"; + +test("CONTEXT_PRIMER states the notebook, topic, and handoff contracts", () => { + assert.doesNotMatch(CONTEXT_PRIMER, /ledger/i, + "CONTEXT_PRIMER should contain zero stale ledger references after the rename"); + + const notebookParts = CONTEXT_PRIMER.split("### Notebook"); + const topicParts = CONTEXT_PRIMER.split("### Active notebook topic"); + const handoffParts = CONTEXT_PRIMER.split("### Handoff"); + const rulesParts = CONTEXT_PRIMER.split("### Rules"); + assert.equal(notebookParts.length, 2); + assert.equal(topicParts.length, 2); + assert.equal(handoffParts.length, 2); + assert.equal(rulesParts.length, 2); + + const notebookSection = notebookParts[1].split("### Active notebook topic")[0]; + const topicSection = topicParts[1].split("### Handoff")[0]; + const handoffSection = handoffParts[1].split("### Rules")[0]; + const rulesSection = rulesParts[1]; + + assert.match(notebookSection, /notebook_index/); + assert.match(notebookSection, /notebook_read/); + assert.match(notebookSection, /future contexts/i); + assert.match(topicSection, /semantic frame/i); + assert.match(topicSection, /prefer spawn/i); + assert.match(topicSection, /prefer handoff/i); + assert.match(handoffSection, /handoff/i); + assert.match(handoffSection, /notebook/i); + assert.match(rulesSection, /one subject, thread, or subsystem/i); +}); + + +test("before_agent_start injects notebook contracts plus live topic and page data", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + const notebookWrite = pi.tools.get("notebook_write"); + await notebookWrite.execute("1", { name: "alpha", content: "first line\nsecond line" }, undefined, undefined, makeTUICtx()); + + const [handler] = pi.handlers.get("before_agent_start")!; + const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + + assert.match(result.systemPrompt, /Base system prompt\./); + assert.match(result.systemPrompt, /## Context management/); + assert.match(result.systemPrompt, /## Active Notebook Topic/); + assert.match(result.systemPrompt, /Current topic: `oauth`/); + assert.match(result.systemPrompt, /## Active Notebook Pages/); + assert.match(result.systemPrompt, /notebook_read/); + assert.match(result.systemPrompt, /Reference pages by name/i); + assert.match(result.systemPrompt, /alpha: first line/); +}); + + +test("before_agent_start injects no-topic guidance when the topic is unset", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("before_agent_start")!; + const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + + assert.match(result.systemPrompt, /## Active Notebook Topic/); + assert.match(result.systemPrompt, /No active notebook topic is set\./); + assert.match(result.systemPrompt, /notebook_topic_set/); +}); diff --git a/tests/unit/topic.test.ts b/tests/unit/topic.test.ts new file mode 100644 index 0000000..eb256ec --- /dev/null +++ b/tests/unit/topic.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { setActiveNotebookTopic, clearActiveNotebookTopic } from "../../notebook/topic.js"; +import { registerNotebookTopicTool } from "../../notebook/topic-tool.js"; +import { createTestPI } from "./helpers.js"; + +test("topic helpers manage the active notebook topic lifecycle", () => { + const state = createState(); + const first = setActiveNotebookTopic(state, "OAuth", "agent"); + assert.deepEqual(first, { + changed: true, + previous: null, + current: "oauth", + boundaryHint: null, + }); + const second = setActiveNotebookTopic(state, "Billing", "human"); + assert.equal(second.boundaryHint?.from, "oauth"); + assert.equal(second.boundaryHint?.to, "billing"); + clearActiveNotebookTopic(state); + assert.equal(state.activeNotebookTopic, null); + assert.equal(state.activeNotebookTopicSource, null); + assert.equal(state.pendingTopicBoundaryHint, null); +}); + +test("notebook_topic_set establishes a fresh topic, is idempotent, and refuses overrides", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookTopicTool(pi as any, state); + + const tool = pi.tools.get("notebook_topic_set"); + const first = await tool.execute("1", { topic: "OAuth" }); + assert.equal(first.details.topic, "oauth"); + assert.equal(state.activeNotebookTopic, "oauth"); + assert.equal(state.activeNotebookTopicSource, "agent"); + + const second = await tool.execute("2", { topic: "oauth" }); + assert.equal(second.details.changed, false); + assert.equal(second.details.source, "agent"); + assert.match(second.content[0].text, /already set to "oauth"/i); + + await assert.rejects(() => tool.execute("3", { topic: "billing" }), /already exists/); +}); + + +test("notebook_topic_set preserves human authority, stays idempotent for equal topics, and rejects empty normalized topics", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookTopicTool(pi as any, state); + const tool = pi.tools.get("notebook_topic_set"); + + setActiveNotebookTopic(state, "oauth", "human"); + const same = await tool.execute("1", { topic: "OAuth" }); + assert.equal(same.details.changed, false); + assert.equal(same.details.source, "human"); + assert.match(same.content[0].text, /already set to "oauth"/i); + await assert.rejects( + () => tool.execute("2", { topic: "billing" }), + /human-set notebook topic is authoritative/i, + ); + + const freshPi = createTestPI(); + const freshState = createState(); + registerNotebookTopicTool(freshPi as any, freshState); + const freshTool = freshPi.tools.get("notebook_topic_set"); + await assert.rejects( + () => freshTool.execute("3", { topic: "@@@" }), + /notebook topic cannot be empty/i, + ); +}); diff --git a/tests/unit/tui-indicators.test.ts b/tests/unit/tui-indicators.test.ts new file mode 100644 index 0000000..83c2a18 --- /dev/null +++ b/tests/unit/tui-indicators.test.ts @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { updateIndicators, STATUS_KEY_TOPIC } from "../../tui.js"; +import { makeTUICtx } from "./helpers.js"; + +test("updateIndicators sets context usage status with correct color tone", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 42, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[accent:42%]"), "42% should use accent tone"); + assert.equal(record.widgets.get("agenticoding-warning"), undefined, "42% is below 70 — no warning widget"); +}); + +test("updateIndicators uses error tone at 70%+ context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 85, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[error:85%]"), "85% should use error tone"); + const w = record.widgets.get("agenticoding-warning"); + assert.ok(w?.[0]?.includes("85%"), "warning widget shown at 85%"); +}); + +test("updateIndicators uses warning tone at 50-69% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 55, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[warning:55%]"), "55% should use warning tone"); +}); + +test("updateIndicators uses accent tone at 30-49% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("[accent:30%]"), "30% should use accent tone"); +}); + +test("updateIndicators handles null context usage", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-ctx"); + assert.ok(s?.includes("--%"), "null usage shows --%"); +}); + +test("updateIndicators no-ops when ctx.hasUI is false", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ hasUI: false, record }); + + updateIndicators(ctx, state); + assert.equal(record.statuses.size, 0, "no-op should not call any setStatus"); + assert.equal(record.widgets.size, 0, "no-op should not call any setWidget"); +}); + +test("updateIndicators shows notebook page count in status", () => { + const state = createState(); + state.notebookPages.set("entry-1", "first entry"); + state.notebookPages.set("entry-2", "second entry"); + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get("agenticoding-notebook"); + assert.ok(s?.includes("2"), "notebook page count should be 2"); +}); + +test("updateIndicators shows active notebook topic when set", () => { + const state = createState(); + state.activeNotebookTopic = "oauth"; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); +}); + +test("updateIndicators hides widget below 70% context", () => { + const state = createState(); + const record = { statuses: new Map(), widgets: new Map() }; + // Pre-set a widget to verify it gets cleared + record.widgets.set("agenticoding-warning", ["existing"]); + const ctx = makeTUICtx({ percent: 30, record }); + + updateIndicators(ctx, state); + assert.equal(record.widgets.get("agenticoding-warning"), undefined, "warning widget should be cleared below 70%"); +}); diff --git a/tests/unit/watchdog.test.ts b/tests/unit/watchdog.test.ts new file mode 100644 index 0000000..2de756a --- /dev/null +++ b/tests/unit/watchdog.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { registerWatchdog } from "../../watchdog.js"; +import { buildNudge } from "../../watchdog.js"; +import registerAgenticoding from "../../index.js"; +import { createTestPI } from "./helpers.js"; + +test("watchdog records context usage without user notifications", async () => { + const pi = createTestPI(); + const state = createState(); + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + + const notifications: string[] = []; + await handler( + {}, + { + hasUI: true, + ui: { notify: (message: string) => notifications.push(message) }, + getContextUsage: () => ({ percent: 70 }), + }, + ); + + assert.equal(state.lastContextPercent, 70); + assert.deepEqual(notifications, []); +}); + +test("context injects watchdog reminder before each LLM call", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { + getContextUsage: () => ({ percent: 70 }), + }, + ); + + assert.equal(result.messages.length, 2); + assert.deepEqual(result.messages[0], { role: "user", content: "hi", timestamp: 1 }); + assert.equal(result.messages[1].role, "custom"); + assert.equal(result.messages[1].customType, "agenticoding-watchdog"); + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /Context at 70%/); + assert.match(result.messages[1].content, /Active notebook topic: oauth/); + assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i); + assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); +}); + + +test("context injects a boundary nudge below 30% after an explicit topic change", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 20 }) }, + ); + + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/); +}); + + +test("context injects a no-topic nudge when context is high", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 70 }) }, + ); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].role, "custom"); + assert.equal(result.messages[1].customType, "agenticoding-watchdog"); + assert.equal(result.messages[1].display, false); + assert.match(result.messages[1].content, /No active notebook topic is set/); + assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); +}); + + +test("context consumes a boundary hint after the first injected nudge", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("notebook")!.handler("oauth", { hasUI: false, getContextUsage: () => null }); + await pi.commands.get("notebook")!.handler("billing", { hasUI: false, getContextUsage: () => null }); + + const first = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => ({ percent: 20 }) }, + ); + assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/); + + const second = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ percent: 20 }) }, + ); + assert.equal(second, undefined); +}); + + +test("buildNudge no longer emits the old percent-only handoff text", () => { + const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46); + assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i); + assert.match(old, /Active notebook topic: oauth/); + assert.match(old, /prefer spawn/i); +}); + + +test("buildNudge handles null percent and boundary hints before topic guidance", () => { + const boundary = buildNudge( + { + activeNotebookTopic: "oauth", + pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, + }, + null, + ); + assert.match(boundary, /Notebook topic changed from oauth to billing/); + assert.doesNotMatch(boundary, /Active notebook topic: oauth/); + + const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null }, null); + assert.match(noTopic, /Topic-aware context reminder/); + assert.match(noTopic, /No active notebook topic is set/); +}); + +test("watchdog stays advisory when a requested handoff is not completed", async () => { + const pi = createTestPI(); + const state = createState(); + state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + + const notifications: string[] = []; + await handler( + {}, + { + hasUI: true, + ui: { + notify: (message: string) => notifications.push(message), + setStatus: () => {}, + }, + getContextUsage: () => ({ percent: 20 }), + }, + ); + + assert.equal(state.pendingRequestedHandoff, null); + assert.deepEqual(notifications, []); + assert.deepEqual(pi.sentUserMessages, []); +}); From 47f3e5e84b714b30adf7648f9d26d005652ea89a Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 7 Jun 2026 09:30:51 +0300 Subject: [PATCH 04/40] Add test runner script, module loader bootstrap, and E2E suite --- register-loader.mjs | 10 +++ scripts/run-node-test.mjs | 30 ++++++++ test-loader.mjs | 22 +++++- tests/e2e/basic.test.ts | 156 ++++++++++++++++++++++++++++++++++++++ tests/e2e/pty-harness.ts | 105 +++++++++++++++++++++++++ tests/e2e/test-host.ts | 147 +++++++++++++++++++++++++++++++++++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 register-loader.mjs create mode 100644 scripts/run-node-test.mjs create mode 100644 tests/e2e/basic.test.ts create mode 100644 tests/e2e/pty-harness.ts create mode 100644 tests/e2e/test-host.ts diff --git a/register-loader.mjs b/register-loader.mjs new file mode 100644 index 0000000..cc75443 --- /dev/null +++ b/register-loader.mjs @@ -0,0 +1,10 @@ +// Bootstrap module for `--import` that registers the custom module loader. +// Replaces the deprecated `--experimental-loader` flag. +// Phase 1: uses module.register() — safe on Node <25. +// Phase 2: migrate to module.registerHooks() when targeting Node >=25. +import { register } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(resolve(HERE, "test-loader.mjs")), pathToFileURL(HERE + "/")); diff --git a/scripts/run-node-test.mjs b/scripts/run-node-test.mjs new file mode 100644 index 0000000..4a2ae9b --- /dev/null +++ b/scripts/run-node-test.mjs @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const args = process.argv.slice(2); +const updateSnapshots = args.includes("--update-snapshots"); +const patterns = args.filter((arg) => arg !== "--update-snapshots"); + +if (patterns.length === 0) { + throw new Error("Pass at least one test file or glob pattern."); +} + +const loader = pathToFileURL(resolve(root, "register-loader.mjs")).href; +const result = spawnSync( + process.execPath, + ["--import", loader, "--test", ...patterns], + { + cwd: root, + stdio: "inherit", + env: updateSnapshots + ? { ...process.env, UPDATE_SNAPSHOTS: "1" } + : process.env, + }, +); + +if (result.error) throw result.error; +if (result.signal) process.kill(process.pid, result.signal); +process.exit(result.status ?? 1); diff --git a/test-loader.mjs b/test-loader.mjs index 986e50a..dad1674 100644 --- a/test-loader.mjs +++ b/test-loader.mjs @@ -1,8 +1,28 @@ import { access } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; +import { existsSync } from "node:fs"; -const PACKAGE_ROOT = "/Users/ofri/.nvm/versions/node/v24.14.1/lib/node_modules/@earendil-works/pi-coding-agent"; +/** + * Walk up from a start directory to find node_modules/. + * Works regardless of how the package was installed (local vs global). + */ +function findPackageRoot(name, startDir) { + let dir = startDir; + while (true) { + const candidate = path.join(dir, "node_modules", name); + if (existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +const PACKAGE_ROOT = findPackageRoot( + "@earendil-works/pi-coding-agent", + path.dirname(fileURLToPath(import.meta.url)), +); +if (!PACKAGE_ROOT) throw new Error("Cannot find @earendil-works/pi-coding-agent package root"); const PACKAGE_ALIASES = { "@earendil-works/pi-coding-agent": `${PACKAGE_ROOT}/dist/index.js`, "@earendil-works/pi-ai": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-ai/dist/index.js`, diff --git a/tests/e2e/basic.test.ts b/tests/e2e/basic.test.ts new file mode 100644 index 0000000..b3b521c --- /dev/null +++ b/tests/e2e/basic.test.ts @@ -0,0 +1,156 @@ +/** + * Process-isolated E2E tests for the agenticoding extension. + * + * These tests spawn a fresh Node.js process per test case. Process isolation + * means no shared singletons and no console races between test cases. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { PytestHarness } from "./pty-harness.js"; + +/** + * Create a fresh host, wait for READY, and return the harness. + */ +async function start(): Promise { + const h = new PytestHarness(); + await h.waitForText("READY"); + return h; +} + +async function withHarness(run: (h: PytestHarness) => Promise): Promise { + const h = await start(); + try { + await run(h); + } finally { + try { + h.write("exit"); + } catch { + // already dead + } + h.close(); + } +} + +describe("agenticoding E2E", () => { + it("host starts and extension registers", async () => withHarness(async (h) => { + h.write("tools"); + await h.waitForText("OK:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("notebook_write"), "notebook_write tool registered"); + assert.ok(snap.includes("notebook_read"), "notebook_read tool registered"); + assert.ok(snap.includes("notebook_index"), "notebook_index tool registered"); + assert.ok(snap.includes("notebook_topic_set"), "notebook_topic_set tool registered"); + assert.ok(snap.includes("handoff"), "handoff tool registered"); + assert.ok(snap.includes("spawn"), "spawn tool registered"); + })); + + it("notebook write/read round-trip", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"my-page","content":"Hello World"}'); + await h.waitForText("OK:Saved notebook page"); + + h.write('tool notebook_read {"name":"my-page"}'); + await h.waitForText("OK:--- my-page ---"); + + const snap = h.snapshot(); + assert.ok(snap.includes("Hello World"), "content persisted"); + })); + + it("notebook index reflects written pages", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"page-a","content":"Page A"}'); + await h.waitForText("OK:"); + + h.write("tool notebook_index {}"); + await h.waitForText("page-a"); + + // Second write should appear in index + h.write('tool notebook_write {"name":"page-b","content":"Page B"}'); + await h.waitForText("OK:"); + + h.write("tool notebook_index {}"); + await h.waitForText("page-b"); + + const snap = h.snapshot(); + assert.ok(snap.includes("page-a"), "page-a in index"); + assert.ok(snap.includes("page-b"), "page-b in index"); + })); + + it("notebook_write overwrites existing page", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"page","content":"v1"}'); + await h.waitForText("OK:"); + + // Clear accumulated output so we only check the second write/read + h.clear(); + h.write('tool notebook_write {"name":"page","content":"v2"}'); + await h.waitForText("OK:"); + + h.clear(); + h.write('tool notebook_read {"name":"page"}'); + await h.waitForText("OK:--- page ---"); + + const snap = h.snapshot(); + assert.ok(snap.includes("v2"), "overwritten content present"); + assert.ok(!snap.includes("v1"), "old content absent from fresh output"); + })); + + it("notebook topic lifecycle: set via command, agent-set blocked", async () => withHarness(async (h) => { + // Set topic via /notebook command (human-set) + h.write("cmd notebook my-e2e-topic"); + await h.waitForText("OK"); + + // Agent-set should be blocked (human is authoritative) + h.write('tool notebook_topic_set {"topic":"agent-topic"}'); + await h.waitForText("ERR:"); + const snap = h.snapshot(); + assert.ok( + snap.includes("authoritative") || snap.includes("already exists"), + "human-set topic blocks agent override", + ); + })); + + it("agent-set topic works when unset", async () => withHarness(async (h) => { + // No topic set yet -- agent can set + h.write('tool notebook_topic_set {"topic":"fresh-agent-topic"}'); + await h.waitForText("OK:Active notebook topic:"); + const snap = h.snapshot(); + assert.ok(snap.includes("fresh-agent-topic")); + })); + + it("handoff tool queues handoff state", async () => withHarness(async (h) => { + h.write('tool handoff {"task":"test handoff task","direction":"next-phase"}'); + await h.waitForText("OK:Handoff started"); + })); + + it("commands are registered", async () => withHarness(async (h) => { + h.write("cmds"); + await h.waitForText("OK:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("notebook"), "/notebook command registered"); + assert.ok(snap.includes("handoff"), "/handoff command registered"); + })); + + it("spawn tool errors gracefully without model infrastructure", async () => withHarness(async (h) => { + // Without a real model/session manager, spawn should throw immediately. + h.write('tool spawn {"prompt":"any task"}'); + await h.waitForText("ERR:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("No model") || snap.includes("ERR"), "spawn errors gracefully"); + })); + + it("handles errors gracefully", async () => withHarness(async (h) => { + // Unknown tool + h.write("tool nonexistent {}"); + await h.waitForText("ERR:unknown tool"); + + // Invalid JSON + h.write("tool notebook_write {bad json}"); + await h.waitForText("ERR:invalid json"); + + // Unknown command + h.write("cmd nonexistent"); + await h.waitForText("ERR:unknown command"); + })); +}); diff --git a/tests/e2e/pty-harness.ts b/tests/e2e/pty-harness.ts new file mode 100644 index 0000000..6ddd519 --- /dev/null +++ b/tests/e2e/pty-harness.ts @@ -0,0 +1,105 @@ +/** + * pty-harness.ts — Process-isolated child-process harness for E2E tests. + * + * Spawns a fresh Node.js process and communicates over stdin/stdout. Process + * isolation keeps runtime singletons and console output private per test case + * without depending on PTY availability in CI. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { isAbsolute, dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "..", ".."); +const LOADER = pathToFileURL(resolve(ROOT, "register-loader.mjs")).href; + +export const DEFAULT_SCRIPT = resolve(HERE, "test-host.ts"); + +export class PytestHarness { + private child: ChildProcessWithoutNullStreams; + private output = ""; + private readOffset = 0; + private timeoutMs: number; + private waiters = new Set<() => void>(); + + constructor( + scriptPath = DEFAULT_SCRIPT, + options?: { timeoutMs?: number }, + ) { + this.timeoutMs = options?.timeoutMs ?? 5000; + + const entry = isAbsolute(scriptPath) ? scriptPath : resolve(ROOT, scriptPath); + + this.child = spawn(process.execPath, ["--import", LOADER, entry], { + cwd: ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + FORCE_COLOR: "0", + NODE_OPTIONS: "", + }, + }); + + const append = (chunk: string | Buffer) => { + this.output += chunk.toString(); + for (const wake of this.waiters) wake(); + this.waiters.clear(); + }; + + this.child.stdout.on("data", append); + this.child.stderr.on("data", append); + } + + private async waitForOutput(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => { + const wake = () => { + clearTimeout(timer); + this.waiters.delete(wake); + resolve(); + }; + const timer = setTimeout(wake, ms); + this.waiters.add(wake); + }); + } + + /** Wait for a fresh substring to appear after the prior match. */ + async waitForText(text: string): Promise { + const deadline = Date.now() + this.timeoutMs; + while (Date.now() < deadline) { + const index = this.output.indexOf(text, this.readOffset); + if (index !== -1) { + this.readOffset = index + text.length; + return; + } + await this.waitForOutput(deadline - Date.now()); + } + throw new Error( + `waitForText timeout after ${this.timeoutMs}ms looking for fresh \"${text}\".\n` + + `Output so far:\n${this.output}`, + ); + } + + /** Write a line of input to the child process. */ + write(input: string): void { + this.child.stdin.write(input + "\n"); + } + + /** Return all accumulated output since creation or last clear(). */ + snapshot(): string { + return this.output; + } + + /** Clear accumulated output and match cursor, keeping the child running. */ + clear(): void { + this.output = ""; + this.readOffset = 0; + } + + /** Kill the child process. */ + close(): void { + this.child.stdin.end(); + if (!this.child.killed) this.child.kill(); + } +} diff --git a/tests/e2e/test-host.ts b/tests/e2e/test-host.ts new file mode 100644 index 0000000..81cc420 --- /dev/null +++ b/tests/e2e/test-host.ts @@ -0,0 +1,147 @@ +/** + * test-host.ts — Minimal pi host for process-isolated E2E tests. + * + * Spawned as a child process. Loads the extension, then runs a + * line-oriented REPL on stdin/stdout. + * + * Protocol: + * → cmd [arg] — call a registered command + * → tool — call a registered tool with JSON params + * → tools — list registered tool names + * → cmds — list registered command names + * → exit — graceful shutdown + * + * ← READY\n — sent after extension registration + * ← OK[:payload]\n — success + * ← ERR:message\n — failure + * + * No TUI. All UI-dependent paths are skipped (hasUI=false). + */ + +import { createInterface } from "node:readline"; +import registerAgenticoding from "../../index.js"; +import { createTestPI } from "../unit/helpers.js"; + +// ── Mock ExtensionAPI ───────────────────────────────────────────── +// Uses createTestPI() from the shared test utilities — a minimal object +// that satisfies what index.ts needs at registration time. +// No TUI dependencies — tools and commands access the state through +// the pi object directly. + +const pi = createTestPI(); +const commands = pi.commands; +const tools = pi.tools; + +// Register the extension — this populates pi.commands and pi.tools +registerAgenticoding(pi); + +// ── Mock ExtensionContext for tool/command execution ────────────── + +const mockCtx = { + hasUI: false, + mode: "non-interactive", + cwd: process.cwd(), + ui: { + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + theme: { fg: () => "" }, + select: () => Promise.resolve(undefined), + confirm: () => Promise.resolve(false), + input: () => Promise.resolve(""), + onTerminalInput: () => () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: () => Promise.resolve(undefined), + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: () => Promise.resolve(""), + addAutocompleteProvider: () => {}, + themes: [], + getTheme: () => undefined, + setTheme: () => ({ ok: true }), + }, + getContextUsage: () => null, + sessionManager: null, + modelRegistry: null, + isIdle: () => true, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => process.exit(0), + compact: () => {}, + getSystemPrompt: () => "", +}; + +// ── REPL loop ──────────────────────────────────────────────────── + +process.stdout.write("READY\n"); + +const rl = createInterface({ input: process.stdin }); +for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed === "exit") { + process.exit(0); + } else if (trimmed === "tools") { + const names = Array.from(tools.keys()).sort().join(","); + process.stdout.write("OK:" + names + "\n"); + } else if (trimmed === "cmds") { + const names = Array.from(commands.keys()).sort().join(","); + process.stdout.write("OK:" + names + "\n"); + } else if (trimmed.startsWith("tool ")) { + const rest = trimmed.slice(5).trim(); + const spaceIdx = rest.indexOf(" "); + if (spaceIdx === -1) { + process.stdout.write("ERR:usage tool \n"); + continue; + } + const toolName = rest.slice(0, spaceIdx); + const jsonArgs = rest.slice(spaceIdx + 1); + const toolDef = tools.get(toolName); + if (!toolDef) { + process.stdout.write("ERR:unknown tool " + toolName + "\n"); + continue; + } + let params; + try { params = JSON.parse(jsonArgs); } + catch (e) { + process.stdout.write("ERR:invalid json: " + e.message + "\n"); + continue; + } + try { + const result = await toolDef.execute("e2e-" + toolName, params, undefined, undefined, mockCtx); + const text = result.content?.map(c => c.text).filter(Boolean).join("\n") || ""; + process.stdout.write("OK:" + text + "\n"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stdout.write("ERR:" + msg + "\n"); + } + } else if (trimmed.startsWith("cmd ")) { + const rest = trimmed.slice(4).trim(); + const spaceIdx = rest.indexOf(" "); + const cmdName = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); + const cmdArg = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1); + const cmdDef = commands.get(cmdName); + if (!cmdDef) { + process.stdout.write("ERR:unknown command " + cmdName + "\n"); + continue; + } + try { + await cmdDef.handler(cmdArg, mockCtx); + process.stdout.write("OK\n"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stdout.write("ERR:" + msg + "\n"); + } + } else { + process.stdout.write("ERR:unknown input\n"); + } +} From 1841b0b6e063e561c8340c6e77cd157be618e091 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 7 Jun 2026 09:30:53 +0300 Subject: [PATCH 05/40] Update package metadata, scripts, and contributing docs for test suite --- CONTRIBUTING.md | 14 +++++++++++--- package.json | 10 +++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 694b0de..d61f242 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,14 +7,15 @@ Welcome! This project welcomes focused, well-validated contributions. Use coding - **Use code research first** — understand the surrounding module responsibilities before editing. - **Make minimal changes** — prefer targeted edits that reuse existing mechanisms. - **Match existing patterns** — keep naming, lifecycle hooks, tool contracts, and TUI behavior consistent with the current code. -- **Preserve context-management semantics** — changes to `spawn`, `ledger`, or `handoff` should keep the agent workflow predictable across session resets and compaction. +- **Preserve context-management semantics** — changes to `spawn`, `notebook`, or `handoff` should keep the agent workflow predictable across session resets and compaction. +- **Use static imports only for `spawn/renderer.ts`** — it registers the frame scheduler into the singleton container at module evaluation time. Switching to `await import()` will silently break test isolation because the test harness cannot overwrite the singleton before registration. - **AI-agent generated contributions are welcome** — include enough human intent and validation context in the PR for reviewers to trust the result. ## Suggested Workflow 1. **Research the area** - - Identify the relevant primitive: spawn, ledger, handoff, watchdog, or extension wiring. - - Read nearby tests in `agenticoding.test.ts` before changing behavior. + - Identify the relevant primitive: spawn, notebook, handoff, watchdog, or extension wiring. + - Read the relevant suite in `tests/unit/` before changing behavior. 2. **Plan the smallest safe change** - Reuse existing state and lifecycle hooks when possible. @@ -38,6 +39,13 @@ Before submitting, check that your change: - Handles reset, cancellation, and stale-session cases where relevant. - Keeps docs aligned with the package version and installed behavior. +## Tests + +- `npm test` — runs the unit suite under `tests/unit/` via the in-repo Node test runner. +- `npm run test:snapshots:check` — runs only the render-snapshot tests; fails on any drift in `tests/__snapshots__/`. +- `npm run test:snapshots:update` — rewrites the golden files in `tests/__snapshots__/` after an intentional render change. Review the diff carefully: snapshot updates are the only signal that catches unintended UI regressions. +- `npm run test:e2e` — runs the process-isolated end-to-end suite under `tests/e2e/`. + ## Community Use GitHub Issues for bug reports and feature requests. Keep discussions concrete: describe the agent workflow you expected, what happened instead, and any reproduction steps. diff --git a/package.json b/package.json index 893bea6..0b7d203 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pi-agenticoding", "version": "0.3.0", "type": "module", - "description": "Context management primitives for the pi coding agent — spawn, ledger, handoff", + "description": "Context management primitives for the pi coding agent — spawn, notebook, handoff", "license": "MIT", "keywords": [ "pi-package" @@ -17,7 +17,15 @@ "@earendil-works/pi-tui": "*", "typebox": "*" }, + "scripts": { + "test": "node ./scripts/run-node-test.mjs tests/unit/**/*.test.ts", + "test:e2e": "node ./scripts/run-node-test.mjs tests/e2e/**/*.test.ts", + "test:all": "npm run test && npm run test:e2e", + "test:snapshots:check": "node ./scripts/run-node-test.mjs tests/unit/render-snapshots.test.ts", + "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts" + }, "devDependencies": { + "fast-check": "^4.8.0", "typescript": "^6.0.3" }, "pi": { From 2689dac2923ef22c19a15c5adac9fc95b57f8177 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 15:50:08 +0300 Subject: [PATCH 06/40] Enable cross-platform E2E tests (Windows, Linux, macOS) - Remove the Windows exclusion guard from E2E test step in CI workflow - The process-isolated child-process harness uses only cross-platform Node.js APIs (spawn, readline, stdio pipes) and is verified to work on Windows - Add .gitattributes to enforce LF line endings on snapshot golden files - Add normalizeEOL() helper in snapshot tests for Windows CRLF handling - Add E2E_TIMEOUT_MS env var support for configurable test timeouts - Add engines.node >=22 to package.json - Add tsconfig.json for type checking - Update CI documentation in CONTRIBUTING.md Closes #12 --- .gitattributes | 1 + .github/workflows/test.yml | 93 +++++++++++++++++++++++++++++ CONTRIBUTING.md | 10 ++++ package.json | 3 + tests/e2e/pty-harness.ts | 4 +- tests/unit/render-snapshots.test.ts | 9 ++- tsconfig.json | 12 ++++ 7 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/test.yml create mode 100644 tsconfig.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a889d52 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/__snapshots__/**/*.txt text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..74b6076 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,93 @@ +# Cross-platform CI for pi-agenticoding +# +# Runs the full unit suite on Linux, macOS, and Windows +# on the minimum Node.js version required by pi coding agent. Snapshot +# tests verify TUI render output against golden files. + +name: test + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths-ignore: ['*.md', '**/docs/**'] + pull_request: + branches: [main] + paths-ignore: ['*.md', '**/docs/**'] + +jobs: + # ── Quick-check gate — catch trivial failures before the full matrix ── + quick-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "npm" + - run: npm ci + + # Fast pre-flight checks — type errors and security issues before matrix + - name: Type check + run: npx tsc --noEmit + + - name: Security audit + run: npm audit --audit-level=moderate + + # ── Cross-platform test matrix ────────────────────────────────────── + # Node 22 (minimum) is tested only on Linux — the primary platform and the only one + # guaranteed to have the oldest toolchain. macOS and Windows test Node 24 (latest) + # to catch regressions in the newest runtime. This asymmetry is intentional: it + # balances CI cost with meaningful coverage while ensuring the minimum version works + # correctly on the platform most likely to encounter toolchain edge cases. + test: + needs: quick-check + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false # report every combination, don't cancel + matrix: + include: + - os: ubuntu-latest + node-version: "22" # minimum version on primary platform + - os: ubuntu-latest + node-version: "24" # latest on primary platform + - os: macos-latest + node-version: "24" # latest on macOS + - os: windows-latest + node-version: "24" # latest on Windows + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - run: npm ci + + # Unit suite (unit tests + snapshot tests + property-based tests) + - name: Unit tests + run: npm test + + # E2E tests — process-isolated child-process harness (stdin/stdout, no PTY). + # Verified cross-platform: runs on Linux, macOS, and Windows. + # See https://github.com/agenticoding/pi-agenticoding/issues/12 + - name: E2E tests + run: npm run test:e2e + + # Upload test results for debugging — artifacts available for 30 days. + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }}-node-${{ matrix.node-version }} + path: | + test-results/ + tests/__snapshots__/ + retention-days: 30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d61f242..acba99c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,16 @@ Before submitting, check that your change: - `npm run test:snapshots:update` — rewrites the golden files in `tests/__snapshots__/` after an intentional render change. Review the diff carefully: snapshot updates are the only signal that catches unintended UI regressions. - `npm run test:e2e` — runs the process-isolated end-to-end suite under `tests/e2e/`. +## CI + +Pull requests are automatically tested via GitHub Actions. The pipeline runs: + +1. **Quick-check** (Ubuntu, Node 22): `npm ci`, type check (`npx tsc --noEmit`), and security audit (`npm audit`). Catches trivial failures before the full matrix. +2. **Cross-platform matrix** (depends on quick-check): Unit tests on Ubuntu (Node 22 + 24), macOS (Node 24), and Windows (Node 24). E2E tests on all platforms. + +Snapshot golden files in `tests/__snapshots__/` are stored with LF line endings (enforced by `.gitattributes`). The `normalizeEOL` helper in the snapshot test file normalizes `\r\n` to `\n` on read, so Windows developers get correct comparisons even if their working tree has CRLF. If you update snapshots, the CI matrix validates them on all platforms. +The E2E suite runs on all platforms including Windows (verified in issue #12). + ## Community Use GitHub Issues for bug reports and feature requests. Keep discussions concrete: describe the agent workflow you expected, what happened instead, and any reproduction steps. diff --git a/package.json b/package.json index 0b7d203..b7992c7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "type": "git", "url": "git+https://github.com/agenticoding/pi-agenticoding.git" }, + "engines": { + "node": ">=22" + }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", diff --git a/tests/e2e/pty-harness.ts b/tests/e2e/pty-harness.ts index 6ddd519..dd621c8 100644 --- a/tests/e2e/pty-harness.ts +++ b/tests/e2e/pty-harness.ts @@ -15,6 +15,8 @@ const ROOT = resolve(HERE, "..", ".."); const LOADER = pathToFileURL(resolve(ROOT, "register-loader.mjs")).href; export const DEFAULT_SCRIPT = resolve(HERE, "test-host.ts"); +const DEFAULT_TIMEOUT_MS = 5000; +const TIMEOUT_MS = parseInt(process.env.E2E_TIMEOUT_MS ?? "", 10) || DEFAULT_TIMEOUT_MS; export class PytestHarness { private child: ChildProcessWithoutNullStreams; @@ -27,7 +29,7 @@ export class PytestHarness { scriptPath = DEFAULT_SCRIPT, options?: { timeoutMs?: number }, ) { - this.timeoutMs = options?.timeoutMs ?? 5000; + this.timeoutMs = options?.timeoutMs ?? TIMEOUT_MS; const entry = isAbsolute(scriptPath) ? scriptPath : resolve(ROOT, scriptPath); diff --git a/tests/unit/render-snapshots.test.ts b/tests/unit/render-snapshots.test.ts index d45b9fa..76c586d 100644 --- a/tests/unit/render-snapshots.test.ts +++ b/tests/unit/render-snapshots.test.ts @@ -56,17 +56,22 @@ function ensureSnapshotDir(): void { } } +/** Normalize line endings so golden files (stored with \n) match on Windows (\r\n). */ +function normalizeEOL(s: string): string { + return s.replace(/\r?\n/g, "\n"); +} + function matchSnapshot(name: string, actual: string): void { ensureSnapshotDir(); const file = join(SNAPSHOT_DIR, `${name}.txt`); if (process.env.UPDATE_SNAPSHOTS) { - writeFileSync(file, actual); + writeFileSync(file, normalizeEOL(actual)); return; } if (!existsSync(file)) { assert.fail(`Snapshot ${name} is missing. Re-run with UPDATE_SNAPSHOTS=1 to create it.`); } - const expected = readFileSync(file, "utf-8"); + const expected = normalizeEOL(readFileSync(file, "utf-8")); assert.equal(actual, expected, `Snapshot ${name} does not match`); } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3ef406e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["*.ts", "**/*.ts"] +} \ No newline at end of file From 0513ce2063bff27801ae6bc6910248ba968c369d Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 15:59:31 +0300 Subject: [PATCH 07/40] Commit package-lock.json for reproducible CI installs --- .gitignore | 3 +- package-lock.json | 3450 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 3451 insertions(+), 2 deletions(-) create mode 100644 package-lock.json diff --git a/.gitignore b/.gitignore index 9de7b0d..ebc8ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -142,8 +142,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -# Lockfiles (library package — consumers manage their own) -package-lock.json +# package-lock.json committed for reproducible CI installs (excluded from publish) # Agenticoding local config (credentials, API keys) .chunkhound.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c100d7c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3450 @@ +{ + "name": "pi-agenticoding", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-agenticoding", + "version": "0.3.0", + "license": "MIT", + "devDependencies": { + "fast-check": "^4.8.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", + "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", + "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", + "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.45.tgz", + "integrity": "sha512-sJe5ZWibO4s7RWjFQ8Zol76KxoJcIYyEZH1/wxQSBMSIAAxzaJ8cS/ITAaIHWUQvDKQdt18+cJAHKWB7n1Jmrg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", + "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.46.tgz", + "integrity": "sha512-cS4w0jzDRb1jOlkiJS3y80OxddHzkky/MN9k3NYs5jganNKVLjF0lpvjlwS118oGMr3cdAfOlVdo8gLurTSE7w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", + "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", + "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/token-providers": "3.1056.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1056.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", + "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", + "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.18.tgz", + "integrity": "sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.14.tgz", + "integrity": "sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.23.tgz", + "integrity": "sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", + "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", + "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.77.0.tgz", + "integrity": "sha512-huS+k+dhQRR9PlTK7crLfeSRUw3a96V6JYfP0ZH3Zkko/m10gsYk8dKQmwScSy5Dll516pXorz19BURfD6S2qQ==", + "hasShrinkwrap": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-agent-core": "^0.77.0", + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-tui": "^0.77.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-ai": "^0.77.0", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "./dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "peer": true, + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "integrity": "sha512-QV/eYtcT3hM9pJjLCkjUFOUmgAm4GPzJ0K4kofVq9+BGU7wNJVzflTO4VY2tYpaI4VSo6A5Hsuw00wY/CDmY/Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@smithy/core": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", + "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz", + "integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", + "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", + "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT", + "peer": true + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT", + "peer": true + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT", + "peer": true + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, + "node_modules/typebox": { + "version": "1.1.39", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.39.tgz", + "integrity": "sha512-vj0afVtOfLQvv0GR0VxVagYxsXN64btL7Z9XoaG0ZggH3mruMMkOO6hXdgMsjCY3shZgEvooAWVeznQVs5c43w==", + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT", + "peer": true + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} From d39d9fafd2e93a6bb52fc11b6e40ff6a660275e4 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 15:59:33 +0300 Subject: [PATCH 08/40] Move type+audit checks into matrix rows, remove gate job --- .github/workflows/test.yml | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74b6076..f8c5b68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,24 +22,6 @@ on: paths-ignore: ['*.md', '**/docs/**'] jobs: - # ── Quick-check gate — catch trivial failures before the full matrix ── - quick-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - cache: "npm" - - run: npm ci - - # Fast pre-flight checks — type errors and security issues before matrix - - name: Type check - run: npx tsc --noEmit - - - name: Security audit - run: npm audit --audit-level=moderate - # ── Cross-platform test matrix ────────────────────────────────────── # Node 22 (minimum) is tested only on Linux — the primary platform and the only one # guaranteed to have the oldest toolchain. macOS and Windows test Node 24 (latest) @@ -47,7 +29,6 @@ jobs: # balances CI cost with meaningful coverage while ensuring the minimum version works # correctly on the platform most likely to encounter toolchain edge cases. test: - needs: quick-check runs-on: ${{ matrix.os }} strategy: fail-fast: false # report every combination, don't cancel @@ -71,6 +52,13 @@ jobs: - run: npm ci + # Uniform pre-flight checks — type errors and security issues on every platform + - name: Type check + run: npx tsc --noEmit + + - name: Security audit + run: npm audit --audit-level=moderate + # Unit suite (unit tests + snapshot tests + property-based tests) - name: Unit tests run: npm test From e87c09fd0292485ef4a5738b3bcdfc50ae2ec021 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:27 +0300 Subject: [PATCH 09/40] chore: upgrade SDK peerDependencies to v0.78.1 --- package-lock.json | 641 +++++++++++++++++++++++----------------------- package.json | 4 + 2 files changed, 328 insertions(+), 317 deletions(-) diff --git a/package-lock.json b/package-lock.json index c100d7c..944ec5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,16 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-coding-agent": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", "fast-check": "^4.8.0", + "typebox": "^1.2.2", "typescript": "^6.0.3" }, + "engines": { + "node": ">=22" + }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", @@ -23,8 +30,8 @@ "version": "0.91.1", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-schema-to-ts": "^3.1.1" }, @@ -44,8 +51,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -59,8 +66,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", @@ -75,8 +82,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -90,8 +97,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" } @@ -100,8 +107,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", @@ -112,8 +119,8 @@ "version": "3.1048.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -138,8 +145,8 @@ "version": "3.974.15", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", @@ -158,8 +165,8 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", @@ -175,8 +182,8 @@ "version": "3.972.43", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", @@ -194,8 +201,8 @@ "version": "4.7.5", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", @@ -209,8 +216,8 @@ "version": "3.972.45", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.45.tgz", "integrity": "sha512-sJe5ZWibO4s7RWjFQ8Zol76KxoJcIYyEZH1/wxQSBMSIAAxzaJ8cS/ITAaIHWUQvDKQdt18+cJAHKWB7n1Jmrg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", @@ -234,8 +241,8 @@ "version": "3.972.45", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", @@ -252,8 +259,8 @@ "version": "3.972.46", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.46.tgz", "integrity": "sha512-cS4w0jzDRb1jOlkiJS3y80OxddHzkky/MN9k3NYs5jganNKVLjF0lpvjlwS118oGMr3cdAfOlVdo8gLurTSE7w==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", @@ -275,8 +282,8 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", @@ -292,8 +299,8 @@ "version": "3.972.45", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", @@ -311,8 +318,8 @@ "version": "3.1056.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", @@ -329,8 +336,8 @@ "version": "3.972.45", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", @@ -347,8 +354,8 @@ "version": "3.972.18", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.18.tgz", "integrity": "sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", @@ -363,8 +370,8 @@ "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.14.tgz", "integrity": "sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", @@ -379,8 +386,8 @@ "version": "3.972.23", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.23.tgz", "integrity": "sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", @@ -398,8 +405,8 @@ "version": "3.997.13", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -420,8 +427,8 @@ "version": "4.7.5", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", @@ -435,8 +442,8 @@ "version": "3.996.30", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", @@ -451,8 +458,8 @@ "version": "3.1048.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", @@ -469,8 +476,8 @@ "version": "3.973.9", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" @@ -483,8 +490,8 @@ "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -496,8 +503,8 @@ "version": "3.972.26", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", @@ -511,8 +518,8 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.0.0" } @@ -521,18 +528,18 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", - "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", @@ -556,20 +563,20 @@ "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.77.0.tgz", - "integrity": "sha512-huS+k+dhQRR9PlTK7crLfeSRUw3a96V6JYfP0ZH3Zkko/m10gsYk8dKQmwScSy5Dll516pXorz19BURfD6S2qQ==", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.78.1.tgz", + "integrity": "sha512-Syjf6Ib8UoY5t9ZdKjp0BRrQZuFkFBc8j2KEU9zG/ZnmYPcAxYeioofdv2Q3MEXnHEX2U8sKQptkSnJIdMsd0g==", + "dev": true, "hasShrinkwrap": true, "license": "MIT", - "peer": true, "dependencies": { - "@earendil-works/pi-agent-core": "^0.77.0", - "@earendil-works/pi-ai": "^0.77.0", - "@earendil-works/pi-tui": "^0.77.0", + "@earendil-works/pi-agent-core": "^0.78.1", + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -599,8 +606,8 @@ "version": "0.91.1", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-schema-to-ts": "^3.1.1" }, @@ -620,8 +627,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -635,8 +642,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", @@ -651,8 +658,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -666,8 +673,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" } @@ -676,8 +683,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", @@ -688,8 +695,8 @@ "version": "3.1048.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -714,8 +721,8 @@ "version": "3.974.11", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", @@ -734,8 +741,8 @@ "version": "3.972.37", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", @@ -751,8 +758,8 @@ "version": "3.972.39", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", @@ -770,8 +777,8 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-env": "^3.972.37", @@ -795,8 +802,8 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", @@ -813,8 +820,8 @@ "version": "3.972.42", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.37", "@aws-sdk/credential-provider-http": "^3.972.39", @@ -836,8 +843,8 @@ "version": "3.972.37", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", @@ -853,8 +860,8 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", @@ -872,8 +879,8 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", @@ -890,8 +897,8 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", @@ -906,8 +913,8 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", @@ -922,8 +929,8 @@ "version": "3.972.19", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", @@ -941,8 +948,8 @@ "version": "3.997.9", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -963,8 +970,8 @@ "version": "3.996.27", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", @@ -980,8 +987,8 @@ "version": "3.1048.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", @@ -998,8 +1005,8 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -1012,8 +1019,8 @@ "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -1025,8 +1032,8 @@ "version": "3.972.24", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", @@ -1041,8 +1048,8 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.0.0" } @@ -1051,19 +1058,19 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-ai": "^0.78.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1073,10 +1080,10 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", @@ -1090,17 +1097,17 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" @@ -1113,9 +1120,9 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", @@ -1138,9 +1145,9 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10" }, @@ -1164,12 +1171,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1178,12 +1185,12 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1195,12 +1202,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1212,6 +1219,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -1220,7 +1228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1232,6 +1239,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -1240,7 +1248,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1252,6 +1259,7 @@ "cpu": [ "riscv64" ], + "dev": true, "libc": [ "glibc" ], @@ -1260,7 +1268,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1272,6 +1279,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -1280,7 +1288,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1292,6 +1299,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -1300,7 +1308,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1312,12 +1319,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1329,12 +1336,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -1343,8 +1350,8 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", @@ -1355,49 +1362,49 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/nodable" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.1" } @@ -1406,50 +1413,50 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0", - "peer": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { "version": "3.24.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", @@ -1463,8 +1470,8 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", @@ -1478,8 +1485,8 @@ "version": "5.4.3", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", @@ -1493,8 +1500,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -1506,8 +1513,8 @@ "version": "4.7.3", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", @@ -1521,8 +1528,8 @@ "version": "5.4.3", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", @@ -1536,8 +1543,8 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -1549,8 +1556,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" @@ -1563,8 +1570,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" @@ -1577,8 +1584,8 @@ "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1587,8 +1594,8 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14" } @@ -1597,8 +1604,8 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -1607,6 +1614,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -1621,15 +1629,14 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -1638,15 +1645,15 @@ "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -1658,15 +1665,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1678,8 +1685,8 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1693,8 +1700,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 12" } @@ -1703,8 +1710,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -1721,8 +1728,8 @@ "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.3.1" } @@ -1731,8 +1738,8 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "safe-buffer": "^5.0.1" } @@ -1741,13 +1748,14 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, "funding": [ { "type": "github", @@ -1755,7 +1763,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" @@ -1765,6 +1772,7 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, "funding": [ { "type": "github", @@ -1772,7 +1780,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", @@ -1787,6 +1794,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, "funding": [ { "type": "github", @@ -1798,7 +1806,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" @@ -1811,8 +1818,8 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fetch-blob": "^3.1.2" }, @@ -1824,8 +1831,8 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -1839,8 +1846,8 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", @@ -1854,8 +1861,8 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1867,8 +1874,8 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -1885,8 +1892,8 @@ "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -1903,8 +1910,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -1913,15 +1920,15 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "peer": true + "dev": true, + "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } @@ -1930,8 +1937,8 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^11.1.0" }, @@ -1943,8 +1950,8 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -1957,8 +1964,8 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -1971,8 +1978,8 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -1981,15 +1988,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "peer": true + "dev": true, + "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -1998,8 +2005,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bignumber.js": "^9.0.0" } @@ -2008,8 +2015,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" @@ -2022,8 +2029,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -2034,8 +2041,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" @@ -2045,15 +2052,15 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "peer": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": "20 || >=22" } @@ -2062,8 +2069,8 @@ "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -2075,8 +2082,8 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -2091,8 +2098,8 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -2101,14 +2108,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", + "dev": true, "funding": [ { "type": "github", @@ -2120,7 +2128,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=10.5.0" } @@ -2129,8 +2136,8 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -2148,8 +2155,8 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -2170,8 +2177,8 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" @@ -2184,20 +2191,21 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, "funding": [ { "type": "github", @@ -2205,7 +2213,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -2214,8 +2221,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2224,8 +2231,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -2241,8 +2248,8 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", @@ -2253,8 +2260,8 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -2263,9 +2270,9 @@ "version": "7.5.9", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -2288,8 +2295,8 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -2298,6 +2305,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -2312,15 +2320,14 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2332,8 +2339,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2342,49 +2349,49 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "peer": true + "dev": true, + "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "dev": true, + "license": "0BSD" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=22.19.0" } @@ -2393,15 +2400,15 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 8" } @@ -2410,8 +2417,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -2426,8 +2433,8 @@ "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2448,6 +2455,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, "funding": [ { "type": "github", @@ -2455,7 +2463,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -2464,8 +2471,8 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -2480,8 +2487,8 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -2490,18 +2497,18 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", - "integrity": "sha512-QV/eYtcT3hM9pJjLCkjUFOUmgAm4GPzJ0K4kofVq9+BGU7wNJVzflTO4VY2tYpaI4VSo6A5Hsuw00wY/CDmY/Q==", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "integrity": "sha512-07GVQo/38a0yvIPlWDr3RJn1B8gk3ZuIX9h2oIQ+Biyu3JN0KppWmgWHfaWRydQgse5JtC++KDw5MWaIRnV0mw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" @@ -2514,9 +2521,9 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", @@ -2539,8 +2546,8 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", @@ -2551,49 +2558,49 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/nodable" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.1" } @@ -2602,43 +2609,43 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@smithy/core": { "version": "3.24.5", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", @@ -2652,8 +2659,8 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz", "integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", @@ -2667,8 +2674,8 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", @@ -2682,8 +2689,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -2695,8 +2702,8 @@ "version": "4.7.3", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", @@ -2710,8 +2717,8 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", @@ -2725,8 +2732,8 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -2738,8 +2745,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" @@ -2752,8 +2759,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" @@ -2766,8 +2773,8 @@ "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -2776,15 +2783,15 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14" } @@ -2793,6 +2800,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -2807,15 +2815,14 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -2824,22 +2831,22 @@ "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause", - "peer": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 12" } @@ -2848,8 +2855,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -2866,8 +2873,8 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "safe-buffer": "^5.0.1" } @@ -2876,8 +2883,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/fast-check": { "version": "4.8.0", @@ -2906,6 +2913,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, "funding": [ { "type": "github", @@ -2913,7 +2921,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" @@ -2923,6 +2930,7 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, "funding": [ { "type": "github", @@ -2930,7 +2938,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", @@ -2945,6 +2952,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, "funding": [ { "type": "github", @@ -2956,7 +2964,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" @@ -2969,8 +2976,8 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fetch-blob": "^3.1.2" }, @@ -2982,8 +2989,8 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -2997,8 +3004,8 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", @@ -3012,8 +3019,8 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3025,8 +3032,8 @@ "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -3043,8 +3050,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -3053,8 +3060,8 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -3067,8 +3074,8 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -3081,8 +3088,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bignumber.js": "^9.0.0" } @@ -3091,8 +3098,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" @@ -3105,8 +3112,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -3117,8 +3124,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" @@ -3128,15 +3135,15 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "peer": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -3148,14 +3155,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", + "dev": true, "funding": [ { "type": "github", @@ -3167,7 +3175,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=10.5.0" } @@ -3176,8 +3183,8 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -3195,8 +3202,8 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -3217,8 +3224,8 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" @@ -3231,13 +3238,14 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, "funding": [ { "type": "github", @@ -3245,7 +3253,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -3254,9 +3261,9 @@ "version": "7.6.1", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -3296,8 +3303,8 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -3306,6 +3313,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -3320,42 +3328,41 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "dev": true, + "license": "0BSD" }, "node_modules/typebox": { - "version": "1.1.39", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.39.tgz", - "integrity": "sha512-vj0afVtOfLQvv0GR0VxVagYxsXN64btL7Z9XoaG0ZggH3mruMMkOO6hXdgMsjCY3shZgEvooAWVeznQVs5c43w==", - "license": "MIT", - "peer": true + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.2.tgz", + "integrity": "sha512-0nqIJFL+baWoAEtwa0l/vfbfXg0+3gEhiWGnHuoIiivXjlk/TpxDddG0WER34CojKNpHi4ZXku8XGEz9H55b5Q==", + "dev": true, + "license": "MIT" }, "node_modules/typescript": { "version": "6.0.3", @@ -3375,15 +3382,15 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 8" } @@ -3392,8 +3399,8 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -3414,6 +3421,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, "funding": [ { "type": "github", @@ -3421,7 +3429,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -3430,8 +3437,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -3440,8 +3447,8 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } diff --git a/package.json b/package.json index b7992c7..5eb2b89 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,11 @@ "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts" }, "devDependencies": { + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-coding-agent": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", "fast-check": "^4.8.0", + "typebox": "^1.2.2", "typescript": "^6.0.3" }, "pi": { From 12ab7b99fd3c2f14306937d47b41cd31603d9526 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:29 +0300 Subject: [PATCH 10/40] refactor(notebook): use TypeBox schemas for typed tool parameters --- notebook/tools.ts | 77 +++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/notebook/tools.ts b/notebook/tools.ts index 4f13c1d..a03bad2 100644 --- a/notebook/tools.ts +++ b/notebook/tools.ts @@ -8,11 +8,39 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; -import { Type } from "typebox"; +import { Type, type Static } from "typebox"; import type { AgenticodingState } from "../state.js"; import { updateIndicators } from "../tui.js"; import { formatPageList, formatPagePreview, getPageNames, saveNotebookPage } from "./store.js"; +// ── Parameter schemas ──────────────────────────────────────────────── +// Extracted to const so type inference works through ToolDefinition. + +const notebookWriteParams = Type.Object({ + name: Type.String({ + description: + "Kebab-case notebook page identifier. Prefer stable subject-oriented names; using an existing name overwrites that page (refinement).", + }), + content: Type.String({ + description: + "Compact markdown for one notebook page. Capture only durable, high-value " + + "grounding for one subject or thread, such as facts, architecture, decisions, constraints, " + + "open questions, or expensive discoveries. Compact sections like Facts / Architecture / Decisions / Constraints / Open questions work well. Truncated at 50KB / 2000 lines.", + }), +}); + +const notebookReadParams = Type.Object({ + name: Type.String({ + description: "Notebook page name to retrieve.", + }), +}); + +const notebookIndexParams = Type.Object({}); + +type WriteArgs = Static; +type ReadArgs = Static; +type IndexArgs = Static; + // ── Factory ─────────────────────────────────────────────────────────── /** @@ -34,7 +62,7 @@ export function createNotebookToolDefinitions( } }; - const notebookWrite: ToolDefinition = { + const notebookWrite: ToolDefinition = { name: "notebook_write", label: "Notebook Write", description: @@ -55,19 +83,8 @@ export function createNotebookToolDefinitions( } : {}), executionMode: "sequential", - parameters: Type.Object({ - name: Type.String({ - description: - "Kebab-case notebook page identifier. Prefer stable subject-oriented names; using an existing name overwrites that page (refinement).", - }), - content: Type.String({ - description: - "Compact markdown for one notebook page. Capture only durable, high-value " + - "grounding for one subject or thread, such as facts, architecture, decisions, constraints, " + - "open questions, or expensive discoveries. Compact sections like Facts / Architecture / Decisions / Constraints / Open questions work well. Truncated at 50KB / 2000 lines.", - }), - }), - renderCall(args, theme, _context) { + parameters: notebookWriteParams, + renderCall(args: WriteArgs, theme, _context) { const preview = formatPagePreview(args.content).trim(); let text = theme.fg("toolTitle", theme.bold("notebook_write ")) + @@ -78,7 +95,7 @@ export function createNotebookToolDefinitions( return new Text(text, 0, 0); }, - renderResult(result, { expanded }, theme, context) { + renderResult(result, { expanded }, theme, context: { args: WriteArgs }) { const details = result.details as { entries: string[]; preview: string }; let text = theme.fg("success", "\u2713 Saved ") + theme.fg("accent", `"${context.args.name}"`); @@ -91,14 +108,14 @@ export function createNotebookToolDefinitions( return new Text(text, 0, 0); }, - async execute(_toolCallId, params, _signal, onUpdate, ctx) { + async execute(_toolCallId, params: WriteArgs, _signal, onUpdate, ctx) { assertFresh(); const saved = await saveNotebookPage(pi, state, params.name, params.content, assertFresh); updateIndicators(ctx, state); onUpdate?.({ content: [{ - type: "text", + type: "text" as const, text: `Saved "${params.name}"` + (saved.preview ? `: ${saved.preview}` : ""), }], details: { entries: saved.entries, preview: saved.preview }, @@ -106,7 +123,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `Saved notebook page "${params.name}".` + (saved.preview ? `\n${saved.preview}` : "") + `\n\nNotebook Pages:\n${formatPageList(state) || "(empty)"}`, @@ -117,7 +134,7 @@ export function createNotebookToolDefinitions( }, }; - const notebookRead: ToolDefinition = { + const notebookRead: ToolDefinition = { name: "notebook_read", label: "Notebook Read", description: @@ -133,12 +150,8 @@ export function createNotebookToolDefinitions( ], } : {}), - parameters: Type.Object({ - name: Type.String({ - description: "Notebook page name to retrieve.", - }), - }), - renderResult(result, { expanded }, theme, context) { + parameters: notebookReadParams, + renderResult(result, { expanded }, theme, context: { args: ReadArgs }) { const details = result.details as { entries: string[]; found: boolean; body?: string }; if (!details.found) { return new Text( @@ -154,7 +167,7 @@ export function createNotebookToolDefinitions( return new Text(text, 0, 0); }, - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, params: ReadArgs, _signal, _onUpdate, _ctx) { assertFresh(); const content = state.notebookPages.get(params.name); const names = getPageNames(state); @@ -163,7 +176,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `Notebook page "${params.name}" not found.` + `\n\nNotebook Pages:\n${formatPageList(state) || "(empty)"}`, @@ -176,7 +189,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `--- ${params.name} ---\n${content}\n` + `---\nNotebook Pages:\n${formatPageList(state) || "(empty)"}`, @@ -187,7 +200,7 @@ export function createNotebookToolDefinitions( }, }; - const notebookIndex: ToolDefinition = { + const notebookIndex: ToolDefinition = { name: "notebook_index", label: "Notebook Index", description: @@ -203,7 +216,7 @@ export function createNotebookToolDefinitions( ], } : {}), - parameters: Type.Object({}), + parameters: notebookIndexParams, renderResult(result, { expanded }, theme, _context) { const entries = (result.details as { entries: string[] }).entries; if (entries.length === 0) { @@ -222,7 +235,7 @@ export function createNotebookToolDefinitions( return { content: [ { - type: "text", + type: "text" as const, text: `Notebook Pages:\n${formatPageList(state) || "(empty)"}`, }, ], From 472b496a6266d254b14c6ed2ac13cb1ec36fa745 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:32 +0300 Subject: [PATCH 11/40] refactor(notebook/rehydration): use CustomEntry generic type --- notebook/rehydration.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notebook/rehydration.ts b/notebook/rehydration.ts index 08e19e2..91e3cf7 100644 --- a/notebook/rehydration.ts +++ b/notebook/rehydration.ts @@ -7,7 +7,7 @@ * notebook_read / notebook_index are active. */ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { CustomEntry, ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; // ── Types ───────────────────────────────────────────────────────────── @@ -45,12 +45,12 @@ export function registerNotebookRehydration( if ( entry.type !== "custom" || - !ENTRY_TYPES.has((entry as Record).customType as string) + !ENTRY_TYPES.has((entry as CustomEntry).customType) ) { continue; } - const data = (entry as Record).data as NotebookEntryData | undefined; + const data = (entry as CustomEntry).data; if (!data?.name || typeof data.content !== "string") continue; // Skip if we already have a newer version of this name From bc056d7d905e12bab9a29e8b7c0d9f42f77217c4 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:38 +0300 Subject: [PATCH 12/40] fix(spawn): widen message types for SDK v0.78.1 content union --- spawn/index.ts | 19 +++++++++++-------- spawn/shared.ts | 11 ++++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/spawn/index.ts b/spawn/index.ts index d344f8a..2353c03 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -16,6 +16,7 @@ import type { ToolDefinition, ToolInfo, } from "@earendil-works/pi-coding-agent"; +import type { TextContent } from "@earendil-works/pi-ai"; import { AuthStorage, createAgentSession, @@ -45,9 +46,11 @@ const CHILD_MAX_BYTES = 50 * 1024; // ── Helpers ─────────────────────────────────────────────────────────── +// Widen to accept AgentMessage variants from session messages. +// Functions that read these use runtime type checks. type AssistantMessageLike = { role: string; - content?: { type: string; text?: string }[]; + content?: unknown; stopReason?: unknown; }; @@ -208,8 +211,8 @@ export async function executeSpawn( signal: AbortSignal | undefined, onUpdate: | ((result: { - content: { type: string; text: string }[]; - details?: unknown; + content: TextContent[]; + details: unknown; }) => void) | undefined, defaultThinking: ThinkingValue, @@ -327,12 +330,12 @@ export async function executeSpawn( throw invalidatedError; } - const resultText = getLastAssistantText(session.messages); + const resultText = getLastAssistantText(session.messages as AssistantMessageLike[]); if (!resultText) { clearChildSession(); throw new Error("Child agent produced no output."); } - const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages); + const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages as AssistantMessageLike[]); const { text: finalText, truncated } = truncateResult(resultText); // Execution should not retain live children after completion. If the TUI @@ -378,7 +381,7 @@ export async function executeSpawn( } return { - content: [{ type: "text" as const, text: finalText }], + content: [{ type: "text" as const, text: finalText }] as TextContent[], details, }; } @@ -414,8 +417,8 @@ export function registerSpawnTool( signal: AbortSignal | undefined, onUpdate: | ((result: { - content: { type: string; text: string }[]; - details?: unknown; + content: TextContent[]; + details: unknown; }) => void) | undefined, ctx: ExtensionContext, diff --git a/spawn/shared.ts b/spawn/shared.ts index 3cc9af9..a38fec0 100644 --- a/spawn/shared.ts +++ b/spawn/shared.ts @@ -10,9 +10,13 @@ export type SpawnResultDetails = { statsUnavailable?: boolean; }; +// Widen content to accept AgentMessage variants (UserMessage may have string content, +// AssistantMessage has (TextContent | ThinkingContent | ToolCall)[] content). +// Functions reading from AgentMessage[] arrays cast via this type at call sites. type AssistantMessageLike = { role: string; - content?: { type: string; text?: string }[]; + content?: unknown; + stopReason?: unknown; }; /** @@ -22,9 +26,10 @@ export function getLastAssistantText(messages: AssistantMessageLike[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "assistant") continue; - const text = (msg.content ?? []) + const blocks = Array.isArray(msg.content) ? (msg.content as Array>) : []; + const text = blocks .filter((block) => block.type === "text" && typeof block.text === "string") - .map((block) => block.text ?? "") + .map((block) => block.text as string ?? "") .join("\n") .trim(); if (text) return text; From 3fd9447748497234df3190b46906161344d2b579 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:40 +0300 Subject: [PATCH 13/40] fix(spawn/renderer): add null-safety and type casts for SDK v0.78.1 --- spawn/renderer.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/spawn/renderer.ts b/spawn/renderer.ts index af4da83..d94c818 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -27,6 +27,7 @@ import { ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent"; + import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; import { Container, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; @@ -153,7 +154,7 @@ function renderPromptPreview(prompt: string, expanded: boolean): { shown: string */ function safeKeyHint(action: string, fallback: string): string { try { - return keyHint(action, fallback); + return keyHint(action as keyof import("@earendil-works/pi-tui").Keybindings, fallback); } catch { return fallback; } @@ -618,7 +619,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget // 1. Apply latest streaming message to the assistant component if (this.pendingAssistantMessage && this.streamingComponent) { try { - this.streamingComponent.updateContent(this.pendingAssistantMessage); + this.streamingComponent.updateContent(this.pendingAssistantMessage as unknown as import("@earendil-works/pi-ai").AssistantMessage); } catch (error) { this.resetStreamingComponent(error, "message_update"); } @@ -693,17 +694,18 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget private addMessageToChat(message: SpawnChildMessage): void { switch (message.role) { case "bashExecution": { - const component = new BashExecutionComponent(message.command, this.fakeUi as unknown as TUI, message.excludeFromContext); + const component = new BashExecutionComponent(message.command ?? "", this.fakeUi as unknown as TUI, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } - component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } : undefined, message.fullOutputPath); + component.setComplete(message.exitCode, message.cancelled ?? false, message.truncated ? { truncated: true } as any : undefined, message.fullOutputPath); this.addChild(component); break; } case "custom": { if (message.display) { - const component = new CustomMessageComponent(message, undefined, this.markdownTheme); + // CustomMessage type is internal to the SDK; SpawnChildMessage is structurally compatible. + const component = new CustomMessageComponent(message as any, undefined, this.markdownTheme); component.setExpanded(this.expanded); this.addChild(component); } @@ -734,7 +736,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget break; } case "assistant": { - this.addChild(new AssistantMessageComponent(message, false, this.markdownTheme, "Thinking...")); + this.addChild(new AssistantMessageComponent(message as unknown as import("@earendil-works/pi-ai").AssistantMessage, false, this.markdownTheme, "Thinking...")); break; } case "toolResult": { @@ -768,7 +770,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.addMessageToChat(message); for (const content of message.content ?? []) { if (content.type !== "toolCall") continue; - const component = this.createToolComponent(content.name, content.id, content.arguments ?? {}); + const component = this.createToolComponent(content.name ?? "", content.id ?? "", content.arguments ?? {}); this.addToolComponent(component); if (!component) continue; if (stopOutcome) { @@ -777,17 +779,17 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget : message.errorMessage || "Error"; component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { - renderedPendingTools.set(content.id, component); + renderedPendingTools.set(content.id ?? "", component); } } continue; } if (message.role === "toolResult") { - const component = renderedPendingTools.get(message.toolCallId); + const component = renderedPendingTools.get(message.toolCallId ?? ""); if (component) { - component.updateResult(message); - renderedPendingTools.delete(message.toolCallId); + component.updateResult({ ...asToolResult(message), isError: false }); + renderedPendingTools.delete(message.toolCallId ?? ""); } continue; } @@ -935,7 +937,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget private handleMessageStart(event: Extract): void { if (event.message.role === "custom" || event.message.role === "user") { - this.addMessageToChat(event.message); + this.addMessageToChat(event.message as unknown as SpawnChildMessage); return; } if (event.message.role === "assistant") { @@ -983,7 +985,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget // Cheap per-event: update the live action text preview const textBlock = event.message.content?.find( (c: any) => c.type === "text" && c.text, - ); + ) as { text: string } | undefined; if (textBlock?.text) { const firstLine = textBlock.text.trim().split("\n")[0]; if (firstLine) { From 3d3e5e681abb46f9c5005d5b2cfb64a86a88b241 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:43 +0300 Subject: [PATCH 14/40] test: fix mocks and helpers for SDK v0.78.1 API surface --- tests/e2e/test-host.ts | 6 +++--- tests/unit/helpers.ts | 8 +++++--- tests/unit/state-invariants.test.ts | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/e2e/test-host.ts b/tests/e2e/test-host.ts index 81cc420..a726a2f 100644 --- a/tests/e2e/test-host.ts +++ b/tests/e2e/test-host.ts @@ -112,13 +112,13 @@ for await (const line of rl) { } let params; try { params = JSON.parse(jsonArgs); } - catch (e) { - process.stdout.write("ERR:invalid json: " + e.message + "\n"); + catch (e: unknown) { + process.stdout.write("ERR:invalid json: " + (e instanceof Error ? e.message : String(e)) + "\n"); continue; } try { const result = await toolDef.execute("e2e-" + toolName, params, undefined, undefined, mockCtx); - const text = result.content?.map(c => c.text).filter(Boolean).join("\n") || ""; + const text = result.content?.map((c: any) => c.text).filter(Boolean).join("\n") || ""; process.stdout.write("OK:" + text + "\n"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index db04423..59ae5f2 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -110,8 +110,8 @@ export function createTestPI() { sourceInfo: { path: `<${_toolSources.get(name) ?? "builtin"}:${name}>`, source: _toolSources.get(name) ?? "builtin", - scope: "temporary", - origin: "top-level", + scope: "temporary" as const, + origin: "top-level" as const, }, })), getThinkingLevel: () => "medium" as const, @@ -142,7 +142,7 @@ export function createTestPI() { sendMessage: () => Promise.resolve(), setSessionName: () => {}, getSessionName: () => undefined, - exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "", code: 0, killed: false, signal: null } as any), getCommands: () => [], setModel: () => Promise.resolve(true), registerProvider: () => {}, @@ -151,6 +151,8 @@ export function createTestPI() { getFlag: () => undefined, registerMessageRenderer: () => {}, setLabel: () => {}, + unregisterProvider: () => {}, + events: { on: () => () => {}, emit: () => {} } as import("@earendil-works/pi-coding-agent").EventBus, setEditorText: () => {}, get commands() { return _commands; }, get tools() { return _tools; }, diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index 4b77360..7313d30 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -266,7 +266,7 @@ test("Property 5: Epoch monotonicity — non-zero after savePage", async () => { assert.equal(state.epoch, 0, "epoch must be 0 on fresh state"); for (const action of actions) { - const prevEpoch = state.epoch; + const prevEpoch: number = state.epoch; await apply(state, action); if (action.type === "savePage") { From 723361f65409f0896009984e2945aed8bb9ab9aa Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:45 +0300 Subject: [PATCH 15/40] test: fix spawn tests for SDK v0.78.1 type changes --- tests/unit/spawn.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index aca3811..ca01575 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Text } from "@earendil-works/pi-tui"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { createState, resetState } from "../../state.js"; import { @@ -1143,7 +1144,7 @@ test("nested spawn invalidate rebuilds from the attached session transcript", () const firstRender = component.render(120); assert.ok(firstRender.some((l: string) => l.includes("before"))); - session.messages[0].content[0].text = "after"; + (session.messages[0] as any).content[0].text = "after"; component.invalidate(); const secondRender = component.render(120); @@ -1393,7 +1394,7 @@ test("nested spawn ignores child renderer invalidations during parent rebuild", (session as any).getToolDefinition = (toolName: string) => toolName === "reentrant" ? { name: "reentrant", - renderCall(_args: any, _theme: Theme, context: any) { + renderCall(_args: any, _theme: any, context: any) { if (!context.state.didInvalidate) { context.state.didInvalidate = true; context.invalidate(); @@ -1937,6 +1938,7 @@ test("executeSpawn detects stale session before session creation", async () => { abort: async () => { abortCalls++; }, getSessionStats: () => undefined, } as any, + extensionsResult: undefined as any, }; }, ); @@ -1976,6 +1978,7 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn undefined, "medium", async () => ({ + extensionsResult: undefined as any, session: { messages: [] as any[], prompt: async () => { From 783686599fc2848f94129e5c47b9d4b83966aefb Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Mon, 8 Jun 2026 17:49:48 +0300 Subject: [PATCH 16/40] test: fix notebook tests for widened content access --- tests/unit/notebook.test.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/unit/notebook.test.ts b/tests/unit/notebook.test.ts index 4eb6cd4..84fdf0c 100644 --- a/tests/unit/notebook.test.ts +++ b/tests/unit/notebook.test.ts @@ -144,14 +144,15 @@ test("notebook tools add/get/list return stable contract details", async () => { assert.equal(pi.appendedEntries[0].data.name, "entry-a"); const getResult = await notebookRead.execute("2", { name: "entry-a" }, undefined, undefined, {} as any); - assert.equal(getResult.details.found, true); - assert.deepEqual(getResult.details.entries, ["entry-a"]); - assert.match(getResult.content[0].text, /--- entry-a ---/); - assert.match(getResult.content[0].text, /second line/); + const details = getResult.details as { found: boolean; entries: string[] }; + assert.equal(details.found, true); + assert.deepEqual(details.entries, ["entry-a"]); + assert.match((getResult.content[0] as any).text, /--- entry-a ---/); + assert.match((getResult.content[0] as any).text, /second line/); const listResult = await notebookIndex.execute("3", {}, undefined, undefined, {} as any); assert.deepEqual(listResult.details, { entries: ["entry-a"] }); - assert.match(listResult.content[0].text, /entry-a: first line/); + assert.match((listResult.content[0] as any).text, /entry-a: first line/); }); test("child notebook tools reject stale access after reset", async () => { @@ -198,10 +199,10 @@ test("notebook_read reports not found with current page names", async () => { const result = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); assert.deepEqual(result.details, { entries: ["entry-a", "entry-b"], found: false }); - assert.match(result.content[0].text, /Notebook page "missing" not found\./); - assert.match(result.content[0].text, /Notebook Pages:\n/); - assert.match(result.content[0].text, /entry-a: alpha/); - assert.match(result.content[0].text, /entry-b: beta/); + assert.match((result.content[0] as any).text, /Notebook page "missing" not found\./); + assert.match((result.content[0] as any).text, /Notebook Pages:\n/); + assert.match((result.content[0] as any).text, /entry-a: alpha/); + assert.match((result.content[0] as any).text, /entry-b: beta/); }); test("notebook tools show empty-state placeholders", async () => { @@ -211,11 +212,11 @@ test("notebook tools show empty-state placeholders", async () => { const missing = await notebookRead.execute("1", { name: "missing" }, undefined, undefined, {} as any); assert.deepEqual(missing.details, { entries: [], found: false }); - assert.match(missing.content[0].text, /Notebook Pages:\n\(empty\)/); + assert.match((missing.content[0] as any).text, /Notebook Pages:\n\(empty\)/); const list = await notebookIndex.execute("2", {}, undefined, undefined, {} as any); assert.deepEqual(list.details, { entries: [] }); - assert.match(list.content[0].text, /Notebook Pages:\n\(empty\)/); + assert.match((list.content[0] as any).text, /Notebook Pages:\n\(empty\)/); }); test("notebook_write pushes onUpdate and refreshes UI indicators", async () => { @@ -233,7 +234,7 @@ test("notebook_write pushes onUpdate and refreshes UI indicators", async () => { makeTUICtx({ percent: 42, record }), ); - assert.equal(update.content[0].text, 'Saved "entry-a": first line'); + assert.equal((update.content[0] as any).text, 'Saved "entry-a": first line'); assert.deepEqual(update.details, { entries: ["entry-a"], preview: "first line" }); assert.equal(record.statuses.get("agenticoding-notebook"), "📒 1"); assert.deepEqual(result.details, { entries: ["entry-a"], preview: "first line" }); @@ -249,7 +250,7 @@ test("notebook tool renderers expose stable call/result summaries", async () => const addResult = notebookWrite.renderResult!( { content: [{ type: "text", text: "" }], details: { entries: ["entry-a"], preview: "first line" } }, - { expanded: true }, + { expanded: true, isPartial: false }, theme, { args: { name: "entry-a", content: "first line\nsecond line" } } as any, ) as Text; @@ -258,7 +259,7 @@ test("notebook tool renderers expose stable call/result summaries", async () => const getResult = notebookRead.renderResult!( { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "body" } }, - { expanded: true }, + { expanded: true, isPartial: false }, theme, { args: { name: "entry-a" } } as any, ) as Text; @@ -267,7 +268,7 @@ test("notebook tool renderers expose stable call/result summaries", async () => const getResultWithDelimiters = notebookRead.renderResult!( { content: [{ type: "text", text: "ignored" }], details: { entries: ["entry-a"], found: true, body: "line 1\n---\nline 2" } }, - { expanded: true }, + { expanded: true, isPartial: false }, theme, { args: { name: "entry-a" } } as any, ) as Text; @@ -276,7 +277,7 @@ test("notebook tool renderers expose stable call/result summaries", async () => const listResult = notebookIndex.renderResult!( { content: [{ type: "text", text: "" }], details: { entries: ["entry-a", "entry-b"] } }, - { expanded: true }, + { expanded: true, isPartial: false }, theme, {} as any, ) as Text; From cca418f4aa39fd0dc1af569e43fd24646def73b2 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:45 +0300 Subject: [PATCH 17/40] Rename PytestHarness to ProcessHarness --- tests/e2e/basic.test.ts | 8 ++++---- tests/e2e/pty-harness.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/basic.test.ts b/tests/e2e/basic.test.ts index b3b521c..ae9ca40 100644 --- a/tests/e2e/basic.test.ts +++ b/tests/e2e/basic.test.ts @@ -7,18 +7,18 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { PytestHarness } from "./pty-harness.js"; +import { ProcessHarness } from "./pty-harness.js"; /** * Create a fresh host, wait for READY, and return the harness. */ -async function start(): Promise { - const h = new PytestHarness(); +async function start(): Promise { + const h = new ProcessHarness(); await h.waitForText("READY"); return h; } -async function withHarness(run: (h: PytestHarness) => Promise): Promise { +async function withHarness(run: (h: ProcessHarness) => Promise): Promise { const h = await start(); try { await run(h); diff --git a/tests/e2e/pty-harness.ts b/tests/e2e/pty-harness.ts index dd621c8..44111aa 100644 --- a/tests/e2e/pty-harness.ts +++ b/tests/e2e/pty-harness.ts @@ -18,7 +18,7 @@ export const DEFAULT_SCRIPT = resolve(HERE, "test-host.ts"); const DEFAULT_TIMEOUT_MS = 5000; const TIMEOUT_MS = parseInt(process.env.E2E_TIMEOUT_MS ?? "", 10) || DEFAULT_TIMEOUT_MS; -export class PytestHarness { +export class ProcessHarness { private child: ChildProcessWithoutNullStreams; private output = ""; private readOffset = 0; From 8dacc92a693018309474accd0e3df4c7acb55538 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:47 +0300 Subject: [PATCH 18/40] Use real AbortSignal in mock test context --- tests/e2e/test-host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test-host.ts b/tests/e2e/test-host.ts index a726a2f..3161fd1 100644 --- a/tests/e2e/test-host.ts +++ b/tests/e2e/test-host.ts @@ -71,7 +71,7 @@ const mockCtx = { sessionManager: null, modelRegistry: null, isIdle: () => true, - signal: undefined, + signal: new AbortController().signal, abort: () => {}, hasPendingMessages: () => false, shutdown: () => process.exit(0), From 72b616745a676701be3484cb0bd2343bbbc45332 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:49 +0300 Subject: [PATCH 19/40] Guard infinite loop in findPackageRoot with maxDepth --- test-loader.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test-loader.mjs b/test-loader.mjs index dad1674..89ad005 100644 --- a/test-loader.mjs +++ b/test-loader.mjs @@ -7,14 +7,17 @@ import { existsSync } from "node:fs"; * Walk up from a start directory to find node_modules/. * Works regardless of how the package was installed (local vs global). */ -function findPackageRoot(name, startDir) { +function findPackageRoot(name, startDir, maxDepth = 50) { let dir = startDir; + let depth = 0; while (true) { + if (depth > maxDepth) return null; const candidate = path.join(dir, "node_modules", name); if (existsSync(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) return null; dir = parent; + depth++; } } From 73cb2724526fa1d642f219288315174b5b05f9a7 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:51 +0300 Subject: [PATCH 20/40] Fix Node version comment for module.register availability --- register-loader.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/register-loader.mjs b/register-loader.mjs index cc75443..175aa34 100644 --- a/register-loader.mjs +++ b/register-loader.mjs @@ -1,6 +1,6 @@ // Bootstrap module for `--import` that registers the custom module loader. // Replaces the deprecated `--experimental-loader` flag. -// Phase 1: uses module.register() — safe on Node <25. +// Phase 1: uses module.register() — available on Node >=22. // Phase 2: migrate to module.registerHooks() when targeting Node >=25. import { register } from "node:module"; import { dirname, resolve } from "node:path"; From 0a413c776e3c323cb344a104c59a009348036503 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:53 +0300 Subject: [PATCH 21/40] Add missing-entry error test for register-loader --- tests/unit/register-loader.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/register-loader.test.ts b/tests/unit/register-loader.test.ts index 7f94130..b893575 100644 --- a/tests/unit/register-loader.test.ts +++ b/tests/unit/register-loader.test.ts @@ -31,3 +31,26 @@ test("register-loader resolves test-loader relative to itself instead of cwd", ( rmSync(cwd, { recursive: true, force: true }); } }); + +test("register-loader errors when entry file does not exist", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-fail-")); + try { + const result = spawnSync( + process.execPath, + ["--import", REGISTER_LOADER, "/nonexistent/entry.mjs"], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.notEqual(result.status, 0, "should exit non-zero for missing entry"); + assert.ok( + result.stderr.includes("nonexistent") || result.stderr.includes("ENOENT"), + "stderr should reference the missing file, got: " + result.stderr, + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); From e565140e4bb35dcf5f3220fdfb03a7fcdbecb1c7 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:55 +0300 Subject: [PATCH 22/40] Document single-active-harness lifecycle constraint --- tests/test-utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 28efb68..75590fe 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -44,6 +44,11 @@ export interface TestHarness { /** * Create a fresh test harness. Every test that needs isolation calls this. * + * IMPORTANT: Do not call createTestHarness() twice without an intervening + * teardown(). The second call captures the first's state, and teardown of the + * second restores stale singletons. Use beforeEach/afterEach to guarantee a + * single active harness per test. + * * CRITICAL: ESM static imports resolve before any module body runs. This means * spawn/renderer.ts registers the production frame scheduler at import time, * and createTestHarness() (called in beforeEach) always wins because tests From 29af425e55eee3d952c9c061981a6c149911e584 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 13:09:58 +0300 Subject: [PATCH 23/40] Move render snapshots from __snapshots__ to tests/snapshots/ --- .gitattributes | 2 +- .github/workflows/test.yml | 3 +-- CONTRIBUTING.md | 18 ++++++++++++------ .../indicator-30.txt | 0 .../indicator-50.txt | 0 .../indicator-70.txt | 0 .../nested-collapsed-running.txt | 0 .../nested-collapsed-success.txt | 0 .../nested-expanded.txt | 4 ++-- .../spawn-call-collapsed.txt | 0 .../spawn-call-long.txt | 0 .../spawn-result-aborted.txt | 0 .../spawn-result-error.txt | 0 .../spawn-result-success.txt | 0 tests/unit/render-snapshots.test.ts | 14 ++++++++++---- 15 files changed, 26 insertions(+), 15 deletions(-) rename tests/{__snapshots__ => snapshots}/indicator-30.txt (100%) rename tests/{__snapshots__ => snapshots}/indicator-50.txt (100%) rename tests/{__snapshots__ => snapshots}/indicator-70.txt (100%) rename tests/{__snapshots__ => snapshots}/nested-collapsed-running.txt (100%) rename tests/{__snapshots__ => snapshots}/nested-collapsed-success.txt (100%) rename tests/{__snapshots__ => snapshots}/nested-expanded.txt (63%) rename tests/{__snapshots__ => snapshots}/spawn-call-collapsed.txt (100%) rename tests/{__snapshots__ => snapshots}/spawn-call-long.txt (100%) rename tests/{__snapshots__ => snapshots}/spawn-result-aborted.txt (100%) rename tests/{__snapshots__ => snapshots}/spawn-result-error.txt (100%) rename tests/{__snapshots__ => snapshots}/spawn-result-success.txt (100%) diff --git a/.gitattributes b/.gitattributes index a889d52..a94c167 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -tests/__snapshots__/**/*.txt text eol=lf +tests/snapshots/**/*.txt text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8c5b68..75c945f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,6 +76,5 @@ jobs: with: name: test-results-${{ matrix.os }}-node-${{ matrix.node-version }} path: | - test-results/ - tests/__snapshots__/ + tests/snapshots/ retention-days: 30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acba99c..99331fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,18 +42,24 @@ Before submitting, check that your change: ## Tests - `npm test` — runs the unit suite under `tests/unit/` via the in-repo Node test runner. -- `npm run test:snapshots:check` — runs only the render-snapshot tests; fails on any drift in `tests/__snapshots__/`. -- `npm run test:snapshots:update` — rewrites the golden files in `tests/__snapshots__/` after an intentional render change. Review the diff carefully: snapshot updates are the only signal that catches unintended UI regressions. +- `npm run test:snapshots:check` — runs only the render-snapshot tests; fails on any drift in `tests/snapshots/`. +- `npm run test:snapshots:update` — rewrites the golden files in `tests/snapshots/` after an intentional render change. Review the diff carefully: snapshot updates are the only signal that catches unintended UI regressions. - `npm run test:e2e` — runs the process-isolated end-to-end suite under `tests/e2e/`. ## CI -Pull requests are automatically tested via GitHub Actions. The pipeline runs: +Pull requests are automatically tested via GitHub Actions. A cross-platform matrix runs on every push and PR: -1. **Quick-check** (Ubuntu, Node 22): `npm ci`, type check (`npx tsc --noEmit`), and security audit (`npm audit`). Catches trivial failures before the full matrix. -2. **Cross-platform matrix** (depends on quick-check): Unit tests on Ubuntu (Node 22 + 24), macOS (Node 24), and Windows (Node 24). E2E tests on all platforms. +| OS | Node | Runs | +|---|---|---| +| Ubuntu | 22 (minimum) | Type check, security audit, unit tests, E2E tests | +| Ubuntu | 24 | Type check, security audit, unit tests, E2E tests | +| macOS | 24 | Unit tests, E2E tests | +| Windows | 24 | Unit tests, E2E tests | -Snapshot golden files in `tests/__snapshots__/` are stored with LF line endings (enforced by `.gitattributes`). The `normalizeEOL` helper in the snapshot test file normalizes `\r\n` to `\n` on read, so Windows developers get correct comparisons even if their working tree has CRLF. If you update snapshots, the CI matrix validates them on all platforms. +Node 22 (minimum) is tested only on Linux — the primary platform and the only one guaranteed to have the oldest toolchain. macOS and Windows test Node 24 (latest) to catch regressions in the newest runtime while balancing CI cost. + +Snapshot golden files in `tests/snapshots/` are stored with LF line endings (enforced by `.gitattributes`). The `normalizeEOL` helper in the snapshot test file normalizes `\r\n` to `\n` on read, so Windows developers get correct comparisons even if their working tree has CRLF. If you update snapshots, the CI matrix validates them on all platforms. The E2E suite runs on all platforms including Windows (verified in issue #12). ## Community diff --git a/tests/__snapshots__/indicator-30.txt b/tests/snapshots/indicator-30.txt similarity index 100% rename from tests/__snapshots__/indicator-30.txt rename to tests/snapshots/indicator-30.txt diff --git a/tests/__snapshots__/indicator-50.txt b/tests/snapshots/indicator-50.txt similarity index 100% rename from tests/__snapshots__/indicator-50.txt rename to tests/snapshots/indicator-50.txt diff --git a/tests/__snapshots__/indicator-70.txt b/tests/snapshots/indicator-70.txt similarity index 100% rename from tests/__snapshots__/indicator-70.txt rename to tests/snapshots/indicator-70.txt diff --git a/tests/__snapshots__/nested-collapsed-running.txt b/tests/snapshots/nested-collapsed-running.txt similarity index 100% rename from tests/__snapshots__/nested-collapsed-running.txt rename to tests/snapshots/nested-collapsed-running.txt diff --git a/tests/__snapshots__/nested-collapsed-success.txt b/tests/snapshots/nested-collapsed-success.txt similarity index 100% rename from tests/__snapshots__/nested-collapsed-success.txt rename to tests/snapshots/nested-collapsed-success.txt diff --git a/tests/__snapshots__/nested-expanded.txt b/tests/snapshots/nested-expanded.txt similarity index 63% rename from tests/__snapshots__/nested-expanded.txt rename to tests/snapshots/nested-expanded.txt index 343f835..b28d324 100644 --- a/tests/__snapshots__/nested-expanded.txt +++ b/tests/snapshots/nested-expanded.txt @@ -1,6 +1,6 @@ ✅ gpt-4o • medium - ]133;A + Here is the implementation plan. Create data access layer, add caching - ]133;B]133;C middleware, wire up the controller. + middleware, wire up the controller. \ No newline at end of file diff --git a/tests/__snapshots__/spawn-call-collapsed.txt b/tests/snapshots/spawn-call-collapsed.txt similarity index 100% rename from tests/__snapshots__/spawn-call-collapsed.txt rename to tests/snapshots/spawn-call-collapsed.txt diff --git a/tests/__snapshots__/spawn-call-long.txt b/tests/snapshots/spawn-call-long.txt similarity index 100% rename from tests/__snapshots__/spawn-call-long.txt rename to tests/snapshots/spawn-call-long.txt diff --git a/tests/__snapshots__/spawn-result-aborted.txt b/tests/snapshots/spawn-result-aborted.txt similarity index 100% rename from tests/__snapshots__/spawn-result-aborted.txt rename to tests/snapshots/spawn-result-aborted.txt diff --git a/tests/__snapshots__/spawn-result-error.txt b/tests/snapshots/spawn-result-error.txt similarity index 100% rename from tests/__snapshots__/spawn-result-error.txt rename to tests/snapshots/spawn-result-error.txt diff --git a/tests/__snapshots__/spawn-result-success.txt b/tests/snapshots/spawn-result-success.txt similarity index 100% rename from tests/__snapshots__/spawn-result-success.txt rename to tests/snapshots/spawn-result-success.txt diff --git a/tests/unit/render-snapshots.test.ts b/tests/unit/render-snapshots.test.ts index 76c586d..de048d0 100644 --- a/tests/unit/render-snapshots.test.ts +++ b/tests/unit/render-snapshots.test.ts @@ -1,7 +1,7 @@ /** * Snapshot tests for TUI render output. * - * Creates golden files in tests/__snapshots__/ for every render variant. + * Creates golden files in tests/snapshots/ for every render variant. * Use UPDATE_SNAPSHOTS=1 to create/update golden files. * * No MockPi needed — uses real Theme, real TUI components via the harness. @@ -26,7 +26,7 @@ import { createSession, makeTUICtx } from "./helpers.js"; // ── Paths ───────────────────────────────────────────────────────────── const __dirname = dirname(fileURLToPath(import.meta.url)); -const SNAPSHOT_DIR = join(__dirname, "..", "__snapshots__"); +const SNAPSHOT_DIR = join(__dirname, "..", "snapshots"); // ── Render test backend ─────────────────────────────────────────────── @@ -61,18 +61,24 @@ function normalizeEOL(s: string): string { return s.replace(/\r?\n/g, "\n"); } +/** Strip OSC terminal escape sequences for portable snapshot comparison. */ +function stripOSC(s: string): string { + return s.replace(/\u001b\]133;[A-Z][^\u0007]*\u0007/g, ""); +} + function matchSnapshot(name: string, actual: string): void { ensureSnapshotDir(); const file = join(SNAPSHOT_DIR, `${name}.txt`); + const cleaned = stripOSC(normalizeEOL(actual)); if (process.env.UPDATE_SNAPSHOTS) { - writeFileSync(file, normalizeEOL(actual)); + writeFileSync(file, cleaned); return; } if (!existsSync(file)) { assert.fail(`Snapshot ${name} is missing. Re-run with UPDATE_SNAPSHOTS=1 to create it.`); } const expected = normalizeEOL(readFileSync(file, "utf-8")); - assert.equal(actual, expected, `Snapshot ${name} does not match`); + assert.equal(cleaned, expected, `Snapshot ${name} does not match`); } function withHarness(run: (h: { state: AgenticodingState } & ReturnType) => void): void { From 1125a15e59e311fcd640d4733ffcbffe00634319 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 14:30:44 +0300 Subject: [PATCH 24/40] Add try/catch recovery to SpawnFrameScheduler flush --- spawn/renderer.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/spawn/renderer.ts b/spawn/renderer.ts index d94c818..5c837a2 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -300,15 +300,30 @@ export class SpawnFrameScheduler { this.dirtyComponents.clear(); const requestRenders = new Set<() => void>(); + const failed: SpawnFrameTarget[] = []; + for (const component of batch) { - // 1. Apply accumulated event state to rendering components - component.flushPendingUpdates(); - // 2. Invalidate render cache so render() recomputes on next TUI paint - component.clearRenderCache(); - // 3. Collect TUI invalidate - const r = component.flushScheduledRender(); - if (r) requestRenders.add(r); + try { + // 1. Apply accumulated event state to rendering components + component.flushPendingUpdates(); + // 2. Invalidate render cache so render() recomputes on next TUI paint + component.clearRenderCache(); + // 3. Collect TUI invalidate + const r = component.flushScheduledRender(); + if (r) requestRenders.add(r); + } catch (e) { + // Component failed during flush — re-queue for next frame. + // The error is logged but we continue processing remaining components. + console.error("[spawn] flush error on component:", e); + failed.push(component); + } } + + // Re-queue failed components for recovery on next frame + for (const component of failed) { + getSingletons().frameScheduler.markDirty(component); + } + // One invalidate per distinct callback per frame tick. for (const requestRender of requestRenders) { requestRender(); From 089cfb917e876181fdb3eec0ab9fd2eada9bf58a Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 14:30:48 +0300 Subject: [PATCH 25/40] Tag noop scheduler with sentinel marker --- runtime-singletons.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/runtime-singletons.ts b/runtime-singletons.ts index 14087e9..1baae6f 100644 --- a/runtime-singletons.ts +++ b/runtime-singletons.ts @@ -20,6 +20,8 @@ export interface RuntimeFrameScheduler { cancelDirty(component: unknown): void; flushNow(): void; clear(): void; + /** Marker property to identify the default noop scheduler. */ + [NOOP_SCHEDULER_MARKER]?: true; } export interface RuntimeWriteLock { @@ -42,15 +44,23 @@ export function createWriteLock(): RuntimeWriteLock { // ── Pre‑init defaults (overwritten by spawn/renderer.ts at import time) ── -let current: RuntimeSingletons = { - writeLock: createWriteLock(), - writeContext: new AsyncLocalStorage(), - frameScheduler: { +/** Sentinel tag to identify the default noop scheduler. */ +const NOOP_SCHEDULER_MARKER = Symbol("no-op-scheduler"); + +function createNoopScheduler(): RuntimeFrameScheduler { + return { markDirty: () => {}, cancelDirty: () => {}, flushNow: () => {}, clear: () => {}, - }, + [NOOP_SCHEDULER_MARKER]: true, + }; +} + +let current: RuntimeSingletons = { + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: createNoopScheduler(), }; // ── Public API ──────────────────────────────────────────────────────── From d0a3161c859cfe415c91c0d4aaf2d630d432b18a Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 14:30:50 +0300 Subject: [PATCH 26/40] Update test harness lifecycle constraints comment --- tests/test-utils.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 75590fe..6898489 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -50,14 +50,15 @@ export interface TestHarness { * single active harness per test. * * CRITICAL: ESM static imports resolve before any module body runs. This means - * spawn/renderer.ts registers the production frame scheduler at import time, - * and createTestHarness() (called in beforeEach) always wins because tests - * import this module after production modules. Never use dynamic import() to - * load spawn/renderer.ts after createTestHarness() — the production scheduler - * would overwrite the test one. + * spawn/renderer.ts registers the production frame scheduler at import time. + * The test harness replaces the frame scheduler with a fresh test scheduler. + * This works correctly as long as test-utils.ts is imported before spawn/renderer.ts + * in the module graph. Never use dynamic import() to load spawn/renderer.ts after + * createTestHarness() — the production scheduler would overwrite the test one. */ export function createTestHarness(): TestHarness { const previousSingletons = getSingletons(); + const singletons: RuntimeSingletons = { writeLock: createWriteLock(), writeContext: new AsyncLocalStorage(), @@ -90,4 +91,4 @@ export function createTestHarness(): TestHarness { console.error = originalError; }, }; -} +} \ No newline at end of file From 77e432e5b5e196ee8ede5ada08552c6fadcba29a Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 14:30:53 +0300 Subject: [PATCH 27/40] Wrap property tests with harness isolation --- tests/unit/state-invariants.test.ts | 375 +++++++++++++++------------- 1 file changed, 202 insertions(+), 173 deletions(-) diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index 7313d30..c52778a 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -16,6 +16,7 @@ import { clearActiveNotebookTopic, } from "../../notebook/topic.js"; import { saveNotebookPage } from "../../notebook/store.js"; +import { createTestHarness } from "../test-utils.js"; // ── Mock ExtensionAPI ───────────────────────────────────────────────── @@ -113,219 +114,247 @@ function assertResetClears(state: AgenticodingState): void { // ── Properties ──────────────────────────────────────────────────────── test("Property 1: Topic-source coupling invariant", async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.oneof( - fc.constant({ type: "reset" } as StateAction), - fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), - fc.constant({ type: "clearTopic" } as StateAction), - fc.record({ type: fc.constant("savePage"), name: arbPageName }), + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + ), + { maxLength: 30 }, ), - { maxLength: 30 }, + async (actions) => { + const state = createState(); + for (const action of actions) { + await apply(state, action); + assertTopicSourceCoupling(state); + } + }, ), - async (actions) => { - const state = createState(); - for (const action of actions) { - await apply(state, action); - assertTopicSourceCoupling(state); - } - }, - ), - { numRuns: 100 }, - ); + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } }); test("Property 2: Child session containment invariant", async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.oneof( - fc.constant({ type: "reset" } as StateAction), - fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), - fc.constant({ type: "abortChildren" } as StateAction), + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, ), - { maxLength: 30 }, + async (actions) => { + const state = createState(); + for (const action of actions) { + await apply(state, action); + assertChildSessionContainment(state); + } + }, ), - async (actions) => { - const state = createState(); - for (const action of actions) { - await apply(state, action); - assertChildSessionContainment(state); - } - }, - ), - { numRuns: 100 }, - ); + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } }); test("Property 3: childSessionEpoch only changes on reset", async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.oneof( - fc.constant({ type: "reset" } as StateAction), - fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), - fc.constant({ type: "clearTopic" } as StateAction), - fc.record({ type: fc.constant("savePage"), name: arbPageName }), - fc.constant({ type: "abortChildren" } as StateAction), + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, ), - { maxLength: 30 }, - ), - async (actions) => { - const state = createState(); - let expectedChildEpoch: number | null = null; - let lastActionType: string | null = null; + async (actions) => { + const state = createState(); + let expectedChildEpoch: number | null = null; - for (const action of actions) { - const prevChildEpoch = state.childSessionEpoch; - await apply(state, action); + for (const action of actions) { + const prevChildEpoch = state.childSessionEpoch; + await apply(state, action); - if (action.type === "reset") { - // After reset, childSessionEpoch should have incremented - if (expectedChildEpoch === null) { - expectedChildEpoch = prevChildEpoch + 1; + if (action.type === "reset") { + // After reset, childSessionEpoch should have incremented + if (expectedChildEpoch === null) { + expectedChildEpoch = prevChildEpoch + 1; + } else { + expectedChildEpoch = state.childSessionEpoch; + } + assert.equal( + state.childSessionEpoch, + expectedChildEpoch, + `childSessionEpoch must be ${expectedChildEpoch} after reset (prev=${prevChildEpoch})`, + ); + expectedChildEpoch = state.childSessionEpoch; } else { + // Non-reset: childSessionEpoch must be unchanged + assert.equal( + state.childSessionEpoch, + prevChildEpoch, + `childSessionEpoch must not change on ${action.type} action`, + ); expectedChildEpoch = state.childSessionEpoch; } - assert.equal( - state.childSessionEpoch, - expectedChildEpoch, - `childSessionEpoch must be ${expectedChildEpoch} after reset (prev=${prevChildEpoch})`, - ); - expectedChildEpoch = state.childSessionEpoch; - } else { - // Non-reset: childSessionEpoch must be unchanged - assert.equal( - state.childSessionEpoch, - prevChildEpoch, - `childSessionEpoch must not change on ${action.type} action`, - ); - expectedChildEpoch = state.childSessionEpoch; } - lastActionType = action.type; - } - }, - ), - { numRuns: 100 }, - ); + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } }); test("Property 4: Reset clears all state fields", async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.oneof( - fc.constant({ type: "reset" } as StateAction), - fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), - fc.constant({ type: "clearTopic" } as StateAction), - fc.record({ type: fc.constant("savePage"), name: arbPageName }), - fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), - fc.constant({ type: "abortChildren" } as StateAction), + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.record({ type: fc.constant("addChildSession"), id: arbSessionId }), + fc.constant({ type: "abortChildren" } as StateAction), + ), + { maxLength: 30 }, ), - { maxLength: 30 }, - ), - async (actions) => { - const state = createState(); + async (actions) => { + const state = createState(); - for (const action of actions) { - await apply(state, action); + for (const action of actions) { + await apply(state, action); - // After every reset, assert full clear - if (action.type === "reset") { - assertResetClears(state); + // After every reset, assert full clear + if (action.type === "reset") { + assertResetClears(state); + } } - } - // Also test explicitly: create fresh state, perform some work, then reset - const s2 = createState(); - setActiveNotebookTopic(s2, "test-topic", "agent"); - await saveNotebookPage(mockPi, s2, "my-page", "some content"); - resetState(s2); - assertResetClears(s2); - }, - ), - { numRuns: 100 }, - ); + // Also test explicitly: create fresh state, perform some work, then reset + const s2 = createState(); + setActiveNotebookTopic(s2, "test-topic", "agent"); + await saveNotebookPage(mockPi, s2, "my-page", "some content"); + resetState(s2); + assertResetClears(s2); + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } }); test("Property 5: Epoch monotonicity — non-zero after savePage", async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.oneof( - fc.constant({ type: "reset" } as StateAction), - fc.record({ type: fc.constant("savePage"), name: arbPageName }), - fc.constant({ type: "clearTopic" } as StateAction), + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + fc.constant({ type: "clearTopic" } as StateAction), + ), + { maxLength: 30 }, ), - { maxLength: 30 }, - ), - async (actions) => { - const state = createState(); + async (actions) => { + const state = createState(); - assert.equal(state.epoch, 0, "epoch must be 0 on fresh state"); + assert.equal(state.epoch, 0, "epoch must be 0 on fresh state"); - for (const action of actions) { - const prevEpoch: number = state.epoch; - await apply(state, action); + for (const action of actions) { + const prevEpoch: number = state.epoch; + await apply(state, action); - if (action.type === "savePage") { - // After first savePage, epoch transitions from 0 to Date.now() (> 0) - // After subsequent saves, epoch is unchanged (set once) - assert.ok( - state.epoch > 0, - `epoch must be > 0 after savePage, got ${state.epoch}`, - ); - if (prevEpoch === 0) { - // First write: epoch transitions from 0 to Date.now() + if (action.type === "savePage") { + // After first savePage, epoch transitions from 0 to Date.now() (> 0) + // After subsequent saves, epoch is unchanged (set once) assert.ok( - state.epoch >= Date.now() - 5000, - `epoch ${state.epoch} should be recent Date.now()`, + state.epoch > 0, + `epoch must be > 0 after savePage, got ${state.epoch}`, ); + if (prevEpoch === 0) { + // First write: epoch transitions from 0 to Date.now() + assert.ok( + state.epoch >= Date.now() - 5000, + `epoch ${state.epoch} should be recent Date.now()`, + ); + } else { + // Subsequent writes: epoch unchanged + assert.equal(state.epoch, prevEpoch, "epoch must not change on subsequent savePage"); + } + } else if (action.type === "reset") { + // Reset sets epoch to 0 + assert.equal(state.epoch, 0, "epoch must be 0 after reset"); } else { - // Subsequent writes: epoch unchanged - assert.equal(state.epoch, prevEpoch, "epoch must not change on subsequent savePage"); + // Non-save, non-reset: epoch unchanged + assert.equal(state.epoch, prevEpoch, "epoch unchanged on non-save/non-reset actions"); } - } else if (action.type === "reset") { - // Reset sets epoch to 0 - assert.equal(state.epoch, 0, "epoch must be 0 after reset"); - } else { - // Non-save, non-reset: epoch unchanged - assert.equal(state.epoch, prevEpoch, "epoch unchanged on non-save/non-reset actions"); } - } - }, - ), - { numRuns: 100 }, - ); + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } }); test("Property 6: childSessionEpoch monotonicity (never decreases)", async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.oneof( - fc.constant({ type: "reset" } as StateAction), - fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), - fc.constant({ type: "clearTopic" } as StateAction), - fc.record({ type: fc.constant("savePage"), name: arbPageName }), + const h = createTestHarness(); + try { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.oneof( + fc.constant({ type: "reset" } as StateAction), + fc.record({ type: fc.constant("setTopic"), name: arbTopicName }), + fc.constant({ type: "clearTopic" } as StateAction), + fc.record({ type: fc.constant("savePage"), name: arbPageName }), + ), + { maxLength: 30 }, ), - { maxLength: 30 }, - ), - async (actions) => { - const state = createState(); - let maxSeenEpoch = 0; + async (actions) => { + const state = createState(); + let maxSeenEpoch = 0; - for (const action of actions) { - await apply(state, action); - assert.ok( - state.childSessionEpoch >= maxSeenEpoch, - `childSessionEpoch must never decrease: was ${maxSeenEpoch}, got ${state.childSessionEpoch}`, - ); - maxSeenEpoch = Math.max(maxSeenEpoch, state.childSessionEpoch); - } - }, - ), - { numRuns: 100 }, - ); + for (const action of actions) { + await apply(state, action); + assert.ok( + state.childSessionEpoch >= maxSeenEpoch, + `childSessionEpoch must never decrease: was ${maxSeenEpoch}, got ${state.childSessionEpoch}`, + ); + maxSeenEpoch = Math.max(maxSeenEpoch, state.childSessionEpoch); + } + }, + ), + { numRuns: 100 }, + ); + } finally { + h.teardown(); + } }); From 5e3e73144a16eeec84b23211855d02b17bedfcaf Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 14:31:24 +0300 Subject: [PATCH 28/40] Split oversized spawn test file into focused modules --- tests/unit/spawn-event.test.ts | 477 +++++++++++++++++ tests/unit/spawn-lifecycle.test.ts | 315 +++++++++++ tests/unit/spawn.test.ts | 806 ++--------------------------- 3 files changed, 838 insertions(+), 760 deletions(-) create mode 100644 tests/unit/spawn-event.test.ts create mode 100644 tests/unit/spawn-lifecycle.test.ts diff --git a/tests/unit/spawn-event.test.ts b/tests/unit/spawn-event.test.ts new file mode 100644 index 0000000..45feaf9 --- /dev/null +++ b/tests/unit/spawn-event.test.ts @@ -0,0 +1,477 @@ +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createState } from "../../state.js"; +import { createSession, createSubscribableSession, createTestPI, createRenderContext, theme } from "./helpers.js"; +import { flushSpawnFrameScheduler } from "../../spawn/renderer.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("nested spawn live action tracks tool execution events", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // message_start → thinking + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + let lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking, got: ${lines.join("\n")}`); + + // message_update with text → live preview + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "writing code now" }] } }); + lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("writing code now")), `expected live text preview, got: ${lines.join("\n")}`); + + // message_end → success marker in identity line + emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "summary" }], stopReason: "end_turn" } }); + lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("✅")), `expected success marker, got: ${lines.join("\n")}`); + + // Tool events degrade gracefully in minimal test env and still update live action + emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); + lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("[bash]")), `expected tool live action, got: ${lines.join("\n")}`); +}); + +test("nested spawn handleEvent recovers from malformed events", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Emit a malformed event that will throw inside handleEvent + emit({ type: "message_start", message: null }); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[1]), /message_start/); + + // Subsequent valid events still process + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`); +}); + +test("nested spawn message_end with aborted stopReason clears pending tools", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Start an assistant message + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + // End it with aborted — sets lastAction to "aborted" + emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "partial" }], stopReason: "aborted", errorMessage: "killed" } }); + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("aborted")), `expected aborted, got: ${lines.join("\n")}`); +}); + +test("nested spawn dispose stops event processing", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + component.dispose(); + + // Emit event after dispose — should not update state or crash + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const after = component.render(120); + + assert.ok(after.every((line: string) => !line.includes("thinking")), `unexpected post-dispose update: ${after.join("\n")}`); +}); + +test("nested spawn dispose aborts a claimed live child session", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(state.liveChildSessions.has("tool-call-1"), true); + + component.dispose(); + + assert.equal(abortCalls, 1); + assert.equal(state.liveChildSessions.has("tool-call-1"), false); +}); + +test("nested spawn rapid events collapse to last state", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Start a tool execution + emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); + + // Rapid burst of updates without rendering between them + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file1" }] } }); + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file2" }] } }); + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file3" }] } }); + + // Single render should reflect last state + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("file3"))); + + // End the tool and verify final state + emit({ type: "tool_execution_end", toolCallId: "tc-1", result: { content: [{ type: "text", text: "done" }] }, isError: false }); + + const finalLines = component.render(120); + assert.ok(finalLines.some((l: string) => l.includes("✓"))); +}); + +// Verifies pendingToolCallCreations accumulation: the last streamed args +// overwrite on each message_update before the first frame flush. +test("nested spawn uses the latest streamed tool-call args before first frame flush", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: true }, + theme, + createRenderContext(), + ) as any; + + // Spy on createToolComponent to capture args while preserving original behavior + let createdArgs: any; + const original = component.createToolComponent.bind(component); + component.createToolComponent = (toolName: string, toolCallId: string, args: any) => { + createdArgs = args; + return original(toolName, toolCallId, args); + }; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + emit({ + type: "message_update", + message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "old" } }] }, + }); + emit({ + type: "message_update", + message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "new" } }] }, + }); + flushSpawnFrameScheduler(); + + assert.deepEqual(createdArgs, { value: "new" }); +}); + +test("nested spawn coalesces same-turn child events into one parent invalidate", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file1" }] } }); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file2" }] } }); + + assert.equal(invalidateCalls, 0, "child events do not invalidate synchronously"); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "same-turn events coalesce into one invalidate"); + + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("file2"))); +}); + +test("nested spawn ignores child renderer invalidations during parent rebuild", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session } = createSubscribableSession([]); + (session as any).getToolDefinition = (toolName: string) => toolName === "reentrant" + ? { + name: "reentrant", + renderCall(_args: any, _theme: any, context: any) { + if (!context.state.didInvalidate) { + context.state.didInvalidate = true; + context.invalidate(); + } + return { render: () => ["reentrant"] }; + }, + } + : undefined; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 0, "initial empty attach does not invalidate"); + + (session as any).messages = [{ + role: "assistant", + content: [{ type: "toolCall", id: "tc-1", name: "reentrant", arguments: {} }], + }]; + component.invalidate(); + flushSpawnFrameScheduler(); + + assert.equal(invalidateCalls, 0, "child renderer invalidate requests stay inside spawn rebuild"); +}); + +test("nested spawn shared scheduler calls each distinct invalidate once per frame", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + const second = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + state.childSessions.set("tool-call-2", second.session); + state.liveChildSessions.set("tool-call-2", second.session); + let firstInvalidates = 0; + let secondInvalidates = 0; + + const firstComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-1", invalidate: () => { firstInvalidates++; } }), + ) as any; + const secondComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-2", invalidate: () => { secondInvalidates++; } }), + ) as any; + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + first.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "first latest" }] } }); + second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "second latest" }] } }); + + assert.equal(firstInvalidates, 0, "shared scheduler defers parent invalidate"); + assert.equal(secondInvalidates, 0, "shared scheduler defers parent invalidate"); + flushSpawnFrameScheduler(); + assert.equal(firstInvalidates, 1); + assert.equal(secondInvalidates, 1); + + assert.ok(firstComponent.render(120).some((l: string) => l.includes("first latest"))); + assert.ok(secondComponent.render(120).some((l: string) => l.includes("second latest"))); +}); + +test("nested spawn shared scheduler still coalesces duplicate invalidate callbacks", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + const second = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + state.childSessions.set("tool-call-2", second.session); + state.liveChildSessions.set("tool-call-2", second.session); + let invalidateCalls = 0; + const invalidate = () => { invalidateCalls++; }; + + childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-1", invalidate }), + ); + childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ toolCallId: "tool-call-2", invalidate }), + ); + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "identical callbacks still coalesce"); +}); + +test("nested spawn renders state changes across frame boundaries", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // First batch: message_start sets thinking state, flush triggers render + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + const firstLines = component.render(120); + assert.ok(firstLines.some((l: string) => l.includes("thinking"))); + + // Second batch: message_update with new text, flush triggers new render + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "batch 2" }] } }); + flushSpawnFrameScheduler(); + const secondLines = component.render(120); + assert.ok(secondLines.some((l: string) => l.includes("batch 2"))); +}); + +test("nested spawn dispose cancels pending and further invalidates after cleanup", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + assert.equal(invalidateCalls, 0, "event does not invalidate synchronously"); + + component.dispose(); + flushSpawnFrameScheduler(); + + // After dispose, emitting more events does not call invalidate + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "after" }] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 0, "dispose cancels pending and future invalidates"); + + // Render still works after dispose without crashing + const lines = component.render(120); + assert.ok(lines.length > 0, "render after dispose should not crash"); +}); + +test("nested spawn recovers batching state after event handler error", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // Bad event triggers an error in handleMessageStart (null message) + // catch block must call resetRenderBatching() so the flag resets + emit({ type: "message_start", message: null } as any); + + // Good event after error — should still schedule and render + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("thinking")), + "error recovery should allow subsequent events to render"); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[0]), /Event handler error/); +}); + +test("handleEvent gracefully degrades with null message events", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + // asToolResult is exercised indirectly through tool_execution_update + // with null partialResult — the runtime guard should handle it without crashing + emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); + emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: null }); + emit({ type: "tool_execution_end", toolCallId: "tc-1", result: null, isError: false }); + + // No crash = asToolResult guard works + const lines = component.render(120); + assert.ok(Array.isArray(lines)); +}); \ No newline at end of file diff --git a/tests/unit/spawn-lifecycle.test.ts b/tests/unit/spawn-lifecycle.test.ts new file mode 100644 index 0000000..611e3d5 --- /dev/null +++ b/tests/unit/spawn-lifecycle.test.ts @@ -0,0 +1,315 @@ +import test, { afterEach, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createState, resetState } from "../../state.js"; +import { createSession, createSubscribableSession, createTestPI, createRenderContext, theme } from "./helpers.js"; +import { createTestHarness, type TestHarness } from "../test-utils.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { flushSpawnFrameScheduler } from "../../spawn/renderer.js"; + +let h: TestHarness; + +function makeChildSpawnTool(state: any) { + const pi = createTestPI(); + registerSpawnTool(pi as any, state); + return pi.tools.get("spawn"); +} + +beforeEach(() => { + h = createTestHarness(); +}); + +afterEach(() => { + h.teardown(); +}); + +test("resetState aborts and clears child session registries", () => { + const state = createState(); + let abortCalls = 0; + const session = { + ...createSession([]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + resetState(state); + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("resetState aborts a claimed child session after render ownership transfer", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + let abortCalls = 0; + const session = { + ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), + abort: async () => { + abortCalls++; + }, + } as any; + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + + childSpawnTool.renderResult( + { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ); + + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(state.liveChildSessions.has("tool-call-1"), true); + + resetState(state); + + assert.equal(abortCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("nested spawn drops events after resetState bumps child epoch", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + resetState(state); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + + const after = component.render(120); + assert.equal(invalidateCalls, 0, "stale events should not request rerender after reset"); + assert.deepEqual(after, before, "stale events should not change rendered state after reset"); +}); + +test("nested spawn drops events when session is replaced in live state", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + const replacementSession = createSubscribableSession([]).session; + state.liveChildSessions.set("tool-call-1", replacementSession); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + + const after = component.render(120); + assert.equal(invalidateCalls, 0, "replaced sessions should not request rerender"); + assert.deepEqual(after, before, "replaced sessions should not change rendered state"); +}); + +test("nested spawn completed-session deletion stays stale even if the toolCallId is later reused", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + state.liveChildSessions.delete("tool-call-1"); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + const afterDeletion = component.render(120); + assert.equal(invalidateCalls, 0, "completed-session deletion should immediately stale the old session"); + assert.deepEqual(afterDeletion, before, "completed-session deletion should freeze the rendered state before reuse"); + + state.liveChildSessions.set("tool-call-1", createSubscribableSession([]).session); + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "should be dropped" }] } }); + const afterReuse = component.render(120); + assert.equal(invalidateCalls, 0, "toolCallId reuse should not revive a completed stale session"); + assert.deepEqual(afterReuse, before, "toolCallId reuse should keep the old rendered state frozen"); + assert.ok(afterReuse.every((l: string) => !l.includes("should be dropped")), "toolCallId reuse should not admit stale text updates"); +}); + +test("nested spawn drops late events after live registry deletion", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + state.liveChildSessions.delete("tool-call-1"); + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + + const after = component.render(120); + assert.equal(invalidateCalls, 0, "completed-session deletion should stop rerenders from late events"); + assert.deepEqual(after, before, "completed-session deletion should freeze the rendered state"); +}); + +test("nested spawn processes stale-state events without invalidating the parent", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + // Emit a message_start while the session is still fresh — triggers a render after flush + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "fresh-session event triggers invalidate"); + + // Now mark the session stale + state.liveChildSessions.delete("tool-call-1"); + + // Subsequent events are dropped by handleEvent's isStaleSession check + emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "stale" }] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "stale-session events do not invalidate"); + + // The optimistic event state was applied (message_start set thinking), + // but stale-session updates are dropped — the component shows the last + // known state before staleness, not a rolled-back version. + const after = component.render(120); + assert.ok(after.some((l: string) => l.includes("thinking")), + "optimistic event state from when session was still fresh is visible"); + assert.ok(!after.some((l: string) => l.includes("stale")), + "stale-session events are dropped"); +}); + +test("nested spawn cancels a queued parent invalidate when the session becomes stale before flush", () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const { session, emit } = createSubscribableSession([]); + state.childSessions.set("tool-call-1", session); + state.liveChildSessions.set("tool-call-1", session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + const before = component.render(120); + + emit({ type: "message_start", message: { role: "assistant", content: [] } }); + state.liveChildSessions.delete("tool-call-1"); + flushSpawnFrameScheduler(); + + assert.equal(invalidateCalls, 0, "stale-before-flush sessions cancel queued parent invalidates"); + assert.deepEqual(component.render(120), before, "stale-before-flush sessions roll back optimistic event state"); +}); + +test("nested spawn reattach resets render guard for the new session", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + let invalidateCalls = 0; + + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ invalidate: () => { invalidateCalls++; } }), + ) as any; + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 1, "first session event triggers invalidate after scheduler flush"); + + // Reattach resets the render guard + const second = createSubscribableSession([{ role: "assistant", content: [{ type: "text", text: "replacement" }] }]); + state.childSessions.set("tool-call-1", second.session); + state.liveChildSessions.set("tool-call-1", second.session); + const sameComponent = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component, invalidate: () => { invalidateCalls++; } }), + ) as any; + + second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "replacement 2" }] } }); + flushSpawnFrameScheduler(); + assert.equal(invalidateCalls, 2, "second session event triggers another invalidate after reattach"); + const lines = sameComponent.render(120); + assert.ok(lines.some((l: string) => l.includes("replacement 2"))); +}); + +test("nested spawn dispose then reattach streams new session events", async () => { + const state = createState(); + const childSpawnTool = makeChildSpawnTool(state); + const first = createSubscribableSession([]); + state.childSessions.set("tool-call-1", first.session); + state.liveChildSessions.set("tool-call-1", first.session); + + const component = childSpawnTool.renderResult( + { content: [{ type: "text", text: "first" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + + first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + flushSpawnFrameScheduler(); + component.dispose(); + + // Attach a second session to the same toolCallId after dispose + const second = createSubscribableSession([ + { role: "assistant", content: [{ type: "text", text: "second" }] }, + ]); + state.childSessions.set("tool-call-1", second.session); + state.liveChildSessions.set("tool-call-1", second.session); + const reattached = childSpawnTool.renderResult( + { content: [{ type: "text", text: "second" }], details: { model: "m", thinking: "low", truncated: false } }, + { expanded: false }, + theme, + createRenderContext({ lastComponent: component }), + ) as any; + + second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); + second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "session B output" }] } }); + flushSpawnFrameScheduler(); + + const lines = reattached.render(120); + assert.ok(lines.some((l: string) => l.includes("session B output")), + "reattached component should render events from the new session"); + assert.equal(lines.some((l: string) => l.includes("first")), false, + "reattached component should not show stale content from disposed session"); +}); \ No newline at end of file diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index ca01575..817b3d4 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -3,7 +3,6 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Text } from "@earendil-works/pi-tui"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { createState, resetState } from "../../state.js"; import { @@ -12,8 +11,8 @@ import { executeSpawn, registerSpawnTool, } from "../../spawn/index.js"; -import { renderSpawnResult, flushSpawnFrameScheduler } from "../../spawn/renderer.js"; -import { createTestPI, theme, ansiTheme, createRenderContext, createSession, createSubscribableSession, stripAnsi, getRenderedLine, getLineContaining, assertShellBackgroundPreserved, createDeferred, createTestAssistantMessage, createTestAssistantStream, messageText, makeTUICtx } from "./helpers.js"; +import { renderSpawnResult } from "../../spawn/renderer.js"; +import { createTestPI, createRenderContext, createSession, createSubscribableSession, messageText, makeTUICtx, theme, createTestAssistantMessage, createTestAssistantStream } from "./helpers.js"; import { createTestHarness, type TestHarness } from "../test-utils.js"; let h: TestHarness; @@ -32,7 +31,6 @@ afterEach(() => { h.teardown(); }); - test("agentic e2e spawn child can use active registered non-builtin tool", async () => { const tempRoot = await mkdtemp(join(tmpdir(), "pi-agenticoding-a10-")); const tempCwd = join(tempRoot, "project"); @@ -330,35 +328,34 @@ test("spawn execute marks stats unavailable when stats collection throws", async pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => { - throw new Error("stats failed"); - }, - }; - return { session: session as any }; + const mockFactory = async () => { + const session = { + messages: [] as any[], + prompt: async () => { + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => { + throw new Error("stats failed"); + }, }; + return { session: session as any }; + }; - registerSpawnTool(pi as any, state, mockFactory as any); - const result = await pi.tools.get("spawn").execute( - "spawn-1", - { prompt: "Do the task" }, - undefined, - undefined, - { model: { id: "mock-model" }, cwd: "/tmp" }, - ); - - assert.equal(result.details.stats, undefined); - assert.equal(result.details.statsUnavailable, true); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[1]), /stats failed/); - assert.equal(h.warnings[0].args[2], "spawn-1"); + registerSpawnTool(pi as any, state, mockFactory as any); + const result = await pi.tools.get("spawn").execute( + "spawn-1", + { prompt: "Do the task" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + assert.equal(result.details.stats, undefined); + assert.equal(result.details.statsUnavailable, true); + assert.equal(h.warnings.length, 1); + assert.match(String(h.warnings[0].args[1]), /stats failed/); + assert.equal(h.warnings[0].args[2], "spawn-1"); }); test("spawn execute throws when child produces no output", async () => { @@ -677,145 +674,6 @@ test("child tool names exclude inactive registered and active phantom tools", () assert.equal(toolNames.includes("spawn"), false); }); - - -test("nested spawn live action tracks tool execution events", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // message_start → thinking - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - let lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking, got: ${lines.join("\n")}`); - - // message_update with text → live preview - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "writing code now" }] } }); - lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("writing code now")), `expected live text preview, got: ${lines.join("\n")}`); - - // message_end → success marker in identity line - emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "summary" }], stopReason: "end_turn" } }); - lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("✅")), `expected success marker, got: ${lines.join("\n")}`); - - // Tool events degrade gracefully in minimal test env and still update live action - emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); - lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("[bash]")), `expected tool live action, got: ${lines.join("\n")}`); - -}); - -test("nested spawn handleEvent recovers from malformed events", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Emit a malformed event that will throw inside handleEvent - emit({ type: "message_start", message: null }); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[1]), /message_start/); - - // Subsequent valid events still process - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), `expected thinking after recovery, got: ${lines.join("\n")}`); - -}); - -test("nested spawn message_end with aborted stopReason clears pending tools", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Start an assistant message - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - // End it with aborted — sets lastAction to "aborted" - emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "partial" }], stopReason: "aborted", errorMessage: "killed" } }); - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("aborted")), `expected aborted, got: ${lines.join("\n")}`); -}); - -test("nested spawn dispose stops event processing", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - component.dispose(); - - // Emit event after dispose — should not update state or crash - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const after = component.render(120); - - assert.ok(after.every((line: string) => !line.includes("thinking")), `unexpected post-dispose update: ${after.join("\n")}`); -}); - -test("nested spawn dispose aborts a claimed live child session", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - let abortCalls = 0; - const session = { - ...createSession([{ role: "assistant", content: [{ type: "text", text: "hello" }] }]), - abort: async () => { - abortCalls++; - }, - } as any; - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "ignored" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(state.liveChildSessions.has("tool-call-1"), true); - - component.dispose(); - - assert.equal(abortCalls, 1); - assert.equal(state.liveChildSessions.has("tool-call-1"), false); -}); - test("spawn execute short-circuits when signal is already aborted", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -925,7 +783,7 @@ test("spawn execute truncates child output by byte limit", async () => { assert.equal(result.details.truncated, true); assert.ok(result.content[0].text.includes("[Result truncated")); assert.ok(result.content[0].text.length < longText.length); - assert.equal(result.content[0].text.includes("\n"), true); + assert.ok(result.content[0].text.includes("\n")); }); test("spawn execute tells children when no notebook pages exist", async () => { @@ -1125,7 +983,6 @@ test("spawn renderCall shows prompt preview and thinking level", () => { assert.ok(!expandedLines.some((l: string) => l.includes("more lines"))); }); - test("nested spawn invalidate rebuilds from the attached session transcript", () => { const state = createState(); const childSpawnTool = makeChildSpawnTool(state); @@ -1237,19 +1094,18 @@ test("nested spawn rebuildFromSession quietly tolerates missing tool definitions } as any; state.childSessions.set("tool-call-1", session); - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false, outcome: "error" } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("⚠ m • low"))); - assert.ok(lines.some((l: string) => l.includes("error"))); - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(h.warnings.length, 0); + const component = childSpawnTool.renderResult( + { content: [], details: { model: "m", thinking: "low", truncated: false, outcome: "error" } }, + { expanded: false }, + theme, + createRenderContext(), + ) as any; + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("⚠ m • low"))); + assert.ok(lines.some((l: string) => l.includes("error"))); + assert.equal(state.childSessions.has("tool-call-1"), false); + assert.equal(h.warnings.length, 0); }); test("nested spawn attachSession recovers from subscribe throwing", () => { @@ -1266,31 +1122,6 @@ test("nested spawn attachSession recovers from subscribe throwing", () => { } as any; state.childSessions.set("tool-call-1", throwingSession); - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Should not crash, session attached, ownership transferred - assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[0]), /Failed to subscribe/); - - // Should still render from session messages despite subscribe failure - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("hello"))); - -}); - -test("nested spawn rapid events collapse to last state", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - const component = childSpawnTool.renderResult( { content: [], details: { model: "m", thinking: "low", truncated: false } }, { expanded: false }, @@ -1298,534 +1129,14 @@ test("nested spawn rapid events collapse to last state", () => { createRenderContext(), ) as any; - // Start a tool execution - emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); - - // Rapid burst of updates without rendering between them - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file1" }] } }); - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file2" }] } }); - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: { content: [{ type: "text", text: "file3" }] } }); - - // Single render should reflect last state - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("file3"))); - - // End the tool and verify final state - emit({ type: "tool_execution_end", toolCallId: "tc-1", result: { content: [{ type: "text", text: "done" }] }, isError: false }); - - const finalLines = component.render(120); - assert.ok(finalLines.some((l: string) => l.includes("✓"))); -}); - -// Verifies pendingToolCallCreations accumulation: the last streamed args -// overwrite on each message_update before the first frame flush. -// -// Uses a spy on createToolComponent because ToolExecutionComponent (from -// pi-tui) cannot be constructed in the unit test environment. The spy wraps -// the real method to capture args while preserving the original behavior -// (which will gracefully return undefined when construction fails). -test("nested spawn uses the latest streamed tool-call args before first frame flush", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: true }, - theme, - createRenderContext(), - ) as any; - - // Spy on createToolComponent to capture args while preserving original behavior - let createdArgs: any; - const original = component.createToolComponent.bind(component); - component.createToolComponent = (toolName: string, toolCallId: string, args: any) => { - createdArgs = args; - return original(toolName, toolCallId, args); - }; - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - emit({ - type: "message_update", - message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "old" } }] }, - }); - emit({ - type: "message_update", - message: { role: "assistant", content: [{ type: "toolCall", id: "tc-1", name: "inspect", arguments: { value: "new" } }] }, - }); - flushSpawnFrameScheduler(); - - assert.deepEqual(createdArgs, { value: "new" }); -}); - -test("nested spawn coalesces same-turn child events into one parent invalidate", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file1" }] } }); - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "file2" }] } }); - - assert.equal(invalidateCalls, 0, "child events do not invalidate synchronously"); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "same-turn events coalesce into one invalidate"); - - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("file2"))); -}); - -test("nested spawn ignores child renderer invalidations during parent rebuild", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session } = createSubscribableSession([]); - (session as any).getToolDefinition = (toolName: string) => toolName === "reentrant" - ? { - name: "reentrant", - renderCall(_args: any, _theme: any, context: any) { - if (!context.state.didInvalidate) { - context.state.didInvalidate = true; - context.invalidate(); - } - return new Text("reentrant", 0, 0); - }, - } - : undefined; - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 0, "initial empty attach does not invalidate"); - - (session as any).messages = [{ - role: "assistant", - content: [{ type: "toolCall", id: "tc-1", name: "reentrant", arguments: {} }], - }]; - component.invalidate(); - flushSpawnFrameScheduler(); - - assert.equal(invalidateCalls, 0, "child renderer invalidate requests stay inside spawn rebuild"); -}); - -test("nested spawn shared scheduler calls each distinct invalidate once per frame", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const first = createSubscribableSession([]); - const second = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - state.childSessions.set("tool-call-2", second.session); - state.liveChildSessions.set("tool-call-2", second.session); - let firstInvalidates = 0; - let secondInvalidates = 0; - - const firstComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-1", invalidate: () => { firstInvalidates++; } }), - ) as any; - const secondComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-2", invalidate: () => { secondInvalidates++; } }), - ) as any; - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - first.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "first latest" }] } }); - second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "second latest" }] } }); - - assert.equal(firstInvalidates, 0, "shared scheduler defers parent invalidate"); - assert.equal(secondInvalidates, 0, "shared scheduler defers parent invalidate"); - flushSpawnFrameScheduler(); - assert.equal(firstInvalidates, 1); - assert.equal(secondInvalidates, 1); - assert.ok(firstComponent.render(120).some((l: string) => l.includes("first latest"))); - assert.ok(secondComponent.render(120).some((l: string) => l.includes("second latest"))); -}); - -test("nested spawn shared scheduler still coalesces duplicate invalidate callbacks", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const first = createSubscribableSession([]); - const second = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - state.childSessions.set("tool-call-2", second.session); - state.liveChildSessions.set("tool-call-2", second.session); - let invalidateCalls = 0; - const invalidate = () => { invalidateCalls++; }; - - childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-1", invalidate }), - ); - childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ toolCallId: "tool-call-2", invalidate }), - ); - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "identical callbacks still coalesce"); -}); - -test("nested spawn renders state changes across frame boundaries", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // First batch: message_start sets thinking state, flush triggers render - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - const firstLines = component.render(120); - assert.ok(firstLines.some((l: string) => l.includes("thinking"))); - - // Second batch: message_update with new text, flush triggers new render - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "batch 2" }] } }); - flushSpawnFrameScheduler(); - const secondLines = component.render(120); - assert.ok(secondLines.some((l: string) => l.includes("batch 2"))); -}); - -test("nested spawn dispose cancels pending and further invalidates after cleanup", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - assert.equal(invalidateCalls, 0, "event does not invalidate synchronously"); - - component.dispose(); - flushSpawnFrameScheduler(); - - // After dispose, emitting more events does not call invalidate - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "after" }] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 0, "dispose cancels pending and future invalidates"); - - // Render still works after dispose without crashing - const lines = component.render(120); - assert.ok(lines.length > 0, "render after dispose should not crash"); -}); - -test("nested spawn reattach resets render guard for the new session", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const first = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "first session event triggers invalidate after scheduler flush"); - - // Reattach resets the render guard - const second = createSubscribableSession([{ role: "assistant", content: [{ type: "text", text: "replacement" }] }]); - state.childSessions.set("tool-call-1", second.session); - state.liveChildSessions.set("tool-call-1", second.session); - const sameComponent = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component, invalidate: () => { invalidateCalls++; } }), - ) as any; - - second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "replacement 2" }] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 2, "second session event triggers another invalidate after reattach"); - const lines = sameComponent.render(120); - assert.ok(lines.some((l: string) => l.includes("replacement 2"))); -}); - -test("nested spawn recovers batching state after event handler error", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // Bad event triggers an error in handleMessageStart (null message) - // catch block must call resetRenderBatching() so the flag resets - emit({ type: "message_start", message: null } as any); - - // Good event after error — should still schedule and render - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - const lines = component.render(120); - assert.ok(lines.some((l: string) => l.includes("thinking")), - "error recovery should allow subsequent events to render"); + // Should not crash, session attached, ownership transferred + assert.equal(state.childSessions.has("tool-call-1"), false); assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[0]), /Event handler error/); - -}); - -test("nested spawn processes stale-state events without invalidating the parent", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - // Emit a message_start while the session is still fresh — triggers a render after flush - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "fresh-session event triggers invalidate"); - - // Now mark the session stale - state.liveChildSessions.delete("tool-call-1"); - - // Subsequent events are dropped by handleEvent's isStaleSession check - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "stale" }] } }); - flushSpawnFrameScheduler(); - assert.equal(invalidateCalls, 1, "stale-session events do not invalidate"); - - // The optimistic event state was applied (message_start set thinking), - // but stale-session updates are dropped — the component shows the last - // known state before staleness, not a rolled-back version. - const after = component.render(120); - assert.ok(after.some((l: string) => l.includes("thinking")), - "optimistic event state from when session was still fresh is visible"); - assert.ok(!after.some((l: string) => l.includes("stale")), - "stale-session events are dropped"); -}); + assert.match(String(h.warnings[0].args[0]), /Failed to subscribe/); -test("nested spawn cancels a queued parent invalidate when the session becomes stale before flush", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - state.liveChildSessions.delete("tool-call-1"); - flushSpawnFrameScheduler(); - - assert.equal(invalidateCalls, 0, "stale-before-flush sessions cancel queued parent invalidates"); - assert.deepEqual(component.render(120), before, "stale-before-flush sessions roll back optimistic event state"); -}); - -test("nested spawn dispose then reattach streams new session events", async () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const first = createSubscribableSession([]); - state.childSessions.set("tool-call-1", first.session); - state.liveChildSessions.set("tool-call-1", first.session); - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "first" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - first.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - flushSpawnFrameScheduler(); - component.dispose(); - - // Attach a second session to the same toolCallId after dispose - const second = createSubscribableSession([ - { role: "assistant", content: [{ type: "text", text: "second" }] }, - ]); - state.childSessions.set("tool-call-1", second.session); - state.liveChildSessions.set("tool-call-1", second.session); - const reattached = childSpawnTool.renderResult( - { content: [{ type: "text", text: "second" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ lastComponent: component }), - ) as any; - - second.emit({ type: "message_start", message: { role: "assistant", content: [] } }); - second.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "session B output" }] } }); - flushSpawnFrameScheduler(); - - const lines = reattached.render(120); - assert.ok(lines.some((l: string) => l.includes("session B output")), - "reattached component should render events from the new session"); - assert.equal(lines.some((l: string) => l.includes("first")), false, - "reattached component should not show stale content from disposed session"); -}); - -test("nested spawn drops late events after live registry deletion", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - state.liveChildSessions.delete("tool-call-1"); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - - const after = component.render(120); - assert.equal(invalidateCalls, 0, "completed-session deletion should stop rerenders from late events"); - assert.deepEqual(after, before, "completed-session deletion should freeze the rendered state"); -}); - -test("nested spawn drops events after resetState bumps child epoch", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - resetState(state); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - - const after = component.render(120); - assert.equal(invalidateCalls, 0, "stale events should not request rerender after reset"); - assert.deepEqual(after, before, "stale events should not change rendered state after reset"); -}); - -test("nested spawn drops events when session is replaced in live state", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - const replacementSession = createSubscribableSession([]).session; - state.liveChildSessions.set("tool-call-1", replacementSession); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - - const after = component.render(120); - assert.equal(invalidateCalls, 0, "replaced sessions should not request rerender"); - assert.deepEqual(after, before, "replaced sessions should not change rendered state"); -}); - -test("nested spawn completed-session deletion stays stale even if the toolCallId is later reused", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - let invalidateCalls = 0; - - const component = childSpawnTool.renderResult( - { content: [{ type: "text", text: "initial" }], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext({ invalidate: () => { invalidateCalls++; } }), - ) as any; - const before = component.render(120); - - state.liveChildSessions.delete("tool-call-1"); - emit({ type: "message_start", message: { role: "assistant", content: [] } }); - const afterDeletion = component.render(120); - assert.equal(invalidateCalls, 0, "completed-session deletion should immediately stale the old session"); - assert.deepEqual(afterDeletion, before, "completed-session deletion should freeze the rendered state before reuse"); - - state.liveChildSessions.set("tool-call-1", createSubscribableSession([]).session); - emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "should be dropped" }] } }); - const afterReuse = component.render(120); - assert.equal(invalidateCalls, 0, "toolCallId reuse should not revive a completed stale session"); - assert.deepEqual(afterReuse, before, "toolCallId reuse should keep the old rendered state frozen"); - assert.ok(afterReuse.every((l: string) => !l.includes("should be dropped")), "toolCallId reuse should not admit stale text updates"); + // Should still render from session messages despite subscribe failure + const lines = component.render(120); + assert.ok(lines.some((l: string) => l.includes("hello"))); }); test("concurrent spawn executions produce independent results", async () => { @@ -2012,31 +1323,6 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn assert.equal(state.liveChildSessions.size, 0); }); -test("handleEvent gracefully degrades with null message events", () => { - const state = createState(); - const childSpawnTool = makeChildSpawnTool(state); - const { session, emit } = createSubscribableSession([]); - state.childSessions.set("tool-call-1", session); - state.liveChildSessions.set("tool-call-1", session); - - const component = childSpawnTool.renderResult( - { content: [], details: { model: "m", thinking: "low", truncated: false } }, - { expanded: false }, - theme, - createRenderContext(), - ) as any; - - // asToolResult is exercised indirectly through tool_execution_update - // with null partialResult — the runtime guard should handle it without crashing - emit({ type: "tool_execution_start", toolCallId: "tc-1", toolName: "bash", args: { command: "ls" } }); - emit({ type: "tool_execution_update", toolCallId: "tc-1", partialResult: null }); - emit({ type: "tool_execution_end", toolCallId: "tc-1", result: null, isError: false }); - - // No crash = asToolResult guard works - const lines = component.render(120); - assert.ok(Array.isArray(lines)); -}); - test("truncateText respects line limit before byte limit", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -2171,4 +1457,4 @@ test("spawn docs document active registered inheritance", async () => { assert.match(unreleased, /active registered parent tools/); assert.match(unreleased, /spawn and handoff/); assert.match(unreleased, /notebook tools/); -}); +}); \ No newline at end of file From 82e299c41a8eafde542f53198feb274009af98e7 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Tue, 9 Jun 2026 14:31:34 +0300 Subject: [PATCH 29/40] Wrap runtime-singletons test with harness isolation --- tests/unit/runtime-singletons.test.ts | 38 +++++++++++++++------------ 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/tests/unit/runtime-singletons.test.ts b/tests/unit/runtime-singletons.test.ts index 091925a..0449ef3 100644 --- a/tests/unit/runtime-singletons.test.ts +++ b/tests/unit/runtime-singletons.test.ts @@ -22,25 +22,29 @@ test("createTestHarness swaps singleton state atomically and restores it on tear }); test("__setSingletons warns when preserving in-flight write lock", () => { - const warnings: string[] = []; - const originalWarn = console.warn; - console.warn = (msg: string) => { - warnings.push(msg); - }; + // Use harness to isolate the test's singleton manipulation + const h = createTestHarness(); try { - const s = getSingletons(); - s.writeLock.pending = 1; // simulate in-flight write - __setSingletons({ - writeLock: createWriteLock(), - writeContext: new AsyncLocalStorage(), - frameScheduler: s.frameScheduler, - }); - assert.ok(warnings.length > 0); - assert.match(warnings[0], /pending/); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (msg: string) => { + warnings.push(msg); + }; + try { + const s = getSingletons(); + s.writeLock.pending = 1; // simulate in-flight write on test singleton + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: s.frameScheduler, + }); + assert.ok(warnings.length > 0); + assert.match(warnings[0], /pending/); + } finally { + console.warn = originalWarn; + } } finally { - getSingletons().writeLock.pending = 0; // clean up - __setSingletons(getSingletons(), { forceWriteLock: true }); // restore clean state - console.warn = originalWarn; + h.teardown(); } }); From 2d4d8d0c5486e2ff3f2aa38c80718f11f958244f Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Wed, 10 Jun 2026 13:27:07 +0300 Subject: [PATCH 30/40] Tighten assertions to test invariants instead of disjunctions --- tests/e2e/basic.test.ts | 2 +- tests/unit/register-loader.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/basic.test.ts b/tests/e2e/basic.test.ts index ae9ca40..1564a98 100644 --- a/tests/e2e/basic.test.ts +++ b/tests/e2e/basic.test.ts @@ -104,7 +104,7 @@ describe("agenticoding E2E", () => { await h.waitForText("ERR:"); const snap = h.snapshot(); assert.ok( - snap.includes("authoritative") || snap.includes("already exists"), + snap.includes("authoritative"), "human-set topic blocks agent override", ); })); diff --git a/tests/unit/register-loader.test.ts b/tests/unit/register-loader.test.ts index b893575..401f2ce 100644 --- a/tests/unit/register-loader.test.ts +++ b/tests/unit/register-loader.test.ts @@ -46,8 +46,9 @@ test("register-loader errors when entry file does not exist", () => { ); assert.notEqual(result.status, 0, "should exit non-zero for missing entry"); + // Node.js always includes the path in ENOENT errors, so checking for "nonexistent" is sufficient assert.ok( - result.stderr.includes("nonexistent") || result.stderr.includes("ENOENT"), + result.stderr.includes("nonexistent"), "stderr should reference the missing file, got: " + result.stderr, ); } finally { From e6d7c3f4ef287b1d13bd339636903f2c352998b3 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Wed, 10 Jun 2026 13:27:09 +0300 Subject: [PATCH 31/40] Fix E2E mock: add model field for spawn ctx.model check --- tests/e2e/test-host.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test-host.ts b/tests/e2e/test-host.ts index 3161fd1..8a3e0be 100644 --- a/tests/e2e/test-host.ts +++ b/tests/e2e/test-host.ts @@ -70,6 +70,8 @@ const mockCtx = { getContextUsage: () => null, sessionManager: null, modelRegistry: null, + // Required by spawn tool which checks ctx.model existence before using it + model: undefined, isIdle: () => true, signal: new AbortController().signal, abort: () => {}, @@ -77,7 +79,7 @@ const mockCtx = { shutdown: () => process.exit(0), compact: () => {}, getSystemPrompt: () => "", -}; +} as any; // Type assertion needed: mock intentionally omits some interface fields // ── REPL loop ──────────────────────────────────────────────────── From 93c3d3047738a3bdc81d5cb453eccbdee4928ed1 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Wed, 10 Jun 2026 20:01:46 +0300 Subject: [PATCH 32/40] Fix misleading JSDoc on __setSingletons --- runtime-singletons.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/runtime-singletons.ts b/runtime-singletons.ts index 1baae6f..b406e25 100644 --- a/runtime-singletons.ts +++ b/runtime-singletons.ts @@ -65,7 +65,11 @@ let current: RuntimeSingletons = { // ── Public API ──────────────────────────────────────────────────────── -/** Atomically replace all singletons. Test‑only — use __ naming convention. */ +/** Atomically replace all singletons. + * Called by spawn/renderer.ts at module evaluation time (production) and by + * tests via createTestHarness(). The __ prefix signals that callers should + * understand the lifecycle implications — see spawn/renderer.ts for the + * production registration pattern. */ export function __setSingletons( s: RuntimeSingletons, options?: { forceWriteLock?: boolean }, From 86620c11bb85cb731a07c930c8d2f2fa27c81ae8 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Wed, 10 Jun 2026 20:01:55 +0300 Subject: [PATCH 33/40] Add isNoopScheduler with import-order guard in test harness --- runtime-singletons.ts | 5 +++++ tests/test-utils.ts | 12 ++++++++++++ tests/unit/runtime-singletons.test.ts | 13 +++++++++++++ 3 files changed, 30 insertions(+) diff --git a/runtime-singletons.ts b/runtime-singletons.ts index b406e25..a8279c4 100644 --- a/runtime-singletons.ts +++ b/runtime-singletons.ts @@ -91,3 +91,8 @@ export function __setSingletons( export function getSingletons(): RuntimeSingletons { return current; } + +/** True when scheduler is the pre-init noop — see createTestHarness() safety check. */ +export function isNoopScheduler(scheduler: RuntimeFrameScheduler): boolean { + return NOOP_SCHEDULER_MARKER in scheduler; +} diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6898489..9c3d1e8 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -26,6 +26,7 @@ import { __setSingletons, createWriteLock, getSingletons, + isNoopScheduler, type RuntimeSingletons, } from "../runtime-singletons.js"; import { SpawnFrameScheduler } from "../spawn/renderer.js"; @@ -68,6 +69,17 @@ export function createTestHarness(): TestHarness { const originalWarn = console.warn; const originalError = console.error; + // Check whether spawn/renderer.ts was already statically imported before + // this harness call — if previousSingletons still holds the noop marker, + // the production registration at the bottom of spawn/renderer.ts never ran. + if (isNoopScheduler(previousSingletons.frameScheduler)) { + console.warn( + "[test-utils] spawn/renderer.ts was not statically imported before " + + "createTestHarness() — the production frame scheduler was never " + + "registered. Frame-batched rendering tests will use the noop scheduler.", + ); + } + // Atomic swap: replace the production singleton container (write lock, // context, frame scheduler) in one call. __setSingletons(singletons); diff --git a/tests/unit/runtime-singletons.test.ts b/tests/unit/runtime-singletons.test.ts index 0449ef3..d45d5c7 100644 --- a/tests/unit/runtime-singletons.test.ts +++ b/tests/unit/runtime-singletons.test.ts @@ -6,6 +6,7 @@ import { __setSingletons, createWriteLock, getSingletons, + isNoopScheduler, } from "../../runtime-singletons.js"; test("createTestHarness swaps singleton state atomically and restores it on teardown", () => { @@ -83,3 +84,15 @@ test("write lock serializes concurrent writers and completes all", async () => { h.teardown(); }); + +test("isNoopScheduler returns false for SpawnFrameScheduler", () => { + // The noop scheduler is created at module init but overwritten by + // spawn/renderer.ts at import time — so the global singleton always + // holds a real scheduler. The true path (returns true) is exercised by + // the import-order guard in createTestHarness() — see test-utils.ts. + assert.equal(isNoopScheduler(getSingletons().frameScheduler), false); + + const h = createTestHarness(); + assert.equal(isNoopScheduler(getSingletons().frameScheduler), false); + h.teardown(); +}); From 70d90202414734a9c419c419e90ac00b0c77fe95 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Wed, 10 Jun 2026 20:02:00 +0300 Subject: [PATCH 34/40] Remove fragile spread pattern from snapshot test harness --- tests/unit/render-snapshots.test.ts | 39 +++++++++++++++-------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/tests/unit/render-snapshots.test.ts b/tests/unit/render-snapshots.test.ts index de048d0..7b79c22 100644 --- a/tests/unit/render-snapshots.test.ts +++ b/tests/unit/render-snapshots.test.ts @@ -81,12 +81,13 @@ function matchSnapshot(name: string, actual: string): void { assert.equal(cleaned, expected, `Snapshot ${name} does not match`); } -function withHarness(run: (h: { state: AgenticodingState } & ReturnType) => void): void { - const h = { state: createState(), ...createTestHarness() }; +function withHarness(run: (state: AgenticodingState) => void): void { + const harness = createTestHarness(); + const state = createState(); try { - run(h); + run(state); } finally { - h.teardown(); + harness.teardown(); } } @@ -134,7 +135,7 @@ test("spawn call long prompt matches snapshot", () => { // 3–5: Spawn result (renderSpawnResult, static Text path, no child session) // ═══════════════════════════════════════════════════════════════════════ -test("spawn result success matches snapshot", () => withHarness((h) => { +test("spawn result success matches snapshot", () => withHarness((state) => { const component = renderSpawnResult( { content: [{ type: "text", text: "Task completed successfully. All tests pass and documentation is updated." }], @@ -148,14 +149,14 @@ test("spawn result success matches snapshot", () => withHarness((h) => { false, theme, { toolCallId: "tc-1", invalidate: () => {}, showImages: false, lastComponent: undefined }, - h.state, + state, ); const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); matchSnapshot("spawn-result-success", rtb.toSnapshot()); })); -test("spawn result error matches snapshot", () => withHarness((h) => { +test("spawn result error matches snapshot", () => withHarness((state) => { const component = renderSpawnResult( { content: [{ type: "text", text: "Failed to connect to API: rate limit exceeded. Retry after 60 seconds." }], @@ -169,14 +170,14 @@ test("spawn result error matches snapshot", () => withHarness((h) => { false, theme, { toolCallId: "tc-2", invalidate: () => {}, showImages: false, lastComponent: undefined }, - h.state, + state, ); const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); matchSnapshot("spawn-result-error", rtb.toSnapshot()); })); -test("spawn result aborted matches snapshot", () => withHarness((h) => { +test("spawn result aborted matches snapshot", () => withHarness((state) => { const component = renderSpawnResult( { content: [{ type: "text", text: "Operation cancelled by user request." }], @@ -190,7 +191,7 @@ test("spawn result aborted matches snapshot", () => withHarness((h) => { false, theme, { toolCallId: "tc-3", invalidate: () => {}, showImages: false, lastComponent: undefined }, - h.state, + state, ); const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); @@ -201,9 +202,9 @@ test("spawn result aborted matches snapshot", () => withHarness((h) => { // 6–8: NestedAgentSessionComponent (via renderSpawnResult with child session) // ═══════════════════════════════════════════════════════════════════════ -test("nested collapsed running matches snapshot", () => withHarness((h) => { +test("nested collapsed running matches snapshot", () => withHarness((state) => { const session = createSession([]); - h.state.childSessions.set("tc-nested-1", session); + state.childSessions.set("tc-nested-1", session); const component = renderSpawnResult( { @@ -213,21 +214,21 @@ test("nested collapsed running matches snapshot", () => withHarness((h) => { false, theme, { toolCallId: "tc-nested-1", invalidate: () => {}, showImages: false, lastComponent: undefined }, - h.state, + state, ); const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); matchSnapshot("nested-collapsed-running", rtb.toSnapshot()); })); -test("nested collapsed success matches snapshot", () => withHarness((h) => { +test("nested collapsed success matches snapshot", () => withHarness((state) => { const session = createSession([ { role: "assistant", content: [{ type: "text", text: "Analysis complete. The optimal solution is to use a cache layer with TTL of 300s." }], }, ]); - h.state.childSessions.set("tc-nested-2", session); + state.childSessions.set("tc-nested-2", session); const component = renderSpawnResult( { @@ -242,21 +243,21 @@ test("nested collapsed success matches snapshot", () => withHarness((h) => { false, theme, { toolCallId: "tc-nested-2", invalidate: () => {}, showImages: false, lastComponent: undefined }, - h.state, + state, ); const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); matchSnapshot("nested-collapsed-success", rtb.toSnapshot()); })); -test("nested expanded matches snapshot", () => withHarness((h) => { +test("nested expanded matches snapshot", () => withHarness((state) => { const session = createSession([ { role: "assistant", content: [{ type: "text", text: "Here is the implementation plan. Create data access layer, add caching middleware, wire up the controller." }], }, ]); - h.state.childSessions.set("tc-nested-3", session); + state.childSessions.set("tc-nested-3", session); const component = renderSpawnResult( { @@ -271,7 +272,7 @@ test("nested expanded matches snapshot", () => withHarness((h) => { true, theme, { toolCallId: "tc-nested-3", invalidate: () => {}, showImages: false, lastComponent: undefined }, - h.state, + state, ); const rtb = new RenderTestBackend().render(component as any, SNAP_WIDTH); From 9b84047190db10af41b35d234c5359a950f9da05 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Thu, 11 Jun 2026 09:32:14 +0300 Subject: [PATCH 35/40] Preserve writeContext alongside writeLock during in-flight singleton swaps --- runtime-singletons.ts | 8 +++++++- tests/unit/runtime-singletons.test.ts | 11 +++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/runtime-singletons.ts b/runtime-singletons.ts index a8279c4..aa068ca 100644 --- a/runtime-singletons.ts +++ b/runtime-singletons.ts @@ -81,7 +81,13 @@ export function __setSingletons( "Use { forceWriteLock: true } to override.", current.writeLock.pending, ); - current = { ...s, writeLock: current.writeLock }; + // Preserve both lock and ALS context together. Swapping only the context + // breaks reentrancy detection for writers already running inside the old lock. + current = { + ...s, + writeLock: current.writeLock, + writeContext: current.writeContext, + }; return; } current = s; diff --git a/tests/unit/runtime-singletons.test.ts b/tests/unit/runtime-singletons.test.ts index d45d5c7..7fcd35d 100644 --- a/tests/unit/runtime-singletons.test.ts +++ b/tests/unit/runtime-singletons.test.ts @@ -22,7 +22,7 @@ test("createTestHarness swaps singleton state atomically and restores it on tear assert.equal(getSingletons(), before); }); -test("__setSingletons warns when preserving in-flight write lock", () => { +test("__setSingletons warns and preserves lock + write context during in-flight writes", () => { // Use harness to isolate the test's singleton manipulation const h = createTestHarness(); try { @@ -32,15 +32,18 @@ test("__setSingletons warns when preserving in-flight write lock", () => { warnings.push(msg); }; try { - const s = getSingletons(); - s.writeLock.pending = 1; // simulate in-flight write on test singleton + const before = getSingletons(); + before.writeLock.pending = 1; // simulate in-flight write on test singleton __setSingletons({ writeLock: createWriteLock(), writeContext: new AsyncLocalStorage(), - frameScheduler: s.frameScheduler, + frameScheduler: before.frameScheduler, }); + const after = getSingletons(); assert.ok(warnings.length > 0); assert.match(warnings[0], /pending/); + assert.equal(after.writeLock, before.writeLock); + assert.equal(after.writeContext, before.writeContext); } finally { console.warn = originalWarn; } From 47e4ba594e698cc4408f6a75ec88735e3924d0df Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Thu, 11 Jun 2026 09:32:16 +0300 Subject: [PATCH 36/40] Add regression test for non-reentrant saveNotebookPage across singleton swaps --- tests/unit/notebook.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/notebook.test.ts b/tests/unit/notebook.test.ts index 84fdf0c..521a895 100644 --- a/tests/unit/notebook.test.ts +++ b/tests/unit/notebook.test.ts @@ -479,6 +479,35 @@ test("saveNotebookPage rejects true reentrancy explicitly", async () => { assert.equal(state.notebookPages.size, 0); }); +test("saveNotebookPage stays non-reentrant across runtime singleton swaps", async () => { + const pi = createTestPI(); + const state = createState(); + const previousSingletons = getSingletons(); + + try { + await assert.rejects( + () => Promise.race([ + saveNotebookPage(pi as any, state, "outer", "outer", async () => { + __setSingletons({ + writeLock: createWriteLock(), + writeContext: new AsyncLocalStorage(), + frameScheduler: getSingletons().frameScheduler, + }); + await saveNotebookPage(pi as any, state, "inner", "inner"); + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("timeout")), 1000); + }), + ]), + /not reentrant/i, + ); + assert.equal(state.notebookPages.size, 0); + } finally { + resetNotebookWriteLock(); + __setSingletons(previousSingletons, { forceWriteLock: true }); + } +}); + test("saveNotebookPage releases the lock when assertWritable throws", async () => { const pi = createTestPI(); const state = createState(); From dda8b6690bbe28a0100d6ca3ee23541e648990d7 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Thu, 11 Jun 2026 09:32:18 +0300 Subject: [PATCH 37/40] Align peer dependency ranges with tested SDK baseline --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 5eb2b89..86336d7 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-coding-agent": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", + "typebox": "^1.2.2" }, "scripts": { "test": "node ./scripts/run-node-test.mjs tests/unit/**/*.test.ts", From f27e667e84d91cf8863c48e8f410d0a289b4e895 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Thu, 11 Jun 2026 12:56:45 +0300 Subject: [PATCH 38/40] Convert pty-harness.ts and basic.test.ts to tab indentation --- tests/e2e/basic.test.ts | 268 +++++++++++++++++++-------------------- tests/e2e/pty-harness.ts | 148 ++++++++++----------- 2 files changed, 208 insertions(+), 208 deletions(-) diff --git a/tests/e2e/basic.test.ts b/tests/e2e/basic.test.ts index 1564a98..30f78eb 100644 --- a/tests/e2e/basic.test.ts +++ b/tests/e2e/basic.test.ts @@ -13,144 +13,144 @@ import { ProcessHarness } from "./pty-harness.js"; * Create a fresh host, wait for READY, and return the harness. */ async function start(): Promise { - const h = new ProcessHarness(); - await h.waitForText("READY"); - return h; + const h = new ProcessHarness(); + await h.waitForText("READY"); + return h; } async function withHarness(run: (h: ProcessHarness) => Promise): Promise { - const h = await start(); - try { - await run(h); - } finally { - try { - h.write("exit"); - } catch { - // already dead - } - h.close(); - } + const h = await start(); + try { + await run(h); + } finally { + try { + h.write("exit"); + } catch { + // already dead + } + h.close(); + } } describe("agenticoding E2E", () => { - it("host starts and extension registers", async () => withHarness(async (h) => { - h.write("tools"); - await h.waitForText("OK:"); - - const snap = h.snapshot(); - assert.ok(snap.includes("notebook_write"), "notebook_write tool registered"); - assert.ok(snap.includes("notebook_read"), "notebook_read tool registered"); - assert.ok(snap.includes("notebook_index"), "notebook_index tool registered"); - assert.ok(snap.includes("notebook_topic_set"), "notebook_topic_set tool registered"); - assert.ok(snap.includes("handoff"), "handoff tool registered"); - assert.ok(snap.includes("spawn"), "spawn tool registered"); - })); - - it("notebook write/read round-trip", async () => withHarness(async (h) => { - h.write('tool notebook_write {"name":"my-page","content":"Hello World"}'); - await h.waitForText("OK:Saved notebook page"); - - h.write('tool notebook_read {"name":"my-page"}'); - await h.waitForText("OK:--- my-page ---"); - - const snap = h.snapshot(); - assert.ok(snap.includes("Hello World"), "content persisted"); - })); - - it("notebook index reflects written pages", async () => withHarness(async (h) => { - h.write('tool notebook_write {"name":"page-a","content":"Page A"}'); - await h.waitForText("OK:"); - - h.write("tool notebook_index {}"); - await h.waitForText("page-a"); - - // Second write should appear in index - h.write('tool notebook_write {"name":"page-b","content":"Page B"}'); - await h.waitForText("OK:"); - - h.write("tool notebook_index {}"); - await h.waitForText("page-b"); - - const snap = h.snapshot(); - assert.ok(snap.includes("page-a"), "page-a in index"); - assert.ok(snap.includes("page-b"), "page-b in index"); - })); - - it("notebook_write overwrites existing page", async () => withHarness(async (h) => { - h.write('tool notebook_write {"name":"page","content":"v1"}'); - await h.waitForText("OK:"); - - // Clear accumulated output so we only check the second write/read - h.clear(); - h.write('tool notebook_write {"name":"page","content":"v2"}'); - await h.waitForText("OK:"); - - h.clear(); - h.write('tool notebook_read {"name":"page"}'); - await h.waitForText("OK:--- page ---"); - - const snap = h.snapshot(); - assert.ok(snap.includes("v2"), "overwritten content present"); - assert.ok(!snap.includes("v1"), "old content absent from fresh output"); - })); - - it("notebook topic lifecycle: set via command, agent-set blocked", async () => withHarness(async (h) => { - // Set topic via /notebook command (human-set) - h.write("cmd notebook my-e2e-topic"); - await h.waitForText("OK"); - - // Agent-set should be blocked (human is authoritative) - h.write('tool notebook_topic_set {"topic":"agent-topic"}'); - await h.waitForText("ERR:"); - const snap = h.snapshot(); - assert.ok( - snap.includes("authoritative"), - "human-set topic blocks agent override", - ); - })); - - it("agent-set topic works when unset", async () => withHarness(async (h) => { - // No topic set yet -- agent can set - h.write('tool notebook_topic_set {"topic":"fresh-agent-topic"}'); - await h.waitForText("OK:Active notebook topic:"); - const snap = h.snapshot(); - assert.ok(snap.includes("fresh-agent-topic")); - })); - - it("handoff tool queues handoff state", async () => withHarness(async (h) => { - h.write('tool handoff {"task":"test handoff task","direction":"next-phase"}'); - await h.waitForText("OK:Handoff started"); - })); - - it("commands are registered", async () => withHarness(async (h) => { - h.write("cmds"); - await h.waitForText("OK:"); - - const snap = h.snapshot(); - assert.ok(snap.includes("notebook"), "/notebook command registered"); - assert.ok(snap.includes("handoff"), "/handoff command registered"); - })); - - it("spawn tool errors gracefully without model infrastructure", async () => withHarness(async (h) => { - // Without a real model/session manager, spawn should throw immediately. - h.write('tool spawn {"prompt":"any task"}'); - await h.waitForText("ERR:"); - - const snap = h.snapshot(); - assert.ok(snap.includes("No model") || snap.includes("ERR"), "spawn errors gracefully"); - })); - - it("handles errors gracefully", async () => withHarness(async (h) => { - // Unknown tool - h.write("tool nonexistent {}"); - await h.waitForText("ERR:unknown tool"); - - // Invalid JSON - h.write("tool notebook_write {bad json}"); - await h.waitForText("ERR:invalid json"); - - // Unknown command - h.write("cmd nonexistent"); - await h.waitForText("ERR:unknown command"); - })); + it("host starts and extension registers", async () => withHarness(async (h) => { + h.write("tools"); + await h.waitForText("OK:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("notebook_write"), "notebook_write tool registered"); + assert.ok(snap.includes("notebook_read"), "notebook_read tool registered"); + assert.ok(snap.includes("notebook_index"), "notebook_index tool registered"); + assert.ok(snap.includes("notebook_topic_set"), "notebook_topic_set tool registered"); + assert.ok(snap.includes("handoff"), "handoff tool registered"); + assert.ok(snap.includes("spawn"), "spawn tool registered"); + })); + + it("notebook write/read round-trip", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"my-page","content":"Hello World"}'); + await h.waitForText("OK:Saved notebook page"); + + h.write('tool notebook_read {"name":"my-page"}'); + await h.waitForText("OK:--- my-page ---"); + + const snap = h.snapshot(); + assert.ok(snap.includes("Hello World"), "content persisted"); + })); + + it("notebook index reflects written pages", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"page-a","content":"Page A"}'); + await h.waitForText("OK:"); + + h.write("tool notebook_index {}"); + await h.waitForText("page-a"); + + // Second write should appear in index + h.write('tool notebook_write {"name":"page-b","content":"Page B"}'); + await h.waitForText("OK:"); + + h.write("tool notebook_index {}"); + await h.waitForText("page-b"); + + const snap = h.snapshot(); + assert.ok(snap.includes("page-a"), "page-a in index"); + assert.ok(snap.includes("page-b"), "page-b in index"); + })); + + it("notebook_write overwrites existing page", async () => withHarness(async (h) => { + h.write('tool notebook_write {"name":"page","content":"v1"}'); + await h.waitForText("OK:"); + + // Clear accumulated output so we only check the second write/read + h.clear(); + h.write('tool notebook_write {"name":"page","content":"v2"}'); + await h.waitForText("OK:"); + + h.clear(); + h.write('tool notebook_read {"name":"page"}'); + await h.waitForText("OK:--- page ---"); + + const snap = h.snapshot(); + assert.ok(snap.includes("v2"), "overwritten content present"); + assert.ok(!snap.includes("v1"), "old content absent from fresh output"); + })); + + it("notebook topic lifecycle: set via command, agent-set blocked", async () => withHarness(async (h) => { + // Set topic via /notebook command (human-set) + h.write("cmd notebook my-e2e-topic"); + await h.waitForText("OK"); + + // Agent-set should be blocked (human is authoritative) + h.write('tool notebook_topic_set {"topic":"agent-topic"}'); + await h.waitForText("ERR:"); + const snap = h.snapshot(); + assert.ok( + snap.includes("authoritative"), + "human-set topic blocks agent override", + ); + })); + + it("agent-set topic works when unset", async () => withHarness(async (h) => { + // No topic set yet -- agent can set + h.write('tool notebook_topic_set {"topic":"fresh-agent-topic"}'); + await h.waitForText("OK:Active notebook topic:"); + const snap = h.snapshot(); + assert.ok(snap.includes("fresh-agent-topic")); + })); + + it("handoff tool queues handoff state", async () => withHarness(async (h) => { + h.write('tool handoff {"task":"test handoff task","direction":"next-phase"}'); + await h.waitForText("OK:Handoff started"); + })); + + it("commands are registered", async () => withHarness(async (h) => { + h.write("cmds"); + await h.waitForText("OK:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("notebook"), "/notebook command registered"); + assert.ok(snap.includes("handoff"), "/handoff command registered"); + })); + + it("spawn tool errors gracefully without model infrastructure", async () => withHarness(async (h) => { + // Without a real model/session manager, spawn should throw immediately. + h.write('tool spawn {"prompt":"any task"}'); + await h.waitForText("ERR:"); + + const snap = h.snapshot(); + assert.ok(snap.includes("No model") || snap.includes("ERR"), "spawn errors gracefully"); + })); + + it("handles errors gracefully", async () => withHarness(async (h) => { + // Unknown tool + h.write("tool nonexistent {}"); + await h.waitForText("ERR:unknown tool"); + + // Invalid JSON + h.write("tool notebook_write {bad json}"); + await h.waitForText("ERR:invalid json"); + + // Unknown command + h.write("cmd nonexistent"); + await h.waitForText("ERR:unknown command"); + })); }); diff --git a/tests/e2e/pty-harness.ts b/tests/e2e/pty-harness.ts index 44111aa..f31a71d 100644 --- a/tests/e2e/pty-harness.ts +++ b/tests/e2e/pty-harness.ts @@ -19,89 +19,89 @@ const DEFAULT_TIMEOUT_MS = 5000; const TIMEOUT_MS = parseInt(process.env.E2E_TIMEOUT_MS ?? "", 10) || DEFAULT_TIMEOUT_MS; export class ProcessHarness { - private child: ChildProcessWithoutNullStreams; - private output = ""; - private readOffset = 0; - private timeoutMs: number; - private waiters = new Set<() => void>(); + private child: ChildProcessWithoutNullStreams; + private output = ""; + private readOffset = 0; + private timeoutMs: number; + private waiters = new Set<() => void>(); - constructor( - scriptPath = DEFAULT_SCRIPT, - options?: { timeoutMs?: number }, - ) { - this.timeoutMs = options?.timeoutMs ?? TIMEOUT_MS; + constructor( + scriptPath = DEFAULT_SCRIPT, + options?: { timeoutMs?: number }, + ) { + this.timeoutMs = options?.timeoutMs ?? TIMEOUT_MS; - const entry = isAbsolute(scriptPath) ? scriptPath : resolve(ROOT, scriptPath); + const entry = isAbsolute(scriptPath) ? scriptPath : resolve(ROOT, scriptPath); - this.child = spawn(process.execPath, ["--import", LOADER, entry], { - cwd: ROOT, - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - FORCE_COLOR: "0", - NODE_OPTIONS: "", - }, - }); + this.child = spawn(process.execPath, ["--import", LOADER, entry], { + cwd: ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + FORCE_COLOR: "0", + NODE_OPTIONS: "", + }, + }); - const append = (chunk: string | Buffer) => { - this.output += chunk.toString(); - for (const wake of this.waiters) wake(); - this.waiters.clear(); - }; + const append = (chunk: string | Buffer) => { + this.output += chunk.toString(); + for (const wake of this.waiters) wake(); + this.waiters.clear(); + }; - this.child.stdout.on("data", append); - this.child.stderr.on("data", append); - } + this.child.stdout.on("data", append); + this.child.stderr.on("data", append); + } - private async waitForOutput(ms: number): Promise { - if (ms <= 0) return; - await new Promise((resolve) => { - const wake = () => { - clearTimeout(timer); - this.waiters.delete(wake); - resolve(); - }; - const timer = setTimeout(wake, ms); - this.waiters.add(wake); - }); - } + private async waitForOutput(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => { + const wake = () => { + clearTimeout(timer); + this.waiters.delete(wake); + resolve(); + }; + const timer = setTimeout(wake, ms); + this.waiters.add(wake); + }); + } - /** Wait for a fresh substring to appear after the prior match. */ - async waitForText(text: string): Promise { - const deadline = Date.now() + this.timeoutMs; - while (Date.now() < deadline) { - const index = this.output.indexOf(text, this.readOffset); - if (index !== -1) { - this.readOffset = index + text.length; - return; - } - await this.waitForOutput(deadline - Date.now()); - } - throw new Error( - `waitForText timeout after ${this.timeoutMs}ms looking for fresh \"${text}\".\n` + - `Output so far:\n${this.output}`, - ); - } + /** Wait for a fresh substring to appear after the prior match. */ + async waitForText(text: string): Promise { + const deadline = Date.now() + this.timeoutMs; + while (Date.now() < deadline) { + const index = this.output.indexOf(text, this.readOffset); + if (index !== -1) { + this.readOffset = index + text.length; + return; + } + await this.waitForOutput(deadline - Date.now()); + } + throw new Error( + `waitForText timeout after ${this.timeoutMs}ms looking for fresh \"${text}\".\n` + + `Output so far:\n${this.output}`, + ); + } - /** Write a line of input to the child process. */ - write(input: string): void { - this.child.stdin.write(input + "\n"); - } + /** Write a line of input to the child process. */ + write(input: string): void { + this.child.stdin.write(input + "\n"); + } - /** Return all accumulated output since creation or last clear(). */ - snapshot(): string { - return this.output; - } + /** Return all accumulated output since creation or last clear(). */ + snapshot(): string { + return this.output; + } - /** Clear accumulated output and match cursor, keeping the child running. */ - clear(): void { - this.output = ""; - this.readOffset = 0; - } + /** Clear accumulated output and match cursor, keeping the child running. */ + clear(): void { + this.output = ""; + this.readOffset = 0; + } - /** Kill the child process. */ - close(): void { - this.child.stdin.end(); - if (!this.child.killed) this.child.kill(); - } + /** Kill the child process. */ + close(): void { + this.child.stdin.end(); + if (!this.child.killed) this.child.kill(); + } } From 0077093cd66b531126afdc3a92c7d83357a31afb Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Fri, 12 Jun 2026 15:49:50 +0300 Subject: [PATCH 39/40] Resolve TypeBox from project dependency tree via exports map --- test-loader.mjs | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/test-loader.mjs b/test-loader.mjs index 89ad005..5ff9178 100644 --- a/test-loader.mjs +++ b/test-loader.mjs @@ -1,7 +1,7 @@ import { access } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; /** * Walk up from a start directory to find node_modules/. @@ -21,20 +21,50 @@ function findPackageRoot(name, startDir, maxDepth = 50) { } } +const PROJECT_ROOT = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = findPackageRoot( "@earendil-works/pi-coding-agent", - path.dirname(fileURLToPath(import.meta.url)), + PROJECT_ROOT, ); if (!PACKAGE_ROOT) throw new Error("Cannot find @earendil-works/pi-coding-agent package root"); + +function findPackageEntry(name, entry, searchRoot) { + const packageRoot = findPackageRoot(name, searchRoot); + if (!packageRoot) throw new Error(`Cannot find ${name} package root`); + const resolved = path.join(packageRoot, entry); + if (existsSync(resolved)) return resolved; + throw new Error(`Cannot find ${name}/${entry}`); +} + +const TYPEBOX_ROOT = findPackageRoot("typebox", PROJECT_ROOT); +if (!TYPEBOX_ROOT) throw new Error("Cannot find typebox package root"); +const TYPEBOX_EXPORTS = JSON.parse(readFileSync(path.join(TYPEBOX_ROOT, "package.json"), "utf8")).exports; + +function resolveTypeboxSpecifier(specifier) { + const exportKey = specifier === "typebox" ? "." : `./${specifier.slice("typebox/".length)}`; + const exportTarget = TYPEBOX_EXPORTS?.[exportKey]; + const entry = typeof exportTarget === "string" ? exportTarget : exportTarget?.import ?? exportTarget?.default; + if (!entry) throw new Error(`Cannot find ${specifier} export in top-level typebox package`); + const resolved = path.join(TYPEBOX_ROOT, entry); + if (!existsSync(resolved)) throw new Error(`Cannot find ${specifier} at ${resolved}`); + return resolved; +} + const PACKAGE_ALIASES = { "@earendil-works/pi-coding-agent": `${PACKAGE_ROOT}/dist/index.js`, - "@earendil-works/pi-ai": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-ai/dist/index.js`, - "@earendil-works/pi-tui": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-tui/dist/index.js`, - "@earendil-works/pi-agent-core": `${PACKAGE_ROOT}/node_modules/@earendil-works/pi-agent-core/dist/index.js`, - typebox: `${PACKAGE_ROOT}/node_modules/typebox/build/index.mjs`, + "@earendil-works/pi-ai": findPackageEntry("@earendil-works/pi-ai", "dist/index.js", PACKAGE_ROOT), + "@earendil-works/pi-tui": findPackageEntry("@earendil-works/pi-tui", "dist/index.js", PACKAGE_ROOT), + "@earendil-works/pi-agent-core": findPackageEntry("@earendil-works/pi-agent-core", "dist/index.js", PACKAGE_ROOT), }; export async function resolve(specifier, context, defaultResolve) { + // typebox handled before PACKAGE_ALIASES — resolved via exports map, not alias entry. + if (specifier === "typebox" || specifier.startsWith("typebox/")) { + const typeboxPath = resolveTypeboxSpecifier(specifier); + // Tests should use the repo's declared top-level TypeBox package, including subpath exports. + return defaultResolve(pathToFileURL(typeboxPath).href, context, defaultResolve); + } + const packagePath = PACKAGE_ALIASES[specifier]; if (packagePath) { return defaultResolve(pathToFileURL(packagePath).href, context, defaultResolve); From bbce65eebad49b2212f5e095309da999a82a9ec3 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Fri, 12 Jun 2026 15:49:52 +0300 Subject: [PATCH 40/40] Add regression tests for TypeBox resolution in register-loader --- tests/unit/register-loader.test.ts | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/unit/register-loader.test.ts b/tests/unit/register-loader.test.ts index 401f2ce..d174921 100644 --- a/tests/unit/register-loader.test.ts +++ b/tests/unit/register-loader.test.ts @@ -55,3 +55,71 @@ test("register-loader errors when entry file does not exist", () => { rmSync(cwd, { recursive: true, force: true }); } }); + +test("register-loader resolves typebox exports from the project dependency tree", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-typebox-")); + try { + const result = spawnSync( + process.execPath, + [ + "--import", + REGISTER_LOADER, + "--input-type=module", + "-e", + [ + 'const specifiers = ["typebox", "typebox/compile", "typebox/value"];', + "const mods = await Promise.all(specifiers.map((specifier) => import(specifier)));", + 'if (typeof mods[0].Type.String !== "function") process.exit(1);', + "console.log(JSON.stringify(Object.fromEntries(specifiers.map((specifier) => [specifier, import.meta.resolve(specifier)]))));", + ].join("\n"), + ], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const resolved = JSON.parse(result.stdout.trim()) as Record; + const [rootSpecifier, ...subpathSpecifiers] = Object.keys(resolved); + const typeboxRoot = resolved[rootSpecifier].replace(/(?:build\/)?index\.m?js$/, ""); + assert.match(typeboxRoot, /^file:/); + for (const specifier of Object.keys(resolved)) { + assert.match(resolved[specifier], /^file:/); + assert.ok( + resolved[specifier].startsWith(typeboxRoot), + `${specifier} should resolve from the same typebox package root`, + ); + } + assert.ok(subpathSpecifiers.length > 0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("register-loader surfaces a clear error for missing typebox exports", () => { + const cwd = mkdtempSync(resolve(tmpdir(), "pi-agenticoding-loader-typebox-missing-")); + try { + const result = spawnSync( + process.execPath, + [ + "--import", + REGISTER_LOADER, + "--input-type=module", + "-e", + 'await import("typebox/not-real");', + ], + { + cwd, + encoding: "utf8", + env: { ...process.env, NODE_OPTIONS: "" }, + }, + ); + + assert.notEqual(result.status, 0, "should exit non-zero for missing typebox export"); + assert.match(result.stderr, /Cannot find typebox\/not-real export/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +});