diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5690294..977e32f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,18 @@ jobs: python -m pytest python -m ruff check . + - name: Set up Node for Pi integration tests + uses: actions/setup-node@v4 + with: + node-version: "22.19.0" + cache: npm + cache-dependency-path: integrations/pi/package-lock.json + + - name: Test Pi integrations + run: | + npm ci --prefix integrations/pi + npm test --prefix integrations/pi + - name: Build distributions run: python -m build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f478fa1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install '.[dev]' + - run: python -m pytest + - run: python -m ruff check . + pi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + cache: npm + cache-dependency-path: integrations/pi/package-lock.json + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install . + - run: npm ci --prefix integrations/pi + - run: npm test --prefix integrations/pi diff --git a/README.md b/README.md index 2a6c9b4..c4e1366 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ printf '%s\n' '{"session_id":"thr_123","cwd":"/workspace/project","hook_event_na | foreman hook --client codex ``` -The explicit `--client` selects a protocol adapter; `codex` is currently the only adapter and +The explicit `--client` selects `codex`, `pi`, or `pi-durable`; `codex` remains the compatibility default. Foreman does not infer a client from arbitrary JSON, and unknown clients fail closed. The adapter normalizes events before they reach the shared attached-worker runtime and translates semantic outcomes back into client-specific hook JSON. @@ -394,6 +394,11 @@ See [coding-assistant hooks and attached workers](docs/hooks.md) for the process [Codex plugin README](https://github.com/thruwire/marketplace/tree/main/plugins/foreman) for installation, trust, and diagnostics. +The [Pi and Pi Durable bridges](integrations/pi/README.md) forward native extension events and +task hooks to the same runtime. They include prompt routing, tool supervision, completion +continuations, and a Durable replay contract. Build and load them from this checkout; the bridge +package has not been published to npm. + ## Persistence and inspection Each repository gets local, ignored state: diff --git a/docs/hooks.md b/docs/hooks.md index a6f7c85..b0c06dc 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -29,7 +29,7 @@ target repository. ## Client adapters The `--client` option selects the protocol adapter. Foreman does not guess from arbitrary input -JSON. It currently ships a `codex` adapter, which remains the default for compatibility: +JSON. It ships `codex`, `pi`, and `pi-durable` adapters. Codex remains the default for compatibility: ```bash foreman hook --client codex @@ -41,6 +41,19 @@ its assistant's event names and fields into `HookEvent`, then renders Foreman's session code only sees those normalized types. Adding another assistant therefore requires a new adapter and registry entry, not another supervision runtime. +Pi uses the TypeScript [bridge package](../integrations/pi/README.md), which wraps native events +in a session/cwd envelope and applies semantic response objects through Pi's native APIs. Its +events are `session_start`, `input`, `tool_call`, `tool_result`, `agent_before_settle`, and +`session_shutdown`. Input can consume a refused prompt before starting the agent; the final +actionable settle boundary supplies completion feedback and requests continuation. + +Pi Durable uses native `GenerationTask` and `ToolTask` hooks: `beforeRequest`, `beforeTool`, +`afterTool`, and `onYield`. The bridge combines a stable storage identity with the conversation +ID, uses actual submitted entries as work, and deduplicates routing using their durable IDs. +Before-tool steering becomes a tool block because that hook cannot inject context. Generation +halt uses the public task abort API because ordinary hook exceptions are logged and swallowed. +See the bridge guide for setup, version support, and parallel-tool limitations. + ## Event flow - `SessionStart` creates or refreshes the local session record. @@ -84,6 +97,13 @@ Sessions expire after seven days of inactivity by default. Set `FOREMAN_HOOK_SESSION_TTL_SECONDS` to change that lifetime, or pass `--data-dir` to isolate the state directory during development and testing. +Clients may provide `work_id` to route a repeated original submission idempotently. The saved +work prompt, responsibility context, and one-continuation allowance survive retries and process +restarts. A new work ID resets the allowance. Optional `event_id` caches a completion response, +so replay between an external decision and a client's own commit does not spend that allowance +again. Reusing an ID with different input fails. The replay cache is bounded by event history +limits and expires with the session; it does not promise exactly-once model or tool execution. + ## Responsibility configuration ### Central attached-worker configuration @@ -156,7 +176,7 @@ Coding-assistant plugin packaging remains separate from the runtime. The protocol and is published through the ThruWire marketplace. The placeholder under `integrations/claude` reserves the future Claude Code adapter location but is not installable. -Command evidence is likewise assistant-neutral: a Codex or future assistant plugin only forwards +Command evidence is likewise assistant-neutral: a Codex, Pi, or Pi Durable bridge only forwards lifecycle events to `foreman hook`. Foreman owns provider invocation and check-specific evidence, so a CLI verifier does not need a separate SDK integration for every coding assistant. See [Evidence providers](evidence.md). diff --git a/integrations/README.md b/integrations/README.md index 08677dd..4961f04 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -7,5 +7,8 @@ are available to marketplace discovery before installation. - The [Foreman Codex plugin](https://github.com/thruwire/marketplace/tree/main/plugins/foreman) invokes the core `foreman hook --client codex` protocol. +- [`pi/`](pi/) supplies buildable Pi extension and Pi Durable task-hook bridges, invoking + `foreman hook --client pi` and `foreman hook --client pi-durable` respectively. This is a local + integration package with installation documentation and offline tests, not an npm release. - [`claude/`](claude/) reserves the future Claude Code integration location. It is not currently an installable plugin. diff --git a/integrations/pi/.gitignore b/integrations/pi/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/integrations/pi/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/integrations/pi/README.md b/integrations/pi/README.md new file mode 100644 index 0000000..3972687 --- /dev/null +++ b/integrations/pi/README.md @@ -0,0 +1,155 @@ +# Foreman for Pi and Pi Durable + +These bridges forward Pi lifecycle events and Pi Durable task hooks to the same +`foreman hook` supervisor used by Codex. Foreman owns routing, evidence, decisions, +request budgets, and attached-session storage. The bridge never makes a model call. + +Tested against `@earendil-works/pi-coding-agent` and +`@earendil-works/pi-durable` **1.0.4**, with Node **22.19+**. Pi Durable's API is +experimental; the peer range is limited to 1.0.x. This integration is a local +package in the repository, not a published npm package. The former +`@mariozechner` packages are not part of this tested contract. + +## Build and configure + +Until this PR is released, install the Foreman core from this checkout: + +```bash +python3 -m pip install -e /absolute/path/to/foreman +cd /absolute/path/to/foreman/integrations/pi +npm ci +npm run build +``` + +Put `TYPESAFE_API_KEY` in the environment or `~/.foreman/.env`. Existing Foreman +responsibilities, repository scope, extensions, and request budgets apply to both +clients; see [the hook guide](../../docs/hooks.md). The default bridge command is +`foreman hook --client pi` or `foreman hook --client pi-durable`, found on `PATH`. + +## Pi coding agent + +Load the built extension: + +```bash +pi -e /absolute/path/to/foreman/integrations/pi/dist/pi.js +``` + +For persistent loading, add that absolute path to `extensions` in Pi's +`~/.pi/agent/settings.json`. Keep the adjacent `transport.js` in place. +For a custom executable, create a Pi extension that exports the factory result: + +```js +import { createForemanPi } from '/absolute/path/to/foreman/integrations/pi/dist/pi.js'; +export default createForemanPi({ + command: ['/absolute/path/to/foreman'], + // dataDir: '/absolute/path/to/isolated-foreman-data', + // timeoutMs: 120000, +}); +``` + +The extension handles session start/shutdown, prompt input, tool calls/results, +and `agent_before_settle`. Routing happens at `input`, which can consume a refused +prompt before the agent starts; `before_agent_start` supplies the returned +responsibility context. Pi's ordinary extension exceptions are notification-only, +so a routing failure consumes the input, and a tool failure returns a denial. + +A completion block appends model-visible Foreman feedback and requests one +continuation. Foreman persists that allowance; another incomplete finish halts. +Post-tool blocking or halt preserves the result, adds feedback, and aborts the +agent. Aborted/error runs are not automatically continued. Manual user commands +such as `!bash` are outside agent tool supervision. Extensions that transform +input after this handler may change the eventual prompt; register Foreman after +input transformers when using them. + +## Pi Durable + +Install the extension in the host's registry before opening the harness: + +```js +import { createRegistry, Harness } from '@earendil-works/pi-durable'; +import { createForemanDurable } from '/absolute/path/to/foreman/integrations/pi/dist/durable.js'; + +let harness; +const registry = createRegistry(); +// Install your tools and other extensions here. +registry.install(createForemanDurable({ + sessionId: '/absolute/path/to/my-pi-storage', + cwd: '/absolute/path/to/my-repository', + harness: () => harness, +})); +harness = await Harness.open(storage, { models, registry }, context); +``` + +`storage`, `models`, and `context` are the host's normal durable setup. The lazy +`harness` accessor resolves after opening, before any work starts. `sessionId` +must uniquely identify the storage and stay the same when reopening it. A +conversation ID is only unique within one storage, so the bridge combines both. +A conversation's configured `cwd` takes precedence over the fallback above. +The extension is selected by default; when explicitly selecting extensions, +include `foreman` in the conversation's selection. + +The native hooks are `GenerationTask.beforeRequest`, `GenerationTask.onYield`, +`GenerationTask.afterTools`, `ToolTask.beforeTool`, and `ToolTask.afterTool`. `beforeRequest` reads the run's +actual submitted entries through the harness, rather than routing the transcript +or a synthetic continuation prompt. Stable submission IDs keep routing and the +one-continuation allowance intact through retries and generation handovers. New +user submissions route new work. Text prompts are required; image-only submissions +are refused because Foreman has no text job to assess. + +Pi Durable's `beforeTool` supports blocking and argument replacement, but no +context injection. Foreman steering at this boundary therefore blocks the +proposed tool with feedback for the model to act on. After a tool, steering is +appended to its result; blocking or halt also marks an error and requests +termination through the native result control. A saved conversation control +record also halts the generation at `afterTools`, including mixed parallel +batches where one result's terminate flag would otherwise be ignored. As with Pi, a tool already run +cannot be undone, and other calls in an existing parallel batch may already be +running. For strict sequential boundaries set `toolExecution: 'sequential'` in +harness settings. + +Pi Durable reports and swallows ordinary generation-hook exceptions. The bridge +uses the public `abortTask` API and waits for invocation cancellation, rather +than joining its own invocation, before propagating a refused request or halt. +Completion decisions use both a task memo and Foreman's replay record, covering +the gap between the external decision and Pi's memo commit. This does not make +external model calls or tool executions exactly once. Foreman records expire +under its normal session TTL; reopening after expiry starts fresh supervision. +There is no shutdown task hook, so durable Foreman sessions expire naturally. + +## Process contract and tests + +Bridge input is one JSON envelope on stdin: + +```json +{"session_id":"native identity","cwd":"/workspace","work_id":"stable work identity","event":{"type":"input","text":"Fix the tests"}} +``` + +The Pi Durable adapter instead accepts `beforeRequest` with `prompt`, tool hooks +with a native `call` (`id`, `name`, `arguments`), `afterTool` with `result`, and +`onYield` with `last_assistant_message`. `work_id` deduplicates routing; +`event_id` identifies replayable completion calls. Reusing either identity with +changed input fails. Successful output is `{}` or a semantic action object: +`{"action":"inject_context|block|halt","reason":"..."}`. The bridge applies it +through the client's native API; this JSON is not a native Pi hook response. + +Commands are executed without a shell. Full arguments and results go over stdin. +Missing executables, nonzero status, invalid/oversized JSON, cancellation, and +timeouts prevent supervised operations. Diagnostics are drained rather than +retained in the bridge; use Foreman's saved state for assessment failure details. + +```bash +npm test +# From the Foreman repository root: +PYTHONPATH=src .venv/bin/python -m pytest +.venv/bin/python -m ruff check . +``` + +Tests use published Pi packages, their offline faux provider, isolated temporary +storage, and a fixture hook process. They do not need provider credentials or +alter the user's installed integrations. + +Upstream contracts: [Pi extensions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md), +[Pi Durable hooks](https://github.com/earendil-works/pi/blob/main/packages/durable/README.md#hooks). +The source inspected for implementation is +[`ae92585`](https://github.com/earendil-works/pi/tree/ae92585d3b3e5f1e4b123d14a34314d826d8d9f5); +type checks and runtime tests use the published 1.0.4 packages in the lockfile. diff --git a/integrations/pi/package-lock.json b/integrations/pi/package-lock.json new file mode 100644 index 0000000..ecc853d --- /dev/null +++ b/integrations/pi/package-lock.json @@ -0,0 +1,2153 @@ +{ + "name": "@thruwire/foreman-pi", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@thruwire/foreman-pi", + "version": "0.1.0", + "devDependencies": { + "@earendil-works/pi-coding-agent": "1.0.4", + "@earendil-works/pi-durable": "1.0.4", + "@types/node": "^22.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=1.0.4 <1.1.0", + "@earendil-works/pi-durable": ">=1.0.4 <1.1.0" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "@earendil-works/pi-durable": { + "optional": true + } + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz", + "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz", + "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz", + "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/chord": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.0.4.tgz", + "integrity": "sha512-d2JZw2utoa/reoSmL07O71wsXiMDhIlnnxTb4LCNV+Oq8Evj2rGLvjVuYrlXZGbXUr0IDNfvd1RT/utCY1UhkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.0.4.tgz", + "integrity": "sha512-/xGICE+N/+G9N7KfYFSxlE8G8fQcBlLHKiY+j1Ehz9vvb1zhvGneWosFK56d2qmA5oteafeG7yKr2nqU4ed9ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^1.0.4", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.0.4.tgz", + "integrity": "sha512-/Eu5R0gfor6wcmRcVyOmulUiZmsHJ0bKu5wXXnpCoSHF3y4FnW4fgnjRlLjNza8NJWXdvW5Uew90Zukx7Sh2rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.0.4", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.0.4.tgz", + "integrity": "sha512-e198pjBsdEQJ/RCjJ9ZeYIjRaQYWUgtoZbPCmJ57auuOsiRsBXeSZ1aiWtTah0/7gx97WIOD1XqHvgRU0gOXtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.0.4.tgz", + "integrity": "sha512-+956nfMFHr5lDUVY/2Q4k+YzojzBuCaBXFgj0eSlXVGr7QVliVddKdc1Pz6yVg1dOlJQmb67doOVrlMsIcIdaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.0.4", + "@earendil-works/pi-agent-core": "^1.0.4", + "@earendil-works/pi-ai": "^1.0.4", + "@earendil-works/pi-codemode": "^1.0.4", + "@earendil-works/pi-mcp": "^1.0.4", + "@earendil-works/pi-tui": "^1.0.4", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-durable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-durable/-/pi-durable-1.0.4.tgz", + "integrity": "sha512-i22unyhavxrTF/KS/JcBOKprQRgepufOIztnqBKZXJCWbEHsXDzcINtjmmBSbFB/3S+4FWIl3eJ5FMbRe33Ybg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.0.4", + "@earendil-works/pi-ai": "^1.0.4", + "diff": "8.0.4", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.0.4.tgz", + "integrity": "sha512-I6SqOqrKED+XrjkkFIXY7Ze38QtXtjojPJlQsJ2v5soCPV4QI1bNxXDwQaqzZ2a+6pNN8meWrRw1qkSMbPy9QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.0.4.tgz", + "integrity": "sha512-DL2eXXtnL5/VQkuQd97fL57GXTG1/zKHtMmDsrv8tGeJ5EgYYTWvzy44dqbYL0lD0Z22v/NOwqU3PHO75HVrqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.0.4.tgz", + "integrity": "sha512-TgvDJohtxvG3XWQID5w6bwEyBocywdG0F9yHZtP+Ywvtiq9RgeSMbeB8quUobk7/4ko6IvFOQo45AqIqNVr8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "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/@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==", + "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==", + "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==", + "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==", + "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", + "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==", + "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==", + "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==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "dev": true, + "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" + }, + "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", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "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==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "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", + "engines": { + "node": ">=0.3.1" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/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", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "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", + "engines": { + "node": "*" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "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", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "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/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/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "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==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "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==", + "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", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "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/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/integrations/pi/package.json b/integrations/pi/package.json new file mode 100644 index 0000000..23a18b5 --- /dev/null +++ b/integrations/pi/package.json @@ -0,0 +1,45 @@ +{ + "name": "@thruwire/foreman-pi", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Pi and Pi Durable bridges to Foreman's attached-worker supervisor", + "exports": { + "./pi": "./dist/pi.js", + "./durable": "./dist/durable.js" + }, + "files": [ + "dist", + "README.md" + ], + "pi": { + "extensions": [ + "./dist/pi.js" + ] + }, + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test tests/*.test.mjs" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=1.0.4 <1.1.0", + "@earendil-works/pi-durable": ">=1.0.4 <1.1.0" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "@earendil-works/pi-durable": { + "optional": true + } + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "1.0.4", + "@earendil-works/pi-durable": "1.0.4", + "@types/node": "^22.0.0", + "typescript": "^5.9.0" + } +} diff --git a/integrations/pi/src/durable.ts b/integrations/pi/src/durable.ts new file mode 100644 index 0000000..91bd219 --- /dev/null +++ b/integrations/pi/src/durable.ts @@ -0,0 +1,254 @@ +import { + AgentDoc, + defineDoc, + defineExtension, + GenerationTask, + hook, + LiveDoc, + ToolTask, + type Harness, + type HookApi, +} from "@earendil-works/pi-durable"; +import type { Context } from "@earendil-works/chord"; +import { + awaitWithContext, + withoutAbortSignal, +} from "@earendil-works/chord/context"; +import { + createTransport, + reason, + text, + type BridgeOptions, + type Envelope, +} from "./transport.js"; + +export type DurableOptions = BridgeOptions & { + /** Stable, unique identity of the storage, reused after reopening it. */ + sessionId: string; + /** Lazy accessor: registry installation happens before Harness.open(). */ + harness: () => Harness; + /** Fallback when the conversation has no configured cwd. */ + cwd: string; +}; + +const Control = defineDoc<{ workId: string; halt: string }>({ + kind: "foreman.control", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ workId: "", halt: "" }), +}); + +/** Native task hooks, selected with the extension name `foreman`. */ +export function createForemanDurable(options: DurableOptions) { + if (!options.sessionId.trim()) + throw new Error("A stable durable sessionId is required"); + const send = createTransport("pi-durable", options); + const halt = async ( + api: HookApi, + context: Context, + error: unknown, + ): Promise => { + // Ordinary hook throws are reported and swallowed by Pi. abortTask() commits + // the mark and then joins this invocation. Wait only until this invocation's + // context is cancelled; awaiting the join itself here would wait on ourselves. + const abort = options + .harness() + .abortTask(api.taskId, withoutAbortSignal(context)); + try { + await awaitWithContext(abort, context); + } catch (abortError) { + if (!context.abortSignal?.aborted) throw abortError; + } + console.error(`Foreman halted durable work: ${String(error)}`); + throw error; + }; + const envelope = async ( + api: HookApi, + context: Context, + event: Envelope["event"], + ): Promise => { + const agent = await api.snapshot(AgentDoc, api.conversationId, context); + return { + session_id: JSON.stringify([options.sessionId, api.conversationId]), + cwd: agent?.cwd ?? options.cwd, + ...(event.type === "onYield" + ? { event_id: `onYield:${api.taskId}` } + : {}), + event, + }; + }; + return defineExtension({ + name: "foreman", + hooks: [ + hook(GenerationTask, { + beforeRequest: async (request, api, context) => { + try { + // Run input IDs survive retries, tool rounds, and onYield handovers. + // Real queued user input changes them. Continuation messages have no + // submission IDs and must never replace the original supervised job. + const live = await api.snapshot( + LiveDoc, + api.conversationId, + context, + ); + if (!live?.run?.inputs.length) + throw new Error("Foreman cannot identify durable run input"); + const harness = options.harness(); + const workId = JSON.stringify(live.run.inputs); + const control = await api.snapshot( + Control, + api.conversationId, + context, + ); + if (control?.workId === workId && control.halt) + throw new Error(control.halt); + if (control?.workId !== workId) + await harness.commit(async (tx) => { + const record = await tx.doc(Control, api.conversationId); + record.workId = workId; + record.halt = ""; + }, context); + const conversation = await harness.conversation( + api.conversationId, + context, + ); + if (!conversation) + throw new Error("Foreman cannot read durable conversation"); + const prompts: string[] = []; + for (const id of live.run.inputs) { + const submission = await harness.submission(id, context); + const status = await submission?.status(context); + if (!status?.entry) + throw new Error("Foreman cannot read durable submitted work"); + const page = await conversation.entries( + { minEntryId: status.entry, maxEntryId: status.entry }, + 1, + undefined, + context, + ); + const entry = page.items[0]; + const prompt = entry?.model + ?.filter((message) => message.role === "user") + .map((message) => text(message.content)) + .join("\n"); + if (!prompt?.trim()) + throw new Error("Foreman requires a text work prompt"); + prompts.push(prompt); + } + const input = await envelope(api, context, { + type: "beforeRequest", + prompt: prompts.join("\n\n"), + }); + input.work_id = workId; + const outcome = await send(input, context.abortSignal); + if (outcome.action === "block" || outcome.action === "halt") + throw new Error(reason(outcome)); + if (outcome.action === "inject_context") + return { + messages: [ + ...request.messages, + { + role: "user", + content: `[Foreman]\n${reason(outcome)}`, + timestamp: Date.now(), + }, + ], + }; + } catch (error) { + return halt(api, context, error); + } + }, + onYield: async (answer, api, context) => { + try { + // Memo the effect before returning it; recovery reuses this task's + // decision instead of consuming the continuation allowance twice. + let outcome = await api.memo("foreman.yield", context); + if (outcome === undefined) { + outcome = await api.memo( + "foreman.yield", + (await send( + await envelope(api, context, { + type: "onYield", + last_assistant_message: text(answer.content), + }), + context.abortSignal, + )) as Record, + context, + ); + } + const result = outcome as Awaited>; + if (result.action === "halt") + return halt(api, context, new Error(reason(result))); + if (result.action === "block" || result.action === "inject_context") + return { continue: reason(result) }; + } catch (error) { + return halt(api, context, error); + } + }, + afterTools: async (_assistant, _results, api, context) => { + try { + const control = await api.snapshot( + Control, + api.conversationId, + context, + ); + if (control?.halt) + return halt(api, context, new Error(control.halt)); + } catch (error) { + return halt(api, context, error); + } + }, + }), + hook(ToolTask, { + beforeTool: async (call, api, context) => { + const control = await api.snapshot( + Control, + api.conversationId, + context, + ); + if (control?.halt) return { block: control.halt }; + const outcome = await send( + await envelope(api, context, { type: "beforeTool", call }), + context.abortSignal, + ); + // beforeTool only supports blocking or argument replacement. Steering + // therefore blocks this call with feedback instead of being discarded. + if (outcome.action && outcome.action !== "allow") + return { block: reason(outcome) }; + }, + afterTool: async (call, result, api, context) => { + let outcome; + try { + outcome = await send( + await envelope(api, context, { type: "afterTool", call, result }), + context.abortSignal, + ); + } catch (error) { + outcome = { action: "halt" as const, reason: String(error) }; + } + if (!outcome.action || outcome.action === "allow") return; + if (outcome.action !== "inject_context") + await options.harness().commit(async (tx) => { + (await tx.doc(Control, api.conversationId)).halt = + reason(outcome); + }, context); + return { + ...result, + content: [ + ...(result.content ?? []), + { type: "text", text: reason(outcome) }, + ], + ...(outcome.action === "inject_context" + ? {} + : { + isError: true, + control: { ...result.control, terminate: true }, + }), + }; + }, + }), + ], + }); +} diff --git a/integrations/pi/src/pi.ts b/integrations/pi/src/pi.ts new file mode 100644 index 0000000..d144025 --- /dev/null +++ b/integrations/pi/src/pi.ts @@ -0,0 +1,181 @@ +import { randomUUID } from "node:crypto"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { + createTransport, + reason, + text, + type BridgeOptions, + type Envelope, +} from "./transport.js"; + +/** Register lifecycle handlers only; loading the factory starts no processes. */ +export function createForemanPi(options: BridgeOptions = {}) { + return (pi: ExtensionAPI) => { + const send = createTransport("pi", options); + let workId: string | undefined; + let context: string | undefined; + let suspended: string | undefined; + const envelope = ( + ctx: ExtensionContext, + event: Envelope["event"], + ): Envelope => ({ + session_id: ctx.sessionManager.getSessionId(), + cwd: ctx.cwd, + work_id: workId, + event, + }); + const notify = (ctx: ExtensionContext, message: string) => { + if (ctx.hasUI) ctx.ui.notify(message, "error"); + else console.error(message); + }; + pi.on("session_start", async (event, ctx) => { + workId = undefined; + context = undefined; + suspended = undefined; + try { + await send(envelope(ctx, { type: event.type })); + } catch (error) { + suspended = String(error); + notify(ctx, suspended); + } + }); + // `input` can consume a refused prompt. before_agent_start cannot cancel, + // and Pi reports exceptions from that handler but still starts the agent. + pi.on("input", async (event, ctx) => { + workId = randomUUID(); + context = undefined; + try { + const outcome = await send( + envelope(ctx, { type: event.type, text: event.text }), + ctx.signal, + ); + if (outcome.action === "block" || outcome.action === "halt") { + suspended = reason(outcome); + notify(ctx, suspended); + return { action: "handled" }; + } + suspended = undefined; + if (outcome.action === "inject_context") context = reason(outcome); + } catch (error) { + suspended = String(error); + notify(ctx, suspended); + return { action: "handled" }; + } + }); + pi.on("before_agent_start", async () => + context + ? { + message: { + customType: "foreman", + content: context, + display: false, + }, + } + : undefined, + ); + pi.on("tool_call", async (event, ctx) => { + if (suspended) return { block: true, reason: suspended }; + try { + const outcome = await send(envelope(ctx, { ...event }), ctx.signal); + if (outcome.action === "block" || outcome.action === "halt") { + return { + block: true, + reason: reason(outcome), + terminate: outcome.action === "halt", + }; + } + if (outcome.action === "inject_context") { + pi.sendMessage( + { customType: "foreman", content: reason(outcome), display: false }, + { deliverAs: "steer" }, + ); + } + } catch (error) { + return { block: true, reason: String(error) }; + } + }); + pi.on("tool_result", async (event, ctx) => { + try { + const outcome = await send(envelope(ctx, { ...event }), ctx.signal); + if (outcome.action === "inject_context") { + return { + content: [ + ...event.content, + { type: "text", text: reason(outcome) }, + ], + structuredContent: event.structuredContent, + }; + } + if (outcome.action !== "block" && outcome.action !== "halt") return; + suspended = reason(outcome); + } catch (error) { + suspended = String(error); + } + ctx.abort(); + notify(ctx, suspended); + return { + content: [...event.content, { type: "text", text: suspended }], + isError: true, + }; + }); + pi.on("agent_before_settle", async (event, ctx) => { + if (suspended || event.outcome !== "completed") return; + const messages = event.context.contextMessages; + const last = [...messages] + .reverse() + .find((message) => message.role === "assistant"); + try { + const outcome = await send( + envelope(ctx, { + type: event.type, + last_assistant_message: text(last?.content), + }), + ctx.signal, + ); + if (outcome.action === "block" || outcome.action === "inject_context") { + return { + entries: [ + { + type: "custom_message", + customType: "foreman", + content: reason(outcome), + display: false, + }, + ], + continue: true, + }; + } + if (outcome.action === "halt") { + suspended = reason(outcome); + notify(ctx, suspended); + return { + entries: [ + { + type: "custom_message", + customType: "foreman", + content: suspended, + display: true, + }, + ], + }; + } + } catch (error) { + suspended = String(error); + notify(ctx, suspended); + ctx.abort(); + } + }); + pi.on("session_shutdown", async (event, ctx) => { + try { + await send(envelope(ctx, { type: event.type })); + } catch (error) { + notify(ctx, String(error)); + } + }); + }; +} + +export default createForemanPi(); diff --git a/integrations/pi/src/transport.ts b/integrations/pi/src/transport.ts new file mode 100644 index 0000000..417a64b --- /dev/null +++ b/integrations/pi/src/transport.ts @@ -0,0 +1,122 @@ +import { spawn } from "node:child_process"; + +export type Outcome = { + action?: "allow" | "inject_context" | "block" | "halt"; + reason?: string; + system_message?: string; +}; +export type Envelope = { + session_id: string; + cwd: string; + work_id?: string; + event_id?: string; + continuation_active?: boolean; + event: Record; +}; +export type BridgeOptions = { + /** Executable and literal arguments; never passed through a shell. */ + command?: readonly [string, ...string[]]; + dataDir?: string; + timeoutMs?: number; +}; + +export function text(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((part) => part?.type === "text") + .map((part) => part.text) + .join("\n"); +} + +export function reason(result: Outcome): string { + return ( + result.system_message ?? result.reason ?? "Foreman blocked this operation" + ); +} + +export function createTransport( + client: "pi" | "pi-durable", + options: BridgeOptions = {}, +) { + const command = options.command ?? ["foreman"]; + const timeoutMs = options.timeoutMs ?? 120_000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) + throw new Error("Invalid Foreman timeout"); + return (payload: Envelope, signal?: AbortSignal): Promise => + new Promise((resolve, reject) => { + const args = [...command.slice(1), "hook", "--client", client]; + if (options.dataDir) args.push("--data-dir", options.dataDir); + // Full arguments go over stdin; process limits must never truncate evidence. + const input = JSON.stringify(payload); + const child = spawn(command[0], args, { + cwd: payload.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + let output = ""; + let bytes = 0; + let settled = false; + const finish = (error?: Error, value?: Outcome) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + if (error) { + child.kill("SIGKILL"); + reject(error); + } else resolve(value!); + }; + const abort = () => finish(new Error("Foreman hook cancelled")); + const timer = setTimeout( + () => finish(new Error("Foreman hook timed out")), + timeoutMs, + ); + child.on("error", () => + finish(new Error("Unable to start Foreman hook executable")), + ); + child.stdin.on("error", () => + finish(new Error("Unable to send Foreman hook input")), + ); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + bytes += Buffer.byteLength(chunk); + if (bytes > 1_048_576) + finish(new Error("Foreman response exceeded 1 MiB")); + else output += chunk; + }); + // Drain diagnostics without retaining or exposing potentially sensitive evidence. + child.stderr.resume(); + child.on("close", (code) => { + if (settled) return; + if (code !== 0) + return finish(new Error(`Foreman hook exited with status ${code}`)); + try { + const value = JSON.parse(output); + if ( + !value || + Array.isArray(value) || + typeof value !== "object" || + Object.keys(value).some( + (key) => !["action", "reason", "system_message"].includes(key), + ) || + (value.action !== undefined && + !["allow", "inject_context", "block", "halt"].includes( + value.action, + )) || + [value.reason, value.system_message].some( + (item) => item !== undefined && typeof item !== "string", + ) || + (value.action === undefined && Object.keys(value).length !== 0) + ) { + throw new Error("Invalid outcome"); + } + finish(undefined, value); + } catch { + finish(new Error("Invalid Foreman hook response")); + } + }); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + else child.stdin.end(input); + }); +} diff --git a/integrations/pi/tests/cli-provider.mjs b/integrations/pi/tests/cli-provider.mjs new file mode 100644 index 0000000..b563208 --- /dev/null +++ b/integrations/pi/tests/cli-provider.mjs @@ -0,0 +1,9 @@ +import { + fauxProvider, + fauxAssistantMessage, +} from "@earendil-works/pi-ai/providers/faux"; +export default function (pi) { + const faux = fauxProvider(); + faux.setResponses([fauxAssistantMessage("CLI smoke complete")]); + pi.registerProvider(faux.provider); +} diff --git a/integrations/pi/tests/core-fixture.py b/integrations/pi/tests/core-fixture.py new file mode 100644 index 0000000..14bac10 --- /dev/null +++ b/integrations/pi/tests/core-fixture.py @@ -0,0 +1,34 @@ +"""Offline Jev doubles behind the real Foreman process protocol.""" +from foreman import cli +from foreman.models import ForemanResult +from foreman.routing import RoutingDecision + + +class Model: + def __init__(self, **kwargs): + pass + + async def assess(self, observation, checks): + scores = {} + for check in checks: + scores.setdefault(check.responsibility_id, {})[check.check_id] = 0.99 + return ForemanResult(checks=scores) + + async def close(self): + pass + + +class Router: + def __init__(self, **kwargs): + pass + + async def route(self, work, candidates): + return RoutingDecision(active_responsibility_ids=list(candidates.global_ids())) + + async def close(self): + pass + + +cli.JevForemanModel = Model +cli.JevResponsibilityRouter = Router +cli.app() diff --git a/integrations/pi/tests/core.test.mjs b/integrations/pi/tests/core.test.mjs new file mode 100644 index 0000000..6f0bd92 --- /dev/null +++ b/integrations/pi/tests/core.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { writeFileSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { createTransport } from "../dist/transport.js"; +import { setup } from "./helpers.mjs"; +const fixture = fileURLToPath(new URL("./core-fixture.py", import.meta.url)); +// CI installs Foreman into Python's environment. Local development may set +// FOREMAN_TEST_PYTHON to the repository virtualenv's absolute executable path. +const python = process.env.FOREMAN_TEST_PYTHON ?? "python3"; +for (const client of ["pi", "pi-durable"]) { + test(`bridge talks to the actual Foreman CLI (${client})`, async (t) => { + const state = setup(t); + const data = state.dir + "/foreman"; + const config = state.dir + "/config.toml"; + writeFileSync(config, '[hooks]\nrepositories = ["' + state.dir + '"]\n'); + const old = process.env.FOREMAN_CONFIG; + process.env.FOREMAN_CONFIG = config; + t.after(() => + old === undefined + ? delete process.env.FOREMAN_CONFIG + : (process.env.FOREMAN_CONFIG = old), + ); + const send = createTransport(client, { + command: [python, fixture], + dataDir: data, + }); + const base = { + session_id: "native-session", + cwd: state.dir, + work_id: "original-work", + }; + const start = + client === "pi" + ? { type: "input", text: "Fix the tests" } + : { type: "beforeRequest", prompt: "Fix the tests" }; + const outcome = await send({ ...base, event: start }); + assert.equal(outcome.action, "inject_context"); + const tool = + client === "pi" + ? { + type: "tool_call", + toolName: "bash", + toolCallId: "c1", + input: { command: "pytest" }, + } + : { + type: "beforeTool", + call: { name: "bash", id: "c1", arguments: { command: "pytest" } }, + }; + const toolOutcome = await send({ ...base, event: tool }); + assert.ok( + [undefined, "inject_context", "block"].includes(toolOutcome.action), + ); + const file = resolve( + data, + "sessions", + readdirSync(data + "/sessions").find((f) => f.endsWith(".json")), + ); + const session = JSON.parse(readFileSync(file, "utf8")); + assert.equal(session.client, client); + assert.equal(session.state.job, "Fix the tests"); + assert.equal(session.state.workers[0].client, client); + }); +} diff --git a/integrations/pi/tests/durable.test.mjs b/integrations/pi/tests/durable.test.mjs new file mode 100644 index 0000000..f262c1e --- /dev/null +++ b/integrations/pi/tests/durable.test.mjs @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { Type } from "@earendil-works/pi-ai"; +import { createModels } from "@earendil-works/pi-ai/models"; +import { + fauxAssistantMessage, + fauxProvider, + fauxToolCall, +} from "@earendil-works/pi-ai/providers/faux"; +import { + createRegistry, + defineExtension, + defineTool, + Harness, + MemoryStorage, +} from "@earendil-works/pi-durable"; +import { openNodeJsonlStorage } from "@earendil-works/pi-durable/storage/jsonl/node"; +import { createForemanDurable } from "../dist/durable.js"; +import { setup } from "./helpers.mjs"; +const context = BACKGROUND_CONTEXT; +async function harness(t, mode = "allow", persistent = false) { + const state = setup(t, mode), + faux = fauxProvider(), + models = createModels(), + registry = createRegistry(); + models.setProvider(faux.provider); + let current, + executed = 0; + registry.install( + defineExtension({ + name: "test-tools", + tools: [ + defineTool({ + name: "probe", + description: "A test tool", + parameters: Type.Object({ value: Type.String() }), + execute: async (args) => { + executed++; + return { content: [{ type: "text", text: args.value }] }; + }, + }), + ], + }), + ); + registry.install( + createForemanDurable({ + ...state, + sessionId: "persistent-storage-1", + harness: () => current, + cwd: state.dir, + }), + ); + const open = async () => { + const storage = persistent + ? await openNodeJsonlStorage(state.dir + "/storage", context) + : new MemoryStorage(); + current = await Harness.open( + storage, + { + models, + registry, + settings: { retry: { enabled: false }, compaction: { enabled: false } }, + }, + context, + ); + return current; + }; + await open(); + t.after(async () => current.close(context)); + return { + ...state, + faux, + models, + registry, + open, + current: () => current, + executed: () => executed, + root: () => + current.root(context, { + agent: { model: { provider: "faux", modelId: "faux-1" } }, + }), + }; +} +test("actual durable task hooks block tool execution and keep original work across rounds", async (t) => { + const h = await harness(t); + const root = await h.root(); + h.faux.setResponses([ + fauxAssistantMessage( + fauxToolCall("probe", { value: "dangerous" }, { id: "c1" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("Finished"), + ]); + const status = await ( + await root.submit( + { type: "input", content: "Fix the original task", requestId: "work-1" }, + context, + ) + ).wait(context); + assert.equal(status.status, "done"); + assert.equal(h.executed(), 0); + const requests = h.events().filter((e) => e.event.type === "beforeRequest"); + assert.equal(requests.length, 2); + assert.equal(requests[0].work_id, requests[1].work_id); + assert.ok(requests.every((e) => e.event.prompt === "Fix the original task")); + const transcript = await root.context(context); + assert.ok(JSON.stringify(transcript.messages).includes("Denied operation")); +}); +test("actual durable afterTool preserves results and emits steering", async (t) => { + const h = await harness(t, "steer-tools"); + const root = await h.root(); + // A beforeTool steering response is a denial in Durable's limited API. + h.faux.setResponses([ + fauxAssistantMessage(fauxToolCall("probe", { value: "safe" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("Finished"), + ]); + await ( + await root.submit({ type: "input", content: "Fix it" }, context) + ).wait(context); + assert.equal(h.executed(), 0); + assert.ok( + JSON.stringify((await root.context(context)).messages).includes( + "Check the tests first", + ), + ); +}); +test("actual durable allowed tool records original result through afterTool", async (t) => { + const h = await harness(t); + const root = await h.root(); + h.faux.setResponses([ + fauxAssistantMessage(fauxToolCall("probe", { value: "actual output" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("Finished"), + ]); + await ( + await root.submit({ type: "input", content: "Fix it" }, context) + ).wait(context); + assert.equal(h.executed(), 1); + const event = h.events().find((e) => e.event.type === "afterTool"); + assert.equal(event.event.result.content[0].text, "actual output"); + assert.equal(event.event.call.arguments.value, "actual output"); +}); +test("actual durable completion continuation retains the job and halts on refusal", async (t) => { + const h = await harness(t, "continue"); + const root = await h.root(); + h.faux.setResponses([ + fauxAssistantMessage("Incomplete"), + fauxAssistantMessage("Still incomplete"), + ]); + const status = await ( + await root.submit({ type: "input", content: "Original job" }, context) + ).wait(context); + assert.equal(h.faux.state.callCount, 2); + assert.equal(status.status, "unanswered"); + const inputs = h.events().filter((e) => e.event.type === "beforeRequest"); + assert.ok(inputs.every((e) => e.event.prompt === "Original job")); + assert.equal(inputs[0].work_id, inputs[1].work_id); +}); +test("durable routing process failure prevents the first provider request", async (t) => { + const h = await harness(t, "error"); + const root = await h.root(); + const status = await ( + await root.submit({ type: "input", content: "Fix it" }, context) + ).wait(context); + assert.equal(status.status, "unanswered"); + assert.equal(h.faux.state.callCount, 0); +}); +test("durable storage reopen and new input maintain namespace and distinct work identity", async (t) => { + const h = await harness(t, "allow", true); + let root = await h.root(); + h.faux.setResponses([ + fauxAssistantMessage("Done"), + fauxAssistantMessage("Done again"), + ]); + await ( + await root.submit( + { type: "input", content: "First job", requestId: "first" }, + context, + ) + ).wait(context); + const id = root.id; + await h.current().close(context); + await h.open(); + root = await h.root(); + assert.equal(root.id, id); + await ( + await root.submit( + { type: "input", content: "Second job", requestId: "second" }, + context, + ) + ).wait(context); + const inputs = h.events().filter((e) => e.event.type === "beforeRequest"); + assert.equal(inputs[0].session_id, inputs[1].session_id); + assert.notEqual(inputs[0].work_id, inputs[1].work_id); + assert.equal(inputs[1].event.prompt, "Second job"); +}); + +test("durable post-tool halt aborts mixed parallel rounds before another model request", async (t) => { + const h = await harness(t, "halt"); + const root = await h.root(); + h.faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall("probe", { value: "actual result" }, { id: "c1" }), + fauxToolCall("probe", { value: "dangerous" }, { id: "c2" }), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("Must not run"), + ]); + const status = await ( + await root.submit({ type: "input", content: "Fix it" }, context) + ).wait(context); + assert.equal(status.status, "unanswered"); + assert.equal(h.faux.state.callCount, 1); + assert.equal(h.executed(), 1); + assert.ok( + JSON.stringify((await root.context(context)).messages).includes( + "actual result", + ), + ); +}); diff --git a/integrations/pi/tests/fixture.mjs b/integrations/pi/tests/fixture.mjs new file mode 100644 index 0000000..c18be15 --- /dev/null +++ b/integrations/pi/tests/fixture.mjs @@ -0,0 +1,56 @@ +import { appendFileSync, readFileSync } from "node:fs"; +const [log, mode] = process.argv.slice(2); +let input = ""; +for await (const chunk of process.stdin) input += chunk; +const payload = JSON.parse(input); +const previous = readFileSync(log, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map(JSON.parse); +appendFileSync(log, JSON.stringify(payload) + "\n"); +const type = payload.event.type; +if (mode === "error") process.exit(2); +if (mode === "invalid") { + process.stdout.write('{"action":"unexpected"}'); +} else if (mode === "timeout") { + setInterval(() => {}, 10_000); + await new Promise(() => {}); +} else if (mode === "flood") { + process.stdout.write("x".repeat(1_048_577)); +} else { + let result = {}; + if (mode === "block-input" && ["input", "beforeRequest"].includes(type)) + result = { action: "block", reason: "Cannot route" }; + else if ( + mode === "halt" && + ["onYield", "agent_before_settle", "afterTool", "tool_result"].includes( + type, + ) + ) + result = { action: "halt", reason: "Needs review" }; + else if (["input", "beforeRequest"].includes(type)) + result = { + action: "inject_context", + reason: "Follow the project checkpoint", + }; + else if ( + mode === "steer-tools" && + ["beforeTool", "afterTool", "tool_call", "tool_result"].includes(type) + ) + result = { action: "inject_context", reason: "Check the tests first" }; + else if ( + ["tool_call", "beforeTool"].includes(type) && + JSON.stringify(payload.event).includes("dangerous") + ) + result = { action: "block", reason: "Denied operation" }; + else if ( + mode === "continue" && + ["onYield", "agent_before_settle"].includes(type) + ) + result = { + action: previous.some((p) => p.event.type === type) ? "halt" : "block", + reason: "Finish the tests", + }; + process.stdout.write(JSON.stringify(result)); +} diff --git a/integrations/pi/tests/helpers.mjs b/integrations/pi/tests/helpers.mjs new file mode 100644 index 0000000..5a911c0 --- /dev/null +++ b/integrations/pi/tests/helpers.mjs @@ -0,0 +1,22 @@ +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +const fixture = fileURLToPath(new URL("./fixture.mjs", import.meta.url)); +export function setup(t, mode = "allow") { + const dir = mkdtempSync(join(tmpdir(), "foreman-pi-")); + const log = join(dir, "events.jsonl"); + writeFileSync(log, ""); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return { + dir, + log, + command: [process.execPath, fixture, log, mode], + events: () => + readFileSync(log, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map(JSON.parse), + }; +} diff --git a/integrations/pi/tests/pi-cli.test.mjs b/integrations/pi/tests/pi-cli.test.mjs new file mode 100644 index 0000000..9af4235 --- /dev/null +++ b/integrations/pi/tests/pi-cli.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { delimiter } from "node:path"; +import { test } from "node:test"; +import { setup } from "./helpers.mjs"; +const cli = fileURLToPath( + new URL( + "../node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js", + import.meta.url, + ), +); +const bridge = fileURLToPath(new URL("../dist/pi.js", import.meta.url)); +const provider = fileURLToPath(new URL("./cli-provider.mjs", import.meta.url)); +const fixture = new URL("./fixture.mjs", import.meta.url).href; +test("actual Pi CLI loads the built extension and forwards its lifecycle", (t) => { + const h = setup(t); + mkdirSync(h.dir + "/bin"); + const shim = h.dir + "/bin/foreman"; + writeFileSync( + shim, + "#!/usr/bin/env node\nprocess.argv.splice(2, 0, " + + JSON.stringify(h.log) + + ', "allow");\nawait import(' + + JSON.stringify(fixture) + + ");\n", + ); + chmodSync(shim, 0o755); + const result = spawnSync( + process.execPath, + [ + cli, + "--print", + "--mode", + "json", + "--no-session", + "--no-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "-e", + provider, + "-e", + bridge, + "--provider", + "faux", + "--model", + "faux-1", + "Fix the project", + ], + { + cwd: h.dir, + encoding: "utf8", + timeout: 20_000, + env: { + ...process.env, + PI_CODING_AGENT_DIR: h.dir + "/agent", + PI_OFFLINE: "1", + PI_TELEMETRY: "0", + PATH: h.dir + "/bin" + delimiter + process.env.PATH, + }, + }, + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.ok(result.stdout.includes("CLI smoke complete")); + const types = h.events().map((e) => e.event.type); + assert.ok(types.includes("session_start"), JSON.stringify(types)); + assert.ok(types.includes("input"), JSON.stringify(types)); + assert.ok(types.includes("agent_before_settle"), JSON.stringify(types)); +}); diff --git a/integrations/pi/tests/pi-sdk.test.mjs b/integrations/pi/tests/pi-sdk.test.mjs new file mode 100644 index 0000000..6f797a1 --- /dev/null +++ b/integrations/pi/tests/pi-sdk.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + createAgentSession, + DefaultResourceLoader, + ModelRuntime, + SessionManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { + fauxAssistantMessage, + fauxProvider, +} from "@earendil-works/pi-ai/providers/faux"; +import { createForemanPi } from "../dist/pi.js"; +import { setup } from "./helpers.mjs"; +async function create(t, mode) { + const state = setup(t, mode), + faux = fauxProvider(); + const modelRuntime = await ModelRuntime.create({ + authPath: state.dir + "/auth.json", + modelsPath: null, + modelsStorePath: state.dir + "/models.json", + refreshOnCreate: false, + }); + modelRuntime.registerNativeProvider(faux.provider); + const settingsManager = SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: false }, + }); + const resourceLoader = new DefaultResourceLoader({ + cwd: state.dir, + agentDir: state.dir, + settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + extensionFactories: [createForemanPi(state)], + }); + await resourceLoader.reload(); + const { session } = await createAgentSession({ + cwd: state.dir, + agentDir: state.dir, + modelRuntime, + model: faux.getModel(), + settingsManager, + resourceLoader, + sessionManager: SessionManager.inMemory(state.dir), + noTools: "all", + }); + await session.bindExtensions({}); + t.after(() => session.dispose()); + return { ...state, session, faux }; +} +test("actual Pi SDK consumes denied input without calling the provider", async (t) => { + const h = await create(t, "block-input"); + await h.session.prompt("Fix the project"); + assert.equal(h.faux.state.callCount, 0); + assert.ok(h.events().some((e) => e.event.type === "input")); +}); +test("actual Pi SDK injects context and uses the final actionable completion event", async (t) => { + const h = await create(t, "allow"); + h.faux.setResponses([fauxAssistantMessage("Finished")]); + await h.session.prompt("Fix the project"); + assert.equal(h.faux.state.callCount, 1); + assert.ok(h.events().some((e) => e.event.type === "agent_before_settle")); + assert.ok( + h.session.state.messages.some( + (m) => + m.role === "custom" && m.content === "Follow the project checkpoint", + ), + ); +}); + +test("actual Pi SDK requests one continuation then reports the halt", async (t) => { + const h = await create(t, "continue"); + h.faux.setResponses([ + fauxAssistantMessage("Not finished"), + fauxAssistantMessage("Still unfinished"), + ]); + await h.session.prompt("Fix the project"); + assert.equal(h.faux.state.callCount, 2); + assert.equal(h.events().filter((e) => e.event.type === "input").length, 1); + assert.equal( + h.events().filter((e) => e.event.type === "agent_before_settle").length, + 2, + ); +}); diff --git a/integrations/pi/tests/pi.test.mjs b/integrations/pi/tests/pi.test.mjs new file mode 100644 index 0000000..ec4a96a --- /dev/null +++ b/integrations/pi/tests/pi.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createForemanPi } from "../dist/pi.js"; +import { setup } from "./helpers.mjs"; +function host(t, mode = "allow") { + const state = setup(t, mode), + handlers = new Map(), + messages = [], + notices = []; + let aborted = 0; + const pi = { + on: (name, fn) => handlers.set(name, fn), + sendMessage: (message) => messages.push(message), + }; + const ctx = { + cwd: state.dir, + sessionManager: { getSessionId: () => "pi-session" }, + hasUI: true, + ui: { notify: (message) => notices.push(message) }, + abort: () => aborted++, + }; + createForemanPi(state)(pi); + return { + ...state, + handlers, + messages, + notices, + ctx, + aborted: () => aborted, + emit: (type, fields = {}) => handlers.get(type)({ type, ...fields }, ctx), + }; +} +const tool = { + toolName: "bash", + toolCallId: "c1", + input: { command: "dangerous" }, +}; +const settle = { + outcome: "completed", + context: { + contextMessages: [ + { role: "assistant", content: [{ type: "text", text: "Not done" }] }, + ], + }, +}; +test("Pi routing context, denial and session cleanup use native event APIs", async (t) => { + const h = host(t); + assert.equal(h.events().length, 0); + await h.emit("session_start"); + assert.equal(await h.emit("input", { text: "Fix the tests" }), undefined); + assert.equal( + (await h.emit("before_agent_start")).message.content, + "Follow the project checkpoint", + ); + assert.deepEqual(await h.emit("tool_call", tool), { + block: true, + reason: "Denied operation", + terminate: false, + }); + await h.emit("session_shutdown"); + assert.deepEqual( + h.events().map((e) => e.event.type), + ["session_start", "input", "tool_call", "session_shutdown"], + ); + assert.equal(h.events()[1].work_id, h.events()[2].work_id); +}); +test("Pi consumes refused input before agent startup", async (t) => { + const h = host(t, "block-input"); + assert.deepEqual(await h.emit("input", { text: "Fix this" }), { + action: "handled", + }); + assert.equal((await h.emit("tool_call", tool)).block, true); +}); +test("Pi hook process errors prevent tools and consume input", async (t) => { + const h = host(t, "error"); + assert.deepEqual(await h.emit("input", { text: "Fix this" }), { + action: "handled", + }); + assert.equal((await h.emit("tool_call", tool)).block, true); + assert.equal(h.notices.length, 1); +}); +test("Pi sends steering and retains structured results", async (t) => { + const h = host(t, "steer-tools"); + await h.emit("input", { text: "Fix this" }); + await h.emit("tool_call", { ...tool, input: { command: "safe" } }); + assert.equal(h.messages[0].content, "Check the tests first"); + const content = [{ type: "text", text: "Actual output" }]; + const result = await h.emit("tool_result", { + ...tool, + content, + isError: false, + structuredContent: { actual: true }, + }); + assert.deepEqual(result.content, [ + ...content, + { type: "text", text: "Check the tests first" }, + ]); + assert.deepEqual(result.structuredContent, { actual: true }); +}); +test("Pi completion continuation and halt are distinct", async (t) => { + const h = host(t, "continue"); + await h.emit("input", { text: "Fix this" }); + assert.equal((await h.emit("agent_before_settle", settle)).continue, true); + assert.equal( + (await h.emit("agent_before_settle", settle)).continue, + undefined, + ); + assert.equal(h.notices[0], "Finish the tests"); +}); +test("Pi post-tool halt aborts the operation and retains actual output", async (t) => { + const h = host(t, "halt"); + const result = await h.emit("tool_result", { + ...tool, + content: [{ type: "text", text: "Actual output" }], + }); + assert.equal(h.aborted(), 1); + assert.equal(result.isError, true); + assert.equal(result.content[0].text, "Actual output"); +}); +test("Pi aborted settlement does not request a continuation", async (t) => { + const h = host(t); + await h.emit("agent_before_settle", { ...settle, outcome: "aborted" }); + assert.equal(h.events().length, 0); +}); diff --git a/integrations/pi/tests/transport.test.mjs b/integrations/pi/tests/transport.test.mjs new file mode 100644 index 0000000..68db67a --- /dev/null +++ b/integrations/pi/tests/transport.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { test } from "node:test"; +import { createTransport } from "../dist/transport.js"; +import { setup } from "./helpers.mjs"; +const payload = { + session_id: "s", + cwd: tmpdir(), + event: { + type: "tool_call", + toolName: "bash", + input: { command: "echo `$(literal)`; x".repeat(5000) }, + }, +}; +test("transport preserves full arguments and shell metacharacters", async (t) => { + const state = setup(t); + assert.deepEqual(await createTransport("pi", state)(payload), {}); + assert.deepEqual(state.events()[0], payload); +}); +for (const [mode, message] of [ + ["error", /status 2/], + ["invalid", /Invalid Foreman/], + ["flood", /exceeded/], + ["timeout", /timed out/], +]) { + test(`transport fails closed on ${mode}`, async (t) => { + const state = setup(t, mode); + await assert.rejects( + createTransport("pi", { + ...state, + timeoutMs: mode === "timeout" ? 100 : 2000, + })(payload), + message, + ); + }); +} +test("transport fails closed when executable is missing", async () => { + await assert.rejects( + createTransport("pi", { command: ["/does/not/exist"] })(payload), + /Unable to start/, + ); +}); +test("transport cancels a child process", async (t) => { + const state = setup(t, "timeout"); + const controller = new AbortController(); + const pending = createTransport("pi", state)(payload, controller.signal); + controller.abort(); + await assert.rejects(pending, /cancelled/); +}); diff --git a/integrations/pi/tsconfig.json b/integrations/pi/tsconfig.json new file mode 100644 index 0000000..ee325ad --- /dev/null +++ b/integrations/pi/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/src/foreman/hook_adapters.py b/src/foreman/hook_adapters.py index b4d9d5e..a283a30 100644 --- a/src/foreman/hook_adapters.py +++ b/src/foreman/hook_adapters.py @@ -126,8 +126,91 @@ def render(self, event: HookEvent, outcome: HookOutcome) -> dict[str, Any]: } +class PiHookInput(BaseModel): + """Bridge envelope around Pi's native events; identities come from its host.""" + + model_config = ConfigDict(extra="forbid") + + session_id: str = Field(min_length=1, max_length=1_000) + cwd: str = Field(min_length=1) + event: dict[str, Any] + work_id: str | None = Field(default=None, min_length=1, max_length=1_000) + event_id: str | None = Field(default=None, min_length=1, max_length=1_000) + continuation_active: bool = False + + +_PI_EVENT_KINDS = { + "session_start": HookEventKind.SESSION_STARTED, + "input": HookEventKind.WORK_SUBMITTED, + "tool_call": HookEventKind.BEFORE_TOOL, + "tool_result": HookEventKind.AFTER_TOOL, + "agent_before_settle": HookEventKind.WORKER_STOPPING, + "session_shutdown": HookEventKind.SESSION_ENDED, +} +_DURABLE_EVENT_KINDS = { + "beforeRequest": HookEventKind.WORK_SUBMITTED, + "beforeTool": HookEventKind.BEFORE_TOOL, + "afterTool": HookEventKind.AFTER_TOOL, + "onYield": HookEventKind.WORKER_STOPPING, +} + + +class PiHookAdapter: + client = "pi" + event_kinds = _PI_EVENT_KINDS + + def parse(self, payload: dict[str, Any]) -> HookEvent: + try: + source = PiHookInput.model_validate(payload) + native = source.event + name = native.get("type") + if name not in self.event_kinds: + raise ValueError(f"unsupported event {name!r}") + durable = self.client == "pi-durable" + call = native.get("call", {}) if durable else native + if not isinstance(call, dict): + raise ValueError("call must be an object") + return HookEvent( + client=self.client, + session_id=source.session_id, + cwd=source.cwd, + kind=self.event_kinds[name], + source_event_name=name, + work_id=source.work_id, + event_id=source.event_id, + turn_id=source.work_id, + prompt=native.get("prompt" if durable else "text"), + tool_name=call.get("name" if durable else "toolName"), + tool_use_id=call.get("id" if durable else "toolCallId"), + tool_input=call.get("arguments" if durable else "input"), + tool_response=native.get("result") if durable else ( + {key: native[key] + for key in ("content", "details", "structuredContent", "isError") + if key in native} if name == "tool_result" else None + ), + continuation_active=source.continuation_active, + last_assistant_message=native.get("last_assistant_message"), + ) + except (ValidationError, ValueError, TypeError) as error: + raise HookError(f"invalid {self.client} hook input: {error}") from error + + def render(self, event: HookEvent, outcome: HookOutcome) -> dict[str, Any]: + # The TypeScript bridges apply these effects through native APIs. A HALT + # remains distinct from BLOCK (which requests a completion continuation). + if outcome.action is HookAction.ALLOW: + return {} + return outcome.model_dump(mode="json", exclude_none=True) + + +class PiDurableHookAdapter(PiHookAdapter): + client = "pi-durable" + event_kinds = _DURABLE_EVENT_KINDS + + _ADAPTERS: dict[str, HookAdapter] = { CodexHookAdapter.client: CodexHookAdapter(), + PiHookAdapter.client: PiHookAdapter(), + PiDurableHookAdapter.client: PiDurableHookAdapter(), } diff --git a/src/foreman/hooks.py b/src/foreman/hooks.py index 87900c9..55e20fe 100644 --- a/src/foreman/hooks.py +++ b/src/foreman/hooks.py @@ -65,6 +65,8 @@ class HookEvent(BaseModel): kind: HookEventKind source_event_name: str = Field(min_length=1) turn_id: str | None = None + work_id: str | None = Field(default=None, min_length=1, max_length=1_000) + event_id: str | None = Field(default=None, min_length=1, max_length=1_000) prompt: str | None = None tool_name: str | None = None tool_use_id: str | None = None @@ -115,6 +117,11 @@ class AttachedSession(BaseModel): client: str = "codex" session_id: str prompt_routed: bool = False + work_id: str | None = None + work_prompt: str | None = None + routed_context: str | None = None + continuation_requested: bool = False + replayed_outcomes: list[dict[str, Any]] = Field(default_factory=list) repository: str created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @@ -374,6 +381,13 @@ async def handle(self, event: HookEvent) -> HookOutcome: session = self.store.load(event.session_id, event.client) or self.store.new( event.session_id, repository, event.client ) + if event.event_id is not None: + fingerprint = hashlib.sha256(event.model_dump_json().encode()).hexdigest() + for cached in session.replayed_outcomes: + if cached["event_id"] == event.event_id: + if cached["fingerprint"] != fingerprint: + raise HookError("event_id was reused with different input") + return HookOutcome.model_validate(cached["outcome"]) session.repository = str(repository) session.event_count += 1 session.recent_events.append(_event_record(event, self.config.output_limit)) @@ -385,7 +399,25 @@ async def handle(self, event: HookEvent) -> HookOutcome: return HookOutcome() if event.kind is HookEventKind.WORK_SUBMITTED: try: - output = await self._route_prompt(session, event) + if event.work_id is not None and event.work_id == session.work_id: + if session.work_prompt != event.prompt.strip(): + raise HookError("work_id was reused with a different prompt") + if session.state is not None and ( + session.state.active_extension_ids != list(self.active_extension_ids) + or session.state.extension_snapshot_revisions + != self.extension_snapshot_revisions + ): + raise HookError("extension snapshots changed; submit new work") + output = HookOutcome( + action=HookAction.INJECT_CONTEXT, + reason=session.routed_context, + ) if session.routed_context else HookOutcome() + else: + output = await self._route_prompt(session, event) + session.work_id = event.work_id + session.work_prompt = event.prompt.strip() + session.routed_context = output.reason + session.continuation_requested = False except ResponsibilityRoutingError as error: output = HookOutcome( action=HookAction.BLOCK, @@ -408,8 +440,29 @@ async def handle(self, event: HookEvent) -> HookOutcome: "extension snapshots changed during this attached session; " "submit the work prompt again" ) + if event.kind is HookEventKind.WORKER_STOPPING and session.work_id is not None: + event = event.model_copy(update={ + "continuation_active": ( + event.continuation_active or session.continuation_requested + ), + }) output = await self._assess_event(session, event) - + if ( + event.kind is HookEventKind.WORKER_STOPPING + and session.work_id is not None + and output.action is HookAction.BLOCK + ): + session.continuation_requested = True + + if event.event_id is not None: + session.replayed_outcomes.append({ + "event_id": event.event_id, + "fingerprint": fingerprint, + "outcome": output.model_dump(mode="json"), + }) + session.replayed_outcomes = session.replayed_outcomes[ + -self.config.event_history_limit : + ] self._bound_state(session) session.touch(self.store.ttl_seconds) self.store.save(session) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 5b6d3f9..b9cd019 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -604,3 +604,98 @@ async def test_attached_runtime_rejects_snapshot_change_between_hooks(tmp_path) changed, event("PreToolUse", tmp_path, tool_name="Bash", tool_input={"command": "pwd"}), ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('client', ['pi', 'pi-durable']) +async def test_pi_runtime_preserves_operation_and_session_namespace(tmp_path, client): + model = StubModel() + supervisor, store, _ = runtime(tmp_path, model=model) + adapter = hook_adapter(client) + base = {'session_id': 'same-id', 'cwd': str(tmp_path), 'work_id': 'work-1'} + submitted = {'type': 'input', 'text': 'Fix the tests'} if client == 'pi' else { + 'type': 'beforeRequest', 'prompt': 'Fix the tests', + } + await supervisor.handle(adapter.parse({**base, 'event': submitted})) + arguments = {'command': 'BEGIN ' + 'x' * 2000 + ' END'} + tool = {'type': 'tool_call', 'toolName': 'bash', 'toolCallId': 'c1', 'input': arguments} + if client == 'pi-durable': + tool = {'type': 'beforeTool', 'call': {'name': 'bash', 'id': 'c1', + 'arguments': arguments}} + await supervisor.handle(adapter.parse({**base, 'event': tool})) + assert model.calls[-1][0].current_operation['tool_input'] == arguments + assert store.load('same-id', client).state.workers[0].client == client + assert store.load('same-id', 'codex') is None + + +@pytest.mark.asyncio +async def test_durable_replay_preserves_routing_and_one_continuation(tmp_path): + supervisor, store, router = runtime(tmp_path) + adapter = hook_adapter('pi-durable') + base = {'session_id': 'store:conversation-1', 'cwd': str(tmp_path)} + work = adapter.parse({**base, 'work_id': '[12]', + 'event': {'type': 'beforeRequest', 'prompt': 'Fix the tests'}}) + first = await supervisor.handle(work) + assert first.action is HookAction.INJECT_CONTEXT + stop = adapter.parse({**base, 'event_id': 'onYield:14', + 'event': {'type': 'onYield', 'last_assistant_message': 'Incomplete'}}) + outcome = await supervisor.handle(stop) + assert outcome.action is HookAction.BLOCK + iterations = store.load(base['session_id'], 'pi-durable').state.iteration + + # A new runtime simulates process restart, including the window after Foreman + # saves its decision but before Pi commits its task memo. + restarted, _, new_router = runtime(tmp_path) + assert await restarted.handle(stop) == outcome + assert await restarted.handle(work) == first + session = store.load(base['session_id'], 'pi-durable') + assert session.state.iteration == iterations + assert session.state.job == 'Fix the tests' + assert router.calls == ['Fix the tests'] and new_router.calls == [] + + next_stop = stop.model_copy(update={'event_id': 'onYield:18'}) + assert (await restarted.handle(next_stop)).action is HookAction.HALT + # A real new submission resets the allowance; identical text is still new work. + await restarted.handle(work.model_copy(update={'work_id': '[20]'})) + assert new_router.calls == ['Fix the tests'] + assert (await restarted.handle(stop.model_copy(update={'event_id': 'onYield:21'}))).action \ + is HookAction.BLOCK + + +@pytest.mark.asyncio +async def test_reused_work_and_event_ids_reject_changed_input(tmp_path): + supervisor, _, _ = runtime(tmp_path) + adapter = hook_adapter('pi-durable') + base = {'session_id': 's', 'cwd': str(tmp_path)} + work = adapter.parse({**base, 'work_id': '1', + 'event': {'type': 'beforeRequest', 'prompt': 'Fix the tests'}}) + await supervisor.handle(work) + with pytest.raises(HookError, match='work_id was reused'): + await supervisor.handle(work.model_copy(update={'prompt': 'Different work'})) + stop = adapter.parse({**base, 'event_id': 'yield:2', + 'event': {'type': 'onYield', 'last_assistant_message': 'Incomplete'}}) + await supervisor.handle(stop) + with pytest.raises(HookError, match='event_id was reused'): + await supervisor.handle(stop.model_copy(update={ + 'last_assistant_message': 'Different answer', + })) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('client', ['pi', 'pi-durable']) +async def test_pi_assessment_failure_denies_tool(tmp_path, client): + supervisor, _, _ = runtime(tmp_path, model=FailingModel()) + adapter = hook_adapter(client) + base = {'session_id': 's', 'cwd': str(tmp_path), 'work_id': 'work-1'} + submitted = {'type': 'input', 'text': 'Fix the tests'} if client == 'pi' else { + 'type': 'beforeRequest', 'prompt': 'Fix the tests', + } + await supervisor.handle(adapter.parse({**base, 'event': submitted})) + tool = {'type': 'tool_call', 'toolName': 'bash', 'input': {'command': 'pytest'}} + if client == 'pi-durable': + tool = {'type': 'beforeTool', 'call': {'name': 'bash', + 'arguments': {'command': 'pytest'}}} + event = adapter.parse({**base, 'event': tool}) + output = adapter.render(event, await supervisor.handle(event)) + assert output['action'] == 'block' + assert 'Jev unavailable' in output['reason'] diff --git a/tests/test_pi_hooks.py b/tests/test_pi_hooks.py new file mode 100644 index 0000000..1811bd4 --- /dev/null +++ b/tests/test_pi_hooks.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from foreman.cli import app +from foreman.hook_adapters import available_hook_clients, hook_adapter +from foreman.hooks import HookAction, HookError, HookEventKind, HookOutcome + + +@pytest.mark.parametrize("client,native,kind", [ + ("pi", {"type": "session_start"}, HookEventKind.SESSION_STARTED), + ("pi", {"type": "input", "text": "Fix this"}, HookEventKind.WORK_SUBMITTED), + ("pi", {"type": "tool_call", "toolName": "bash", "toolCallId": "c1", + "input": {"command": "npm test"}}, HookEventKind.BEFORE_TOOL), + ("pi", {"type": "tool_result", "toolName": "bash", "toolCallId": "c1", + "input": {"command": "npm test"}, "content": [{"type": "text", "text": "ok"}], + "isError": False}, HookEventKind.AFTER_TOOL), + ("pi", {"type": "agent_before_settle", "last_assistant_message": "Done"}, + HookEventKind.WORKER_STOPPING), + ("pi", {"type": "session_shutdown"}, HookEventKind.SESSION_ENDED), + ("pi-durable", {"type": "beforeRequest", "prompt": "Fix this"}, + HookEventKind.WORK_SUBMITTED), + ("pi-durable", {"type": "beforeTool", "call": {"name": "bash", "id": "c1", + "arguments": {"command": "npm test"}}}, HookEventKind.BEFORE_TOOL), + ("pi-durable", {"type": "afterTool", "call": {"name": "bash", "id": "c1", + "arguments": {"command": "npm test"}}, + "result": {"content": [{"type": "text", "text": "ok"}]}}, + HookEventKind.AFTER_TOOL), + ("pi-durable", {"type": "onYield", "last_assistant_message": "Done"}, + HookEventKind.WORKER_STOPPING), +]) +def test_native_pi_events(client, native, kind, tmp_path): + event = hook_adapter(client).parse({ + "session_id": "native-session", "cwd": str(tmp_path), "event": native, + "work_id": "original-submission", "event_id": "event-1", "continuation_active": True, + }) + assert event.client == client and event.kind is kind + assert event.session_id == "native-session" + assert event.work_id == "original-submission" and event.event_id == "event-1" + assert event.continuation_active is True + if kind in {HookEventKind.BEFORE_TOOL, HookEventKind.AFTER_TOOL}: + assert event.tool_name == "bash" and event.tool_use_id == "c1" + assert event.tool_input == {"command": "npm test"} + if kind is HookEventKind.AFTER_TOOL: + assert event.tool_response["content"] == [{"type": "text", "text": "ok"}] + + +@pytest.mark.parametrize("client", ["pi", "pi-durable"]) +@pytest.mark.parametrize("action", list(HookAction)) +def test_pi_outcome_preserves_effect(client, action, tmp_path): + adapter = hook_adapter(client) + native = {"type": "input", "text": "Fix this"} if client == "pi" else { + "type": "beforeRequest", "prompt": "Fix this", + } + event = adapter.parse({"session_id": "s", "cwd": str(tmp_path), "event": native}) + outcome = HookOutcome(action=action, reason="Reason", system_message="Message") + assert adapter.render(event, outcome) == ( + {} if action is HookAction.ALLOW else outcome.model_dump(mode="json", exclude_none=True) + ) + + +@pytest.mark.parametrize("client", ["pi", "pi-durable"]) +@pytest.mark.parametrize("native", [ + {"type": "unknown"}, {"type": "tool_call"}, {"type": "beforeTool", "call": []}, + {"type": "input", "text": " "}, {"type": "beforeRequest", "prompt": " "}, +]) +def test_invalid_native_event_fails_before_state(client, native, tmp_path): + with pytest.raises(HookError): + hook_adapter(client).parse({"session_id": "s", "cwd": str(tmp_path), "event": native}) + + +@pytest.mark.parametrize("client,native", [ + ("pi", {"type": "input", "text": "Fix this"}), + ("pi-durable", {"type": "beforeRequest", "prompt": "Fix this"}), +]) +def test_pi_cli_excluded_repository_is_noop(client, native, tmp_path, monkeypatch): + config = tmp_path / "config.toml" + config.write_text("[hooks]\nrepositories = []\n") + monkeypatch.setenv("FOREMAN_CONFIG", str(config)) + result = CliRunner().invoke(app, ["hook", "--client", client, "--data-dir", str(tmp_path)], + input=json.dumps({"session_id": "s", "cwd": str(tmp_path), "event": native})) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {} + assert not list((tmp_path / "sessions").glob("*.json")) + + +def test_pi_clients_registered(): + assert available_hook_clients() == ("codex", "pi", "pi-durable")