From 99dd2aa2ae9e6356f0cc65f959c031255570bbaa Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:26:16 +0000 Subject: [PATCH 1/2] Wire cua-cli non-interactive paths onto CuaAgentHarness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of the cua-cli → CuaAgentHarness migration plan (docs/cua-cli-harness-migration.md). Wires the non-interactive surface of cua-cli onto CuaAgentHarness + pi 0.79 while leaving the interactive TUI on the legacy stack for now. Engine: - harness.ts assembles a CuaAgentHarness from a Kernel client + browser + jsonl Session + cua-cli skills + pi-coding-agent's createCodingTools as extraTools. - harness-browser.ts provisions Kernel browsers via the SDK directly (drops the cua-translator browserSession wrapper on new paths). - harness-sessions.ts wraps JsonlSessionRepo for list / find-latest / resolve-by-ref and is tolerant of unknown files in the sessions root. - harness-models.ts resolves -m flags through @onkernel/cua-ai's listCuaModels / parseCuaModelRef catalog; default is openai:gpt-5.5. - harness-skills.ts loads skills via pi 0.79 loadSkills. - harness-named-sessions.ts re-implements the named-session CLI on the SDK. CLI: - print.ts and action/harness-runner.ts drive the harness for --print and one-shot action subcommands (open/click/type/press/observe/url/ screenshot/do), aborting at maxTurns via harness.abort. - output/harness-jsonl.ts sources the documented event schema from harness.subscribe and stamps a schema_version field. - cli.ts dispatches `cua models`, `--print`, action subcommands, and `cua session ...` to the new wiring through cli-harness.ts. The interactive entry point still uses the legacy stack. Tests + CI: - vitest config + fixtures: registerApiProvider-based scripted-provider driver and a fake Kernel client that stubs browsers.computer.batch / captureScreenshot. Test suites cover --print text + jsonl envelope, action exit codes (ok / not_found / error / screenshot), session resolution (list, latest, prefix, legacy-tolerance), model-ref parsing (default, ref pass-through, bare-id, ambiguity), and harness assembly invariants (coding-tools assignable to extraTools, default system prompt + skill block composition, first-prompt screenshot via harness.prompt({ images })). - CI gains a cli-unit job that runs the new tests on every PR. Old and new dep trees coexist intentionally in this PR; @mariozechner/* stays on the package while interactive remains on it. PR 2 rebuilds the TUI on harness + pi-tui 0.79 and PR 3 deletes the legacy code. --- .github/workflows/ci.yml | 15 + package-lock.json | 1790 ++++++++++++++++- packages/cua-cli/package.json | 8 +- packages/cua-cli/src/action/harness-runner.ts | 188 ++ packages/cua-cli/src/cli-harness.ts | 631 ++++++ packages/cua-cli/src/cli.ts | 519 +---- packages/cua-cli/src/harness-browser.ts | 94 + packages/cua-cli/src/harness-models.ts | 52 + .../cua-cli/src/harness-named-sessions.ts | 246 +++ packages/cua-cli/src/harness-sessions.ts | 114 ++ packages/cua-cli/src/harness-skills.ts | 55 + packages/cua-cli/src/harness.ts | 70 + packages/cua-cli/src/output/harness-jsonl.ts | 171 ++ packages/cua-cli/src/print.ts | 117 ++ packages/cua-cli/test/action-runner.test.ts | 81 + packages/cua-cli/test/fixtures/fake-kernel.ts | 66 + packages/cua-cli/test/fixtures/harness.ts | 70 + .../test/fixtures/scripted-provider.ts | 167 ++ .../cua-cli/test/harness-assembly.test.ts | 110 + packages/cua-cli/test/harness-models.test.ts | 28 + .../cua-cli/test/harness-sessions.test.ts | 72 + packages/cua-cli/test/print.test.ts | 122 ++ packages/cua-cli/vitest.config.ts | 13 + 23 files changed, 4342 insertions(+), 457 deletions(-) create mode 100644 packages/cua-cli/src/action/harness-runner.ts create mode 100644 packages/cua-cli/src/cli-harness.ts create mode 100644 packages/cua-cli/src/harness-browser.ts create mode 100644 packages/cua-cli/src/harness-models.ts create mode 100644 packages/cua-cli/src/harness-named-sessions.ts create mode 100644 packages/cua-cli/src/harness-sessions.ts create mode 100644 packages/cua-cli/src/harness-skills.ts create mode 100644 packages/cua-cli/src/harness.ts create mode 100644 packages/cua-cli/src/output/harness-jsonl.ts create mode 100644 packages/cua-cli/src/print.ts create mode 100644 packages/cua-cli/test/action-runner.test.ts create mode 100644 packages/cua-cli/test/fixtures/fake-kernel.ts create mode 100644 packages/cua-cli/test/fixtures/harness.ts create mode 100644 packages/cua-cli/test/fixtures/scripted-provider.ts create mode 100644 packages/cua-cli/test/harness-assembly.test.ts create mode 100644 packages/cua-cli/test/harness-models.test.ts create mode 100644 packages/cua-cli/test/harness-sessions.test.ts create mode 100644 packages/cua-cli/test/print.test.ts create mode 100644 packages/cua-cli/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bac47d6..bfe0c398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,21 @@ jobs: - name: Agent unit tests run: npm test --workspace @onkernel/cua-agent -- --exclude "**/*.live.test.ts" + cli-unit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build --workspace @onkernel/cua-ai + - run: npm run build --workspace @onkernel/cua-agent + - name: CLI unit tests + run: npm test --workspace @onkernel/cua-cli + integration: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/package-lock.json b/package-lock.json index eca44fe7..c5ee1f96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -650,6 +650,1790 @@ "zod-to-json-schema": "^3.25.0" } }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.1.tgz", + "integrity": "sha512-dLnje4U5H3/ZytJpvhjhPINeDT/yvx85e4OH/ziMQRLpPlfNP12/peY9jRQd4W11Xth2+y2xGAFwS+NeVf2ZwA==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.1.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.79.1", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.1.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.1.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -5481,10 +7265,13 @@ "name": "@onkernel/cua-cli", "version": "0.1.0", "dependencies": { + "@earendil-works/pi-coding-agent": "0.79.1", "@mariozechner/pi-agent-core": "0.67.6", "@mariozechner/pi-ai": "0.67.6", "@mariozechner/pi-coding-agent": "0.67.6", "@mariozechner/pi-tui": "0.67.6", + "@onkernel/cua-agent": "0.3.3", + "@onkernel/cua-ai": "0.3.0", "@onkernel/cua-anthropic": "0.1.0", "@onkernel/cua-gemini": "0.1.0", "@onkernel/cua-openai": "0.1.0", @@ -5498,7 +7285,8 @@ "cua": "dist/cli.js" }, "devDependencies": { - "@onkernel/ptywright": "0.1.0" + "@onkernel/ptywright": "0.1.0", + "vitest": "^3.2.4" } }, "packages/cua-cli/node_modules/smol-toml": { diff --git a/packages/cua-cli/package.json b/packages/cua-cli/package.json index ea5f90cc..2a7b158e 100644 --- a/packages/cua-cli/package.json +++ b/packages/cua-cli/package.json @@ -15,13 +15,16 @@ "scripts": { "build": "tsc -b && chmod +x dist/cli.js", "clean": "tsc -b --clean", - "test": "node --test dist/tui/testing/*.test.js" + "test": "vitest --run" }, "dependencies": { + "@earendil-works/pi-coding-agent": "0.79.1", "@mariozechner/pi-agent-core": "0.67.6", "@mariozechner/pi-ai": "0.67.6", "@mariozechner/pi-coding-agent": "0.67.6", "@mariozechner/pi-tui": "0.67.6", + "@onkernel/cua-agent": "0.3.3", + "@onkernel/cua-ai": "0.3.0", "@onkernel/cua-anthropic": "0.1.0", "@onkernel/cua-gemini": "0.1.0", "@onkernel/cua-openai": "0.1.0", @@ -32,6 +35,7 @@ "smol-toml": "1.5.1" }, "devDependencies": { - "@onkernel/ptywright": "0.1.0" + "@onkernel/ptywright": "0.1.0", + "vitest": "^3.2.4" } } diff --git a/packages/cua-cli/src/action/harness-runner.ts b/packages/cua-cli/src/action/harness-runner.ts new file mode 100644 index 00000000..8856764e --- /dev/null +++ b/packages/cua-cli/src/action/harness-runner.ts @@ -0,0 +1,188 @@ +import type { AgentHarnessEvent, CuaAgentHarness, Session } from "@onkernel/cua-agent"; +import { writeFile } from "node:fs/promises"; +import { stderr, stdout } from "node:process"; +import { captureScreenshot, type CuaBrowserHandle } from "../harness-browser"; +import { type ActionRequest, buildPrompt, DEFAULT_MAX_TURNS } from "./prompts"; +import { type ActionEventInfo, type ActionResult, exitCodeFor, formatCompact, parseResult } from "./result"; + +export interface HarnessRunOptions { + harness: CuaAgentHarness; + browserHandle: CuaBrowserHandle; + session: Session; + verbose?: boolean; + maxTurns?: number; +} + +export interface ScreenshotOutput { + out: string; // path or "-" for stdout +} + +export interface RunActionResult { + result: ActionResult; + exitCode: number; +} + +/** + * Run a single action subcommand against an existing harness + browser and + * return the parsed result plus exit code. The `screenshot` action is + * model-free — it captures directly through the SDK. All other actions + * drive the harness for at most `maxTurns` turns. + */ +export async function runAction( + req: ActionRequest, + opts: HarnessRunOptions, + screenshot?: ScreenshotOutput, +): Promise { + const startedAt = Date.now(); + + if (req.action === "screenshot") { + const out = screenshot ?? { out: "screenshot.png" }; + const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); + if (!png) { + const elapsed = Date.now() - startedAt; + const result: ActionResult = { + action: "screenshot", + status: "error", + text: "failed to capture screenshot", + elapsedMs: elapsed, + timestamp: Date.now(), + }; + return { result, exitCode: exitCodeFor(result) }; + } + if (out.out === "-") { + stdout.write(png); + } else { + await writeFile(out.out, png); + } + const elapsed = Date.now() - startedAt; + const result = parseResult("screenshot", "", [], elapsed); + result.text = out.out === "-" ? "(stdout)" : out.out; + return { result, exitCode: 0 }; + } + + const prompt = buildPrompt(req); + const maxTurns = req.maxTurns ?? opts.maxTurns ?? DEFAULT_MAX_TURNS; + + const events: ActionEventInfo[] = []; + let assistantText = ""; + let turns = 0; + let aborted = false; + let lastToolError: string | undefined; + + const unsubscribe = opts.harness.subscribe((event: AgentHarnessEvent) => { + switch (event.type) { + case "tool_execution_start": + collectActionEvent(event.toolName, event.args, events); + return; + case "tool_execution_end": { + if (event.isError) { + lastToolError = extractToolErrorText(event.result) ?? "tool execution failed"; + } + return; + } + case "message_update": + if (event.assistantMessageEvent.type === "text_delta") { + assistantText += event.assistantMessageEvent.delta; + } + return; + case "turn_end": + turns += 1; + if (turns >= maxTurns && !aborted) { + aborted = true; + void opts.harness.abort(); + } + return; + default: + return; + } + }); + + let runError: Error | undefined; + try { + const assistant = await opts.harness.prompt(prompt); + if (assistant.stopReason === "error") { + runError = new Error(assistant.errorMessage ?? "agent stopped with error"); + } + } catch (err) { + runError = err instanceof Error ? err : new Error(String(err)); + } finally { + unsubscribe(); + } + + const elapsed = Date.now() - startedAt; + + if (runError) { + const result: ActionResult = { + action: req.action, + status: "error", + text: runError.message, + elapsedMs: elapsed, + timestamp: Date.now(), + }; + return { result, exitCode: exitCodeFor(result) }; + } + + const result = parseResult(req.action, assistantText, events, elapsed, lastToolError); + return { result, exitCode: exitCodeFor(result) }; +} + +/** + * Collect click coordinates from canonical CUA tool calls. The harness + * dispatches batched calls via `computer_batch` (args: { actions: [...] }) + * and single-action calls via per-action tools (args: cua action without + * the `type` field, which we recover from the tool name). + */ +function collectActionEvent(toolName: string, args: unknown, events: ActionEventInfo[]): void { + if (toolName === "computer_batch") { + const actions = (args as { actions?: unknown }).actions; + if (Array.isArray(actions)) { + for (const action of actions) { + if (action && typeof action === "object") { + addClickEvent( + (action as { type?: unknown }).type, + (action as { x?: unknown }).x, + (action as { y?: unknown }).y, + events, + ); + } + } + } + return; + } + if (args && typeof args === "object") { + const x = (args as { x?: unknown }).x; + const y = (args as { y?: unknown }).y; + addClickEvent(toolName, x, y, events); + } +} + +function addClickEvent(type: unknown, x: unknown, y: unknown, events: ActionEventInfo[]): void { + if (typeof type !== "string") return; + if (type !== "click" && type !== "double_click") return; + if (typeof x !== "number" || typeof y !== "number") return; + events.push({ actionType: type, x, y }); +} + +function extractToolErrorText(result: unknown): string | undefined { + if (!result || typeof result !== "object") return undefined; + const content = (result as { content?: unknown }).content; + if (!Array.isArray(content)) return undefined; + const parts: string[] = []; + for (const block of content) { + if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") { + const text = (block as { text?: unknown }).text; + if (typeof text === "string" && text.trim().length > 0) parts.push(text.trim()); + } + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +/** Print a compact result line and return its exit code. */ +export function emitCompact(res: RunActionResult): number { + const text = formatCompact(res.result); + if (text) stdout.write(`${text}\n`); + if (res.exitCode !== 0 && !text.startsWith("error") && res.result.status === "error") { + stderr.write(`error ${res.result.text ?? ""}\n`); + } + return res.exitCode; +} diff --git a/packages/cua-cli/src/cli-harness.ts b/packages/cua-cli/src/cli-harness.ts new file mode 100644 index 00000000..bae54aca --- /dev/null +++ b/packages/cua-cli/src/cli-harness.ts @@ -0,0 +1,631 @@ +import { + type JsonlSessionMetadata, + type JsonlSessionRepo, + NodeExecutionEnv, + type Session, + type Skill, +} from "@onkernel/cua-agent"; +import { + type CuaModelRef, + getCuaEnvApiKey, + parseCuaModelRef, +} from "@onkernel/cua-ai"; +import { parseArgs } from "node:util"; +import { stderr, stdout } from "node:process"; +import type { CuaBrowserHandle } from "./harness-browser"; +import { + type ActionRequest, + type ActionType, +} from "./action/prompts"; +import { runAction, emitCompact } from "./action/harness-runner"; +import { buildCuaHarness } from "./harness"; +import { provisionBrowser } from "./harness-browser"; +import { DEFAULT_CUA_MODEL_REF, listSupportedModels, resolveCuaModelRef } from "./harness-models"; +import { + attachNamedSession, + formatRelativeAge, + listNamedSessions, + type NamedSessionMetadata, + recordTranscriptPath, + shortKernelId, + startNamedSession, + stopNamedSession, + validateSlug, +} from "./harness-named-sessions"; +import { + appendBrowserEntry, + createSession, + createSessionRepo, + findLatestSession, + listSessionsForCwd, + openSession, + resolveSessionRef, +} from "./harness-sessions"; +import { discoverCuaSkills } from "./harness-skills"; +import { runPrint } from "./print"; + +const MODELS_HELP = `cua models — list supported -m/--model values + +Usage: + cua models + cua models -p openai + cua models --provider anthropic + cua models --json + +Options: + -p, --provider Filter by provider: openai | anthropic | google | gemini | tzafon | yutori + --json Output JSON + -h, --help Show this help +`; + +interface ModelsFlags { + provider?: string; + json: boolean; + help: boolean; +} + +function parseModelsArgs(argv: string[]): ModelsFlags { + const parsed = parseArgs({ + args: argv, + options: { + provider: { type: "string", short: "p" }, + json: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + strict: true, + }); + const positionalProvider = parsed.positionals[0]; + if (parsed.positionals.length > 1) { + throw new Error(`unexpected arguments: ${parsed.positionals.slice(1).join(" ")}`); + } + return { + provider: (parsed.values.provider as string | undefined) ?? positionalProvider, + json: !!parsed.values.json, + help: !!parsed.values.help, + }; +} + +/** `cua models` subcommand backed by cua-ai's `listCuaModels()`. */ +export async function runModelsSubcommand(argv: string[]): Promise { + let flags: ModelsFlags; + try { + flags = parseModelsArgs(argv); + } catch (err) { + stderr.write(`${(err as Error).message}\n\n${MODELS_HELP}`); + return 2; + } + if (flags.help) { + stdout.write(MODELS_HELP); + return 0; + } + let models; + try { + models = listSupportedModels(flags.provider); + } catch (err) { + stderr.write(`${(err as Error).message}\n`); + return 2; + } + if (flags.json) { + stdout.write(`${JSON.stringify(models, null, 2)}\n`); + return 0; + } + stdout.write(formatModelsTable(models)); + return 0; +} + +function formatModelsTable(models: ReturnType): string { + const rows = models.map((entry) => ({ + ref: entry.ref, + provider: entry.provider, + model: entry.model, + default: entry.ref === DEFAULT_CUA_MODEL_REF ? "yes" : "", + name: entry.name, + })); + const headers = { ref: "REF", provider: "PROVIDER", model: "MODEL", default: "DEFAULT", name: "NAME" }; + const widths = { + ref: columnWidth(headers.ref, rows.map((r) => r.ref)), + provider: columnWidth(headers.provider, rows.map((r) => r.provider)), + model: columnWidth(headers.model, rows.map((r) => r.model)), + default: columnWidth(headers.default, rows.map((r) => r.default)), + name: columnWidth(headers.name, rows.map((r) => r.name)), + }; + const lines = [ + [ + headers.ref.padEnd(widths.ref), + headers.provider.padEnd(widths.provider), + headers.model.padEnd(widths.model), + headers.default.padEnd(widths.default), + headers.name, + ].join(" "), + [ + "-".repeat(widths.ref), + "-".repeat(widths.provider), + "-".repeat(widths.model), + "-".repeat(widths.default), + "-".repeat(widths.name), + ].join(" "), + ]; + for (const row of rows) { + lines.push( + [ + row.ref.padEnd(widths.ref), + row.provider.padEnd(widths.provider), + row.model.padEnd(widths.model), + row.default.padEnd(widths.default), + row.name, + ].join(" "), + ); + } + return `${lines.join("\n")}\n`; +} + +function columnWidth(header: string, values: string[]): number { + return Math.max(header.length, ...values.map((value) => value.length)); +} + +export interface HarnessCliFlags { + verbose: boolean; + profileSaveChanges: boolean; + continueLatest: boolean; + resumePicker: boolean; + noSession: boolean; + noSkills: boolean; + jsonlIncludeDeltas: boolean; + jsonlIncludeImages: boolean; + model?: string; + thinking?: string; + browserProfile?: string; + browserTimeout?: number; + maxSteps?: number; + out?: string; + output?: string; + namedSession?: string; + sessionRef?: string; + sessionDir?: string; + skillPaths: string[]; +} + +interface ResolvedAuth { + kernelApiKey: string; + kernelBaseUrl?: string; + modelRef: CuaModelRef; +} + +function requireKernelApiKey(): { apiKey: string; baseUrl?: string } { + const apiKey = process.env.KERNEL_API_KEY?.trim(); + if (!apiKey) throw new Error("missing Kernel API key (set KERNEL_API_KEY)"); + const baseUrl = process.env.KERNEL_BASE_URL?.trim() || undefined; + return { apiKey, baseUrl }; +} + +function resolveAuth(flags: HarnessCliFlags): ResolvedAuth { + const { apiKey, baseUrl } = requireKernelApiKey(); + const modelRef = resolveCuaModelRef(flags.model); + const { provider } = parseCuaModelRef(modelRef); + const providerKey = getCuaEnvApiKey(provider); + if (!providerKey) { + throw new Error(`missing API key for provider "${provider}"`); + } + return { kernelApiKey: apiKey, kernelBaseUrl: baseUrl, modelRef }; +} + +interface ProvisionedBrowser { + handle: CuaBrowserHandle; + named?: NamedSessionMetadata; +} + +async function provisionForFlags(flags: HarnessCliFlags, auth: ResolvedAuth): Promise { + if (flags.namedSession) { + const { client, browser, meta } = await attachNamedSession({ + name: flags.namedSession, + apiKey: auth.kernelApiKey, + baseUrl: auth.kernelBaseUrl, + }); + if (flags.verbose) { + stderr.write(`[cua] attached named session "${meta.name}" (browser=${browser.session_id})\n`); + if (browser.browser_live_view_url) stderr.write(`[cua] live view=${browser.browser_live_view_url}\n`); + } + const handle: CuaBrowserHandle = { + client, + browser, + async close(): Promise { + // no-op: named-session browsers are torn down via `cua session stop`. + }, + }; + return { handle, named: meta }; + } + if (flags.verbose) stderr.write("[cua] provisioning Kernel browser...\n"); + const handle = await provisionBrowser({ + apiKey: auth.kernelApiKey, + baseUrl: auth.kernelBaseUrl, + timeoutSeconds: flags.browserTimeout, + profileSelector: flags.browserProfile, + saveChanges: flags.profileSaveChanges, + }); + if (flags.verbose) { + stderr.write(`[cua] browser session=${handle.browser.session_id}\n`); + if (handle.browser.browser_live_view_url) { + stderr.write(`[cua] live view=${handle.browser.browser_live_view_url}\n`); + } + } + return { handle }; +} + +interface ResolvedSession { + session: Session; + transcriptPath: string; + resumed: boolean; +} + +async function resolveSession( + repo: JsonlSessionRepo, + cwd: string, + flags: HarnessCliFlags, + namedMeta?: NamedSessionMetadata, +): Promise { + if (flags.noSession) return undefined; + if (flags.sessionRef) { + const metadata = await resolveSessionRef(repo, cwd, flags.sessionRef); + return { session: await openSession(repo, metadata), transcriptPath: metadata.path, resumed: true }; + } + if (flags.continueLatest) { + const latest = await findLatestSession(repo, cwd); + if (!latest) { + stderr.write("[cua] no previous session for this cwd; starting fresh\n"); + const fresh = await createSession(repo, cwd); + const metadata = await fresh.getMetadata(); + return { session: fresh, transcriptPath: metadata.path, resumed: false }; + } + return { session: await openSession(repo, latest), transcriptPath: latest.path, resumed: true }; + } + if (flags.resumePicker) { + const sessions = await listSessionsForCwd(repo, cwd); + if (sessions.length === 0) { + stderr.write("[cua] no previous sessions for this cwd; starting fresh\n"); + const fresh = await createSession(repo, cwd); + const metadata = await fresh.getMetadata(); + return { session: fresh, transcriptPath: metadata.path, resumed: false }; + } + const picked = await pickSession(sessions); + if (!picked) { + const fresh = await createSession(repo, cwd); + const metadata = await fresh.getMetadata(); + return { session: fresh, transcriptPath: metadata.path, resumed: false }; + } + return { session: await openSession(repo, picked), transcriptPath: picked.path, resumed: true }; + } + if (namedMeta?.transcript_path) { + const sessions = await listSessionsForCwd(repo, cwd); + const match = sessions.find((m) => m.path === namedMeta.transcript_path); + if (match) { + return { session: await openSession(repo, match), transcriptPath: match.path, resumed: true }; + } + } + const fresh = await createSession(repo, cwd); + const metadata = await fresh.getMetadata(); + return { session: fresh, transcriptPath: metadata.path, resumed: false }; +} + +async function pickSession(sessions: JsonlSessionMetadata[]): Promise { + const sorted = [...sessions].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + stderr.write("\nResume which session?\n"); + const limit = Math.min(sorted.length, 20); + for (let i = 0; i < limit; i++) { + const s = sorted[i]!; + stderr.write(` [${i + 1}] ${s.id.slice(0, 8)} · ${s.createdAt}\n`); + } + if (sorted.length > limit) { + stderr.write(` (${sorted.length - limit} more not shown; use --session to select directly)\n`); + } + const { createInterface } = await import("node:readline/promises"); + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = (await rl.question("Pick a number (or blank to skip): ")).trim(); + if (!answer) return undefined; + const n = Number(answer); + if (!Number.isFinite(n) || n < 1 || n > limit) { + stderr.write("[cua] invalid selection; starting fresh\n"); + return undefined; + } + return sorted[n - 1]; + } finally { + rl.close(); + } +} + +interface HarnessRuntime { + handle: CuaBrowserHandle; + resolved: ResolvedSession | undefined; + skills: Skill[]; + harness: ReturnType; + provider: string; + modelRef: CuaModelRef; +} + +async function setupHarnessRuntime(flags: HarnessCliFlags): Promise { + const auth = resolveAuth(flags); + const cwd = process.cwd(); + const env = new NodeExecutionEnv({ cwd }); + const { skills } = await discoverCuaSkills({ + cwd, + env, + extraPaths: flags.skillPaths, + disabled: flags.noSkills, + }); + + const provisioned = await provisionForFlags(flags, auth); + const repo = createSessionRepo(flags.sessionDir); + + const resolved = await resolveSession(repo, cwd, flags, provisioned.named); + + let inMemorySession: Session | undefined; + if (!resolved) { + const { InMemorySessionRepo } = await import("@onkernel/cua-agent"); + const memRepo = new InMemorySessionRepo(); + inMemorySession = await memRepo.create(); + } + + const session = resolved?.session ?? inMemorySession!; + const { provider } = parseCuaModelRef(auth.modelRef); + + if (resolved) { + await appendBrowserEntry(session, { + sessionId: provisioned.handle.browser.session_id, + liveUrl: provisioned.handle.browser.browser_live_view_url, + createdAt: Date.now(), + }); + if (provisioned.named) { + await recordTranscriptPath(provisioned.named.name, resolved.transcriptPath); + } + if (flags.verbose) { + stderr.write(`[cua] session=${resolved.transcriptPath}\n`); + if (resolved.resumed) stderr.write("[cua] resumed prior session into fresh browser\n"); + } + } + + const thinkingLevel = mapThinkingLevel(flags.thinking); + const harness = buildCuaHarness({ + cwd, + client: provisioned.handle.client, + browser: provisioned.handle.browser, + session, + model: auth.modelRef, + skills, + thinkingLevel, + }); + + return { + handle: provisioned.handle, + resolved, + skills, + harness, + provider, + modelRef: auth.modelRef, + }; +} + +function mapThinkingLevel(raw: string | undefined): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" { + const v = (raw ?? "low").trim().toLowerCase(); + switch (v) { + case "off": + case "none": + return "off"; + case "minimal": + return "minimal"; + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + return "xhigh"; + case "low": + case "": + default: + return "low"; + } +} + +/** Run a single prompt through the new harness wiring (`--print`). */ +export async function runPrintCommand(prompt: string, flags: HarnessCliFlags): Promise { + const runtime = await setupHarnessRuntime(flags); + const jsonlMode = (flags.output ?? "text").toLowerCase() === "jsonl"; + try { + return await runPrint({ + harness: runtime.harness, + browserHandle: runtime.handle, + session: (runtime.resolved?.session ?? (await fallbackInMemorySession())) as Session, + modelRef: runtime.modelRef, + provider: runtime.provider, + prompt, + skills: runtime.skills, + skipInitialScreenshot: runtime.resolved?.resumed === true, + verbose: flags.verbose, + jsonlMode, + jsonlIncludeDeltas: flags.jsonlIncludeDeltas, + jsonlIncludeImages: flags.jsonlIncludeImages, + }); + } finally { + try { + await runtime.handle.close(); + } catch (err) { + stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); + } + } +} + +async function fallbackInMemorySession(): Promise { + const { InMemorySessionRepo } = await import("@onkernel/cua-agent"); + const repo = new InMemorySessionRepo(); + return repo.create(); +} + +/** Run a one-shot action subcommand through the new harness wiring. */ +export async function runActionCommand( + action: ActionType, + rest: string[], + flags: HarnessCliFlags, +): Promise { + const runtime = await setupHarnessRuntime(flags); + const req: ActionRequest = buildActionRequest(action, rest); + if (flags.maxSteps !== undefined) req.maxTurns = flags.maxSteps; + const screenshotOut = flags.out + ? { out: flags.out } + : action === "screenshot" + ? { out: "screenshot.png" } + : undefined; + try { + const res = await runAction(req, { + harness: runtime.harness, + browserHandle: runtime.handle, + session: (runtime.resolved?.session ?? (await fallbackInMemorySession())) as Session, + verbose: flags.verbose, + }, screenshotOut); + return emitCompact(res); + } finally { + try { + await runtime.handle.close(); + } catch (err) { + stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); + } + } +} + +function buildActionRequest(action: ActionType, rest: string[]): ActionRequest { + switch (action) { + case "open": + return { action, text: rest[0] }; + case "click": + return { action, target: rest.join(" ") }; + case "type": + return { action, target: rest[0], text: rest[1] }; + case "press": + return { action, keys: rest }; + case "observe": + return { action, text: rest.join(" ") }; + case "url": + return { action }; + case "screenshot": + return { action }; + case "do": + return { action, text: rest.join(" ") }; + } +} + +/** Named-session subcommand handlers wired to the new SDK-backed implementation. */ +export async function runSessionSubcommand(args: string[], flags: HarnessCliFlags): Promise { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + stdout.write(`${sessionHelp()}\n`); + return 0; + } + const auth = resolveAuthOrFail(); + switch (sub) { + case "start": { + const name = (args[1] ?? "").trim() || generateSessionSlug(); + validateSlug(name); + const { meta, metadataPath, browser } = await startNamedSession({ + name, + apiKey: auth.kernelApiKey, + baseUrl: auth.kernelBaseUrl, + browserTimeoutSeconds: flags.browserTimeout, + profileId: flags.browserProfile, + saveProfileChanges: flags.profileSaveChanges, + }); + stdout.write(`name=${meta.name}\n`); + stdout.write(`kernel_session_id=${browser.session_id}\n`); + if (browser.browser_live_view_url) stdout.write(`live_url=${browser.browser_live_view_url}\n`); + stdout.write(`metadata=${metadataPath}\n`); + stdout.write(`\nUse: cua -s ${meta.name} ...\n`); + return 0; + } + case "stop": { + const name = (args[1] ?? "").trim(); + if (!name) { + stderr.write("usage: cua session stop \n"); + return 2; + } + validateSlug(name); + const result = await stopNamedSession({ + name, + apiKey: auth.kernelApiKey, + baseUrl: auth.kernelBaseUrl, + }); + if (!result.existed) { + stderr.write(`no named session "${name}"\n`); + return 1; + } + stdout.write( + result.kernelDeleted + ? `stopped ${name} (kernel browser deleted)\n` + : `stopped ${name} (kernel browser was already gone)\n`, + ); + return 0; + } + case "list": { + const sessions = await listNamedSessions(); + if (sessions.length === 0) { + stdout.write("(no named sessions; run `cua session start [name]`)\n"); + return 0; + } + const header = ["NAME", "KERNEL_ID", "AGE", "LIVE_URL"].join("\t"); + stdout.write(`${header}\n`); + for (const s of sessions) { + stdout.write( + [ + s.name, + shortKernelId(s.kernel_session_id), + formatRelativeAge(s.created_at), + s.live_url ?? "-", + ].join("\t") + "\n", + ); + } + return 0; + } + case "show": { + const name = (args[1] ?? "").trim(); + if (!name) { + stderr.write("usage: cua session show \n"); + return 2; + } + validateSlug(name); + const sessions = await listNamedSessions(); + const meta = sessions.find((s) => s.name === name); + if (!meta) { + stderr.write(`no named session "${name}"\n`); + return 1; + } + stdout.write(`${JSON.stringify(meta, null, 2)}\n`); + return 0; + } + default: + stderr.write(`unknown session subcommand: ${sub}\n${sessionHelp()}\n`); + return 2; + } +} + +function resolveAuthOrFail(): { kernelApiKey: string; kernelBaseUrl?: string } { + const { apiKey, baseUrl } = requireKernelApiKey(); + return { kernelApiKey: apiKey, kernelBaseUrl: baseUrl }; +} + +function generateSessionSlug(): string { + const adjectives = ["calm", "brisk", "swift", "quiet", "bright", "sharp"]; + const nouns = ["fox", "owl", "lynx", "hawk", "wolf", "moth"]; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)] ?? "calm"; + const noun = nouns[Math.floor(Math.random() * nouns.length)] ?? "fox"; + const stamp = Date.now().toString(36).slice(-4); + return `${adj}-${noun}-${stamp}`; +} + +function sessionHelp(): string { + return [ + "cua session start [name] Start a new named browser session.", + "cua session stop Tear down a named session.", + "cua session list List existing named sessions.", + "cua session show Print full metadata for a named session.", + "", + "Use `-s ` on any other command to reuse the named session's", + "browser (e.g. `cua -s login open https://...`).", + ].join("\n"); +} diff --git a/packages/cua-cli/src/cli.ts b/packages/cua-cli/src/cli.ts index 4491cc83..6a0c2607 100644 --- a/packages/cua-cli/src/cli.ts +++ b/packages/cua-cli/src/cli.ts @@ -2,41 +2,29 @@ import { browserSession, type BrowserSession } from "@onkernel/cua-translator"; import { stderr, stdout } from "node:process"; import { parseArgs } from "node:util"; -import { type ActionRequest, type ActionType } from "./action/prompts"; -import { emitCompact, runAction, type RunActionResult } from "./action/runner"; -import { createCuaAgent } from "./agent"; -import { promptWithScreenshot } from "./agent-prompt"; -import * as configMod from "./config"; +import { type ActionType } from "./action/prompts"; import { - DEFAULT_MODEL_ID, - SUPPORTED_PROVIDERS, - type ProviderId, - listSupportedModels, - resolveProvider, -} from "./models"; + runActionCommand, + runModelsSubcommand as runModelsSubcommandHarness, + runPrintCommand, + runSessionSubcommand as runSessionSubcommandHarness, + type HarnessCliFlags, +} from "./cli-harness"; +import * as configMod from "./config"; +import { DEFAULT_MODEL_ID, resolveProvider } from "./models"; import { - attachNamedSession, - formatRelativeAge, - listNamedSessions, type NamedSessionMetadata, recordTranscriptPath, - shortKernelId, - startNamedSession, - stopNamedSession, - validateSlug, + attachNamedSession, } from "./named-sessions"; -import { attachJsonlSink } from "./output/jsonl"; import { - appendBrowserMetadata, findLatestSession, listSessions, openSession, - persistAgentEvents, resolveSessionPath, - seedAgentFromSession, type SessionInfo, } from "./sessions"; -import { discoverCuaSkills, discoverStartupResources, expandSkillInvocation } from "./skills"; +import { discoverStartupResources } from "./skills"; import { runInteractive } from "./tui/main"; const HELP = `cua — Kernel-cloud-browser computer-use agent @@ -58,14 +46,18 @@ Usage: Options: -p, --print Run a single prompt and exit - -m, --model Model id (default: ${DEFAULT_MODEL_ID}) + -m, --model Model ref (default: openai:${DEFAULT_MODEL_ID}) + Accepts \`provider:model\` refs or bare ids that + match exactly one entry in \`cua models\`. Recommended: - openai: ${DEFAULT_MODEL_ID} - anthropic: claude-opus-4-7 - gemini: gemini-3-flash-preview - tzafon: tzafon.northstar-cua-fast - yutori: n1.5-latest - --config-profile

Config profile to load (default: from default_profile) + openai: openai:${DEFAULT_MODEL_ID} + anthropic: anthropic:claude-opus-4-7 + google: google:gemini-3-flash-preview + tzafon: tzafon:tzafon.northstar-cua-fast + yutori: yutori:n1.5-latest + --thinking Thinking level: off | minimal | low | medium | high | xhigh + (default: low; applies to providers that support it) + --config-profile

Config profile to load (default: from default_profile; interactive only) --profile Kernel browser profile to load --profile-no-save-changes Do not persist changes back to the profile --browser-timeout Browser inactivity timeout in seconds (default 300) @@ -121,6 +113,7 @@ interface CliFlags { jsonlIncludeDeltas: boolean; jsonlIncludeImages: boolean; model?: string; + thinking?: string; configProfile?: string; browserProfile?: string; browserTimeout?: number; @@ -149,6 +142,7 @@ function parseCliArgs(argv: string[]): CliFlags { print: { type: "boolean", short: "p", default: false }, verbose: { type: "boolean", short: "v", default: false }, model: { type: "string", short: "m" }, + thinking: { type: "string" }, "config-profile": { type: "string" }, profile: { type: "string" }, "profile-no-save-changes": { type: "boolean", default: false }, @@ -192,6 +186,7 @@ function parseCliArgs(argv: string[]): CliFlags { noSkills: !!parsed.values["no-skills"], debugTui: !!parsed.values["debug-tui"], model: parsed.values.model as string | undefined, + thinking: parsed.values.thinking as string | undefined, configProfile: parsed.values["config-profile"] as string | undefined, browserProfile: parsed.values.profile as string | undefined, browserTimeout: Number.isFinite(browserTimeout) ? browserTimeout : undefined, @@ -209,10 +204,34 @@ function parseCliArgs(argv: string[]): CliFlags { }; } +function toHarnessFlags(flags: CliFlags): HarnessCliFlags { + return { + verbose: flags.verbose, + profileSaveChanges: flags.profileSaveChanges, + continueLatest: flags.continueLatest, + resumePicker: flags.resumePicker, + noSession: flags.noSession, + noSkills: flags.noSkills, + jsonlIncludeDeltas: flags.jsonlIncludeDeltas, + jsonlIncludeImages: flags.jsonlIncludeImages, + model: flags.model, + thinking: flags.thinking, + browserProfile: flags.browserProfile, + browserTimeout: flags.browserTimeout, + maxSteps: flags.maxSteps, + out: flags.out, + output: flags.output, + namedSession: flags.namedSession, + sessionRef: flags.sessionRef, + sessionDir: flags.sessionDir, + skillPaths: flags.skillPaths, + }; +} + /** - * Load the cua config and verify the keys we need for the requested - * provider. The provider comes from the supported model table, matching - * what {@link createCuaAgent} will use at run time. + * Load the legacy cua config and verify the keys we need for the requested + * provider. Only the interactive entry point still consumes this; the new + * non-interactive paths read API keys from env vars directly. */ async function loadConfigOrFail(flags: CliFlags): Promise { const cfg = await configMod.load(flags.configProfile); @@ -239,134 +258,10 @@ async function loadConfigOrFail(flags: CliFlags): Promise { return cfg; } -const MODELS_HELP = `cua models — list supported -m/--model values - -Usage: - cua models - cua models -p openai - cua models --provider anthropic - cua models --json - -Options: - -p, --provider Filter by provider: openai | anthropic | gemini | tzafon | yutori - --json Output JSON - -h, --help Show this help -`; - -interface ModelsFlags { - provider?: ProviderId; - json: boolean; - help: boolean; -} - -function parseModelsProvider(value?: string): ProviderId | undefined { - if (!value) return undefined; - const v = value.trim().toLowerCase(); - if (SUPPORTED_PROVIDERS.includes(v as ProviderId)) return v as ProviderId; - throw new Error(`unknown provider "${value}" (expected: ${SUPPORTED_PROVIDERS.join(" | ")})`); -} - -function parseModelsArgs(argv: string[]): ModelsFlags { - const parsed = parseArgs({ - args: argv, - options: { - provider: { type: "string", short: "p" }, - json: { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - strict: true, - }); - const positionalProvider = parsed.positionals[0]; - if (parsed.positionals.length > 1) { - throw new Error(`unexpected arguments: ${parsed.positionals.slice(1).join(" ")}`); - } - return { - provider: parseModelsProvider((parsed.values.provider as string | undefined) ?? positionalProvider), - json: !!parsed.values.json, - help: !!parsed.values.help, - }; -} - -async function runModelsSubcommand(args: string[]): Promise { - let flags: ModelsFlags; - try { - flags = parseModelsArgs(args); - } catch (err) { - stderr.write(`${(err as Error).message}\n\n${MODELS_HELP}`); - return 2; - } - if (flags.help) { - stdout.write(MODELS_HELP); - return 0; - } - - const models = listSupportedModels(flags.provider); - if (flags.json) { - stdout.write(`${JSON.stringify(models, null, 2)}\n`); - return 0; - } - - stdout.write(formatModelsTable(models)); - return 0; -} - -function formatModelsTable(models: ReturnType): string { - const rows = models.map((model) => ({ - provider: model.provider, - model: model.model, - default: model.default ? "yes" : "", - name: model.name, - })); - const headers = { - provider: "PROVIDER", - model: "MODEL", - default: "DEFAULT", - name: "NAME", - }; - const widths = { - provider: columnWidth(headers.provider, rows.map((row) => row.provider)), - model: columnWidth(headers.model, rows.map((row) => row.model)), - default: columnWidth(headers.default, rows.map((row) => row.default)), - name: columnWidth(headers.name, rows.map((row) => row.name)), - }; - const lines = [ - [ - headers.provider.padEnd(widths.provider), - headers.model.padEnd(widths.model), - headers.default.padEnd(widths.default), - headers.name, - ].join(" "), - [ - "-".repeat(widths.provider), - "-".repeat(widths.model), - "-".repeat(widths.default), - "-".repeat(widths.name), - ].join(" "), - ]; - for (const row of rows) { - lines.push( - [ - row.provider.padEnd(widths.provider), - row.model.padEnd(widths.model), - row.default.padEnd(widths.default), - row.name, - ].join(" "), - ); - } - return `${lines.join("\n")}\n`; -} - -function columnWidth(header: string, values: string[]): number { - return Math.max(header.length, ...values.map((value) => value.length)); -} - /** - * Resolve the session policy from CLI flags. Returns the source of truth - * for whether to attach to an existing file, create a fresh one, or skip - * persistence entirely. When `namedMeta` is provided (i.e. `-s ` - * was used), its `transcript_path` becomes the default session path - * unless an explicit `--session` / `-c` / `-r` flag overrides it. + * Resolve the session policy from CLI flags for the legacy interactive + * stack. Returns whether to attach to an existing file, create a fresh + * one, or skip persistence entirely. */ async function resolveSessionFlags( flags: CliFlags, @@ -454,13 +349,11 @@ function formatRelative(date: Date): string { return `${d}d ago`; } -interface ProvisionedBrowser { - browser: BrowserSession; - /** Named session metadata when `-s ` was used; otherwise undefined. */ - named?: NamedSessionMetadata; -} - -async function provisionBrowser(cfg: configMod.Config, flags: CliFlags): Promise { +/** Provision a legacy-stack browser session for the interactive entry point. */ +async function provisionInteractiveBrowser( + cfg: configMod.Config, + flags: CliFlags, +): Promise<{ browser: BrowserSession; named?: NamedSessionMetadata }> { if (flags.namedSession) { const { browser, meta } = await attachNamedSession({ name: flags.namedSession, cfg }); if (flags.verbose) { @@ -469,7 +362,6 @@ async function provisionBrowser(cfg: configMod.Config, flags: CliFlags): Promise } return { browser, named: meta }; } - if (flags.verbose) stderr.write("[cua] provisioning Kernel browser...\n"); const browser = await browserSession.open({ apiKey: cfg.kernelApiKey, @@ -504,292 +396,11 @@ async function runConfigSubcommand(args: string[], profileFlag?: string): Promis return 2; } -const SESSION_HELP = `cua session start [name] Start a new named browser session. -cua session stop Tear down a named session. -cua session list List existing named sessions. -cua session show Print full metadata for a named session. - -Use \`-s \` on any other command to reuse the named session's -browser (e.g. \`cua -s login open https://...\`).`; - -function generateSessionSlug(): string { - const adjectives = ["calm", "brisk", "swift", "quiet", "bright", "sharp"]; - const nouns = ["fox", "owl", "lynx", "hawk", "wolf", "moth"]; - const adj = adjectives[Math.floor(Math.random() * adjectives.length)] ?? "calm"; - const noun = nouns[Math.floor(Math.random() * nouns.length)] ?? "fox"; - const stamp = Date.now().toString(36).slice(-4); - return `${adj}-${noun}-${stamp}`; -} - -async function runSessionSubcommand(args: string[], flags: CliFlags): Promise { - const sub = args[0]; - if (!sub || sub === "help" || sub === "--help" || sub === "-h") { - stdout.write(`${SESSION_HELP}\n`); - return 0; - } - - switch (sub) { - case "start": { - const name = (args[1] ?? "").trim() || generateSessionSlug(); - validateSlug(name); - const cfg = await loadConfigOrFail(flags); - const { meta, metadataPath, browser } = await startNamedSession({ - name, - cfg, - configProfile: flags.configProfile, - browserProfile: flags.browserProfile, - browserTimeoutSeconds: flags.browserTimeout, - saveProfileChanges: flags.profileSaveChanges, - }); - stdout.write(`name=${meta.name}\n`); - stdout.write(`kernel_session_id=${browser.sessionId}\n`); - if (browser.liveUrl) stdout.write(`live_url=${browser.liveUrl}\n`); - stdout.write(`metadata=${metadataPath}\n`); - stdout.write(`\nUse: cua -s ${meta.name} ...\n`); - return 0; - } - case "stop": { - const name = (args[1] ?? "").trim(); - if (!name) { - stderr.write("usage: cua session stop \n"); - return 2; - } - validateSlug(name); - const cfg = await loadConfigOrFail(flags); - const result = await stopNamedSession({ name, cfg }); - if (!result.existed) { - stderr.write(`no named session "${name}"\n`); - return 1; - } - stdout.write( - result.kernelDeleted - ? `stopped ${name} (kernel browser deleted)\n` - : `stopped ${name} (kernel browser was already gone)\n`, - ); - return 0; - } - case "list": { - const sessions = await listNamedSessions(); - if (sessions.length === 0) { - stdout.write("(no named sessions; run `cua session start [name]`)\n"); - return 0; - } - const header = ["NAME", "KERNEL_ID", "AGE", "LIVE_URL"].join("\t"); - stdout.write(`${header}\n`); - for (const s of sessions) { - stdout.write( - [s.name, shortKernelId(s.kernel_session_id), formatRelativeAge(s.created_at), s.live_url ?? "-"].join("\t") + - "\n", - ); - } - return 0; - } - case "show": { - const name = (args[1] ?? "").trim(); - if (!name) { - stderr.write("usage: cua session show \n"); - return 2; - } - validateSlug(name); - const sessions = await listNamedSessions(); - const meta = sessions.find((s) => s.name === name); - if (!meta) { - stderr.write(`no named session "${name}"\n`); - return 1; - } - stdout.write(`${JSON.stringify(meta, null, 2)}\n`); - return 0; - } - default: - stderr.write(`unknown session subcommand: ${sub}\n${SESSION_HELP}\n`); - return 2; - } -} - -async function runPrint(prompt: string, flags: CliFlags): Promise { - const cfg = await loadConfigOrFail(flags); - const cwd = process.cwd(); - const provision = await provisionBrowser(cfg, flags); - const browser = provision.browser; - const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); - const sm = openSession({ - cwd, - sessionDir: flags.sessionDir, - sessionPath: sessionPolicy.sessionPath, - ephemeral: sessionPolicy.ephemeral, - }); - const { skills } = discoverCuaSkills({ cwd, extraPaths: flags.skillPaths, disabled: flags.noSkills }); - const { expanded, skill: invokedSkill } = expandSkillInvocation(prompt, skills); - if (invokedSkill && flags.verbose) stderr.write(`[cua] expanded /skill:${invokedSkill.name}\n`); - const handle = createCuaAgent({ - cwd, - browser, - config: cfg, - modelId: flags.model, - sessionId: browser.sessionId, - skills, - }); - if (sessionPolicy.resumed) seedAgentFromSession(handle.agent, sm); - appendBrowserMetadata(sm, browser); - const unsubscribePersist = persistAgentEvents(handle.agent, sm); - const transcriptPath = sm.getSessionFile(); - if (provision.named && transcriptPath) { - await recordTranscriptPath(provision.named.name, transcriptPath); - } - if (flags.verbose) { - if (transcriptPath) stderr.write(`[cua] session=${transcriptPath}\n`); - if (sessionPolicy.resumed) stderr.write("[cua] resumed prior session into fresh browser\n"); - } - - const jsonlMode = (flags.output ?? "text").toLowerCase() === "jsonl"; - let unsubscribeJsonl: (() => void) | undefined; - if (jsonlMode) { - unsubscribeJsonl = attachJsonlSink(handle.agent, { - browser, - modelId: handle.model.id, - provider: handle.provider, - includeDeltas: flags.jsonlIncludeDeltas, - includeImages: flags.jsonlIncludeImages, - }); - } - - const unsubscribe = handle.agent.subscribe((event) => { - if (jsonlMode) return; - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - stdout.write(event.assistantMessageEvent.delta); - return; - } - if (flags.verbose && event.type === "tool_execution_start") { - stderr.write(`\n[cua] tool ${event.toolName} ${JSON.stringify(event.args)}\n`); - } - if (flags.verbose && event.type === "tool_execution_end") { - stderr.write(`[cua] tool ${event.toolName} done\n`); - } - }); - - let exitCode = 0; - try { - await promptWithScreenshot({ - agent: handle.agent, - translator: handle.translator, - prompt: expanded, - options: { skipInitialScreenshot: sessionPolicy.resumed }, - }); - const agentError = (handle.agent.state as { errorMessage?: string }).errorMessage; - if (agentError) { - throw new Error(agentError); - } - if (!jsonlMode) stdout.write("\n"); - } catch (err) { - if (jsonlMode) { - stdout.write( - JSON.stringify({ - type: "error", - code: "run_failed", - message: (err as Error).message, - ts: Date.now(), - }) + "\n", - ); - } else { - stderr.write(`\n[cua] error: ${(err as Error).message}\n`); - } - exitCode = 1; - } finally { - unsubscribe(); - unsubscribeJsonl?.(); - unsubscribePersist(); - try { - await handle.dispose(); - } catch (err) { - stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); - } - } - return exitCode; -} - -async function runActionSub(action: ActionType, rest: string[], flags: CliFlags): Promise { - const cfg = await loadConfigOrFail(flags); - const cwd = process.cwd(); - const provision = await provisionBrowser(cfg, flags); - const browser = provision.browser; - - const req: ActionRequest = buildActionRequest(action, rest); - if (flags.maxSteps !== undefined) req.maxTurns = flags.maxSteps; - - const screenshotOut = flags.out - ? { out: flags.out } - : action === "screenshot" - ? { out: "screenshot.png" } - : undefined; - - // For named sessions the transcript should persist across action calls so - // external analysis can correlate them. For one-shot subcommand calls - // without a named session we skip the SessionManager entirely. - let sm: ReturnType | undefined; - if (provision.named) { - const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); - sm = openSession({ - cwd, - sessionDir: flags.sessionDir, - sessionPath: sessionPolicy.sessionPath, - ephemeral: sessionPolicy.ephemeral, - }); - appendBrowserMetadata(sm, browser); - const transcriptPath = sm.getSessionFile(); - if (transcriptPath) await recordTranscriptPath(provision.named.name, transcriptPath); - if (flags.verbose && transcriptPath) stderr.write(`[cua] session=${transcriptPath}\n`); - } - - let res: RunActionResult; - try { - res = await runAction( - req, - { - cwd, - browser, - config: cfg, - modelId: flags.model, - verbose: flags.verbose, - sessionManager: sm, - }, - screenshotOut, - ); - } finally { - try { - await browser.close(); - } catch (err) { - stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); - } - } - return emitCompact(res); -} - -function buildActionRequest(action: ActionType, rest: string[]): ActionRequest { - switch (action) { - case "open": - return { action, text: rest[0] }; - case "click": - return { action, target: rest.join(" ") }; - case "type": - return { action, target: rest[0], text: rest[1] }; - case "press": - return { action, keys: rest }; - case "observe": - return { action, text: rest.join(" ") }; - case "url": - return { action }; - case "screenshot": - return { action }; - case "do": - return { action, text: rest.join(" ") }; - } -} - const SUBCOMMANDS = new Set(["open", "click", "type", "press", "observe", "url", "screenshot", "do"]); export async function main(argv: string[]): Promise { if (argv[0] === "models") { - return await runModelsSubcommand(argv.slice(1)); + return await runModelsSubcommandHarness(argv.slice(1)); } let flags: CliFlags; @@ -819,7 +430,7 @@ export async function main(argv: string[]): Promise { if (first === "session") { try { - return await runSessionSubcommand(positionals.slice(1), flags); + return await runSessionSubcommandHarness(positionals.slice(1), toHarnessFlags(flags)); } catch (err) { stderr.write(`session error: ${(err as Error).message}\n`); return 2; @@ -828,7 +439,7 @@ export async function main(argv: string[]): Promise { if (first && SUBCOMMANDS.has(first)) { try { - return await runActionSub(first as ActionType, positionals.slice(1), flags); + return await runActionCommand(first as ActionType, positionals.slice(1), toHarnessFlags(flags)); } catch (err) { stderr.write(`error: ${(err as Error).message}\n`); return 2; @@ -843,7 +454,7 @@ export async function main(argv: string[]): Promise { return 2; } try { - return await runPrint(prompt, flags); + return await runPrintCommand(prompt, toHarnessFlags(flags)); } catch (err) { stderr.write(`error: ${(err as Error).message}\n`); return 1; @@ -861,7 +472,7 @@ export async function main(argv: string[]): Promise { async function runInteractiveCli(initialPrompt: string, flags: CliFlags): Promise { const cfg = await loadConfigOrFail(flags); const cwd = process.cwd(); - const provision = await provisionBrowser(cfg, flags); + const provision = await provisionInteractiveBrowser(cfg, flags); const browser = provision.browser; const sessionPolicy = await resolveSessionFlags(flags, cwd, provision.named); const sm = openSession({ diff --git a/packages/cua-cli/src/harness-browser.ts b/packages/cua-cli/src/harness-browser.ts new file mode 100644 index 00000000..b010772d --- /dev/null +++ b/packages/cua-cli/src/harness-browser.ts @@ -0,0 +1,94 @@ +import type { KernelBrowser } from "@onkernel/cua-agent"; +import Kernel, { NotFoundError } from "@onkernel/sdk"; + +/** Plain SDK-backed Kernel browser handle for the new harness wiring. */ +export interface CuaBrowserHandle { + client: Kernel; + browser: KernelBrowser; + close(): Promise; +} + +export interface ProvisionBrowserOptions { + apiKey: string; + baseUrl?: string; + timeoutSeconds?: number; + /** Profile id or name. If a name is supplied that does not exist, it is created. */ + profileSelector?: string; + /** Explicit profile id (skips lookup). */ + profileId?: string; + /** Persist changes back to the profile when the session ends. Defaults to false. */ + saveChanges?: boolean; +} + +const CUID2_LENGTH = 24; +const CUID2_PATTERN = /^[a-z][a-z0-9]{23}$/; + +function looksLikeProfileId(selector: string): boolean { + const trimmed = selector.trim(); + return trimmed.length === CUID2_LENGTH && CUID2_PATTERN.test(trimmed); +} + +async function resolveProfileId(client: Kernel, selector: string): Promise { + const trimmed = selector.trim(); + if (!trimmed) throw new Error("profile selector is empty"); + try { + const existing = await client.profiles.retrieve(trimmed); + return existing.id; + } catch (err) { + if (!(err instanceof NotFoundError)) { + throw new Error(`looking up browser profile "${trimmed}": ${(err as Error).message}`, { cause: err }); + } + if (looksLikeProfileId(trimmed)) { + throw new Error(`browser profile "${trimmed}" was not found`); + } + const created = await client.profiles.create({ name: trimmed }); + return created.id; + } +} + +/** Create a Kernel SDK client with the supplied auth. */ +export function createKernelClient(apiKey: string, baseUrl?: string): Kernel { + return new Kernel({ apiKey, ...(baseUrl ? { baseURL: baseUrl } : {}) }); +} + +/** Provision a fresh Kernel cloud browser session and return a handle. */ +export async function provisionBrowser(opts: ProvisionBrowserOptions): Promise { + const client = createKernelClient(opts.apiKey, opts.baseUrl); + const timeoutSeconds = opts.timeoutSeconds && opts.timeoutSeconds > 0 ? opts.timeoutSeconds : 300; + + let profileId = (opts.profileId ?? "").trim(); + if (!profileId && opts.profileSelector && opts.profileSelector.trim()) { + profileId = await resolveProfileId(client, opts.profileSelector); + } + + const params: Parameters[0] = { + stealth: true, + timeout_seconds: timeoutSeconds, + }; + if (profileId) { + params.profile = { id: profileId, save_changes: opts.saveChanges ?? false }; + } + + const browser = await client.browsers.create(params); + return { + client, + browser, + async close(): Promise { + await client.browsers.deleteByID(browser.session_id); + }, + }; +} + +/** + * Capture a screenshot through the SDK. Falls back to undefined when the + * call fails — first-prompt images are best-effort. + */ +export async function captureScreenshot(client: Kernel, sessionId: string): Promise { + try { + const response = await client.browsers.computer.captureScreenshot(sessionId); + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch { + return undefined; + } +} diff --git a/packages/cua-cli/src/harness-models.ts b/packages/cua-cli/src/harness-models.ts new file mode 100644 index 00000000..68d358af --- /dev/null +++ b/packages/cua-cli/src/harness-models.ts @@ -0,0 +1,52 @@ +import { + type CuaModelInfo, + type CuaModelRef, + type CuaProvider, + formatCuaModelRef, + getCuaModel, + isCuaProvider, + listCuaModels, + parseCuaModelRef, +} from "@onkernel/cua-ai"; + +/** Default model used by the new harness wiring. */ +export const DEFAULT_CUA_MODEL_REF: CuaModelRef = "openai:gpt-5.5"; + +/** + * Resolve a model ref from CLI input. Accepts either a provider-qualified + * `provider:model` ref or a bare model id when it matches exactly one + * catalog entry. Throws when bare ids are ambiguous or unknown. + */ +export function resolveCuaModelRef(input: string | undefined): CuaModelRef { + if (!input || !input.trim()) return DEFAULT_CUA_MODEL_REF; + const value = input.trim(); + if (value.includes(":")) { + const { provider, model } = parseCuaModelRef(value); + const ref = formatCuaModelRef(provider, model); + // Validate the ref resolves to a concrete model so failures surface early. + getCuaModel(ref); + return ref; + } + const matches = listCuaModels().filter((m) => m.model === value); + if (matches.length === 0) { + throw new Error(`unknown model "${value}" (run \`cua models\` to list supported -m/--model values)`); + } + if (matches.length > 1) { + const refs = matches.map((m) => m.ref).join(", "); + throw new Error(`ambiguous model "${value}" (matches: ${refs}); pass a provider-qualified ref like "openai:${value}"`); + } + return matches[0]!.ref; +} + +/** + * List supported models, optionally filtered to a provider. Accepts either + * the canonical `"google"` or the CLI-friendly `"gemini"` alias. + */ +export function listSupportedModels(provider?: string): CuaModelInfo[] { + if (!provider) return listCuaModels(); + const normalized = provider === "gemini" ? "google" : provider; + if (!isCuaProvider(normalized)) { + throw new Error(`unknown provider "${provider}"`); + } + return listCuaModels(normalized as CuaProvider); +} diff --git a/packages/cua-cli/src/harness-named-sessions.ts b/packages/cua-cli/src/harness-named-sessions.ts new file mode 100644 index 00000000..d0f4119d --- /dev/null +++ b/packages/cua-cli/src/harness-named-sessions.ts @@ -0,0 +1,246 @@ +import type { KernelBrowser } from "@onkernel/cua-agent"; +import Kernel from "@onkernel/sdk"; +import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createKernelClient } from "./harness-browser"; + +/** + * Named sessions: durable, slug-keyed pointers to a Kernel cloud browser + * session that can be reused across `cua` invocations. The metadata file + * format and path are preserved from the legacy implementation; only the + * Kernel calls move from `cua-translator.browserSession` to the SDK. + */ + +export interface NamedSessionMetadata { + name: string; + kernel_session_id: string; + live_url?: string; + profile_id?: string; + transcript_path?: string; + config_profile?: string; + created_at: number; +} + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/; + +export function namedSessionsDir(): string { + const xdg = process.env.XDG_DATA_HOME; + if (xdg) return join(xdg, "cua", "named-sessions"); + return join(homedir(), ".local", "share", "cua", "named-sessions"); +} + +function sessionFilePath(name: string): string { + return join(namedSessionsDir(), `${name}.json`); +} + +export function validateSlug(name: string): void { + if (!SLUG_PATTERN.test(name)) { + throw new Error( + `invalid session name "${name}": must match ${SLUG_PATTERN} (lowercase a-z, 0-9, hyphens; 1-63 chars; cannot start with a hyphen)`, + ); + } +} + +async function fileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +export async function readNamedSession(name: string): Promise { + const path = sessionFilePath(name); + if (!(await fileExists(path))) return undefined; + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as NamedSessionMetadata; +} + +export async function writeNamedSession(meta: NamedSessionMetadata): Promise { + validateSlug(meta.name); + const path = sessionFilePath(meta.name); + await mkdir(namedSessionsDir(), { recursive: true }); + await writeFile(path, JSON.stringify(meta, null, 2) + "\n", { mode: 0o600 }); + return path; +} + +export async function deleteNamedSession(name: string): Promise { + const path = sessionFilePath(name); + if (!(await fileExists(path))) return false; + await unlink(path); + return true; +} + +export async function listNamedSessions(): Promise { + const dir = namedSessionsDir(); + if (!(await fileExists(dir))) return []; + const entries = await readdir(dir); + const out: NamedSessionMetadata[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + try { + const raw = await readFile(join(dir, entry), "utf8"); + out.push(JSON.parse(raw) as NamedSessionMetadata); + } catch { + // skip unreadable / malformed entries + } + } + out.sort((a, b) => b.created_at - a.created_at); + return out; +} + +export interface StartNamedSessionOptions { + name: string; + apiKey: string; + baseUrl?: string; + configProfile?: string; + browserTimeoutSeconds?: number; + profileId?: string; + saveProfileChanges?: boolean; +} + +export interface StartNamedSessionResult { + meta: NamedSessionMetadata; + metadataPath: string; + client: Kernel; + browser: KernelBrowser; +} + +/** Provision a fresh Kernel browser and persist a named-session metadata file. */ +export async function startNamedSession(opts: StartNamedSessionOptions): Promise { + validateSlug(opts.name); + const existing = await readNamedSession(opts.name); + if (existing) { + throw new Error( + `named session "${opts.name}" already exists (kernel_session_id=${existing.kernel_session_id}). Run \`cua session stop ${opts.name}\` first.`, + ); + } + + const client = createKernelClient(opts.apiKey, opts.baseUrl); + const timeoutSeconds = opts.browserTimeoutSeconds && opts.browserTimeoutSeconds > 0 ? opts.browserTimeoutSeconds : 300; + const params: Parameters[0] = { + stealth: true, + timeout_seconds: timeoutSeconds, + }; + if (opts.profileId) { + params.profile = { id: opts.profileId, save_changes: opts.saveProfileChanges ?? false }; + } + const browser = await client.browsers.create(params); + + const meta: NamedSessionMetadata = { + name: opts.name, + kernel_session_id: browser.session_id, + live_url: browser.browser_live_view_url, + profile_id: opts.profileId, + config_profile: opts.configProfile, + created_at: Date.now(), + }; + const metadataPath = await writeNamedSession(meta); + return { meta, metadataPath, client, browser }; +} + +export interface AttachNamedSessionOptions { + name: string; + apiKey: string; + baseUrl?: string; +} + +export interface AttachNamedSessionResult { + meta: NamedSessionMetadata; + client: Kernel; + browser: KernelBrowser; +} + +/** + * Attach to a previously-started named session. Performs a liveness check + * via `client.browsers.retrieve` so the caller can fail fast when the + * server-side session has timed out or been deleted. + */ +export async function attachNamedSession(opts: AttachNamedSessionOptions): Promise { + const meta = await readNamedSession(opts.name); + if (!meta) { + throw new Error( + `unknown named session "${opts.name}". Run \`cua session list\` to see available sessions, or \`cua session start ${opts.name}\` to create one.`, + ); + } + const client = createKernelClient(opts.apiKey, opts.baseUrl); + let browser: KernelBrowser; + try { + browser = await client.browsers.retrieve(meta.kernel_session_id); + } catch (err) { + const status = (err as { status?: unknown }).status; + if (status === 404) { + throw new Error( + `named session "${opts.name}" is no longer alive on Kernel (browser timed out or was deleted). Run \`cua session stop ${opts.name} && cua session start ${opts.name}\` to provision a fresh one.`, + ); + } + throw new Error(`liveness check for named session "${opts.name}" failed: ${(err as Error).message}`, { cause: err }); + } + const deletedAt = (browser as { deleted_at?: unknown }).deleted_at; + if (deletedAt) { + throw new Error( + `named session "${opts.name}" is no longer alive on Kernel (browser timed out or was deleted). Run \`cua session stop ${opts.name} && cua session start ${opts.name}\` to provision a fresh one.`, + ); + } + return { meta, client, browser }; +} + +export interface StopNamedSessionOptions { + name: string; + apiKey: string; + baseUrl?: string; +} + +export interface StopNamedSessionResult { + existed: boolean; + kernelDeleted: boolean; +} + +/** Tear down a named session: delete the Kernel browser and remove the metadata file. */ +export async function stopNamedSession(opts: StopNamedSessionOptions): Promise { + const meta = await readNamedSession(opts.name); + if (!meta) return { existed: false, kernelDeleted: false }; + const client = createKernelClient(opts.apiKey, opts.baseUrl); + let kernelDeleted = false; + try { + await client.browsers.deleteByID(meta.kernel_session_id); + kernelDeleted = true; + } catch (err) { + const status = (err as { status?: unknown }).status; + if (status !== 404) { + throw new Error( + `failed to delete Kernel browser ${meta.kernel_session_id} for named session "${opts.name}": ${(err as Error).message}`, + { cause: err }, + ); + } + } + await deleteNamedSession(opts.name); + return { existed: true, kernelDeleted }; +} + +/** Update the persisted `transcript_path` on a named session. */ +export async function recordTranscriptPath(name: string, transcriptPath: string): Promise { + const meta = await readNamedSession(name); + if (!meta) return; + if (meta.transcript_path === transcriptPath) return; + meta.transcript_path = transcriptPath; + await writeNamedSession(meta); +} + +export function shortKernelId(id: string): string { + return id.length > 10 ? `${id.slice(0, 8)}…` : id; +} + +export function formatRelativeAge(createdAt: number): string { + const diff = Date.now() - createdAt; + const sec = Math.max(0, Math.floor(diff / 1000)); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h`; + const d = Math.floor(hr / 24); + return `${d}d`; +} diff --git a/packages/cua-cli/src/harness-sessions.ts b/packages/cua-cli/src/harness-sessions.ts new file mode 100644 index 00000000..55ba5505 --- /dev/null +++ b/packages/cua-cli/src/harness-sessions.ts @@ -0,0 +1,114 @@ +import { + type JsonlSessionMetadata, + JsonlSessionRepo, + NodeExecutionEnv, + type Session, +} from "@onkernel/cua-agent"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Resolve the default sessions directory: `$XDG_DATA_HOME/cua/sessions` + * (or `~/.local/share/cua/sessions`). + */ +export function defaultSessionsRoot(): string { + const xdg = process.env.XDG_DATA_HOME; + if (xdg) return join(xdg, "cua", "sessions"); + return join(homedir(), ".local", "share", "cua", "sessions"); +} + +/** Build a `JsonlSessionRepo` rooted at the resolved sessions directory. */ +export function createSessionRepo(sessionsRoot?: string): JsonlSessionRepo { + const root = sessionsRoot ?? defaultSessionsRoot(); + return new JsonlSessionRepo({ + fs: new NodeExecutionEnv({ cwd: process.cwd() }), + sessionsRoot: root, + }); +} + +export interface SessionInfo { + metadata: JsonlSessionMetadata; + mtimeMs?: number; +} + +/** List sessions for a cwd; legacy / malformed files are skipped. */ +export async function listSessionsForCwd( + repo: JsonlSessionRepo, + cwd: string, +): Promise { + const all = await repo.list({ cwd }); + return all; +} + +/** Find the most recent session metadata for cwd (lexicographic by id; uuidv7 ids sort by creation). */ +export async function findLatestSession( + repo: JsonlSessionRepo, + cwd: string, +): Promise { + const sessions = await listSessionsForCwd(repo, cwd); + if (sessions.length === 0) return undefined; + return [...sessions].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; +} + +/** + * Resolve a `--session ` argument. Accepts: + * - an absolute or relative path to an existing session file + * - `latest` for the most recent session for cwd + * - any other string as a prefix matched against session ids + */ +export async function resolveSessionRef( + repo: JsonlSessionRepo, + cwd: string, + ref: string, +): Promise { + const trimmed = ref.trim(); + if (!trimmed) throw new Error("session reference is empty"); + if (trimmed.includes("/") || trimmed.endsWith(".jsonl")) { + const sessions = await listSessionsForCwd(repo, cwd); + const match = sessions.find((m) => m.path === trimmed); + if (match) return match; + throw new Error(`no session at "${trimmed}"`); + } + if (trimmed === "latest") { + const latest = await findLatestSession(repo, cwd); + if (!latest) throw new Error("no sessions found"); + return latest; + } + const sessions = await listSessionsForCwd(repo, cwd); + const matches = sessions.filter((s) => s.id.startsWith(trimmed)); + if (matches.length === 0) throw new Error(`no session matches "${trimmed}"`); + if (matches.length > 1) throw new Error(`ambiguous session prefix "${trimmed}" (${matches.length} matches)`); + return matches[0]!; +} + +/** Open (resume) a session by metadata. */ +export function openSession(repo: JsonlSessionRepo, metadata: JsonlSessionMetadata): Promise> { + return repo.open(metadata); +} + +/** Create a brand-new session for cwd. */ +export function createSession(repo: JsonlSessionRepo, cwd: string): Promise> { + return repo.create({ cwd }); +} + +/** Custom entry type used to record the Kernel browser the session ran against. */ +export const CUA_BROWSER_ENTRY = "cua-browser"; + +export interface CuaBrowserEntryData { + sessionId: string; + liveUrl?: string; + profileId?: string; + createdAt: number; +} + +/** Append a browser-metadata custom entry to the session. */ +export async function appendBrowserEntry( + session: Session, + data: CuaBrowserEntryData, +): Promise { + try { + await session.appendCustomEntry(CUA_BROWSER_ENTRY, data); + } catch { + // best-effort; never block a run on bookkeeping + } +} diff --git a/packages/cua-cli/src/harness-skills.ts b/packages/cua-cli/src/harness-skills.ts new file mode 100644 index 00000000..38d8f41e --- /dev/null +++ b/packages/cua-cli/src/harness-skills.ts @@ -0,0 +1,55 @@ +import { type ExecutionEnv, loadSkills, type Skill, type SkillDiagnostic } from "@onkernel/cua-agent"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface DiscoverSkillsOptions { + cwd: string; + env: ExecutionEnv; + /** Extra explicit skill paths (files or directories) from `--skill` flags. */ + extraPaths?: string[]; + /** Disable all skill discovery. */ + disabled?: boolean; +} + +export interface DiscoverSkillsResult { + skills: Skill[]; + sources: string[]; + diagnostics: SkillDiagnostic[]; +} + +/** + * Discover skills following the cross-agent `~/.agents/skills/` standard. + * + * Discovery order: explicit `--skill` paths, then `~/.agents/skills/`, + * then `/.agents/skills/`. Missing paths are skipped silently. + */ +export async function discoverCuaSkills(opts: DiscoverSkillsOptions): Promise { + if (opts.disabled) return { skills: [], sources: [], diagnostics: [] }; + const extras = (opts.extraPaths ?? []).filter((p) => p && p.trim().length > 0); + const userAgentsDir = join(homedir(), ".agents", "skills"); + const projectAgentsDir = join(opts.cwd, ".agents", "skills"); + const candidates = [...extras, userAgentsDir, projectAgentsDir]; + const sources = candidates.filter((p) => existsSync(p)); + if (sources.length === 0) return { skills: [], sources: [], diagnostics: [] }; + const result = await loadSkills(opts.env, sources); + return { skills: result.skills, sources, diagnostics: result.diagnostics }; +} + +/** + * Resolve a `/skill:` invocation. Returns the matched skill (so the + * caller can use `harness.skill(name)`) plus any remainder text the user + * typed after the skill name, which the caller can append as an additional + * instruction. + */ +export function parseSkillInvocation( + text: string, + skills: Skill[], +): { skill?: Skill; remainder: string } | undefined { + const trimmed = text.trim(); + const match = trimmed.match(/^\/skill:([A-Za-z0-9_\-.]+)\s*(.*)$/); + if (!match) return undefined; + const [, name, rest] = match; + const skill = skills.find((s) => s.name === name); + return { skill, remainder: (rest ?? "").trim() }; +} diff --git a/packages/cua-cli/src/harness.ts b/packages/cua-cli/src/harness.ts new file mode 100644 index 00000000..62b46daa --- /dev/null +++ b/packages/cua-cli/src/harness.ts @@ -0,0 +1,70 @@ +import { + CuaAgentHarness, + type CuaAgentHarnessOptions, + formatSkillsForSystemPrompt, + type KernelBrowser, + NodeExecutionEnv, + type Session, + type Skill, + type ThinkingLevel, +} from "@onkernel/cua-agent"; +import { + type CuaModelRef, + getCuaEnvApiKey, + resolveCuaRuntimeSpec, +} from "@onkernel/cua-ai"; +import type Kernel from "@onkernel/sdk"; +import { createCodingTools } from "@earendil-works/pi-coding-agent"; + +/** Options for {@link buildCuaHarness}. */ +export interface BuildCuaHarnessOptions { + cwd: string; + client: Kernel; + browser: KernelBrowser; + session: Session; + model: CuaModelRef; + skills?: Skill[]; + thinkingLevel?: ThinkingLevel; + /** Override the default coding-tools extraTools (bash/read/edit/write/grep/find/ls). */ + extraTools?: CuaAgentHarnessOptions["extraTools"]; + /** Override env-var API-key resolution (mainly for tests). */ + getApiKeyAndHeaders?: CuaAgentHarnessOptions["getApiKeyAndHeaders"]; +} + +/** + * Build a `CuaAgentHarness` wired with cua-cli's defaults: pi `NodeExecutionEnv`, + * caller-supplied jsonl `Session`, pi-coding-agent's `createCodingTools` as + * `extraTools`, env-var API-key resolution (via cua-ai conventions), and a + * `systemPrompt` that composes the runtime spec's default prompt with the + * formatted skill block. + */ +export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { + const skills = opts.skills ?? []; + const extraTools = opts.extraTools ?? createCodingTools(opts.cwd); + return new CuaAgentHarness({ + env: new NodeExecutionEnv({ cwd: opts.cwd }), + session: opts.session, + model: opts.model, + browser: opts.browser, + client: opts.client, + extraTools, + resources: { skills }, + thinkingLevel: opts.thinkingLevel, + systemPrompt: ({ model }) => { + const runtime = resolveCuaRuntimeSpec(model); + return composeSystemPrompt(runtime.defaultSystemPrompt, skills); + }, + getApiKeyAndHeaders: + opts.getApiKeyAndHeaders ?? + (async (model) => { + const apiKey = getCuaEnvApiKey(model.provider); + return apiKey ? { apiKey } : undefined; + }), + }); +} + +function composeSystemPrompt(base: string, skills: Skill[]): string { + const skillBlock = formatSkillsForSystemPrompt(skills).trim(); + if (!skillBlock) return base; + return `${base.trim()}\n\n${skillBlock}\n`; +} diff --git a/packages/cua-cli/src/output/harness-jsonl.ts b/packages/cua-cli/src/output/harness-jsonl.ts new file mode 100644 index 00000000..5e0dea2e --- /dev/null +++ b/packages/cua-cli/src/output/harness-jsonl.ts @@ -0,0 +1,171 @@ +import type { + AgentHarnessEvent, + CuaAgentHarness, + KernelBrowser, +} from "@onkernel/cua-agent"; + +/** + * Schema version stamped on every `session_created` event. Bump when the + * jsonl shape changes in a way external consumers need to detect. + */ +export const CUA_JSONL_SCHEMA_VERSION = 1; + +export interface JsonlSinkOptions { + harness: CuaAgentHarness; + browser: KernelBrowser; + modelRef: string; + provider: string; + /** Where to write each line. Defaults to process.stdout. */ + write?: (line: string) => void; + /** When true, emit `assistant_text_delta` events. Default: false. */ + includeDeltas?: boolean; + /** When true, include base64 screenshot bytes in `tool_result` events. Default: false. */ + includeImages?: boolean; +} + +interface JsonlEventBase { + type: string; + ts: number; +} + +/** + * Subscribe to a harness and emit one JSON object per line for downstream + * tooling. The event schema mirrors the legacy `output/jsonl.ts`: only the + * source of each field changes. + */ +export function attachHarnessJsonlSink(opts: JsonlSinkOptions): () => void { + const write = opts.write ?? ((line: string) => process.stdout.write(line + "\n")); + const emit = (obj: JsonlEventBase & Record): void => { + try { + write(JSON.stringify(obj)); + } catch { + write( + JSON.stringify({ + type: "error", + code: "serialize_failed", + message: "could not serialize event", + ts: Date.now(), + }), + ); + } + }; + + emit({ + type: "session_created", + schema_version: CUA_JSONL_SCHEMA_VERSION, + model: opts.modelRef, + provider: opts.provider, + ts: Date.now(), + }); + emit({ + type: "browser_created", + browser_session_id: opts.browser.session_id, + live_url: opts.browser.browser_live_view_url, + profile_id: undefined, + ts: Date.now(), + }); + + let turn = 0; + const includeDeltas = opts.includeDeltas === true; + const includeImages = opts.includeImages === true; + + return opts.harness.subscribe((event: AgentHarnessEvent) => { + switch (event.type) { + case "turn_start": + turn += 1; + return; + case "turn_end": + emit({ type: "turn_done", turn, ts: Date.now() }); + return; + case "agent_end": + emit({ type: "run_complete", turns: turn, ts: Date.now() }); + return; + case "message_end": { + const msg = event.message; + if (msg.role === "user") { + const text = textOf(msg.content); + emit({ type: "user_message", text, ts: Date.now() }); + } else if (msg.role === "assistant") { + const text = textOf(msg.content); + if (text) emit({ type: "assistant_text_done", text, ts: Date.now() }); + } + return; + } + case "message_update": { + if (!includeDeltas) return; + if (event.assistantMessageEvent.type === "text_delta") { + emit({ + type: "assistant_text_delta", + delta: event.assistantMessageEvent.delta, + ts: Date.now(), + }); + } + return; + } + case "tool_execution_start": + emit({ + type: "tool_call", + tool_name: event.toolName, + call_id: event.toolCallId, + args: event.args, + ts: Date.now(), + }); + return; + case "tool_execution_end": { + const result = event.result as + | { + content?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>; + details?: unknown; + } + | undefined; + const ok = !event.isError; + let contentText: string | undefined; + let screenshotBytes: number | undefined; + const screenshotsB64: string[] = []; + if (result?.content) { + const textParts: string[] = []; + for (const c of result.content) { + if (c?.type === "text" && typeof c.text === "string") textParts.push(c.text); + if (c?.type === "image" && typeof c.data === "string") { + const len = c.data.length; + screenshotBytes = (screenshotBytes ?? 0) + len; + if (includeImages) screenshotsB64.push(c.data); + } + } + contentText = textParts.join("\n").trim() || undefined; + } + emit({ + type: "tool_result", + tool_name: event.toolName, + call_id: event.toolCallId, + ok, + content_text: contentText, + screenshot_bytes: screenshotBytes, + ...(includeImages && screenshotsB64.length ? { screenshots_b64: screenshotsB64 } : {}), + details: result?.details, + ts: Date.now(), + }); + return; + } + default: + return; + } + }); +} + +function textOf(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const c of content) { + if ( + c && + typeof c === "object" && + (c as { type?: unknown }).type === "text" && + typeof (c as { text?: unknown }).text === "string" + ) { + parts.push((c as { text: string }).text); + } + } + return parts.join("\n"); +} diff --git a/packages/cua-cli/src/print.ts b/packages/cua-cli/src/print.ts new file mode 100644 index 00000000..5ab8a801 --- /dev/null +++ b/packages/cua-cli/src/print.ts @@ -0,0 +1,117 @@ +import type { AgentHarnessEvent, CuaAgentHarness, Session, Skill } from "@onkernel/cua-agent"; +import type { ImageContent } from "@onkernel/cua-ai"; +import { stderr, stdout } from "node:process"; +import { captureScreenshot } from "./harness-browser"; +import type { CuaBrowserHandle } from "./harness-browser"; +import { attachHarnessJsonlSink } from "./output/harness-jsonl"; +import { parseSkillInvocation } from "./harness-skills"; + +export interface RunPrintOptions { + harness: CuaAgentHarness; + browserHandle: CuaBrowserHandle; + session: Session; + modelRef: string; + provider: string; + prompt: string; + skills?: Skill[]; + /** When true, skip the auto-attached first-prompt screenshot (resume case). */ + skipInitialScreenshot?: boolean; + verbose?: boolean; + jsonlMode?: boolean; + jsonlIncludeDeltas?: boolean; + jsonlIncludeImages?: boolean; +} + +/** + * Run a single prompt through the harness and stream output to stdout + * (text mode) or as jsonl events. Returns the process exit code (0 ok, + * 1 on failure). + */ +export async function runPrint(opts: RunPrintOptions): Promise { + const jsonlMode = opts.jsonlMode === true; + let unsubscribeJsonl: (() => void) | undefined; + if (jsonlMode) { + unsubscribeJsonl = attachHarnessJsonlSink({ + harness: opts.harness, + browser: opts.browserHandle.browser, + modelRef: opts.modelRef, + provider: opts.provider, + includeDeltas: opts.jsonlIncludeDeltas, + includeImages: opts.jsonlIncludeImages, + }); + } + + const unsubscribeText = opts.harness.subscribe((event: AgentHarnessEvent) => { + if (jsonlMode) return; + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + stdout.write(event.assistantMessageEvent.delta); + return; + } + if (opts.verbose && event.type === "tool_execution_start") { + stderr.write(`\n[cua] tool ${event.toolName} ${JSON.stringify(event.args)}\n`); + } + if (opts.verbose && event.type === "tool_execution_end") { + stderr.write(`[cua] tool ${event.toolName} done\n`); + } + }); + + let exitCode = 0; + try { + const invocation = parseSkillInvocation(opts.prompt, opts.skills ?? []); + let assistant; + if (invocation?.skill) { + if (opts.verbose) stderr.write(`[cua] expanded /skill:${invocation.skill.name}\n`); + assistant = await opts.harness.skill(invocation.skill.name, invocation.remainder || undefined); + } else { + const images = await maybeInitialScreenshot(opts); + assistant = await opts.harness.prompt(opts.prompt, images ? { images } : undefined); + } + if (assistant.stopReason === "error" || assistant.stopReason === "aborted") { + throw new Error(assistant.errorMessage ?? `agent stopped with ${assistant.stopReason}`); + } + if (!jsonlMode) stdout.write("\n"); + } catch (err) { + if (jsonlMode) { + stdout.write( + JSON.stringify({ + type: "error", + code: "run_failed", + message: (err as Error).message, + ts: Date.now(), + }) + "\n", + ); + } else { + stderr.write(`\n[cua] error: ${(err as Error).message}\n`); + } + exitCode = 1; + } finally { + unsubscribeText(); + unsubscribeJsonl?.(); + } + return exitCode; +} + +async function maybeInitialScreenshot(opts: RunPrintOptions): Promise { + if (opts.skipInitialScreenshot) return undefined; + const hasPriorTurn = await sessionHasPriorTurn(opts.session); + if (hasPriorTurn) return undefined; + const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); + if (!png) return undefined; + return [ + { + type: "image", + data: png.toString("base64"), + mimeType: "image/png", + }, + ]; +} + +async function sessionHasPriorTurn(session: Session): Promise { + const entries = await session.getBranch(); + for (const entry of entries) { + if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { + return true; + } + } + return false; +} diff --git a/packages/cua-cli/test/action-runner.test.ts b/packages/cua-cli/test/action-runner.test.ts new file mode 100644 index 00000000..73998c35 --- /dev/null +++ b/packages/cua-cli/test/action-runner.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { runAction } from "../src/action/harness-runner"; +import { buildTestHarness, type TestHarnessFixture } from "./fixtures/harness"; + +let fixture: TestHarnessFixture | undefined; + +afterEach(async () => { + await fixture?.dispose(); + fixture = undefined; +}); + +describe("action harness-runner", () => { + it("exits 0 with formatted result when a click action succeeds", async () => { + fixture = await buildTestHarness({ + turns: [ + { + steps: [ + { + type: "tool_call", + toolName: "click", + args: { x: 123, y: 45 }, + }, + ], + }, + { + steps: [{ type: "text", text: "clicked" }], + }, + ], + }); + const res = await runAction( + { action: "click", target: "the button" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session, maxTurns: 5 }, + ); + expect(res.exitCode).toBe(0); + expect(res.result.coordinates).toEqual([123, 45]); + expect(res.result.action).toBe("click"); + }); + + it("exits 1 when the model says NOT_FOUND", async () => { + fixture = await buildTestHarness({ + turns: [ + { + steps: [{ type: "text", text: "NOT_FOUND: no match" }], + }, + ], + }); + const res = await runAction( + { action: "click", target: "missing" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session, maxTurns: 5 }, + ); + expect(res.exitCode).toBe(1); + expect(res.result.status).toBe("not_found"); + expect(res.result.text).toBe("no match"); + }); + + it("captures a screenshot via the SDK without invoking the harness", async () => { + fixture = await buildTestHarness({ turns: [] }); + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((_chunk: string | Uint8Array): boolean => true) as typeof process.stdout.write; + try { + const res = await runAction( + { action: "screenshot" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session }, + { out: "-" }, + ); + expect(res.exitCode).toBe(0); + expect(fixture.provider.callCount()).toBe(0); + expect(fixture.kernel.screenshots).toBe(1); + } finally { + process.stdout.write = originalWrite; + } + }); +}); + +function handleFor(fixture: TestHarnessFixture) { + return { + client: fixture.kernel.client, + browser: fixture.kernel.browser, + async close(): Promise {}, + }; +} diff --git a/packages/cua-cli/test/fixtures/fake-kernel.ts b/packages/cua-cli/test/fixtures/fake-kernel.ts new file mode 100644 index 00000000..4a51c8bc --- /dev/null +++ b/packages/cua-cli/test/fixtures/fake-kernel.ts @@ -0,0 +1,66 @@ +import type Kernel from "@onkernel/sdk"; +import type { KernelBrowser } from "@onkernel/cua-agent"; + +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + "base64", +); + +export interface FakeBatchCall { + id: string; + body: unknown; +} + +/** Minimal Kernel client + browser pair sufficient to run the CUA harness. */ +export interface FakeKernelEnvironment { + client: Kernel; + browser: KernelBrowser; + batchCalls: FakeBatchCall[]; + screenshots: number; + deleted: string[]; +} + +export function createFakeKernelEnvironment(overrides: Partial = {}): FakeKernelEnvironment { + const browser = { + session_id: overrides.session_id ?? "browser_test_123", + browser_live_view_url: overrides.browser_live_view_url ?? "https://example.test/live", + cdp_ws_url: overrides.cdp_ws_url ?? "wss://example.test/cdp", + created_at: overrides.created_at ?? new Date().toISOString(), + viewport: overrides.viewport ?? { width: 1024, height: 768 }, + } as KernelBrowser; + + const env: FakeKernelEnvironment = { + client: undefined as unknown as Kernel, + browser, + batchCalls: [], + screenshots: 0, + deleted: [], + }; + + env.client = { + browsers: { + create: async () => browser, + retrieve: async () => browser, + deleteByID: async (id: string) => { + env.deleted.push(id); + }, + computer: { + batch: async (sessionId: string, body: unknown) => { + env.batchCalls.push({ id: sessionId, body }); + }, + captureScreenshot: async () => { + env.screenshots += 1; + return new Response(new Uint8Array(TINY_PNG)); + }, + getMousePosition: async () => ({ x: 0, y: 0 }), + readClipboard: async () => ({ text: "" }), + }, + }, + profiles: { + retrieve: async () => ({ id: "profile_test", name: "test" }), + create: async ({ name }: { name: string }) => ({ id: "profile_test", name }), + }, + } as unknown as Kernel; + + return env; +} diff --git a/packages/cua-cli/test/fixtures/harness.ts b/packages/cua-cli/test/fixtures/harness.ts new file mode 100644 index 00000000..ec3051e6 --- /dev/null +++ b/packages/cua-cli/test/fixtures/harness.ts @@ -0,0 +1,70 @@ +import { + InMemorySessionRepo, + type Session, + type Skill, +} from "@onkernel/cua-agent"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { buildCuaHarness } from "../../src/harness"; +import { createFakeKernelEnvironment, type FakeKernelEnvironment } from "./fake-kernel"; +import type { ScriptedProviderHandle, ScriptedTurn } from "./scripted-provider"; +import { registerScriptedProvider } from "./scripted-provider"; + +export interface TestHarnessFixture { + provider: ScriptedProviderHandle; + kernel: FakeKernelEnvironment; + session: Session; + cwd: string; + harness: ReturnType; + dispose(): Promise; +} + +export interface BuildTestHarnessOptions { + turns: ScriptedTurn[]; + skills?: Skill[]; + /** CUA model ref. Defaults to "openai:gpt-5.5". */ + modelRef?: string; + /** API id the scripted provider serves. Default infers from modelRef. */ + api?: string; +} + +const DEFAULT_API_FOR_MODEL: Record = { + "openai:gpt-5.5": "openai-responses", + "anthropic:claude-opus-4-7": "anthropic-messages", + "google:gemini-3-flash-preview": "google-generative-ai", +}; + +export async function buildTestHarness(opts: BuildTestHarnessOptions): Promise { + const modelRef = opts.modelRef ?? "openai:gpt-5.5"; + const api = opts.api ?? DEFAULT_API_FOR_MODEL[modelRef] ?? "openai-responses"; + const provider = registerScriptedProvider(api, opts.turns); + + const kernel = createFakeKernelEnvironment(); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-test-")); + + const sessionRepo = new InMemorySessionRepo(); + const session = await sessionRepo.create(); + + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: modelRef as never, + skills: opts.skills, + extraTools: [], + getApiKeyAndHeaders: async () => ({ apiKey: "test-key" }), + }); + + return { + provider, + kernel, + session, + cwd, + harness, + async dispose(): Promise { + provider.dispose(); + }, + }; +} diff --git a/packages/cua-cli/test/fixtures/scripted-provider.ts b/packages/cua-cli/test/fixtures/scripted-provider.ts new file mode 100644 index 00000000..92ee7a27 --- /dev/null +++ b/packages/cua-cli/test/fixtures/scripted-provider.ts @@ -0,0 +1,167 @@ +import { + type Api, + type AssistantMessage, + type Context, + createAssistantMessageEventStream, + type Model, + registerApiProvider, + type SimpleStreamOptions, + unregisterApiProviders, +} from "@onkernel/cua-ai"; + +/** One scripted step replayed when the harness asks the provider for a turn. */ +export type ScriptedStep = + | { type: "text"; text: string } + | { type: "tool_call"; toolName: string; args: Record; id?: string } + | { type: "error"; message: string }; + +export interface ScriptedTurn { + steps: ScriptedStep[]; + /** + * Stop reason for the turn. Defaults to "stop" when there are no tool + * calls and to "toolUse" otherwise. + */ + stopReason?: "stop" | "toolUse" | "length"; +} + +export interface ScriptedProviderHandle { + /** Reset the turn cursor; the next provider call replays the first turn. */ + reset(): void; + /** Number of provider calls dispatched so far. */ + callCount(): number; + /** Latest context the provider was called with (assistant-side mock). */ + lastContext(): Context | undefined; + /** Remove the registered provider. Safe to call from `afterEach`. */ + dispose(): void; +} + +const sourceCounter = { value: 0 }; + +/** + * Register a scripted provider on the pi-ai api registry. The provider + * replays one `ScriptedTurn` per provider call against the supplied API + * id; the harness drives this exactly like a real provider. + */ +export function registerScriptedProvider(api: Api, turns: ScriptedTurn[]): ScriptedProviderHandle { + const sourceId = `cua-cli-test-${++sourceCounter.value}`; + const state = { + index: 0, + lastContext: undefined as Context | undefined, + }; + registerApiProvider( + { + api, + streamSimple: (model, context, _options?: SimpleStreamOptions) => { + state.lastContext = context; + const turn = turns[state.index]; + state.index += 1; + return buildStream(model, turn); + }, + stream: (model, context, _options) => { + state.lastContext = context; + const turn = turns[state.index]; + state.index += 1; + return buildStream(model, turn); + }, + }, + sourceId, + ); + return { + reset(): void { + state.index = 0; + }, + callCount(): number { + return state.index; + }, + lastContext(): Context | undefined { + return state.lastContext; + }, + dispose(): void { + unregisterApiProviders(sourceId); + }, + }; +} + +function buildStream(model: Model, turn: ScriptedTurn | undefined) { + const stream = createAssistantMessageEventStream(); + void (async () => { + const message = baseAssistantMessage(model); + if (!turn) { + message.stopReason = "stop"; + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return; + } + + stream.push({ type: "start", partial: message }); + + let hasToolCall = false; + let errorStep: { message: string } | undefined; + let contentIndex = 0; + + for (const step of turn.steps) { + if (step.type === "text") { + message.content.push({ type: "text", text: step.text }); + stream.push({ type: "text_start", contentIndex, partial: message }); + stream.push({ type: "text_delta", contentIndex, delta: step.text, partial: message }); + stream.push({ type: "text_end", contentIndex, content: step.text, partial: message }); + contentIndex += 1; + } else if (step.type === "tool_call") { + hasToolCall = true; + const id = step.id ?? `call_${contentIndex + 1}`; + message.content.push({ + type: "toolCall", + id, + name: step.toolName, + arguments: step.args, + }); + stream.push({ type: "toolcall_start", contentIndex, partial: message }); + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: { id, name: step.toolName, arguments: step.args }, + partial: message, + }); + contentIndex += 1; + } else if (step.type === "error") { + errorStep = { message: step.message }; + break; + } + } + + if (errorStep) { + message.stopReason = "error"; + message.errorMessage = errorStep.message; + stream.push({ type: "error", reason: "error", error: message }); + stream.end(message); + return; + } + + const stopReason = turn.stopReason ?? (hasToolCall ? "toolUse" : "stop"); + message.stopReason = stopReason; + stream.push({ type: "done", reason: stopReason, message }); + stream.end(message); + })(); + return stream; +} + +function baseAssistantMessage(model: Model): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} diff --git a/packages/cua-cli/test/harness-assembly.test.ts b/packages/cua-cli/test/harness-assembly.test.ts new file mode 100644 index 00000000..00d85282 --- /dev/null +++ b/packages/cua-cli/test/harness-assembly.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + formatSkillsForSystemPrompt, + InMemorySessionRepo, + type Skill, +} from "@onkernel/cua-agent"; +import { createCodingTools } from "@earendil-works/pi-coding-agent"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { resolveCuaRuntimeSpec } from "@onkernel/cua-ai"; +import { buildCuaHarness } from "../src/harness"; +import { createFakeKernelEnvironment } from "./fixtures/fake-kernel"; +import { registerScriptedProvider, type ScriptedProviderHandle } from "./fixtures/scripted-provider"; + +let provider: ScriptedProviderHandle | undefined; + +afterEach(() => { + provider?.dispose(); + provider = undefined; +}); + +describe("buildCuaHarness", () => { + it("installs createCodingTools as extraTools by default (pi-coding-agent 0.79 type compatibility)", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-harness-")); + const kernel = createFakeKernelEnvironment(); + const session = await new InMemorySessionRepo().create(); + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: "openai:gpt-5.5", + }); + const toolNames = harness.getTools().map((tool) => tool.name); + const codingToolNames = createCodingTools(cwd).map((tool) => tool.name); + for (const name of codingToolNames) { + expect(toolNames).toContain(name); + } + }); + + it("composes the cua-ai default system prompt with the skill block", async () => { + provider = registerScriptedProvider("openai-responses", [ + { steps: [{ type: "text", text: "ok" }] }, + ]); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-harness-")); + const kernel = createFakeKernelEnvironment(); + const session = await new InMemorySessionRepo().create(); + const skill: Skill = { + name: "demo", + description: "demo skill for tests", + content: "Use the demo workflow.", + filePath: join(cwd, "demo.md"), + }; + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: "openai:gpt-5.5", + skills: [skill], + extraTools: [], + getApiKeyAndHeaders: async () => ({ apiKey: "test-key" }), + }); + let capturedSystemPrompt: string | undefined; + harness.on("before_agent_start", (event) => { + capturedSystemPrompt = event.systemPrompt; + return undefined; + }); + await harness.prompt("hi"); + const runtime = resolveCuaRuntimeSpec("openai:gpt-5.5"); + const skillBlock = formatSkillsForSystemPrompt([skill]).trim(); + expect(capturedSystemPrompt).toContain(runtime.defaultSystemPrompt.trim()); + expect(capturedSystemPrompt).toContain(skillBlock); + }); + + it("delivers the first prompt with an image attached via harness.prompt({ images })", async () => { + provider = registerScriptedProvider("openai-responses", [ + { steps: [{ type: "text", text: "done" }] }, + ]); + + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-harness-")); + const kernel = createFakeKernelEnvironment(); + const session = await new InMemorySessionRepo().create(); + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: "openai:gpt-5.5", + extraTools: [], + getApiKeyAndHeaders: async () => ({ apiKey: "test-key" }), + }); + + const tinyPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + await harness.prompt("look at this", { + images: [{ type: "image", data: tinyPngBase64, mimeType: "image/png" }], + }); + + const entries = await session.getBranch(); + const firstUser = entries.find((e) => e.type === "message" && e.message.role === "user"); + expect(firstUser).toBeDefined(); + const content = (firstUser as { message: { content: unknown[] } }).message.content as Array<{ + type: string; + data?: string; + }>; + expect(content.some((c) => c.type === "image" && c.data === tinyPngBase64)).toBe(true); + }); +}); diff --git a/packages/cua-cli/test/harness-models.test.ts b/packages/cua-cli/test/harness-models.test.ts new file mode 100644 index 00000000..9354f364 --- /dev/null +++ b/packages/cua-cli/test/harness-models.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CUA_MODEL_REF, listSupportedModels, resolveCuaModelRef } from "../src/harness-models"; + +describe("resolveCuaModelRef", () => { + it("defaults to openai:gpt-5.5", () => { + expect(resolveCuaModelRef(undefined)).toBe(DEFAULT_CUA_MODEL_REF); + expect(resolveCuaModelRef("")).toBe(DEFAULT_CUA_MODEL_REF); + }); + + it("passes provider-qualified refs through", () => { + expect(resolveCuaModelRef("openai:gpt-5.5")).toBe("openai:gpt-5.5"); + }); + + it("accepts bare ids when they match exactly one catalog entry", () => { + expect(resolveCuaModelRef("gpt-5.5")).toBe("openai:gpt-5.5"); + }); + + it("throws on unknown bare ids", () => { + expect(() => resolveCuaModelRef("does-not-exist")).toThrow(/unknown model/); + }); + + it("treats 'gemini' as an alias for google when filtering", () => { + const fromGemini = listSupportedModels("gemini"); + const fromGoogle = listSupportedModels("google"); + expect(fromGemini.map((m) => m.ref)).toEqual(fromGoogle.map((m) => m.ref)); + expect(fromGoogle.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cua-cli/test/harness-sessions.test.ts b/packages/cua-cli/test/harness-sessions.test.ts new file mode 100644 index 00000000..c5bb4abf --- /dev/null +++ b/packages/cua-cli/test/harness-sessions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createSession, + createSessionRepo, + findLatestSession, + listSessionsForCwd, + resolveSessionRef, +} from "../src/harness-sessions"; + +function freshRoot(): string { + return mkdtempSync(join(tmpdir(), "cua-cli-sessions-")); +} + +describe("JsonlSessionRepo-backed sessions", () => { + it("creates and lists sessions for a cwd", async () => { + const root = freshRoot(); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-cwd-")); + const repo = createSessionRepo(root); + await createSession(repo, cwd); + const sessions = await listSessionsForCwd(repo, cwd); + expect(sessions.length).toBe(1); + expect(sessions[0]?.cwd).toBe(cwd); + }); + + it("tolerates legacy / unknown files in the sessions root", async () => { + const root = freshRoot(); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-cwd-")); + const repo = createSessionRepo(root); + + // Create a session via the repo first so the root layout exists. + await createSession(repo, cwd); + + // Now drop a legacy file alongside it that does not match the v0.79 layout. + const legacy = join(root, "legacy-session.jsonl"); + writeFileSync(legacy, '{"role":"user","content":"hi"}\n', "utf8"); + const orphanDir = join(root, "definitely-not-a-session"); + await mkdir(orphanDir, { recursive: true }); + writeFileSync(join(orphanDir, "garbage.txt"), "noise", "utf8"); + + // list() must still succeed and return only the valid session. + const sessions = await listSessionsForCwd(repo, cwd); + expect(sessions.length).toBe(1); + }); + + it("resolves the latest session for a cwd", async () => { + const root = freshRoot(); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-cwd-")); + const repo = createSessionRepo(root); + await createSession(repo, cwd); + await new Promise((r) => setTimeout(r, 5)); + const second = await createSession(repo, cwd); + const latest = await findLatestSession(repo, cwd); + expect(latest?.id).toBe((await second.getMetadata()).id); + const viaLatest = await resolveSessionRef(repo, cwd, "latest"); + expect(viaLatest.id).toBe(latest?.id); + }); + + it("resolves by id prefix and errors on ambiguity / miss", async () => { + const root = freshRoot(); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-cwd-")); + const repo = createSessionRepo(root); + const created = await createSession(repo, cwd); + const id = (await created.getMetadata()).id; + const byPrefix = await resolveSessionRef(repo, cwd, id.slice(0, 6)); + expect(byPrefix.id).toBe(id); + await expect(resolveSessionRef(repo, cwd, "no-such")).rejects.toThrow(/no session matches/); + }); +}); diff --git a/packages/cua-cli/test/print.test.ts b/packages/cua-cli/test/print.test.ts new file mode 100644 index 00000000..17734178 --- /dev/null +++ b/packages/cua-cli/test/print.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { runPrint } from "../src/print"; +import { buildTestHarness, type TestHarnessFixture } from "./fixtures/harness"; + +let fixture: TestHarnessFixture | undefined; + +afterEach(async () => { + await fixture?.dispose(); + fixture = undefined; +}); + +describe("runPrint", () => { + it("streams assistant text in plain text mode", async () => { + fixture = await buildTestHarness({ + turns: [ + { + steps: [{ type: "text", text: "Hello, world." }], + }, + ], + }); + const lines: string[] = []; + const exitCode = await runPrintIntoBuffer(fixture, "say hi", lines); + expect(exitCode).toBe(0); + expect(lines.join("\n")).toContain("Hello, world."); + }); + + it("emits jsonl with the documented session_created and run_complete envelope", async () => { + fixture = await buildTestHarness({ + turns: [ + { + steps: [{ type: "text", text: "ok" }], + }, + ], + }); + const events = await runPrintAsJsonl(fixture, "go"); + const types = events.map((e) => e.type); + expect(types[0]).toBe("session_created"); + expect(types).toContain("browser_created"); + expect(types).toContain("assistant_text_done"); + expect(types).toContain("turn_done"); + expect(types).toContain("run_complete"); + expect((events[0] as { schema_version: number }).schema_version).toBe(1); + }); + + it("returns exit code 1 when the provider emits an error", async () => { + fixture = await buildTestHarness({ + turns: [ + { steps: [{ type: "error", message: "boom" }] }, + ], + }); + const lines: string[] = []; + const exitCode = await runPrintIntoBuffer(fixture, "fail", lines); + expect(exitCode).toBe(1); + }); +}); + +async function runPrintIntoBuffer( + fixture: TestHarnessFixture, + prompt: string, + out: string[], +): Promise { + const stdoutWrite = process.stdout.write.bind(process.stdout); + const stderrWrite = process.stderr.write.bind(process.stderr); + const stdoutChunks: string[] = []; + process.stdout.write = ((chunk: string | Uint8Array): boolean => { + stdoutChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((_chunk: string | Uint8Array): boolean => true) as typeof process.stderr.write; + try { + const code = await runPrint({ + harness: fixture.harness, + browserHandle: { + client: fixture.kernel.client, + browser: fixture.kernel.browser, + async close(): Promise {}, + }, + session: fixture.session, + modelRef: "openai:gpt-5.5", + provider: "openai", + prompt, + }); + out.push(...stdoutChunks); + return code; + } finally { + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + } +} + +async function runPrintAsJsonl( + fixture: TestHarnessFixture, + prompt: string, +): Promise>> { + const lines: string[] = []; + const stdoutWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array): boolean => { + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); + for (const line of text.split("\n")) { + if (line.trim()) lines.push(line); + } + return true; + }) as typeof process.stdout.write; + try { + await runPrint({ + harness: fixture.harness, + browserHandle: { + client: fixture.kernel.client, + browser: fixture.kernel.browser, + async close(): Promise {}, + }, + session: fixture.session, + modelRef: "openai:gpt-5.5", + provider: "openai", + prompt, + jsonlMode: true, + }); + } finally { + process.stdout.write = stdoutWrite; + } + return lines.map((line) => JSON.parse(line) as Record); +} diff --git a/packages/cua-cli/vitest.config.ts b/packages/cua-cli/vitest.config.ts new file mode 100644 index 00000000..3402d488 --- /dev/null +++ b/packages/cua-cli/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + server: { + host: "127.0.0.1", + }, + test: { + include: ["test/**/*.test.ts"], + environment: "node", + hookTimeout: 30_000, + testTimeout: 30_000, + }, +}); From c4d78e9cca3360971cf1eedb71205ee2e317ca34 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:03:07 +0000 Subject: [PATCH 2/2] Address PR review on cua-cli harness wiring - Action subcommands no longer create on-disk session files unless an explicit session flag is set (-s / -c / -r / --session). - Action runner reattaches the legacy first-prompt screenshot via harness.prompt({ images }) on fresh sessions. - runAction falls back to the returned AssistantMessage text when no text_delta events arrived, and prefers details.error over content text when extracting tool errors. - cua session start --profile now goes through resolveProfileId so a name is created/looked up before provisioning, and the resolved id is what gets persisted in named-session metadata. - --session and named transcript_path resolution accept paths from any cwd by reading the session header directly. - -c / latest sorts by file mtime (legacy semantics) instead of header createdAt. - jsonl browser_created emits profile_id when --profile is used, and the README documents the schema_version + model-ref change. - _BASE_URL env overrides flow through buildCuaHarness onto the resolved model object. - --thinking values are validated up front; unknown values exit 2. - HarnessRuntime exposes the assembled Session, dropping the fallback-in-memory session and dynamic InMemorySessionRepo imports. - systemPrompt callback reads resources.skills, so a future setResources() call picks up the new skill set. - resolveAuth uses requireCuaEnvApiKey for an env-var-named error. - Tests cover error exit 2, the turn-cap abort path, jsonl tool steps, the first-prompt screenshot, and --session from a different cwd. --- packages/cua-cli/README.md | 6 ++ packages/cua-cli/src/action/harness-runner.ts | 60 ++++++++++++-- packages/cua-cli/src/cli-harness.ts | 78 +++++++++++++------ packages/cua-cli/src/cli.ts | 9 +++ packages/cua-cli/src/harness-browser.ts | 11 ++- .../cua-cli/src/harness-named-sessions.ts | 15 ++-- packages/cua-cli/src/harness-sessions.ts | 76 ++++++++++++++++-- packages/cua-cli/src/harness.ts | 20 +++-- packages/cua-cli/src/output/harness-jsonl.ts | 4 +- packages/cua-cli/src/print.ts | 1 + packages/cua-cli/test/action-runner.test.ts | 59 ++++++++++++++ .../cua-cli/test/harness-sessions.test.ts | 13 ++++ packages/cua-cli/test/print.test.ts | 28 +++++++ packages/cua-cli/vitest.config.ts | 5 ++ 14 files changed, 335 insertions(+), 50 deletions(-) diff --git a/packages/cua-cli/README.md b/packages/cua-cli/README.md index dab8c34d..f67b36c0 100644 --- a/packages/cua-cli/README.md +++ b/packages/cua-cli/README.md @@ -152,6 +152,12 @@ cua --print -o jsonl "open https://example.com" \ Add `--jsonl-include-deltas` for assistant-token deltas and `--jsonl-include-images` for base64 screenshots in `tool_result` events. +The first event of every `--print -o jsonl` run is +`session_created` with a `schema_version` field. The current schema +version is `1`. The `model` field carries a provider-qualified ref +(e.g. `openai:gpt-5.5`); use `parseCuaModelRef` from `@onkernel/cua-ai` +if you only need the bare model id. + ## Sessions and transcripts `--print`, the interactive TUI, and any `-s ` invocation persist diff --git a/packages/cua-cli/src/action/harness-runner.ts b/packages/cua-cli/src/action/harness-runner.ts index 8856764e..da1f68da 100644 --- a/packages/cua-cli/src/action/harness-runner.ts +++ b/packages/cua-cli/src/action/harness-runner.ts @@ -1,4 +1,5 @@ import type { AgentHarnessEvent, CuaAgentHarness, Session } from "@onkernel/cua-agent"; +import type { AssistantMessage, ImageContent } from "@onkernel/cua-ai"; import { writeFile } from "node:fs/promises"; import { stderr, stdout } from "node:process"; import { captureScreenshot, type CuaBrowserHandle } from "../harness-browser"; @@ -9,7 +10,8 @@ export interface HarnessRunOptions { harness: CuaAgentHarness; browserHandle: CuaBrowserHandle; session: Session; - verbose?: boolean; + /** Skip the auto-attached first-prompt screenshot (resume case). */ + skipInitialScreenshot?: boolean; maxTurns?: number; } @@ -68,6 +70,7 @@ export async function runAction( let turns = 0; let aborted = false; let lastToolError: string | undefined; + let lastToolErrorDetail: string | undefined; const unsubscribe = opts.harness.subscribe((event: AgentHarnessEvent) => { switch (event.type) { @@ -76,7 +79,9 @@ export async function runAction( return; case "tool_execution_end": { if (event.isError) { - lastToolError = extractToolErrorText(event.result) ?? "tool execution failed"; + const { text, detail } = inspectToolError(event.result); + lastToolError = text ?? "tool execution failed"; + lastToolErrorDetail = detail; } return; } @@ -98,8 +103,10 @@ export async function runAction( }); let runError: Error | undefined; + let assistant: AssistantMessage | undefined; try { - const assistant = await opts.harness.prompt(prompt); + const images = await maybeInitialScreenshot(opts); + assistant = await opts.harness.prompt(prompt, images ? { images } : undefined); if (assistant.stopReason === "error") { runError = new Error(assistant.errorMessage ?? "agent stopped with error"); } @@ -122,10 +129,44 @@ export async function runAction( return { result, exitCode: exitCodeFor(result) }; } - const result = parseResult(req.action, assistantText, events, elapsed, lastToolError); + if (!assistantText.trim() && assistant) { + assistantText = textFromAssistant(assistant); + } + + const toolError = lastToolErrorDetail ?? lastToolError; + const result = parseResult(req.action, assistantText, events, elapsed, toolError); return { result, exitCode: exitCodeFor(result) }; } +async function maybeInitialScreenshot(opts: HarnessRunOptions): Promise { + if (opts.skipInitialScreenshot) return undefined; + const hasPriorTurn = await sessionHasPriorTurn(opts.session); + if (hasPriorTurn) return undefined; + const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); + if (!png) return undefined; + return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }]; +} + +async function sessionHasPriorTurn(session: Session): Promise { + const entries = await session.getBranch(); + for (const entry of entries) { + if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { + return true; + } + } + return false; +} + +function textFromAssistant(message: AssistantMessage): string { + const parts: string[] = []; + for (const block of message.content) { + if (block && block.type === "text" && typeof block.text === "string") { + parts.push(block.text); + } + } + return parts.join(""); +} + /** * Collect click coordinates from canonical CUA tool calls. The harness * dispatches batched calls via `computer_batch` (args: { actions: [...] }) @@ -163,10 +204,12 @@ function addClickEvent(type: unknown, x: unknown, y: unknown, events: ActionEven events.push({ actionType: type, x, y }); } -function extractToolErrorText(result: unknown): string | undefined { - if (!result || typeof result !== "object") return undefined; +function inspectToolError(result: unknown): { text?: string; detail?: string } { + if (!result || typeof result !== "object") return {}; + const detailsError = (result as { details?: { error?: unknown } }).details?.error; + const detail = typeof detailsError === "string" ? detailsError.trim() : undefined; const content = (result as { content?: unknown }).content; - if (!Array.isArray(content)) return undefined; + if (!Array.isArray(content)) return { detail }; const parts: string[] = []; for (const block of content) { if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") { @@ -174,7 +217,8 @@ function extractToolErrorText(result: unknown): string | undefined { if (typeof text === "string" && text.trim().length > 0) parts.push(text.trim()); } } - return parts.length > 0 ? parts.join("\n") : undefined; + const text = parts.length > 0 ? parts.join("\n") : undefined; + return { text, detail }; } /** Print a compact result line and return its exit code. */ diff --git a/packages/cua-cli/src/cli-harness.ts b/packages/cua-cli/src/cli-harness.ts index bae54aca..c5007981 100644 --- a/packages/cua-cli/src/cli-harness.ts +++ b/packages/cua-cli/src/cli-harness.ts @@ -1,4 +1,5 @@ import { + InMemorySessionRepo, type JsonlSessionMetadata, type JsonlSessionRepo, NodeExecutionEnv, @@ -7,8 +8,8 @@ import { } from "@onkernel/cua-agent"; import { type CuaModelRef, - getCuaEnvApiKey, parseCuaModelRef, + requireCuaEnvApiKey, } from "@onkernel/cua-ai"; import { parseArgs } from "node:util"; import { stderr, stdout } from "node:process"; @@ -39,6 +40,7 @@ import { findLatestSession, listSessionsForCwd, openSession, + readMetadataFromFile, resolveSessionRef, } from "./harness-sessions"; import { discoverCuaSkills } from "./harness-skills"; @@ -203,10 +205,8 @@ function resolveAuth(flags: HarnessCliFlags): ResolvedAuth { const { apiKey, baseUrl } = requireKernelApiKey(); const modelRef = resolveCuaModelRef(flags.model); const { provider } = parseCuaModelRef(modelRef); - const providerKey = getCuaEnvApiKey(provider); - if (!providerKey) { - throw new Error(`missing API key for provider "${provider}"`); - } + // Throws naming the env vars the user must set (`requireCuaEnvApiKey`). + requireCuaEnvApiKey(provider); return { kernelApiKey: apiKey, kernelBaseUrl: baseUrl, modelRef }; } @@ -229,6 +229,7 @@ async function provisionForFlags(flags: HarnessCliFlags, auth: ResolvedAuth): Pr const handle: CuaBrowserHandle = { client, browser, + profileId: meta.profile_id, async close(): Promise { // no-op: named-session browsers are torn down via `cua session stop`. }, @@ -296,10 +297,9 @@ async function resolveSession( return { session: await openSession(repo, picked), transcriptPath: picked.path, resumed: true }; } if (namedMeta?.transcript_path) { - const sessions = await listSessionsForCwd(repo, cwd); - const match = sessions.find((m) => m.path === namedMeta.transcript_path); - if (match) { - return { session: await openSession(repo, match), transcriptPath: match.path, resumed: true }; + const direct = await readMetadataFromFile(namedMeta.transcript_path); + if (direct) { + return { session: await openSession(repo, direct), transcriptPath: direct.path, resumed: true }; } } const fresh = await createSession(repo, cwd); @@ -337,13 +337,27 @@ async function pickSession(sessions: JsonlSessionMetadata[]): Promise; provider: string; modelRef: CuaModelRef; } -async function setupHarnessRuntime(flags: HarnessCliFlags): Promise { +export interface SetupHarnessRuntimeOptions { + /** + * When true, never create or open a JsonlSession; use an InMemorySession instead. + * One-shot action subcommands without -s/-c/-r/--session pass this so they + * don't pollute the on-disk transcript list. The print path always persists + * (so `-c` / `--session latest` keeps working). + */ + skipDiskSession?: boolean; +} + +async function setupHarnessRuntime( + flags: HarnessCliFlags, + opts: SetupHarnessRuntimeOptions = {}, +): Promise { const auth = resolveAuth(flags); const cwd = process.cwd(); const env = new NodeExecutionEnv({ cwd }); @@ -357,11 +371,11 @@ async function setupHarnessRuntime(flags: HarnessCliFlags): Promise 0 ? value : undefined; +} + function mapThinkingLevel(raw: string | undefined): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" { const v = (raw ?? "low").trim().toLowerCase(); switch (v) { @@ -421,8 +454,11 @@ function mapThinkingLevel(raw: string | undefined): "off" | "minimal" | "low" | return "xhigh"; case "low": case "": - default: return "low"; + default: + throw new Error( + `invalid --thinking value "${raw}"; expected one of: off | minimal | low | medium | high | xhigh`, + ); } } @@ -434,7 +470,7 @@ export async function runPrintCommand(prompt: string, flags: HarnessCliFlags): P return await runPrint({ harness: runtime.harness, browserHandle: runtime.handle, - session: (runtime.resolved?.session ?? (await fallbackInMemorySession())) as Session, + session: runtime.session, modelRef: runtime.modelRef, provider: runtime.provider, prompt, @@ -454,19 +490,13 @@ export async function runPrintCommand(prompt: string, flags: HarnessCliFlags): P } } -async function fallbackInMemorySession(): Promise { - const { InMemorySessionRepo } = await import("@onkernel/cua-agent"); - const repo = new InMemorySessionRepo(); - return repo.create(); -} - /** Run a one-shot action subcommand through the new harness wiring. */ export async function runActionCommand( action: ActionType, rest: string[], flags: HarnessCliFlags, ): Promise { - const runtime = await setupHarnessRuntime(flags); + const runtime = await setupHarnessRuntime(flags, { skipDiskSession: true }); const req: ActionRequest = buildActionRequest(action, rest); if (flags.maxSteps !== undefined) req.maxTurns = flags.maxSteps; const screenshotOut = flags.out @@ -478,8 +508,8 @@ export async function runActionCommand( const res = await runAction(req, { harness: runtime.harness, browserHandle: runtime.handle, - session: (runtime.resolved?.session ?? (await fallbackInMemorySession())) as Session, - verbose: flags.verbose, + session: runtime.session, + skipInitialScreenshot: runtime.resolved?.resumed === true, }, screenshotOut); return emitCompact(res); } finally { @@ -529,7 +559,7 @@ export async function runSessionSubcommand(args: string[], flags: HarnessCliFlag apiKey: auth.kernelApiKey, baseUrl: auth.kernelBaseUrl, browserTimeoutSeconds: flags.browserTimeout, - profileId: flags.browserProfile, + profileSelector: flags.browserProfile, saveProfileChanges: flags.profileSaveChanges, }); stdout.write(`name=${meta.name}\n`); diff --git a/packages/cua-cli/src/cli.ts b/packages/cua-cli/src/cli.ts index 6a0c2607..aecc9176 100644 --- a/packages/cua-cli/src/cli.ts +++ b/packages/cua-cli/src/cli.ts @@ -174,6 +174,15 @@ function parseCliArgs(argv: string[]): CliFlags { const browserTimeout = browserTimeoutRaw ? Number(browserTimeoutRaw) : undefined; const maxStepsRaw = parsed.values["max-steps"]; const maxSteps = maxStepsRaw ? Number(maxStepsRaw) : undefined; + const thinkingRaw = parsed.values.thinking as string | undefined; + if (thinkingRaw !== undefined) { + const allowed = new Set(["off", "none", "minimal", "low", "medium", "high", "xhigh"]); + if (!allowed.has(thinkingRaw.trim().toLowerCase())) { + throw new Error( + `invalid --thinking value "${thinkingRaw}"; expected one of: off | minimal | low | medium | high | xhigh`, + ); + } + } return { help: !!parsed.values.help, diff --git a/packages/cua-cli/src/harness-browser.ts b/packages/cua-cli/src/harness-browser.ts index b010772d..268ca74b 100644 --- a/packages/cua-cli/src/harness-browser.ts +++ b/packages/cua-cli/src/harness-browser.ts @@ -5,6 +5,8 @@ import Kernel, { NotFoundError } from "@onkernel/sdk"; export interface CuaBrowserHandle { client: Kernel; browser: KernelBrowser; + /** Resolved Kernel profile id when --profile was used, otherwise undefined. */ + profileId?: string; close(): Promise; } @@ -28,7 +30,13 @@ function looksLikeProfileId(selector: string): boolean { return trimmed.length === CUID2_LENGTH && CUID2_PATTERN.test(trimmed); } -async function resolveProfileId(client: Kernel, selector: string): Promise { +/** + * Resolve a `--profile ` selector to a concrete profile id. + * Looks up by id first; if the API reports not-found and the selector does + * not look like a CUID2 id, the profile is created with that name (same + * semantics as the legacy `cua-translator.browserSession.open` path). + */ +export async function resolveProfileId(client: Kernel, selector: string): Promise { const trimmed = selector.trim(); if (!trimmed) throw new Error("profile selector is empty"); try { @@ -73,6 +81,7 @@ export async function provisionBrowser(opts: ProvisionBrowserOptions): Promise { await client.browsers.deleteByID(browser.session_id); }, diff --git a/packages/cua-cli/src/harness-named-sessions.ts b/packages/cua-cli/src/harness-named-sessions.ts index d0f4119d..2791d22f 100644 --- a/packages/cua-cli/src/harness-named-sessions.ts +++ b/packages/cua-cli/src/harness-named-sessions.ts @@ -3,7 +3,7 @@ import Kernel from "@onkernel/sdk"; import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { createKernelClient } from "./harness-browser"; +import { createKernelClient, resolveProfileId } from "./harness-browser"; /** * Named sessions: durable, slug-keyed pointers to a Kernel cloud browser @@ -97,7 +97,8 @@ export interface StartNamedSessionOptions { baseUrl?: string; configProfile?: string; browserTimeoutSeconds?: number; - profileId?: string; + /** Profile id or name (created if missing). Same semantics as `--profile`. */ + profileSelector?: string; saveProfileChanges?: boolean; } @@ -120,12 +121,16 @@ export async function startNamedSession(opts: StartNamedSessionOptions): Promise const client = createKernelClient(opts.apiKey, opts.baseUrl); const timeoutSeconds = opts.browserTimeoutSeconds && opts.browserTimeoutSeconds > 0 ? opts.browserTimeoutSeconds : 300; + let profileId: string | undefined; + if (opts.profileSelector && opts.profileSelector.trim()) { + profileId = await resolveProfileId(client, opts.profileSelector); + } const params: Parameters[0] = { stealth: true, timeout_seconds: timeoutSeconds, }; - if (opts.profileId) { - params.profile = { id: opts.profileId, save_changes: opts.saveProfileChanges ?? false }; + if (profileId) { + params.profile = { id: profileId, save_changes: opts.saveProfileChanges ?? false }; } const browser = await client.browsers.create(params); @@ -133,7 +138,7 @@ export async function startNamedSession(opts: StartNamedSessionOptions): Promise name: opts.name, kernel_session_id: browser.session_id, live_url: browser.browser_live_view_url, - profile_id: opts.profileId, + profile_id: profileId, config_profile: opts.configProfile, created_at: Date.now(), }; diff --git a/packages/cua-cli/src/harness-sessions.ts b/packages/cua-cli/src/harness-sessions.ts index 55ba5505..d326a860 100644 --- a/packages/cua-cli/src/harness-sessions.ts +++ b/packages/cua-cli/src/harness-sessions.ts @@ -4,8 +4,9 @@ import { NodeExecutionEnv, type Session, } from "@onkernel/cua-agent"; +import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, resolve as resolvePath, join } from "node:path"; /** * Resolve the default sessions directory: `$XDG_DATA_HOME/cua/sessions` @@ -40,14 +41,36 @@ export async function listSessionsForCwd( return all; } -/** Find the most recent session metadata for cwd (lexicographic by id; uuidv7 ids sort by creation). */ +/** + * Find the most recent session metadata for cwd. The pi `JsonlSessionRepo` + * already orders by `createdAt` descending, but legacy `-c` semantics + * resumed by last *modified* time so a session that was reopened and + * appended to comes back first. We stat each file and prefer the newest + * mtime; results that fail to stat fall back to `createdAt`. + */ export async function findLatestSession( repo: JsonlSessionRepo, cwd: string, ): Promise { const sessions = await listSessionsForCwd(repo, cwd); if (sessions.length === 0) return undefined; - return [...sessions].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; + const ranked = await Promise.all( + sessions.map(async (meta) => { + try { + const s = await stat(meta.path); + return { meta, mtime: s.mtimeMs }; + } catch { + return { meta, mtime: Number.NaN }; + } + }), + ); + ranked.sort((a, b) => { + const am = Number.isFinite(a.mtime) ? a.mtime : -Infinity; + const bm = Number.isFinite(b.mtime) ? b.mtime : -Infinity; + if (am !== bm) return bm - am; + return b.meta.createdAt.localeCompare(a.meta.createdAt); + }); + return ranked[0]?.meta; } /** @@ -64,8 +87,13 @@ export async function resolveSessionRef( const trimmed = ref.trim(); if (!trimmed) throw new Error("session reference is empty"); if (trimmed.includes("/") || trimmed.endsWith(".jsonl")) { - const sessions = await listSessionsForCwd(repo, cwd); - const match = sessions.find((m) => m.path === trimmed); + const absolute = isAbsolute(trimmed) ? trimmed : resolvePath(cwd, trimmed); + const direct = await readMetadataFromFile(absolute); + if (direct) return direct; + // Best-effort scan of the repo (no cwd filter) in case the path was + // re-encoded somewhere (e.g. symlinks) and only matches a known session. + const sessions = await repo.list(); + const match = sessions.find((m) => m.path === absolute); if (match) return match; throw new Error(`no session at "${trimmed}"`); } @@ -81,6 +109,44 @@ export async function resolveSessionRef( return matches[0]!; } +/** + * Load the header line of a jsonl session file from disk and return its + * metadata, or undefined when the file is missing/empty/legacy. Used to + * resolve `--session ` and named transcript_path entries that may + * have been created from a different cwd (so the repo's per-cwd listing + * wouldn't see them). + */ +export async function readMetadataFromFile( + absolutePath: string, +): Promise { + try { + const raw = await readFile(absolutePath, "utf8"); + const firstLine = raw.split("\n", 1)[0]?.trim(); + if (!firstLine) return undefined; + const header = JSON.parse(firstLine) as { + type?: string; + version?: unknown; + id?: unknown; + timestamp?: unknown; + cwd?: unknown; + parentSession?: unknown; + }; + if (header.type !== "session") return undefined; + if (typeof header.id !== "string" || typeof header.timestamp !== "string" || typeof header.cwd !== "string") { + return undefined; + } + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path: absolutePath, + ...(typeof header.parentSession === "string" ? { parentSessionPath: header.parentSession } : {}), + }; + } catch { + return undefined; + } +} + /** Open (resume) a session by metadata. */ export function openSession(repo: JsonlSessionRepo, metadata: JsonlSessionMetadata): Promise> { return repo.open(metadata); diff --git a/packages/cua-cli/src/harness.ts b/packages/cua-cli/src/harness.ts index 62b46daa..815a4d25 100644 --- a/packages/cua-cli/src/harness.ts +++ b/packages/cua-cli/src/harness.ts @@ -9,8 +9,11 @@ import { type ThinkingLevel, } from "@onkernel/cua-agent"; import { + type Api, type CuaModelRef, + type Model, getCuaEnvApiKey, + getCuaModel, resolveCuaRuntimeSpec, } from "@onkernel/cua-ai"; import type Kernel from "@onkernel/sdk"; @@ -29,6 +32,8 @@ export interface BuildCuaHarnessOptions { extraTools?: CuaAgentHarnessOptions["extraTools"]; /** Override env-var API-key resolution (mainly for tests). */ getApiKeyAndHeaders?: CuaAgentHarnessOptions["getApiKeyAndHeaders"]; + /** Override the catalog `baseUrl` on the resolved model (e.g. from `_BASE_URL`). */ + modelBaseUrl?: string; } /** @@ -41,23 +46,26 @@ export interface BuildCuaHarnessOptions { export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { const skills = opts.skills ?? []; const extraTools = opts.extraTools ?? createCodingTools(opts.cwd); + const model: CuaModelRef | Model = opts.modelBaseUrl + ? { ...getCuaModel(opts.model), baseUrl: opts.modelBaseUrl } + : opts.model; return new CuaAgentHarness({ env: new NodeExecutionEnv({ cwd: opts.cwd }), session: opts.session, - model: opts.model, + model, browser: opts.browser, client: opts.client, extraTools, resources: { skills }, thinkingLevel: opts.thinkingLevel, - systemPrompt: ({ model }) => { - const runtime = resolveCuaRuntimeSpec(model); - return composeSystemPrompt(runtime.defaultSystemPrompt, skills); + systemPrompt: ({ model: activeModel, resources }) => { + const runtime = resolveCuaRuntimeSpec(activeModel); + return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? []); }, getApiKeyAndHeaders: opts.getApiKeyAndHeaders ?? - (async (model) => { - const apiKey = getCuaEnvApiKey(model.provider); + (async (resolvedModel) => { + const apiKey = getCuaEnvApiKey(resolvedModel.provider); return apiKey ? { apiKey } : undefined; }), }); diff --git a/packages/cua-cli/src/output/harness-jsonl.ts b/packages/cua-cli/src/output/harness-jsonl.ts index 5e0dea2e..5b4b8673 100644 --- a/packages/cua-cli/src/output/harness-jsonl.ts +++ b/packages/cua-cli/src/output/harness-jsonl.ts @@ -15,6 +15,8 @@ export interface JsonlSinkOptions { browser: KernelBrowser; modelRef: string; provider: string; + /** Kernel profile id used to provision the browser, when --profile was set. */ + profileId?: string; /** Where to write each line. Defaults to process.stdout. */ write?: (line: string) => void; /** When true, emit `assistant_text_delta` events. Default: false. */ @@ -61,7 +63,7 @@ export function attachHarnessJsonlSink(opts: JsonlSinkOptions): () => void { type: "browser_created", browser_session_id: opts.browser.session_id, live_url: opts.browser.browser_live_view_url, - profile_id: undefined, + ...(opts.profileId ? { profile_id: opts.profileId } : {}), ts: Date.now(), }); diff --git a/packages/cua-cli/src/print.ts b/packages/cua-cli/src/print.ts index 5ab8a801..8d244a9e 100644 --- a/packages/cua-cli/src/print.ts +++ b/packages/cua-cli/src/print.ts @@ -34,6 +34,7 @@ export async function runPrint(opts: RunPrintOptions): Promise { unsubscribeJsonl = attachHarnessJsonlSink({ harness: opts.harness, browser: opts.browserHandle.browser, + profileId: opts.browserHandle.profileId, modelRef: opts.modelRef, provider: opts.provider, includeDeltas: opts.jsonlIncludeDeltas, diff --git a/packages/cua-cli/test/action-runner.test.ts b/packages/cua-cli/test/action-runner.test.ts index 73998c35..0d521bbc 100644 --- a/packages/cua-cli/test/action-runner.test.ts +++ b/packages/cua-cli/test/action-runner.test.ts @@ -70,6 +70,65 @@ describe("action harness-runner", () => { process.stdout.write = originalWrite; } }); + + it("exits 2 when the provider returns an error", async () => { + fixture = await buildTestHarness({ + turns: [{ steps: [{ type: "error", message: "boom" }] }], + }); + const res = await runAction( + { action: "do", text: "fail" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session, maxTurns: 3 }, + ); + expect(res.exitCode).toBe(2); + expect(res.result.status).toBe("error"); + expect(res.result.text).toContain("boom"); + }); + + it("invokes harness.abort once the turn cap is reached", async () => { + const toolCall = { + steps: [ + { + type: "tool_call" as const, + toolName: "click", + args: { x: 1, y: 1 }, + }, + ], + }; + fixture = await buildTestHarness({ + turns: Array.from({ length: 10 }, () => toolCall), + }); + // Spy on harness.abort so we don't depend on the scripted provider + // honouring the abort signal (it runs synchronously below the loop). + let abortCalls = 0; + const originalAbort = fixture.harness.abort.bind(fixture.harness); + fixture.harness.abort = async () => { + abortCalls += 1; + return originalAbort(); + }; + await runAction( + { action: "do", text: "loop" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session, maxTurns: 2 }, + ); + expect(abortCalls).toBeGreaterThanOrEqual(1); + }); + + it("attaches a screenshot to the first user message on a fresh session", async () => { + fixture = await buildTestHarness({ + turns: [{ steps: [{ type: "text", text: "ok" }] }], + }); + await runAction( + { action: "do", text: "look" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session, maxTurns: 3 }, + ); + expect(fixture.kernel.screenshots).toBeGreaterThanOrEqual(1); + const entries = await fixture.session.getBranch(); + const firstUser = entries.find((e) => e.type === "message" && e.message.role === "user"); + expect(firstUser).toBeDefined(); + const content = (firstUser as { message: { content: unknown[] } }).message.content as Array<{ + type: string; + }>; + expect(content.some((c) => c.type === "image")).toBe(true); + }); }); function handleFor(fixture: TestHarnessFixture) { diff --git a/packages/cua-cli/test/harness-sessions.test.ts b/packages/cua-cli/test/harness-sessions.test.ts index c5bb4abf..17f3440b 100644 --- a/packages/cua-cli/test/harness-sessions.test.ts +++ b/packages/cua-cli/test/harness-sessions.test.ts @@ -69,4 +69,17 @@ describe("JsonlSessionRepo-backed sessions", () => { expect(byPrefix.id).toBe(id); await expect(resolveSessionRef(repo, cwd, "no-such")).rejects.toThrow(/no session matches/); }); + + it("resolves an absolute --session from a different cwd", async () => { + const root = freshRoot(); + const originCwd = mkdtempSync(join(tmpdir(), "cua-cli-cwd-")); + const otherCwd = mkdtempSync(join(tmpdir(), "cua-cli-other-")); + const repo = createSessionRepo(root); + const created = await createSession(repo, originCwd); + const path = (await created.getMetadata()).path; + // Invoke resolution from a different cwd than the session was created in. + const resolved = await resolveSessionRef(repo, otherCwd, path); + expect(resolved.path).toBe(path); + expect(resolved.cwd).toBe(originCwd); + }); }); diff --git a/packages/cua-cli/test/print.test.ts b/packages/cua-cli/test/print.test.ts index 17734178..8223f1c4 100644 --- a/packages/cua-cli/test/print.test.ts +++ b/packages/cua-cli/test/print.test.ts @@ -52,6 +52,34 @@ describe("runPrint", () => { const exitCode = await runPrintIntoBuffer(fixture, "fail", lines); expect(exitCode).toBe(1); }); + + it("emits tool_call and tool_result envelopes for tool turns in jsonl mode", async () => { + fixture = await buildTestHarness({ + turns: [ + { + steps: [ + { + type: "tool_call", + toolName: "click", + args: { x: 12, y: 34 }, + }, + ], + }, + { steps: [{ type: "text", text: "done" }] }, + ], + }); + const events = await runPrintAsJsonl(fixture, "click button"); + const types = events.map((e) => e.type); + expect(types).toContain("tool_call"); + expect(types).toContain("tool_result"); + const call = events.find((e) => e.type === "tool_call") as Record; + expect(call.tool_name).toBe("click"); + const result = events.find((e) => e.type === "tool_result") as Record; + expect(result.tool_name).toBe("click"); + // ok / call_id present on the result envelope, mirroring the documented schema. + expect(typeof result.ok).toBe("boolean"); + expect(typeof result.call_id).toBe("string"); + }); }); async function runPrintIntoBuffer( diff --git a/packages/cua-cli/vitest.config.ts b/packages/cua-cli/vitest.config.ts index 3402d488..a622b2f8 100644 --- a/packages/cua-cli/vitest.config.ts +++ b/packages/cua-cli/vitest.config.ts @@ -1,5 +1,10 @@ import { defineConfig } from "vitest/config"; +// `server.host` pins vitest's internal dev server to a literal IP. Without +// this, `localhost` is resolved by Node's DNS — in sandboxed CI environments +// that don't have `localhost` in /etc/hosts the bootstrap fails with +// `ENOTFOUND localhost`. The setting is a no-op when running tests directly +// but keeps the dev-server bootstrap from doing a DNS lookup. export default defineConfig({ server: { host: "127.0.0.1",