diff --git a/docs/development.md b/docs/development.md index b0f824396..861dbe782 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,6 +43,22 @@ In any worktree-style or portless setup, never assume default ports. `http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP. Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +### Desktop macOS compositor watchdog + +macOS display sleep can leave Chromium's GPU-process display link — the vsync +source that drives frame production — stuck on a stale display. The compositor +then stops producing frames and the window looks frozen: unresponsive to clicks +and keys even though the renderer and every process stay alive. It self-recovers +after a few minutes, which is too long for a foreground app. + +`setupDarwinCompositorWatchdog` +(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against +this. It polls the renderer for frame production every couple of seconds and, +after a sustained stall while the window is visible and unlocked, restarts the +GPU process so Chromium rebuilds the display link. The probe is skipped while +the screen is locked or the window is hidden or minimized, since a window +legitimately stops producing frames then. + ### Daemon logs Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set diff --git a/docs/providers.md b/docs/providers.md index 1c02c41df..6e3c39150 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -16,6 +16,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types. Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. +Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions. + Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. --- diff --git a/docs/rpc-namespacing.md b/docs/rpc-namespacing.md index cf1b9e1d9..cd949ffdb 100644 --- a/docs/rpc-namespacing.md +++ b/docs/rpc-namespacing.md @@ -11,7 +11,7 @@ The namespace reads left to right: - Domain: `checkout` - Provider or subsystem: `github` -- Operation: `set_auto_merge` +- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead. - Direction: `request` or `response` Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing. diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 00b9214ca..40fc0dfd5 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-sRHRfPuhsoOx+wtEb+VYfs29wz3BdKu6vDYhaHr7LQE= +sha256-t6vqegESlQGlbkOfjpjOHbf6ufiDeBl/ApFtQHvOeLc= diff --git a/package-lock.json b/package-lock.json index 5a36be6c2..d165c1f36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -212,9 +212,9 @@ ] }, "node_modules/@anthropic-ai/sdk": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", - "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", + "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" @@ -260,44 +260,6 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/sha256-browser/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/@aws-crypto/sha256-browser/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/@aws-crypto/sha256-browser/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/@aws-crypto/sha256-js": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", @@ -332,96 +294,25 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/util/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/@aws-crypto/util/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/@aws-crypto/util/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/@aws-sdk/client-bedrock-runtime": { - "version": "3.1045.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1045.0.tgz", - "integrity": "sha512-aPC6gAz9uKRiwfnKB7peTs6yD0FpSzmVnSkx0f2QtJfosFM6J6KtBvR1lMKby050K4C4PAyEScwA5YTsGfTcGA==", + "version": "3.1049.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1049.0.tgz", + "integrity": "sha512-YM8b2baoRY8ul47b4amQW2VlUthLmM8DnqdlGO20LJmmmRpjnT91SaQJai3OMehA6uE0Gig88VyDCT1vEACSww==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-node": "^3.972.39", - "@aws-sdk/eventstream-handler-node": "^3.972.14", - "@aws-sdk/middleware-eventstream": "^3.972.10", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/middleware-websocket": "^3.972.16", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1045.0", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/credential-provider-node": "^3.972.43", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.20", + "@aws-sdk/token-providers": "3.1049.0", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/eventstream-serde-config-resolver": "^4.3.14", - "@smithy/eventstream-serde-node": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -429,24 +320,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", - "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", + "version": "3.974.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", + "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.22", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@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", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -454,14 +339,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", - "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", + "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -470,20 +355,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", - "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", + "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -491,23 +373,22 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", - "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", + "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-login": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/credential-provider-env": "^3.972.38", + "@aws-sdk/credential-provider-http": "^3.972.40", + "@aws-sdk/credential-provider-login": "^3.972.42", + "@aws-sdk/credential-provider-process": "^3.972.38", + "@aws-sdk/credential-provider-sso": "^3.972.42", + "@aws-sdk/credential-provider-web-identity": "^3.972.42", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -516,17 +397,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", - "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", + "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -535,21 +414,20 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", - "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", + "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-ini": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/credential-provider-env": "^3.972.38", + "@aws-sdk/credential-provider-http": "^3.972.40", + "@aws-sdk/credential-provider-ini": "^3.972.42", + "@aws-sdk/credential-provider-process": "^3.972.38", + "@aws-sdk/credential-provider-sso": "^3.972.42", + "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -558,15 +436,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", - "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", + "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -575,35 +452,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", - "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", + "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", + "@aws-sdk/token-providers": "3.1049.0", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1041.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", - "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -612,16 +470,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", - "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", + "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -630,13 +487,13 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", - "integrity": "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==", + "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/eventstream-codec": "^4.2.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -645,13 +502,13 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.10.tgz", - "integrity": "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==", + "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/protocol-http": "^5.3.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -659,112 +516,18 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", - "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", - "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", - "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", - "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", - "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.16.tgz", - "integrity": "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==", + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.20.tgz", + "integrity": "sha512-LM6P0i+Lu6pi25oNw2nqxjRxiEOtLgPB7xIvHfa+FxHTRLg8wcgqu3qg2COl4QaT7Es2yCxYdeRLVYazKAwL8g==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-format-url": "^3.972.10", - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -772,64 +535,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", - "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", + "version": "3.997.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", + "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", - "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.17", - "@smithy/node-config-provider": "^4.3.14", + "@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" }, @@ -838,15 +556,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", - "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", + "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/middleware-sdk-s3": "^3.972.37", "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -855,16 +572,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1045.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1045.0.tgz", - "integrity": "sha512-/o4qcty0DmQola0DBniRVeBakYY6ALOvKEFo1AtJpTmMn/cJ+Fk3RWGe5ieT/f/eYbHG9k5E7poKge/E+WGv4Q==", + "version": "3.1049.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", + "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.12", + "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -885,49 +601,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", - "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-endpoints": "^3.4.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", - "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", @@ -940,52 +613,15 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", - "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", - "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/types": "^3.973.8", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", + "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.2", + "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { @@ -3427,16 +3063,6 @@ "node": ">=6.9.0" } }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", - "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@clack/core": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", @@ -3731,6 +3357,279 @@ "react": ">=16.8.0" } }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.3.tgz", + "integrity": "sha512-azg09GSrckQa3ffbH09YEZC7DyHgmNSX+vmWEoEhQvp4icbzqbqLfIeMayMNEK/aGusm1SghZC4bPlDdagDALg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.75.3", + "ignore": "^7.0.5", + "typebox": "^1.1.24", + "yaml": "^2.8.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core/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-ai": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.3.tgz", + "integrity": "sha512-UKccS+ADlkSVJ49a00346jUfXmUi6zzzB+pPWotsyA6SxhKr2ejjkGQksGyR1DyNVrsEP/WWlsOSTUUwVlzNaA==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.91.1", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "^2.2.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "typebox": "^1.1.24" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/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-ai/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-ai/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-ai/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": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.3.tgz", + "integrity": "sha512-LIi5/CdUBfcLp3BAtpLx1BfnHDLmDOQVdzYfS1H9fjjCw2dcPr9voSI5ncrhvZdgyFSnfHck4BCbNcfZk+TEHQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-tui": "^0.75.3", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "cross-spawn": "^7.0.6", + "diff": "^8.0.2", + "glob": "^13.0.1", + "highlight.js": "^10.7.3", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "jiti": "^2.7.0", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "typebox": "^1.1.24", + "undici": "^8.3.0", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.6" + } + }, + "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/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/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/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/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/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/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/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/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/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-tui": { + "version": "0.75.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", + "integrity": "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -7244,9 +7143,10 @@ "link": true }, "node_modules/@google/genai": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", - "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", + "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", @@ -8681,31 +8581,31 @@ } }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.3.tgz", - "integrity": "sha512-e7jASirzfm+ROiOGFh843+cFZTy3DfzP+jldCvh8RnEk0C3QihDTn7dd7Yh7KAJydwIJ18FJSZ2swHvCJhk18g==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", "license": "MIT", "optional": true, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.3", - "@mariozechner/clipboard-darwin-universal": "0.3.3", - "@mariozechner/clipboard-darwin-x64": "0.3.3", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.3", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.3", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.3", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.3", - "@mariozechner/clipboard-linux-x64-musl": "0.3.3", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.3", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.3" + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" } }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.3.tgz", - "integrity": "sha512-+zhuZGXqVrdkbIRdnwiZNbTJ7V3elq/A+C5d5laJoyhJgWs41eO5NUMkBkj6f23F2L4PRXEhdn5/ktlPx+bG3Q==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", "cpu": [ "arm64" ], @@ -8719,9 +8619,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.3.tgz", - "integrity": "sha512-x9aRfTyndVqpEQ44LNNCK/EXZd9y8rWkLQgNhmWpby9PXrjPhNxfjUc2Db4mt4nJjU/4zzO8F5v/XyzlUGSdhQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", "license": "MIT", "optional": true, "os": [ @@ -8732,9 +8632,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.3.tgz", - "integrity": "sha512-6ut/NawB0KiYPCwrirgNp6Br62LntL978q7G6d/Rs2pmPvQb53bP96eUMYl+Y3a7Qk13bGZ4w9rVPFxRE9m9ag==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", "cpu": [ "x64" ], @@ -8748,9 +8648,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.3.tgz", - "integrity": "sha512-gf3dH4kBddU1AOyHVB53mjLUFfJAKlTmxTMw51jdeg7eE7IjfEBXVvM4bifMtBxbWkT0eA0FUZ1C0KQ6Z5l6pw==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", "cpu": [ "arm64" ], @@ -8764,9 +8664,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.3.tgz", - "integrity": "sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", "cpu": [ "arm64" ], @@ -8780,9 +8680,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.3.tgz", - "integrity": "sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", "cpu": [ "riscv64" ], @@ -8796,9 +8696,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.3.tgz", - "integrity": "sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", "cpu": [ "x64" ], @@ -8812,9 +8712,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.3.tgz", - "integrity": "sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", "cpu": [ "x64" ], @@ -8828,9 +8728,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.3.tgz", - "integrity": "sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", "cpu": [ "arm64" ], @@ -8844,9 +8744,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.3.tgz", - "integrity": "sha512-5hvaEq/bgYovTIGx43O/S7loIHYV3ue90WcV1dz0wdMXroVKZKeU/yfwM0PALQA1OcrEHiGXGySFReXr72lGtA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", "cpu": [ "x64" ], @@ -8859,332 +8759,6 @@ "node": ">= 10" } }, - "node_modules/@mariozechner/jiti": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz", - "integrity": "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==", - "license": "MIT", - "dependencies": { - "std-env": "^3.10.0", - "yoctocolors": "^2.1.2" - }, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/@mariozechner/pi-agent-core": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.70.2.tgz", - "integrity": "sha512-g1hIdKyDwmQOoBGO0R4OhpemKeMENeK0vE5FJtuQKqEcsdCAkVBgZAK6aZUARYZVxMA718JS6WPLFWoddzjD7g==", - "license": "MIT", - "dependencies": { - "@mariozechner/pi-ai": "^0.70.2", - "typebox": "^1.1.24" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mariozechner/pi-ai": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.70.2.tgz", - "integrity": "sha512-+30LRPjXsXF+oI96DvGWMbdPGeqoLJvadh6UPev7wx2DzhC9FEqXkQcoMZ0usbCm7E9pl8ua8a9s/pQ5ikaUbg==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "chalk": "^5.6.2", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@mariozechner/pi-ai/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/@mariozechner/pi-ai/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/@mariozechner/pi-coding-agent": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.70.2.tgz", - "integrity": "sha512-asfNqV89HKAmKvJ1wENBY/UQMIf77kLtkzBrvXnMQV4YbH7D/6KT+VeVzPG6zm5PAZP2UtdLY9B9Cge7IxH37w==", - "license": "MIT", - "dependencies": { - "@mariozechner/jiti": "^2.6.2", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", - "@mariozechner/pi-tui": "^0.70.2", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", - "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", - "glob": "^13.0.1", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "marked": "^15.0.12", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "uuid": "^14.0.0", - "yaml": "^2.8.2" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=20.6.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.3" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@mariozechner/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/@mariozechner/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@mariozechner/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/@mariozechner/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/@mariozechner/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@mariozechner/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/@mariozechner/pi-coding-agent/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@mariozechner/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/@mariozechner/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/@mariozechner/pi-coding-agent/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@mariozechner/pi-coding-agent/node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/@mariozechner/pi-tui": { - "version": "0.70.2", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.70.2.tgz", - "integrity": "sha512-PtKC0NepnrYcqMx6MXkWTrBzC9tI62KeC6w940oT46lCbfvgmfqXciR15+9BZpxxc1H4jd3CMrKsmOPVeUqZ0A==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" - } - }, - "node_modules/@mariozechner/pi-tui/node_modules/@types/mime-types": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", - "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", - "license": "MIT" - }, - "node_modules/@mariozechner/pi-tui/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/@mariozechner/pi-tui/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@mistralai/mistralai": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", @@ -10716,9 +10290,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "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/@protobufjs/eventemitter": { @@ -10728,13 +10302,12 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "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", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -10744,9 +10317,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "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/@protobufjs/path": { @@ -10762,9 +10335,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "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/@radix-ui/primitive": { @@ -11699,8 +11272,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -11713,8 +11285,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -11727,8 +11298,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -11741,8 +11311,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -11755,8 +11324,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -11769,8 +11337,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -11783,8 +11350,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -11797,8 +11363,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -11811,8 +11376,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -11825,8 +11389,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -11839,8 +11402,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -11853,8 +11415,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -11867,8 +11428,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -11881,8 +11441,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -11895,8 +11454,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -11909,8 +11467,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -11923,8 +11480,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -11937,8 +11493,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -11951,8 +11506,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -11965,8 +11519,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -11979,8 +11532,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -11993,8 +11545,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -12007,8 +11558,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -12021,8 +11571,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -12035,8 +11584,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -12241,38 +11789,14 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.17", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", - "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/core": { - "version": "3.23.17", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "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": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12280,85 +11804,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", - "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", + "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/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", - "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", - "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", - "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", - "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", - "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12366,43 +11818,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.17", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", - "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", + "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/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", - "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", - "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12410,201 +11832,25 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "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": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", - "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.32", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", - "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", - "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/service-error-classification": "^4.3.1", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", - "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", - "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", - "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/node-http-handler": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", - "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", + "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/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", - "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", - "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", - "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", - "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", - "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", - "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12612,36 +11858,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", - "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "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/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.13", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", - "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -12649,61 +11872,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", - "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "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" @@ -12713,170 +11884,29 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "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": "^4.2.2", + "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", - "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.54", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", - "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.17", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", - "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", - "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.8.tgz", - "integrity": "sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.3.1", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.25", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", - "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "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": "^4.2.2", + "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@speed-highlight/core": { @@ -14074,29 +13104,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", - "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" - }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -14107,12 +13114,6 @@ "node": ">= 10" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -14701,6 +13702,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -17155,15 +16157,6 @@ "node": ">=6.0.0" } }, - "node_modules/basic-ftp": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", - "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -17443,6 +16436,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -18135,77 +17129,6 @@ "node": ">=8" } }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", - "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "license": "MIT" - }, - "node_modules/cli-highlight/node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/cli-highlight/node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "license": "MIT" - }, - "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/cli-progress": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", @@ -18862,12 +17785,12 @@ "license": "MIT" }, "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "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": ">= 14" + "node": ">= 12" } }, "node_modules/data-urls": { @@ -19247,32 +18170,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/degenerator/node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -20498,6 +19395,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -20519,6 +19417,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "engines": { @@ -21479,6 +20378,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -21508,6 +20408,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -23911,6 +22812,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -23931,6 +22833,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -24048,9 +22951,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", + "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", @@ -24060,7 +22963,7 @@ "license": "MIT", "dependencies": { "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", + "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -24131,6 +23034,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -24244,24 +23148,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -24854,15 +23740,6 @@ "node": ">= 14" } }, - "node_modules/gaxios/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/gaxios/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -24936,9 +23813,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "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" @@ -25075,20 +23952,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/getenv": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", @@ -28130,9 +26993,9 @@ } }, "node_modules/koffi": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", - "integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -30959,15 +29822,6 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, - "node_modules/netmask": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", - "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -32036,73 +30890,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-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/pac-proxy-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/pac-proxy-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/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -32429,6 +31216,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -32959,24 +31747,24 @@ } }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -32995,73 +31783,11 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-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/proxy-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/proxy-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/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, "license": "MIT" }, "node_modules/psl": { @@ -35663,6 +34389,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -35686,6 +34413,7 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -35700,6 +34428,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -35714,6 +34443,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -35988,6 +34718,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -36332,22 +35063,6 @@ ], "license": "MIT" }, - "node_modules/strtok3": { - "version": "10.3.5", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", - "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -36994,24 +35709,6 @@ "node": ">=0.6" } }, - "node_modules/token-types": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", - "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -37417,9 +36114,9 @@ } }, "node_modules/typebox": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.33.tgz", - "integrity": "sha512-+/MWwlQ1q2GSVwoxi/+u5JsHkgLQKcCN2Nsjree9c+K7GJu40qbaHrFETmfV1i9Fs1TcOVfynW+jJvIWcXtvjw==", + "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/typed-array-buffer": { @@ -37552,18 +36249,6 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/unbash": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", @@ -39111,6 +37796,7 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -39129,6 +37815,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -39157,18 +37844,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -39593,12 +38268,12 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.2.133", + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-coding-agent": "^0.75.3", "@getpaseo/highlight": "0.1.78", "@getpaseo/relay": "0.1.78", "@isaacs/ttlcache": "^2.1.4", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", - "@mariozechner/pi-coding-agent": "^0.70.2", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", "@sctg/sentencepiece-js": "^1.1.0", diff --git a/packages/app/e2e/helpers/rename.ts b/packages/app/e2e/helpers/rename.ts new file mode 100644 index 000000000..f2b18949f --- /dev/null +++ b/packages/app/e2e/helpers/rename.ts @@ -0,0 +1,40 @@ +import { type Page } from "@playwright/test"; + +/** + * Listens for outbound WebSocket "session" frames of a given inner message type + * and accumulates them. The returned array is populated in-place as frames arrive. + */ +export function captureWsSessionFrames>( + page: Page, + messageType: string, + extract: (inner: Record) => T, +): T[] { + const captured: T[] = []; + page.on("websocket", (ws) => { + ws.on("framesent", (frame) => { + const raw = frame.payload; + const text = typeof raw === "string" ? raw : raw.toString("utf8"); + try { + const outer = JSON.parse(text) as { type?: string; message?: Record }; + if (outer.type === "session" && outer.message?.type === messageType) { + captured.push(extract(outer.message)); + } + } catch { + // Ignore non-JSON and binary frames. + } + }); + }); + return captured; +} + +export function renameModalInput(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-input`); +} + +export function renameModalSubmit(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-submit`); +} + +export function renameModalError(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-error`); +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index aa16c1a25..7302cebbf 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise { export async function expectHostLabelDisplayed(page: Page): Promise { await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); + await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0); } export async function clickEditHostLabel(page: Page): Promise { @@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise { } export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise { - await expect(page.getByTestId("host-page-label-input")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel); - await expect(page.getByTestId("host-page-label-save")).toBeVisible(); + await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible(); + await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel); + await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible(); } export async function expectHostConnectionsCard(page: Page, port: string): Promise { diff --git a/packages/app/e2e/sidebar-workspace-rename.spec.ts b/packages/app/e2e/sidebar-workspace-rename.spec.ts new file mode 100644 index 000000000..b51d0f13c --- /dev/null +++ b/packages/app/e2e/sidebar-workspace-rename.spec.ts @@ -0,0 +1,138 @@ +import { execSync } from "node:child_process"; +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; +import { captureWsSessionFrames } from "./helpers/rename"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +function workspaceRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +function workspaceRenameModalTestId(workspaceId: string, suffix: string): string { + return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`; +} + +async function openProjectViaDaemon( + client: Awaited>, + cwd: string, +): Promise<{ id: string; name: string; workspaceDirectory: string }> { + const result = await client.openProject(cwd); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${cwd}`); + } + return { + id: String(result.workspace.id), + name: result.workspace.name, + workspaceDirectory: result.workspace.workspaceDirectory, + }; +} + +async function openRenameModal(page: Page, workspaceId: string) { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input")); + await expect(input).toBeVisible({ timeout: 10_000 }); + return input; +} + +test.describe("Sidebar workspace rename", () => { + test("renaming via kebab updates the branch name on disk and in the sidebar", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-rename-"); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + expect(workspace.name).toBe("main"); + + const renameRequests = captureWsSessionFrames( + page, + "checkout.rename_branch.request", + (inner) => ({ + branch: String(inner.branch ?? ""), + cwd: String(inner.cwd ?? ""), + }), + ); + + await gotoAppShell(page); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({ + timeout: 30_000, + }); + + const input = await openRenameModal(page, workspace.id); + await expect(input).toHaveValue("main"); + await input.fill("Feature Rename 2"); + + await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText( + "feature-rename-2", + { timeout: 15_000 }, + ); + + expect(renameRequests.length).toBeGreaterThan(0); + expect(renameRequests.at(-1)).toEqual({ + branch: "feature-rename-2", + cwd: workspace.workspaceDirectory, + }); + + const currentBranchOnDisk = execSync("git branch --show-current", { + cwd: repo.path, + stdio: "pipe", + }) + .toString() + .trim(); + expect(currentBranchOnDisk).toBe("feature-rename-2"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] }); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + + await gotoAppShell(page); + const input = await openRenameModal(page, workspace.id); + await expect(input).toHaveValue("main"); + + await input.fill("taken"); + await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + + const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error")); + await expect(errorNode).toBeVisible({ timeout: 15_000 }); + await expect(errorNode).toContainText(/already exists|branch/i); + await expect(input).toBeVisible(); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/workspace-agent-tab-rename.spec.ts new file mode 100644 index 000000000..91d04613d --- /dev/null +++ b/packages/app/e2e/workspace-agent-tab-rename.spec.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { test, expect, type Page } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectArchiveTabDaemonClient, + createIdleAgent, + expectWorkspaceTabVisible, +} from "./helpers/archive-tab"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) { + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, agent.id); +} + +test.describe("Workspace agent tab rename", () => { + test("right-click rename sends update_agent_request and updates the tab label", async ({ + page, + }) => { + test.setTimeout(120_000); + + const client = await connectArchiveTabDaemonClient(); + const repo = await createTempGitRepo("workspace-agent-rename-"); + + try { + const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`; + const agent = await createIdleAgent(client, { + cwd: repo.path, + title: initialTitle, + }); + + const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({ + agentId: String(inner.agentId ?? ""), + name: String(inner.name ?? ""), + requestId: String(inner.requestId ?? ""), + })); + + await openAgentInWorkspace(page, agent); + + const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); + await expect(tab).toContainText(initialTitle, { timeout: 15_000 }); + + await tab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({ + timeout: 10_000, + }); + const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + await expect(input).toHaveValue(initialTitle); + + const renamed = "My Renamed Agent"; + await input.fill(renamed); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(tab).toContainText(renamed, { timeout: 15_000 }); + + expect(updateFrames.length).toBeGreaterThan(0); + const lastFrame = updateFrames.at(-1)!; + expect(lastFrame.agentId).toBe(agent.id); + expect(lastFrame.name).toBe(renamed); + expect(lastFrame.requestId.length).toBeGreaterThan(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts new file mode 100644 index 000000000..54a485fa8 --- /dev/null +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf"; +import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; + +test.describe("Workspace terminal tab rename", () => { + test("right-click rename sends terminal.rename.request and updates the tab label", async ({ + page, + }) => { + test.setTimeout(60_000); + + const client = await connectTerminalClient(); + const repo = await createTempGitRepo("workspace-terminal-rename-"); + + try { + const seeded = await client.openProject(repo.path); + if (!seeded.workspace) { + throw new Error(seeded.error ?? "Failed to seed workspace"); + } + const workspaceId = seeded.workspace.id; + + const created = await client.createTerminal(repo.path); + if (!created.terminal) { + throw new Error(created.error ?? "Failed to create terminal"); + } + const terminalId = created.terminal.id; + + const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({ + terminalId: String(inner.terminalId ?? ""), + title: String(inner.title ?? ""), + requestId: String(inner.requestId ?? ""), + })); + + await navigateToTerminal(page, { workspaceId, terminalId }); + + const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); + await expect(tab).toBeVisible({ timeout: 15_000 }); + + await tab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ + timeout: 10_000, + }); + const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + + await input.fill("My Renamed Terminal"); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 }); + + expect(renameFrames.length).toBeGreaterThan(0); + const lastFrame = renameFrames.at(-1)!; + expect(lastFrame.terminalId).toBe(terminalId); + expect(lastFrame.title).toBe("My Renamed Terminal"); + expect(lastFrame.requestId.length).toBeGreaterThan(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 7b1a93aeb..0a0e19207 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -1,6 +1,5 @@ export { AssistantInlineCodePathLink, - AssistantInlinePathLink, AssistantMarkdownCodeLink, AssistantMarkdownLink, } from "./link"; @@ -9,5 +8,9 @@ export { normalizeInlinePathTarget, type InlinePathTarget, } from "./parse"; +export { + AssistantFileLinkResolverProvider, + type AssistantFileLinkResolverProviderProps, +} from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; -export { useAssistantFileLinkResolver } from "./use-resolver"; +export { useAssistantFileLinkActions } from "./use-file-link"; diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 046ba15ea..b4395c353 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useMemo, - useState, - type CSSProperties, - type ReactNode, - type MouseEvent, -} from "react"; +import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; import { Pressable, Text, @@ -16,128 +9,66 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; -import type { OpenFileDisposition } from "@/workspace/file-open"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { classifyAssistantFileLink, type InlinePathTarget } from "./parse"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useAssistantFileLinkResolverContext } from "./provider"; import type { AssistantFileLinkSource } from "./resolver"; - -interface AssistantInlinePathLinkProps { - content: string; - parsed: InlinePathTarget; - onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - workspaceRoot?: string; - style: StyleProp; -} - -export function AssistantInlinePathLink({ - content, - parsed, - onPress, - workspaceRoot, - style, -}: AssistantInlinePathLinkProps) { - const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]); - const handleAnchorClickCapture = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onPress(parsed, "side"); - }, - [onPress, parsed], - ); - - if (!isNative) { - return ( - - - - {content} - - - - ); - } - - return ( - - {content} - - ); -} +import { useFileLink } from "./use-file-link"; interface AssistantMarkdownLinkProps { source: AssistantFileLinkSource; style: StyleProp; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; children: ReactNode; } -export function AssistantMarkdownLink({ - source, - style, - onPress, - onPrefetch, - workspaceRoot, - children, -}: AssistantMarkdownLinkProps) { +export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) { const [hovered, setHovered] = useState(false); - const href = source.href; - const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]); - const handleAnchorClickCapture = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onPress(source, "side"); - }, - [onPress, source], + const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); + const { configRef } = useAssistantFileLinkResolverContext(); + const workspaceRoot = configRef.current.workspaceRoot; + const tooltipPath = useMemo( + () => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null), + [target, workspaceRoot], ); - const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]); - const handleHoverIn = useCallback(() => { + const handleAnchorClickCapture = useStableEvent((event: MouseEvent) => { + event.preventDefault(); + if (!isModifiedOpenEvent(event)) { + return; + } + event.stopPropagation(); + onAuxPress(); + }); + const handleHoverIn = useStableEvent(() => { setHovered(true); - handlePrefetch(); - }, [handlePrefetch]); - const handleHoverOut = useCallback(() => setHovered(false), []); + onHoverIn(); + }); + const handleHoverOut = useStableEvent(() => setHovered(false)); const hoveredTextStyle = useMemo>( () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); - const tooltipFilePath = useMemo( - () => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot), - [source.href, workspaceRoot], - ); + if (isNative) { return ( - - {children} - + + + {children} + + ); } const anchor = ( @@ -146,10 +77,7 @@ export function AssistantMarkdownLink({ ); - if (tooltipFilePath) { - return {anchor}; - } - return anchor; + return {anchor}; } interface AssistantMarkdownCodeLinkProps { @@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps { inheritedStyles: TextStyle; codeInlineStyle: TextStyle; linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; children: ReactNode; } @@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({ inheritedStyles, codeInlineStyle, linkStyle, - onPress, - onPrefetch, - workspaceRoot, children, }: AssistantMarkdownCodeLinkProps) { const style = useMemo( @@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({ [inheritedStyles, codeInlineStyle, linkStyle], ); return ( - + {children} ); } -interface AssistantInlineCodePathLinkProps { - content: string; - inheritedStyles: TextStyle; - codeInlineStyle: TextStyle; - linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; -} - -export function AssistantInlineCodePathLink({ - content, - inheritedStyles, - codeInlineStyle, - linkStyle, - onPress, - onPrefetch, - workspaceRoot, -}: AssistantInlineCodePathLinkProps) { - const source = useMemo( - () => ({ - href: content, - text: content, - sourceType: "inline-code", - }), - [content], - ); - - return ( - - {content} - - ); -} - -function getMarkdownLinkTooltipFilePath( - href: string, - workspaceRoot: string | undefined, -): string | null { - const classification = classifyAssistantFileLink(href, { workspaceRoot }); - if (classification?.kind !== "directFile") { - return null; - } - return formatInlinePathTargetForTooltip(classification.target, workspaceRoot); -} - function formatInlinePathTargetForTooltip( - target: InlinePathTarget, + target: { path: string; lineStart?: number; lineEnd?: number }, workspaceRoot: string | undefined, ): string { let result = relativizePathToWorkspace(target.path, workspaceRoot); @@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und return filePath; } +interface AssistantInlineCodePathLinkProps { + content: string; + inheritedStyles: TextStyle; + codeInlineStyle: TextStyle; + linkStyle: TextStyle; +} + +export function AssistantInlineCodePathLink({ + content, + inheritedStyles, + codeInlineStyle, + linkStyle, +}: AssistantInlineCodePathLinkProps) { + const source = useMemo( + () => ({ + href: content, + text: content, + sourceType: "inline-code", + }), + [content], + ); + + return ( + + {content} + + ); +} + const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { // RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps // the tooltip wrapper from breaking inline link flow. @@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"]; -function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) { +function FileLinkHoverTooltip({ + filePath, + children, +}: { + filePath: string | null; + children: ReactNode; +}) { if (!isWeb) { return children; } @@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr {children} - - - - {filePath} - - - - - click for side pane + {filePath ? ( + + + + {filePath} + + + + click for side pane + + - - + + ) : null} ); } diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx new file mode 100644 index 000000000..77d0b2e03 --- /dev/null +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -0,0 +1,87 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + type MutableRefObject, + type ReactNode, +} from "react"; +import React from "react"; +import type { ToastApi } from "@/components/toast-host"; +import type { OpenFileDisposition } from "@/workspace/file-open"; +import type { InlinePathTarget } from "./parse"; +import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver"; + +export interface AssistantFileLinkDaemonClient { + getDirectorySuggestions: GetDirectorySuggestions; +} + +export interface AssistantFileLinkResolverConfig { + client?: AssistantFileLinkDaemonClient | null; + serverId?: string; + workspaceRoot?: string; + onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; + toast?: ToastApi | null; +} + +export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig { + children: ReactNode; +} + +export interface AssistantFileLinkResolverContextValue { + configRef: MutableRefObject; + getDirectorySuggestions: GetDirectorySuggestions; +} + +const AssistantFileLinkResolverContext = + createContext(null); + +export function AssistantFileLinkResolverProvider({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile, + toast, + children, +}: AssistantFileLinkResolverProviderProps) { + const configRef = useRef({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile, + toast, + }); + configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast }; + + const getDirectorySuggestions = useCallback(async (input) => { + const activeClient = configRef.current.client; + if (!activeClient) { + return { entries: [], error: null }; + } + + const result = await activeClient.getDirectorySuggestions(input); + return { entries: result.entries, error: result.error }; + }, []); + + const value = useMemo( + () => ({ configRef, getDirectorySuggestions }), + [getDirectorySuggestions], + ); + + return ( + + {children} + + ); +} + +export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue { + const context = useContext(AssistantFileLinkResolverContext); + if (!context) { + throw new Error("AssistantFileLinkResolverProvider is required for assistant file links."); + } + return context; +} + +export type { AssistantFileLinkContext }; diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index d085611e0..865e95e09 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { - createAssistantFileLinkResolver, + classifyForResolution, + fetchDaemonResolution, getAssistantFileLinkToken, + UnresolvedFileLinkError, type AssistantFileLinkContext, type DirectorySuggestionEntry, type DirectorySuggestionResult, } from "./resolver"; -import type { OpenFileDisposition } from "@/workspace/file-open"; -import type { InlinePathTarget } from "./parse"; const CONTEXT: AssistantFileLinkContext = { - serverId: "server-1", workspaceRoot: "/Users/test/project", }; @@ -20,554 +19,201 @@ function resolvedSuggestions( return { entries, error: null }; } -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - - return { promise, resolve, reject }; +function suggestionsFromMap(entriesByQuery: Record): { + getDirectorySuggestions: ReturnType; + searches: Array<{ + query: string; + cwd: string; + matchMode: "suffix"; + limit: number; + }>; +} { + const searches: Array<{ + query: string; + cwd: string; + matchMode: "suffix"; + limit: number; + }> = []; + const getDirectorySuggestions = vi.fn( + async (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; + }) => { + searches.push({ + query: input.query, + cwd: input.cwd, + matchMode: input.matchMode, + limit: input.limit, + }); + return resolvedSuggestions(entriesByQuery[input.query] ?? []); + }, + ); + return { getDirectorySuggestions, searches }; } -interface DirectorySearch { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; -} +describe("classifyForResolution", () => { + it("returns the directFile target synchronously", () => { + const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); -interface OpenedFile { - target: InlinePathTarget; - disposition: OpenFileDisposition; -} - -class FakeWorkspaceFiles { - readonly searches: DirectorySearch[] = []; - readonly openedFiles: OpenedFile[] = []; - readonly unresolvedTokens: string[] = []; - - constructor(private readonly entriesByQuery: Record) {} - - createResolver() { - return createAssistantFileLinkResolver({ - getDirectorySuggestions: this.getDirectorySuggestions, - openWorkspaceFile: this.openWorkspaceFile, - openExternalUrl: async () => {}, - onUnresolvedFileCandidate: this.onUnresolvedFileCandidate, + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "src/components/message.tsx#L33", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: undefined, + }, + }, }); - } + }); - private getDirectorySuggestions = async ( - search: DirectorySearch, - ): Promise => { - this.searches.push(search); - return resolvedSuggestions(this.entriesByQuery[search.query] ?? []); - }; + it("preserves line ranges on direct workspace files", () => { + const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT); - private openWorkspaceFile = ( - target: InlinePathTarget, - disposition: OpenFileDisposition, - ): void => { - this.openedFiles.push({ target, disposition }); - }; + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "src/components/message.tsx:33-40", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: 40, + }, + }, + }); + }); - private onUnresolvedFileCandidate = (token: string): void => { - this.unresolvedTokens.push(token); - }; -} - -describe("assistant file link resolver", () => { - it("dedupes in-flight prefetches and serves the click from cache", async () => { - const suggestions = vi.fn(async () => - resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]), + it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => { + const result = classifyForResolution( + { href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" }, + CONTEXT, ); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - await Promise.all([ - resolver.prefetch({ context: CONTEXT, source }), - resolver.prefetch({ context: CONTEXT, source }), - ]); - const result = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(suggestions).toHaveBeenCalledTimes(1); - expect(suggestions).toHaveBeenCalledWith({ - query: "dumm.md", - cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/src/dumm.md", - lineStart: undefined, + expect(result).toEqual({ + kind: "needsLookup", + ambiguousQuery: "file.ts", + token: "file.ts:12", + target: { + raw: "file.ts:12", + path: "/Users/test/project/file.ts", + lineStart: 12, lineEnd: undefined, }, - "main", + }); + }); + + it("keeps explicit external URLs external", () => { + const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { kind: "external", url: "http://dumm.md" }, + }); + }); + + it("keeps auto-linkified normal domains external", () => { + const result = classifyForResolution( + { href: "http://google.com", text: "google.com", markup: "linkify" }, + CONTEXT, ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(result.opened).toBe(true); + + expect(result).toEqual({ + kind: "resolved", + value: { kind: "external", url: "http://google.com" }, + }); }); - it("click consumes an in-flight hover resolution", async () => { - const deferred = createDeferred(); - const suggestions = vi.fn(() => deferred.promise); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }; + it("returns ignored for non-file-looking content", () => { + const result = classifyForResolution({ href: "" }, CONTEXT); - const prefetch = resolver.prefetch({ context: CONTEXT, source }); - const opened = resolver.open({ context: CONTEXT, source, disposition: "main" }); - deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - - await prefetch; - const result = await opened; - - expect(suggestions).toHaveBeenCalledTimes(1); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(result.opened).toBe(true); + expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } }); }); +}); - it("retries a click after hover prefetch fails to query suggestions", async () => { - const suggestions = vi - .fn() - .mockRejectedValueOnce(new Error("daemon unavailable")) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - const prefetchResult = await resolver.prefetch({ context: CONTEXT, source }); - const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(prefetchResult).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - }); - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(openResult.opened).toBe(true); - }); - - it("does not cache unresolved candidates", async () => { - const suggestions = vi - .fn() - .mockResolvedValueOnce(resolvedSuggestions([])) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - onUnresolvedFileCandidate, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - const first = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - const second = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(first).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); - expect(second.opened).toBe(true); - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); - }); - - it("keys cache entries by server, workspace, and token", async () => { - const suggestions = vi - .fn() - .mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }])) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - await resolver.open({ context: CONTEXT, source, disposition: "main" }); - await resolver.open({ - context: { serverId: "server-1", workspaceRoot: "/Users/test/other" }, - source, - disposition: "main", - }); - - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenLastCalledWith( - { - raw: "dumm.md", - path: "/Users/test/other/two/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - }); - - it("does not apply stale async results after the active context changes", async () => { - const deferred = createDeferred(); - let isCurrent = true; - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(() => deferred.promise), - openWorkspaceFile, - openExternalUrl: vi.fn(), - isCurrentContext: () => isCurrent, - }); - - const opened = resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, - disposition: "main", - }); - isCurrent = false; - deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }])); - const result = await opened; - - expect(openWorkspaceFile).not.toHaveBeenCalled(); - expect(result.opened).toBe(false); - expect(result.kind).toBe("file"); - }); - - it("opens direct workspace file links without querying suggestions", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx#L33" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }, - "main", - ); - expect(result.opened).toBe(true); - }); - - it("preserves direct workspace file line ranges", async () => { - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx:33-40" }, - disposition: "main", - }); - - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx:33-40", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: 40, - }, - "main", - ); - }); - - it("opens basename line refs when the daemon returns that exact filename", async () => { - const workspaceFiles = new FakeWorkspaceFiles({ +describe("fetchDaemonResolution", () => { + it("resolves daemon suggestions into workspace file targets", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ "file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }], }); - const resolver = workspaceFiles.createResolver(); - const result = await resolver.open({ - context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, - source: { - href: "file.ts:12", - text: "file.ts:12", - sourceType: "inline-code", + const result = await fetchDaemonResolution({ + ambiguousQuery: "file.ts", + token: "file.ts:12", + target: { + raw: "file.ts:12", + path: "/Users/test/project/file.ts", + lineStart: 12, + lineEnd: undefined, }, - disposition: "main", + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, }); - expect(workspaceFiles.searches).toEqual([ + expect(searches).toEqual([ { query: "file.ts", cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, matchMode: "suffix", limit: 1, }, ]); - expect(workspaceFiles.openedFiles).toEqual([ - { + expect(result).toEqual({ + raw: "file.ts:12", + path: "/Users/test/project/packages/app/src/file.ts", + lineStart: 12, + lineEnd: undefined, + }); + }); + + it("throws a typed unresolved error when the daemon finds no match", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({}); + + await expect( + fetchDaemonResolution({ + ambiguousQuery: "src/file.ts", + token: "src/file.ts", target: { - raw: "file.ts:12", - path: "/Users/test/project/packages/app/src/file.ts", - lineStart: 12, + raw: "src/file.ts", + path: "/Users/test/project/src/file.ts", + lineStart: undefined, lineEnd: undefined, }, - disposition: "main", - }, - ]); - expect(result.opened).toBe(true); - }); - - it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => { - const workspaceFiles = new FakeWorkspaceFiles({}); - const resolver = workspaceFiles.createResolver(); - - const result = await resolver.open({ - context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, - source: { - href: "src/file.ts", - text: "src/file.ts", - sourceType: "inline-code", - }, - disposition: "main", - }); - - expect(workspaceFiles.searches).toEqual([ - { - query: "src/file.ts", - cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }, - ]); - expect(workspaceFiles.openedFiles).toEqual([]); - expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "src/file.ts", - opened: false, - }); - }); - - it("passes side open disposition to workspace file links", async () => { - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx#L33" }, - disposition: "side", - }); - - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }, - "side", - ); - }); - - it("keeps explicit external URLs external", async () => { - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md" }, - disposition: "main", - }); - - expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md"); - expect(result).toEqual({ - kind: "external", - url: "http://dumm.md", - opened: true, - }); - }); - - it("keeps auto-linkified normal domains external", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://google.com", text: "google.com", markup: "linkify" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith("http://google.com"); - expect(result).toEqual({ - kind: "external", - url: "http://google.com", - opened: true, - }); - }); - - it("keeps auto-linkified normal domain paths external", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path"); - expect(result).toEqual({ - kind: "external", - url: "http://openai.com/path", - opened: true, - }); - }); - - it("does not open unresolved linkified markdown filenames in the browser", async () => { - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl, - onUnresolvedFileCandidate, - }); - - const prefetchResult = await resolver.prefetch({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, - }); - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, - disposition: "main", - }); - - expect(openWorkspaceFile).not.toHaveBeenCalled(); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(prefetchResult).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - }); - expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); - expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md"); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); - }); - - it("keeps failed ambiguous resolution out of the browser", async () => { - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => { - throw new Error("daemon unavailable"); + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, }), - openWorkspaceFile: vi.fn(), - openExternalUrl, - onUnresolvedFileCandidate, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, - disposition: "main", - }); - - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md"); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); + ).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts")); }); + it("throws a typed unresolved error when the daemon throws", async () => { + const getDirectorySuggestions = vi.fn(async () => { + throw new Error("daemon unavailable"); + }); + + await expect( + fetchDaemonResolution({ + ambiguousQuery: "dumm.md", + token: "dumm.md", + target: { + raw: "dumm.md", + path: "/Users/test/project/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).rejects.toEqual(new UnresolvedFileLinkError("dumm.md")); + }); +}); + +describe("getAssistantFileLinkToken", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { expect( getAssistantFileLinkToken({ diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index f1310c642..a43fbb46e 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -4,12 +4,6 @@ import { type AssistantFileLinkClassification, type InlinePathTarget, } from "./parse"; -import type { OpenFileDisposition } from "@/workspace/file-open"; - -export interface AssistantFileLinkContext { - serverId?: string; - workspaceRoot?: string; -} export interface AssistantFileLinkSource { href: string; @@ -19,6 +13,10 @@ export interface AssistantFileLinkSource { sourceType?: "inline-code"; } +export interface AssistantFileLinkContext { + workspaceRoot?: string; +} + export interface DirectorySuggestionEntry { path: string; kind: "file" | "directory"; @@ -29,152 +27,121 @@ export interface DirectorySuggestionResult { error: string | null; } -export interface AssistantFileLinkResolverDependencies { - getDirectorySuggestions: (input: { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; - }) => Promise; - openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - openExternalUrl: (url: string) => void | Promise; - onUnresolvedFileCandidate?: (token: string) => void; - isCurrentContext?: (context: AssistantFileLinkContext) => boolean; -} - -export interface AssistantFileLinkResolver { - prefetch(input: AssistantFileLinkPrefetchInput): Promise; - open(input: AssistantFileLinkOpenInput): Promise; -} - -export interface AssistantFileLinkPrefetchInput { - context: AssistantFileLinkContext; - source: AssistantFileLinkSource; -} - -export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput { - disposition: OpenFileDisposition; -} +export type GetDirectorySuggestions = (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; +}) => Promise; export type ResolvedAssistantFileLink = + | { kind: "external"; url: string } + | { kind: "file"; target: InlinePathTarget } + | { kind: "ignored" }; + +export type AssistantFileLinkResolution = + | { kind: "resolved"; value: ResolvedAssistantFileLink } | { - kind: "external"; - url: string; - } - | { - kind: "file"; - target: InlinePathTarget; - } - | { - kind: "unresolvedFileCandidate"; + kind: "needsLookup"; + ambiguousQuery: string; token: string; - } - | { - kind: "ignored"; + target: InlinePathTarget; }; -export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & { - opened: boolean; -}; - -type CachedAssistantFileLink = Exclude; - -interface ParsedAssistantFileLinkInteraction { +export interface FetchDaemonResolutionInput { + ambiguousQuery: string; token: string; - classification: AssistantFileLinkClassification; + target: InlinePathTarget; + workspaceRoot?: string; + getDirectorySuggestions: GetDirectorySuggestions; } -export function createAssistantFileLinkResolver( - dependencies: AssistantFileLinkResolverDependencies, -): AssistantFileLinkResolver { - const cache = new Map(); - const inFlight = new Map>(); +export class UnresolvedFileLinkError extends Error { + constructor(readonly token: string) { + super(`No file found for ${token}`); + this.name = "UnresolvedFileLinkError"; + } +} - async function resolve( - input: AssistantFileLinkPrefetchInput, - ): Promise { - const parsed = parseInteraction(input); - if (!parsed) { - return { kind: "ignored" }; - } +export async function fetchDaemonResolution({ + ambiguousQuery, + token, + target, + workspaceRoot, + getDirectorySuggestions, +}: FetchDaemonResolutionInput): Promise { + const trimmedRoot = workspaceRoot?.trim(); + if (!trimmedRoot) { + throw new UnresolvedFileLinkError(token); + } - if (parsed.classification.kind === "external") { - return { kind: "external", url: parsed.classification.raw }; - } + let suggestions: DirectorySuggestionResult; + try { + suggestions = await getDirectorySuggestions({ + query: ambiguousQuery, + cwd: trimmedRoot, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + limit: 1, + }); + } catch { + throw new UnresolvedFileLinkError(token); + } - if ( - parsed.classification.kind === "directFile" && - !shouldResolveDirectFileThroughSuggestions({ - context: input.context, - source: input.source, - token: parsed.token, - target: parsed.classification.target, - }) - ) { - return { kind: "file", target: parsed.classification.target }; - } - - const key = getResolutionKey(input.context, parsed.token); - const cached = cache.get(key); - if (cached) { - return cached; - } - - const active = inFlight.get(key); - if (active) { - return active; - } - - const request = resolveAmbiguousCandidate({ - context: input.context, - token: parsed.token, - target: parsed.classification.target, - getDirectorySuggestions: dependencies.getDirectorySuggestions, - }) - .then((result) => { - if (result.kind === "file") { - cache.set(key, result); - } - inFlight.delete(key); - return result; - }) - .catch((): CachedAssistantFileLink => { - inFlight.delete(key); - return { kind: "unresolvedFileCandidate", token: parsed.token }; - }); - - inFlight.set(key, request); - return request; + const match = suggestions.entries.find((entry) => entry.kind === "file"); + if (!match || suggestions.error) { + throw new UnresolvedFileLinkError(token); } return { - prefetch(input) { - return resolve(input); - }, - async open(input) { - const resolved = await resolve(input); - if (!canApplyResult(input.context, dependencies.isCurrentContext)) { - return { ...resolved, opened: false }; - } + ...target, + path: joinWorkspacePath(trimmedRoot, match.path), + }; +} - if (resolved.kind === "file") { - dependencies.openWorkspaceFile(resolved.target, input.disposition); - return { ...resolved, opened: true }; - } +export function classifyForResolution( + source: AssistantFileLinkSource, + context: AssistantFileLinkContext, +): AssistantFileLinkResolution { + const token = getAssistantFileLinkToken(source).trim(); + if (!token) { + return { kind: "resolved", value: { kind: "ignored" } }; + } - if (resolved.kind === "external") { - await dependencies.openExternalUrl(resolved.url); - return { ...resolved, opened: true }; - } + const classification = classifyAssistantFileLink(token, { + workspaceRoot: context.workspaceRoot, + }); + if (!classification) { + return { kind: "resolved", value: { kind: "ignored" } }; + } + if (classification.kind === "external") { + return { kind: "resolved", value: { kind: "external", url: classification.raw } }; + } + if ( + classification.kind === "directFile" && + !shouldResolveDirectFileThroughSuggestions({ + context, + source, + token, + target: classification.target, + }) + ) { + return { kind: "resolved", value: { kind: "file", target: classification.target } }; + } - if (resolved.kind === "unresolvedFileCandidate") { - dependencies.onUnresolvedFileCandidate?.(resolved.token); - } + const workspaceRoot = context.workspaceRoot?.trim(); + if (!workspaceRoot) { + return { kind: "resolved", value: { kind: "ignored" } }; + } - return { ...resolved, opened: false }; - }, + return { + kind: "needsLookup", + ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot), + token, + target: classification.target, }; } @@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri return source.href; } -function parseInteraction( - input: AssistantFileLinkPrefetchInput, -): ParsedAssistantFileLinkInteraction | null { - const token = getAssistantFileLinkToken(input.source).trim(); - if (!token) { - return null; - } - - const classification = classifyAssistantFileLink(token, { - workspaceRoot: input.context.workspaceRoot, - }); - if (!classification) { - return null; - } - - return { token, classification }; -} - -async function resolveAmbiguousCandidate(input: { - context: AssistantFileLinkContext; - token: string; - target: InlinePathTarget; - getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"]; -}): Promise { - const workspaceRoot = input.context.workspaceRoot?.trim(); - if (!workspaceRoot) { - return { kind: "unresolvedFileCandidate", token: input.token }; - } - - const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot); - const suggestions = await input.getDirectorySuggestions({ - query, - cwd: workspaceRoot, - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }); - const match = suggestions.entries.find((entry) => entry.kind === "file"); - if (!match || suggestions.error) { - return { kind: "unresolvedFileCandidate", token: input.token }; - } - - return { - kind: "file", - target: { - ...input.target, - path: joinWorkspacePath(workspaceRoot, match.path), - }, - }; -} - -function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string { +export function getAmbiguousSuggestionQuery( + target: InlinePathTarget, + workspaceRoot: string, +): string { const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedPath = target.path.replace(/\\/g, "/"); const prefix = `${normalizedRoot}/`; @@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath; } -function shouldResolveDirectFileThroughSuggestions(input: { +export function shouldResolveDirectFileThroughSuggestions(input: { context: AssistantFileLinkContext; source: AssistantFileLinkSource; token: string; @@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean { ); } -function getResolutionKey(context: AssistantFileLinkContext, token: string): string { - return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0"); -} - function isLinkifiedSource(source: AssistantFileLinkSource): boolean { return source.markup === "linkify" || source.sourceInfo === "auto"; } -function canApplyResult( - context: AssistantFileLinkContext, - isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"], -): boolean { - return isCurrentContext ? isCurrentContext(context) : true; -} - function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); return root ? `${root}/${child}` : child; } + +export type { AssistantFileLinkClassification }; diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx new file mode 100644 index 000000000..e35dff98e --- /dev/null +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -0,0 +1,328 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React, { useCallback, useMemo, useState, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { InlinePathTarget } from "./parse"; +import { AssistantFileLinkResolverProvider } from "./provider"; +import type { DirectorySuggestionResult } from "./resolver"; +import { useFileLink } from "./use-file-link"; +import type { OpenFileDisposition } from "@/workspace/file-open"; + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: vi.fn(async () => {}), +})); + +const SOURCE = { + href: "http://dumm.md", + text: "dumm.md", + markup: "linkify", +}; + +function resolvedSuggestions( + entries: DirectorySuggestionResult["entries"], +): DirectorySuggestionResult { + return { entries, error: null }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +interface OpenedFile { + target: InlinePathTarget; + disposition: OpenFileDisposition; +} + +interface TestClient { + getDirectorySuggestions: (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; + }) => Promise; +} + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function createWrapper(input: { + client: TestClient; + openedFiles: OpenedFile[]; + toast?: { + show: ReturnType; + copied: ReturnType; + error: ReturnType; + }; +}) { + const queryClient = createQueryClient(); + return function Wrapper({ children }: { children: ReactNode }) { + const openWorkspaceFile = useCallback( + (target: InlinePathTarget, disposition: OpenFileDisposition) => { + input.openedFiles.push({ target, disposition }); + }, + [], + ); + + return ( + + + {children} + + + ); + }; +} + +describe("useFileLink", () => { + it("returns the same object across no-op parent rerenders", () => { + const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([])); + const queryClient = createQueryClient(); + const Provider = AssistantFileLinkResolverProvider as React.ComponentType< + Omit, "children"> & { + children?: ReactNode; + } + >; + + function ChurningProviderWrapper({ children }: { children: ReactNode }) { + return React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + Provider, + { + client: { getDirectorySuggestions }, + serverId: "server-1", + workspaceRoot: "/Users/test/project", + onOpenWorkspaceFile: () => {}, + toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() }, + }, + children, + ), + ); + } + + const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), { + wrapper: ChurningProviderWrapper, + }); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + expect(result.current.onHoverIn).toBe(first.onHoverIn); + expect(result.current.onPress).toBe(first.onPress); + expect(result.current.onAuxPress).toBe(first.onAuxPress); + expect(result.current.open).toBe(first.open); + }); + + it("does not cache unresolved lookups forever", async () => { + const getDirectorySuggestions = vi + .fn() + .mockResolvedValueOnce(resolvedSuggestions([])) + .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + const openedFiles: OpenedFile[] = []; + const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() }; + const { result } = renderHook(() => useFileLink(SOURCE), { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + toast, + }), + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", { + variant: "error", + testID: "assistant-file-link-not-found-toast", + }); + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(openedFiles).toEqual([ + { + target: { + raw: "dumm.md", + path: "/Users/test/project/docs/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + disposition: "main", + }, + ]); + }); + expect(getDirectorySuggestions).toHaveBeenCalledTimes(2); + }); + + it("click retries after hover prefetch fails", async () => { + const getDirectorySuggestions = vi + .fn() + .mockRejectedValueOnce(new Error("daemon unavailable")) + .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + const openedFiles: OpenedFile[] = []; + const { result } = renderHook(() => useFileLink(SOURCE), { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + }), + }); + + act(() => { + result.current.onHoverIn(); + }); + await waitFor(() => { + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(openedFiles).toHaveLength(1); + }); + expect(getDirectorySuggestions).toHaveBeenCalledTimes(2); + }); + + it("dedupes two links pointing at the same source", async () => { + const deferred = createDeferred(); + const getDirectorySuggestions = vi.fn(() => deferred.promise); + const openedFiles: OpenedFile[] = []; + const { result } = renderHook( + () => ({ + first: useFileLink(SOURCE), + second: useFileLink(SOURCE), + }), + { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + }), + }, + ); + + act(() => { + result.current.first.onHoverIn(); + result.current.second.onHoverIn(); + }); + await waitFor(() => { + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + await waitFor(() => { + expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md"); + expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md"); + }); + }); + + it("hover then click uses the prefetched result", async () => { + const getDirectorySuggestions = vi.fn(async () => + resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]), + ); + const openedFiles: OpenedFile[] = []; + const { result } = renderHook(() => useFileLink(SOURCE), { + wrapper: createWrapper({ + client: { getDirectorySuggestions }, + openedFiles, + }), + }); + + act(() => { + result.current.onHoverIn(); + }); + await waitFor(() => { + expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md"); + }); + + act(() => { + result.current.onPress(); + }); + await waitFor(() => { + expect(openedFiles).toHaveLength(1); + }); + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + + it("does not open a stale result after the workspace changes", async () => { + const deferred = createDeferred(); + const getDirectorySuggestions = vi.fn(() => deferred.promise); + const openedFiles: OpenedFile[] = []; + const queryClient = createQueryClient(); + + function Wrapper({ children }: { children: ReactNode }) { + const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project"); + const client = useMemo(() => ({ getDirectorySuggestions }), []); + const openWorkspaceFile = useCallback( + (target: InlinePathTarget, disposition: OpenFileDisposition) => { + openedFiles.push({ target, disposition }); + }, + [], + ); + return ( + + + + {children} + + + + ); + } + + const { result } = renderHook( + () => ({ + link: useFileLink(SOURCE), + setWorkspaceRoot: React.useContext(WorkspaceSwitchContext), + }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current.link.onPress(); + }); + act(() => { + result.current.setWorkspaceRoot("/Users/test/other"); + }); + deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); + + await waitFor(() => { + expect(getDirectorySuggestions).toHaveBeenCalledTimes(1); + }); + expect(openedFiles).toEqual([]); + }); +}); + +const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {}); diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts new file mode 100644 index 000000000..9f129011d --- /dev/null +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -0,0 +1,347 @@ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import type { OpenFileDisposition } from "@/workspace/file-open"; +import { openExternalUrl } from "@/utils/open-external-url"; +import type { InlinePathTarget } from "./parse"; +import { + useAssistantFileLinkResolverContext, + type AssistantFileLinkResolverContextValue, +} from "./provider"; +import { + classifyForResolution, + fetchDaemonResolution, + UnresolvedFileLinkError, + type AssistantFileLinkResolution, + type AssistantFileLinkSource, +} from "./resolver"; + +export interface UseFileLinkResult { + target: InlinePathTarget | null; + onHoverIn: () => void; + onPress: () => void; + onAuxPress: () => void; + open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; +} + +export interface AssistantFileLinkActions { + open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void; + canOpen(source: AssistantFileLinkSource): boolean; + canResolveFile(source: AssistantFileLinkSource): boolean; +} + +type AssistantFileLinkQueryKey = readonly [ + "assistantFileLink", + string | null, + string | null, + string, +]; + +const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const; + +export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult { + const context = useAssistantFileLinkResolverContext(); + const queryClient = useQueryClient(); + const stableSource = useStableSource(source); + const activeConfig = context.configRef.current; + const workspaceRoot = activeConfig.workspaceRoot; + const serverId = activeConfig.serverId; + const resolution = useMemo( + () => + classifyForResolution(stableSource, { + workspaceRoot, + }), + [stableSource, workspaceRoot], + ); + const queryKey = useMemo( + () => + resolution.kind === "needsLookup" + ? assistantFileLinkQueryKey({ + serverId, + workspaceRoot, + ambiguousQuery: resolution.ambiguousQuery, + }) + : DISABLED_QUERY_KEY, + [resolution, serverId, workspaceRoot], + ); + + const query = useQuery({ + queryKey, + queryFn: () => { + if (resolution.kind !== "needsLookup") { + throw new Error("Assistant file link lookup requested for a sync link."); + } + return fetchDaemonResolution({ + ambiguousQuery: resolution.ambiguousQuery, + token: resolution.token, + target: resolution.target, + workspaceRoot, + getDirectorySuggestions: context.getDirectorySuggestions, + }); + }, + enabled: false, + retry: 0, + staleTime: Infinity, + }); + + const open = useStableEvent( + (nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => { + openAssistantFileLink({ + source: nextSource, + disposition, + context, + queryClient, + }); + }, + ); + + const onHoverIn = useStableEvent(() => { + if (resolution.kind !== "needsLookup") { + return; + } + + void queryClient.prefetchQuery({ + queryKey, + queryFn: () => + fetchDaemonResolution({ + ambiguousQuery: resolution.ambiguousQuery, + token: resolution.token, + target: resolution.target, + workspaceRoot, + getDirectorySuggestions: context.getDirectorySuggestions, + }), + retry: 0, + staleTime: Infinity, + }); + }); + + const onPress = useStableEvent(() => { + open(stableSource, "main"); + }); + const onAuxPress = useStableEvent(() => { + open(stableSource, "side"); + }); + + const target = useMemo(() => { + if (resolution.kind === "resolved") { + return resolution.value.kind === "file" ? resolution.value.target : null; + } + return query.data ?? null; + }, [query.data, resolution]); + + return useMemo( + () => ({ target, onHoverIn, onPress, onAuxPress, open }), + [target, onHoverIn, onPress, onAuxPress, open], + ); +} + +export function useAssistantFileLinkActions(): AssistantFileLinkActions { + const context = useAssistantFileLinkResolverContext(); + const actionLink = useFileLink(ACTION_LINK_SOURCE); + + const open = useStableEvent( + (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => { + actionLink.open(source, disposition); + }, + ); + const canOpen = useCallback( + (source: AssistantFileLinkSource) => + canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot), + [context.configRef], + ); + const canResolveFile = useCallback( + (source: AssistantFileLinkSource) => + canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot), + [context.configRef], + ); + + return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]); +} + +function openAssistantFileLink(input: { + source: AssistantFileLinkSource; + disposition: OpenFileDisposition; + context: AssistantFileLinkResolverContextValue; + queryClient: ReturnType; +}): void { + const capturedConfig = input.context.configRef.current; + const capturedResolution = classifyForResolution(input.source, { + workspaceRoot: capturedConfig.workspaceRoot, + }); + + if (capturedResolution.kind === "resolved") { + void dispatchResolvedLink({ + resolution: capturedResolution, + disposition: input.disposition, + capturedServerId: capturedConfig.serverId, + capturedWorkspaceRoot: capturedConfig.workspaceRoot, + context: input.context, + }); + return; + } + + const capturedQueryKey = assistantFileLinkQueryKey({ + serverId: capturedConfig.serverId, + workspaceRoot: capturedConfig.workspaceRoot, + ambiguousQuery: capturedResolution.ambiguousQuery, + }); + + const run = async () => { + try { + const target = await input.queryClient.fetchQuery({ + queryKey: capturedQueryKey, + queryFn: () => + fetchDaemonResolution({ + ambiguousQuery: capturedResolution.ambiguousQuery, + token: capturedResolution.token, + target: capturedResolution.target, + workspaceRoot: capturedConfig.workspaceRoot, + getDirectorySuggestions: input.context.getDirectorySuggestions, + }), + retry: 0, + staleTime: Infinity, + }); + await dispatchFileTarget({ + target, + disposition: input.disposition, + capturedServerId: capturedConfig.serverId, + capturedWorkspaceRoot: capturedConfig.workspaceRoot, + context: input.context, + }); + } catch (error) { + await dispatchUnresolvedError({ + error, + fallbackToken: capturedResolution.token, + capturedServerId: capturedConfig.serverId, + capturedWorkspaceRoot: capturedConfig.workspaceRoot, + context: input.context, + }); + } + }; + + void run(); +} + +function canOpenAssistantFileLink( + source: AssistantFileLinkSource, + workspaceRoot: string | undefined, +): boolean { + const resolution = classifyForResolution(source, { workspaceRoot }); + return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored"; +} + +function canResolveAssistantFileLinkToFile( + source: AssistantFileLinkSource, + workspaceRoot: string | undefined, +): boolean { + const resolution = classifyForResolution(source, { workspaceRoot }); + return resolution.kind === "needsLookup" || resolution.value.kind === "file"; +} + +function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource { + const { href, text, markup, sourceInfo, sourceType } = source; + return useMemo( + () => ({ href, text, markup, sourceInfo, sourceType }), + [href, text, markup, sourceInfo, sourceType], + ); +} + +function assistantFileLinkQueryKey(input: { + serverId?: string; + workspaceRoot?: string; + ambiguousQuery: string; +}): AssistantFileLinkQueryKey { + return [ + "assistantFileLink", + input.serverId ?? null, + input.workspaceRoot ?? null, + input.ambiguousQuery, + ]; +} + +async function dispatchResolvedLink(input: { + resolution: Extract; + disposition: OpenFileDisposition; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const { value } = input.resolution; + if (value.kind === "file") { + await dispatchFileTarget({ + target: value.target, + disposition: input.disposition, + capturedServerId: input.capturedServerId, + capturedWorkspaceRoot: input.capturedWorkspaceRoot, + context: input.context, + }); + return; + } + if (value.kind === "external") { + await dispatchExternalUrl({ + url: value.url, + capturedServerId: input.capturedServerId, + capturedWorkspaceRoot: input.capturedWorkspaceRoot, + context: input.context, + }); + } +} + +async function dispatchFileTarget(input: { + target: InlinePathTarget; + disposition: OpenFileDisposition; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const current = input.context.configRef.current; + if ( + current.serverId !== input.capturedServerId || + current.workspaceRoot !== input.capturedWorkspaceRoot + ) { + return; + } + current.onOpenWorkspaceFile?.(input.target, input.disposition); +} + +async function dispatchExternalUrl(input: { + url: string; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const current = input.context.configRef.current; + if ( + current.serverId !== input.capturedServerId || + current.workspaceRoot !== input.capturedWorkspaceRoot + ) { + return; + } + await openExternalUrl(input.url); +} + +async function dispatchUnresolvedError(input: { + error: unknown; + fallbackToken: string; + capturedServerId?: string; + capturedWorkspaceRoot?: string; + context: AssistantFileLinkResolverContextValue; +}) { + const current = input.context.configRef.current; + if ( + current.serverId !== input.capturedServerId || + current.workspaceRoot !== input.capturedWorkspaceRoot + ) { + return; + } + const token = + input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken; + current.toast?.show(`No file found for ${token}`, { + variant: "error", + testID: "assistant-file-link-not-found-toast", + }); +} + +const ACTION_LINK_SOURCE: AssistantFileLinkSource = { + href: "", +}; diff --git a/packages/app/src/assistant-file-links/use-resolver.ts b/packages/app/src/assistant-file-links/use-resolver.ts deleted file mode 100644 index c3facded4..000000000 --- a/packages/app/src/assistant-file-links/use-resolver.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useMemo, useRef } from "react"; -import type { DaemonClient } from "@server/client/daemon-client"; -import type { ToastApi } from "@/components/toast-host"; -import { - createAssistantFileLinkResolver, - type AssistantFileLinkContext, - type AssistantFileLinkOpenInput, - type AssistantFileLinkPrefetchInput, -} from "./resolver"; -import type { InlinePathTarget } from "./parse"; -import type { OpenFileDisposition } from "@/workspace/file-open"; -import { openExternalUrl } from "@/utils/open-external-url"; - -export interface UseAssistantFileLinkResolverOptions { - client?: DaemonClient | null; - serverId?: string; - workspaceRoot?: string; - onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - toast?: ToastApi | null; -} - -export interface AssistantFileLinkActions { - prefetch(input: Omit): void; - open(input: Omit): void; -} - -export function useAssistantFileLinkResolver({ - client, - serverId, - workspaceRoot, - onOpenWorkspaceFile, - toast, -}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions { - const context: AssistantFileLinkContext = useMemo( - () => ({ - serverId, - workspaceRoot, - }), - [serverId, workspaceRoot], - ); - const latestContextRef = useRef(context); - latestContextRef.current = context; - - const resolver = useMemo( - () => - createAssistantFileLinkResolver({ - async getDirectorySuggestions(input) { - if (!client) { - return { entries: [], error: null }; - } - - const result = await client.getDirectorySuggestions(input); - return { - entries: result.entries, - error: result.error, - }; - }, - openWorkspaceFile(target, disposition) { - onOpenWorkspaceFile?.(target, disposition); - }, - openExternalUrl, - onUnresolvedFileCandidate(token) { - toast?.show(`No file found for ${token}`, { - variant: "error", - testID: "assistant-file-link-not-found-toast", - }); - }, - isCurrentContext(candidate) { - const current = latestContextRef.current; - return ( - current.serverId === candidate.serverId && - current.workspaceRoot === candidate.workspaceRoot - ); - }, - }), - [client, onOpenWorkspaceFile, toast], - ); - - return useMemo( - () => ({ - prefetch(input) { - void resolver.prefetch({ ...input, context }); - }, - open(input) { - void resolver.open({ ...input, context }); - }, - }), - [context, resolver], - ); -} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d1ae7f5e7..054d13f24 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -202,6 +202,12 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], }, + adaptiveInputOutline: { + outlineColor: theme.colors.accent, + }, + adaptiveInputPlaceholder: { + color: theme.colors.foregroundMuted, + }, })); const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; @@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & { export const AdaptiveTextInput = forwardRef( function AdaptiveTextInputInner(props, ref) { const isMobile = useIsCompactFormFactor(); - const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props; + const { + value: _value, + initialValue, + resetKey, + defaultValue, + style, + placeholderTextColor, + ...inputProps + } = props; + // Recolor the browser's :focus-visible outline (defined in public/index.html) + // so it matches the active theme's accent instead of its hard-coded fallback. + // Consumer style wins if it sets outlineColor explicitly. const textInputProps = { ...inputProps, defaultValue: initialValue ?? defaultValue, + placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color, + style: [styles.adaptiveInputOutline, style], }; if (isMobile && isNative) { diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2826f8a8c..8db233236 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -74,7 +74,10 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; -import { normalizeInlinePathTarget } from "@/assistant-file-links"; +import { + AssistantFileLinkResolverProvider, + normalizeInlinePathTarget, +} from "@/assistant-file-links"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -83,6 +86,7 @@ import { } from "@/workspace/file-open"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef { if (!target.path) { return; @@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef { - handleInlinePathPress({ raw: filePath, path: filePath }, "main"); - }, - [handleInlinePathPress], - ); + const handleToolCallOpenFile = useStableEvent((filePath: string) => { + handleInlinePathPress({ raw: filePath, path: filePath }, "main"); + }); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ @@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef + > + + ); }, - [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast], + [client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 18015ad60..5cf4f99a0 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import type { OpenFileDisposition } from "@/workspace/file-open"; import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; -import type { ToastApi } from "@/components/toast-host"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; @@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { AssistantInlineCodePathLink, - classifyAssistantFileLink, type AssistantFileLinkSource, AssistantMarkdownCodeLink, AssistantMarkdownLink, type InlinePathTarget, - useAssistantFileLinkResolver, + useAssistantFileLinkActions, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({ interface AssistantMessageProps { message: string; timestamp: number; - onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; workspaceRoot?: string; serverId?: string; client?: DaemonClient | null; - toast?: ToastApi | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; } @@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie export const AssistantMessage = memo(function AssistantMessage({ message, timestamp: _timestamp, - onInlinePathPress, workspaceRoot, serverId, client, - toast, spacing = "default", }: AssistantMessageProps) { const markdownParser = useMemo(() => { @@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({ return parser; }, []); - const fileLinkResolver = useAssistantFileLinkResolver({ - client, - serverId, - workspaceRoot, - onOpenWorkspaceFile: onInlinePathPress, - toast, + const fileLinkActions = useAssistantFileLinkActions(); + const handleMarkdownLinkPress = useStableEvent((url: string) => { + fileLinkActions.open({ href: url }, "main"); + // react-native-markdown-display opens the link itself when this returns true. + // We already handled it above, so return false to avoid duplicate opens. + return false; }); - const handleLinkPress = useCallback( - (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => { - fileLinkResolver.open({ source, disposition }); - }, - [fileLinkResolver], - ); - const handleLinkPrefetch = useCallback( - (source: AssistantFileLinkSource) => { - fileLinkResolver.prefetch({ source }); - }, - [fileLinkResolver], - ); - const handleMarkdownLinkPress = useCallback( - (url: string) => { - fileLinkResolver.open({ source: { href: url }, disposition: "main" }); - // react-native-markdown-display opens the link itself when this returns true. - // We already handled it above, so return false to avoid duplicate opens. - return false; - }, - [fileLinkResolver], - ); - const markdownRules = useMemo(() => { return { text: ( @@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({ ) => { const content = node.content ?? ""; const isLinkedInlineCode = nodeHasParentType(parent, "link"); - const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot }); + const inlineCodeSource: AssistantFileLinkSource = { + href: content, + text: content, + sourceType: "inline-code", + }; const shouldResolveInlinePath = - onInlinePathPress && - !isLinkedInlineCode && - inlineCodeFileLink && - inlineCodeFileLink.kind !== "external"; + !isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource); if (shouldResolveInlinePath) { return ( @@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({ inheritedStyles={inheritedStyles} codeInlineStyle={styles.code_inline} linkStyle={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} /> ); } @@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({ inheritedStyles={inheritedStyles} codeInlineStyle={styles.code_inline} linkStyle={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} > {content} @@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({ key={node.key} source={getMarkdownLinkSource(node)} style={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} > {Children.map(children, (child) => { if (!isValidElement(child)) return child; @@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, }; - }, [ - client, - handleLinkPrefetch, - handleLinkPress, - markdownParser, - onInlinePathPress, - serverId, - workspaceRoot, - ]); + }, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 6c8a689c5..1a85cf97f 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,4 +1,4 @@ -import { Bot } from "lucide-react-native"; +import { Bot, PackagePlus } from "lucide-react-native"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; @@ -9,6 +9,7 @@ const PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot, copilot: CopilotIcon as unknown as typeof Bot, + kiro: PackagePlus, opencode: OpenCodeIcon as unknown as typeof Bot, pi: PiIcon as unknown as typeof Bot, }; diff --git a/packages/app/src/components/rename-modal.test.tsx b/packages/app/src/components/rename-modal.test.tsx new file mode 100644 index 000000000..f8d8ffbbe --- /dev/null +++ b/packages/app/src/components/rename-modal.test.tsx @@ -0,0 +1,368 @@ +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AdaptiveRenameModal } from "./rename-modal"; + +const { theme, adaptiveInputState } = vi.hoisted(() => ({ + adaptiveInputState: { + latestProps: null as { + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + } | null, + }, + theme: { + spacing: { 2: 8, 3: 12 }, + fontSize: { sm: 13, base: 15 }, + borderRadius: { md: 6 }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + palette: { red: { 300: "#f87171" } }, + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("@/components/adaptive-modal-sheet", async () => { + const ReactModule = await import("react"); + const AdaptiveModalSheet = ({ + visible, + title, + children, + onClose, + testID, + }: { + visible: boolean; + title: string; + children: React.ReactNode; + onClose: () => void; + testID?: string; + }) => { + if (!visible) return null; + return ReactModule.createElement( + "div", + { "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title }, + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": "adaptive-modal-sheet-close", + onClick: onClose, + }, + "Close", + ), + children, + ); + }; + // Mirrors production AdaptiveTextInput: native-owned input seeded by + // initialValue, remounted (via key) when resetKey changes so the new + // initialValue takes effect. + const AdaptiveTextInput = ReactModule.forwardRef>( + (props, ref) => { + const p = props as { + initialValue?: string; + defaultValue?: string; + editable?: boolean; + maxLength?: number; + testID?: string; + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + }; + adaptiveInputState.latestProps = { + onChangeText: p.onChangeText, + onSubmitEditing: p.onSubmitEditing, + }; + return ReactModule.createElement("input", { + ref, + defaultValue: p.initialValue ?? p.defaultValue ?? "", + disabled: p.editable === false, + maxLength: p.maxLength, + "data-testid": p.testID, + onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value), + onKeyDown: (e: { key: string; preventDefault: () => void }) => { + if (e.key === "Enter") { + e.preventDefault(); + p.onSubmitEditing?.(); + } + }, + }); + }, + ); + return { AdaptiveModalSheet, AdaptiveTextInput }; +}); + +vi.mock("@/components/ui/button", async () => { + const ReactModule = await import("react"); + return { + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": testID, + disabled: disabled || undefined, + onClick: () => !disabled && onPress?.(), + }, + children, + ), + }; +}); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement); + vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + adaptiveInputState.latestProps = null; +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +interface RenderOptions { + visible?: boolean; + initialValue?: string; + title?: string; + placeholder?: string; + submitLabel?: string; + onClose?: () => void; + onSubmit?: (value: string) => Promise | void; + validate?: (value: string) => string | null; + maxLength?: number; +} + +function renderModal(options: RenderOptions = {}): void { + const { + visible = true, + initialValue = "", + title = "Rename", + placeholder, + submitLabel, + onClose = vi.fn(), + onSubmit = vi.fn(), + validate, + maxLength, + } = options; + act(() => { + root?.render( + , + ); + }); +} + +function queryInput(): HTMLInputElement | null { + return document.querySelector('[data-testid="rename-modal-input"]'); +} + +function querySubmit(): HTMLButtonElement | null { + return document.querySelector('[data-testid="rename-modal-submit"]'); +} + +function queryCancel(): HTMLButtonElement | null { + return document.querySelector('[data-testid="rename-modal-cancel"]'); +} + +function queryError(): HTMLElement | null { + return document.querySelector('[data-testid="rename-modal-error"]'); +} + +function click(element: Element | null): void { + if (!element) throw new Error("Cannot click null element"); + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function typeInto(value: string): void { + act(() => { + adaptiveInputState.latestProps?.onChangeText?.(value); + }); +} + +function pressEnter(): void { + act(() => { + adaptiveInputState.latestProps?.onSubmitEditing?.(); + }); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("RenameModal", () => { + it("renders with the initial value pre-filled and selects it after open", async () => { + vi.useFakeTimers(); + renderModal({ initialValue: "main" }); + const input = queryInput(); + expect(input).not.toBeNull(); + expect(input?.value).toBe("main"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + const focused = document.activeElement as HTMLInputElement | null; + expect(focused).toBe(input); + expect(focused?.selectionStart).toBe(0); + expect(focused?.selectionEnd).toBe("main".length); + }); + + it("submits on Enter keypress in the input when the value has changed", async () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + renderModal({ initialValue: "feature", onSubmit, onClose }); + + typeInto("feature-2"); + pressEnter(); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("feature-2"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + renderModal({ initialValue: "main", onClose, onSubmit }); + + click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("disables submit when draft equals initialValue and re-enables after a change", async () => { + const onSubmit = vi.fn(); + renderModal({ initialValue: "main", onSubmit }); + + expect(querySubmit()?.disabled).toBe(true); + + pressEnter(); + await flush(); + expect(onSubmit).not.toHaveBeenCalled(); + + typeInto("main-v2"); + await flush(); + expect(querySubmit()?.disabled).toBe(false); + + typeInto("main"); + await flush(); + expect(querySubmit()?.disabled).toBe(true); + }); + + it("surfaces validate errors inline and blocks submission", async () => { + const onSubmit = vi.fn(); + const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null)); + renderModal({ initialValue: "ok", validate, onSubmit }); + + typeInto("bad"); + const submit = querySubmit()!; + expect(submit.disabled).toBe(true); + + pressEnter(); + await flush(); + + expect(onSubmit).not.toHaveBeenCalled(); + const errorNode = queryError(); + expect(errorNode?.textContent).toContain("Invalid name"); + }); + + it("disables the submit button while onSubmit is pending", async () => { + let resolve: () => void = () => {}; + const onSubmit = vi.fn( + () => + new Promise((r) => { + resolve = r; + }), + ); + renderModal({ initialValue: "main", onSubmit }); + + typeInto("main-renamed"); + click(querySubmit()); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(querySubmit()?.disabled).toBe(true); + expect(queryCancel()?.disabled).toBe(true); + + await act(async () => { + resolve(); + await Promise.resolve(); + }); + }); + + it("keeps the modal open with an error when onSubmit rejects", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no")); + const onClose = vi.fn(); + renderModal({ initialValue: "main", onSubmit, onClose }); + + typeInto("main-renamed"); + click(querySubmit()); + await flush(); + + expect(onClose).not.toHaveBeenCalled(); + expect(queryError()?.textContent).toContain("Server said no"); + expect(querySubmit()?.disabled).toBe(false); + }); +}); diff --git a/packages/app/src/components/rename-modal.tsx b/packages/app/src/components/rename-modal.tsx new file mode 100644 index 000000000..c0cfa7de1 --- /dev/null +++ b/packages/app/src/components/rename-modal.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Text, TextInput, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { isWeb } from "@/constants/platform"; + +export interface AdaptiveRenameModalProps { + visible: boolean; + title: string; + initialValue: string; + placeholder?: string; + submitLabel?: string; + onClose: () => void; + onSubmit: (value: string) => Promise | void; + validate?: (value: string) => string | null; + maxLength?: number; + testID?: string; +} + +export function AdaptiveRenameModal({ + visible, + title, + initialValue, + placeholder, + submitLabel = "Rename", + onClose, + onSubmit, + validate, + maxLength, + testID, +}: AdaptiveRenameModalProps) { + const [draft, setDraft] = useState(initialValue); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (!visible) return; + setDraft(initialValue); + setError(null); + setIsPending(false); + }, [visible, initialValue]); + + useEffect(() => { + if (!visible) return; + const length = initialValue.length; + const timeout = setTimeout(() => { + const node = inputRef.current; + if (!node) return; + node.focus(); + if (isWeb && node instanceof HTMLInputElement) { + node.setSelectionRange(0, length); + } else if (!isWeb && length > 0) { + node.setNativeProps({ selection: { start: 0, end: length } }); + } + }, 50); + return () => clearTimeout(timeout); + }, [visible, initialValue]); + + const computeError = useCallback( + (value: string): string | null => { + if (!value.trim()) return "Name is required"; + return validate ? validate(value) : null; + }, + [validate], + ); + + const handleChange = useCallback((value: string) => { + setDraft(value); + setError(null); + }, []); + + const handleSubmit = useCallback(async () => { + if (isPending) return; + const value = draft; + if (value === initialValue) return; + const validationError = computeError(value); + if (validationError) { + setError(validationError); + return; + } + try { + setIsPending(true); + await onSubmit(value); + setIsPending(false); + onClose(); + } catch (err) { + setIsPending(false); + const message = err instanceof Error && err.message ? err.message : "Unable to save"; + setError(message); + } + }, [isPending, draft, initialValue, computeError, onSubmit, onClose]); + + const handleCancel = useCallback(() => { + if (isPending) return; + onClose(); + }, [isPending, onClose]); + + const handleSubmitVoid = useCallback(() => { + void handleSubmit(); + }, [handleSubmit]); + + const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null; + const inputTestID = testID ? `${testID}-input` : undefined; + const errorTestID = testID ? `${testID}-error` : undefined; + const submitTestID = testID ? `${testID}-submit` : undefined; + const cancelTestID = testID ? `${testID}-cancel` : undefined; + const sheetHeader = useMemo(() => ({ title }), [title]); + + return ( + + + + {error ? ( + + {error} + + ) : null} + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + gap: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + input: { + backgroundColor: theme.colors.surface0, + color: theme.colors.foreground, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + errorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + actionButton: { + flex: 1, + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index b386e9a4d..72e3330a6 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -12,7 +12,10 @@ import { type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useQueries } from "@tanstack/react-query"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { useCallback, useMemo, @@ -45,6 +48,7 @@ import { SquareTerminal, Monitor, MoreVertical, + Pencil, Plus, Trash2, } from "lucide-react-native"; @@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedPencil = withUnistyles(Pencil); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps { onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + onRename?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -565,6 +571,7 @@ const trash2LeadingIcon = ; const copyLeadingIcon = ; const archiveLeadingIcon = ; +const renameLeadingIcon = ; function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( @@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({ onArchive, onCopyBranchName, onCopyPath, + onRename, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; @@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({ onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + onRename?: () => void; }) { const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); return ( @@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({ workspaceKey={workspace.workspaceKey} onCopyPath={onCopyPath} onCopyBranchName={onCopyBranchName} + onRename={onRename} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -701,6 +711,7 @@ function WorkspaceKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, + onRename, onArchive, archiveLabel, archiveStatus, @@ -710,6 +721,7 @@ function WorkspaceKebabMenu({ workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; + onRename?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -750,6 +762,15 @@ function WorkspaceKebabMenu({ Copy branch name ) : null} + {onRename ? ( + + Rename workspace + + ) : null} {prHint ? ( @@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({ const toast = useToast(); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceExecutionDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); @@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({ toast.copied("Branch name copied"); }, [toast, workspace.name]); + const renameMutation = useMutation({ + mutationFn: async (branch: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error("Host is not connected"); + } + const targetCwd = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + const payload = await client.renameBranch({ cwd: targetCwd, branch }); + if (!payload.success || payload.error) { + throw new Error(payload.error?.message ?? "Failed to rename branch"); + } + return { targetCwd }; + }, + onSuccess: async ({ targetCwd }) => { + await invalidateCheckoutGitQueriesForClient(queryClient, { + serverId: workspace.serverId, + cwd: targetCwd, + }); + }, + }); + + const handleOpenRename = useCallback(() => { + setIsRenameOpen(true); + }, []); + + const handleCloseRename = useCallback(() => { + setIsRenameOpen(false); + }, []); + + const handleSubmitRename = useCallback( + async (value: string) => { + await renameMutation.mutateAsync(slugify(value)); + }, + [renameMutation], + ); + + const validateRenameSlug = useCallback((value: string): string | null => { + const result = validateBranchSlug(slugify(value)); + if (result.valid) return null; + return result.error ?? "Invalid branch name"; + }, []); + const archiveShortcutKeys = useShortcutKeys("archive-worktree"); useKeyboardActionHandler({ @@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({ }); return ( - + <> + + + ); } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index ba6111bf8..9657c09e6 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -86,6 +86,7 @@ interface SplitContainerProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; @@ -362,6 +363,7 @@ export function SplitContainer({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -577,6 +579,7 @@ export function SplitContainer({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -716,6 +719,7 @@ function SplitNodeView({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -768,6 +772,7 @@ function SplitNodeView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -813,6 +818,7 @@ function SplitNodeView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -864,6 +870,7 @@ function SplitPaneView({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -1004,6 +1011,7 @@ function SplitPaneView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 74c819598..85895109c 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -240,6 +240,15 @@ const CATALOG_DATA = [ installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli", command: ["kilo", "acp"], }, + { + id: "kiro", + title: "Kiro CLI", + description: "Amazon's AI coding agent with native ACP support", + version: "manual", + iconId: null, + installLink: "https://kiro.dev/docs/cli/acp/", + command: ["kiro-cli", "acp"], + }, { id: "kimi", title: "Kimi CLI", diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index d96fcc5bf..13ff91a1a 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -26,7 +26,7 @@ describe("ACP provider catalog", () => { }); it("bundles SVG icons for catalog entries that declare an icon", () => { - const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.id !== "hermes"); + const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg !== null); expect(entriesWithIcons.length).toBeGreaterThan(0); for (const entry of entriesWithIcons) { @@ -39,6 +39,7 @@ describe("ACP provider catalog", () => { expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); + expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); }); diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 1469c769c..f7894079d 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,30 +1,31 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Pressable, Text, TextInput, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; -import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { getHostRuntimeStore, isHostRuntimeConnected, + useHostMutations, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, - useHostMutations, useHosts, } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; -import { confirmDialog } from "@/utils/confirm-dialog"; -import { settingsStyles } from "@/styles/settings"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { SettingsSection } from "@/screens/settings/settings-section"; import { ProvidersSection } from "@/screens/settings/providers-section"; -import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; -import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { useSessionStore } from "@/stores/session-store"; +import { settingsStyles } from "@/styles/settings"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; const RESTART_CONFIRMATION_MESSAGE = "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically."; @@ -68,7 +69,6 @@ function formatDaemonVersionBadge(version: string | null): string | null { return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; } -const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" }; const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" }; @@ -178,64 +178,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const { renameHost } = useHostMutations(); const [isEditing, setIsEditing] = useState(false); - const [draftLabel, setDraftLabel] = useState(host.label ?? ""); - const [isSaving, setIsSaving] = useState(false); - const inputRef = useRef(null); - useEffect(() => { - setDraftLabel(host.label ?? ""); - }, [host.serverId, host.label]); - - useEffect(() => { - if (isEditing) { - const timeout = setTimeout(() => inputRef.current?.focus(), 50); - return () => clearTimeout(timeout); - } - return undefined; - }, [isEditing]); - - const handleSave = useCallback(async () => { - const nextLabel = draftLabel.trim(); - if (!nextLabel) { - Alert.alert("Label required", "Enter a label for this host."); - return; - } - if (isSaving) return; - if (nextLabel === host.label.trim()) { - setIsEditing(false); - return; - } - try { - setIsSaving(true); + const handleSubmit = useCallback( + async (value: string) => { + const nextLabel = value.trim(); + if (nextLabel === host.label.trim()) return; await renameHost(host.serverId, nextLabel); - setIsEditing(false); - } catch (error) { - console.error("[HostPage] Failed to rename host", error); - Alert.alert("Error", "Unable to save host"); - } finally { - setIsSaving(false); - } - }, [draftLabel, host.label, host.serverId, isSaving, renameHost]); + }, + [host.label, host.serverId, renameHost], + ); - const handleCancel = useCallback(() => { - if (isSaving) return; - setDraftLabel(host.label ?? ""); - setIsEditing(false); - }, [host.label, isSaving]); - - const handleStartEdit = useCallback(() => { - setDraftLabel(host.label ?? ""); - setIsEditing(true); - }, [host.label]); - - const handleSavePress = useCallback(() => { - void handleSave(); - }, [handleSave]); + const openEditor = useCallback(() => setIsEditing(true), []); + const closeEditor = useCallback(() => setIsEditing(false), []); return ( <> - - - - - - - - - + /> ); } @@ -836,25 +763,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - renameBody: { - gap: theme.spacing[3], - paddingBottom: theme.spacing[2], - }, - renameInput: { - backgroundColor: theme.colors.surface0, - color: theme.colors.foreground, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - }, - renameActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, })); const FLEX_1_STYLE = { flex: 1 }; diff --git a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx new file mode 100644 index 000000000..958329cf7 --- /dev/null +++ b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx @@ -0,0 +1,125 @@ +import { useCallback, useState } from "react"; +import { type QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { ListTerminalsResponse } from "@server/shared/messages"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +interface RenamingTabState { + kind: "terminal" | "agent"; + id: string; + currentTitle: string; +} + +interface UseWorkspaceTabRenameInput { + client: DaemonClient | null; + normalizedServerId: string; + queryClient: QueryClient; + terminalsData: ListTerminalsResponse["payload"] | undefined; + terminalsQueryKey: readonly unknown[]; +} + +interface UseWorkspaceTabRenameResult { + renamingTab: RenamingTabState | null; + handleRenameTab: (tab: WorkspaceTabDescriptor) => void; + handleRenameModalSubmit: (nextTitle: string) => Promise; + handleRenameModalClose: () => void; +} + +export function useWorkspaceTabRename( + input: UseWorkspaceTabRenameInput, +): UseWorkspaceTabRenameResult { + const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input; + const [renamingTab, setRenamingTab] = useState(null); + + const handleRenameTab = useCallback( + (tab: WorkspaceTabDescriptor) => { + if (tab.target.kind === "terminal") { + const { terminalId } = tab.target; + const terminal = terminalsData?.terminals.find((entry) => entry.id === terminalId) ?? null; + const currentTitle = terminal?.title ?? terminal?.name ?? ""; + setRenamingTab({ kind: "terminal", id: terminalId, currentTitle }); + return; + } + if (tab.target.kind === "agent") { + const { agentId } = tab.target; + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const currentTitle = agent?.title ?? ""; + setRenamingTab({ kind: "agent", id: agentId, currentTitle }); + } + }, + [normalizedServerId, terminalsData], + ); + + const handleRenameModalSubmit = useCallback( + async (nextTitle: string) => { + if (!renamingTab) return; + if (!client) { + throw new Error("Host is not connected"); + } + const trimmed = nextTitle.trim(); + if (renamingTab.kind === "terminal") { + const result = await client.renameTerminal({ + terminalId: renamingTab.id, + title: trimmed, + }); + if (!result.success) { + throw new Error(result.error ?? "Failed to rename terminal"); + } + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + return; + } + await client.updateAgent(renamingTab.id, { name: trimmed }); + void queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", normalizedServerId], + }); + void queryClient.invalidateQueries({ + queryKey: ["allAgents", normalizedServerId], + }); + }, + [client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey], + ); + + const handleRenameModalClose = useCallback(() => { + setRenamingTab(null); + }, []); + + return { + renamingTab, + handleRenameTab, + handleRenameModalSubmit, + handleRenameModalClose, + }; +} + +export interface WorkspaceTabRenameModalProps { + renamingTab: RenamingTabState | null; + onClose: () => void; + onSubmit: (nextTitle: string) => Promise; +} + +export function WorkspaceTabRenameModal({ + renamingTab, + onClose, + onSubmit, +}: WorkspaceTabRenameModalProps) { + const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent"; + const initialValue = renamingTab?.currentTitle ?? ""; + const testID = renamingTab + ? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}` + : undefined; + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 5b75de7a9..f55ea4b68 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -22,6 +22,7 @@ import { ArrowRightToLine, Columns2, Copy, + Pencil, RotateCw, Rows2, Globe, @@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); @@ -101,6 +103,8 @@ function TabContextMenuItem({ return ; case "copy-x": return ; + case "pencil": + return ; case "x": return ; default: @@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand, onNavigateTab, onReloadAgent, + onRenameTab, setHoveredCloseTabKey, setHoveredTabKey, tabDropPreviewIndex, @@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, @@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({ onCopyAgentId, onCopyResumeCommand, onReloadAgent, + onRenameTab, tabCount, ], ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 25a61f089..33fe3b300 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -11,7 +11,7 @@ import { import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; @@ -26,6 +26,7 @@ import { Globe, Import as ImportIcon, PanelRight, + Pencil, RotateCw, Settings, SquarePen, @@ -109,6 +110,10 @@ import { WorkspaceTabOptionRow, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; +import { + useWorkspaceTabRename, + WorkspaceTabRenameModal, +} from "@/screens/workspace/use-workspace-tab-rename"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, @@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); const ThemedX = withUnistyles(X); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); @@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({ return ; case "copy-x": return ; + case "pencil": + return ; case "x": return ; default: @@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore: onCloseTabsAbove, onCloseTabsAfter: onCloseTabsBelow, @@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTab={onCloseTab} onCloseTabsAbove={onCloseTabsAbove} onCloseTabsBelow={onCloseTabsBelow} @@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({ openWorkspaceTabFocused, toast, }); + const queryClient = useQueryClient(); const { createMutation: createTerminalMutation, createTerminal, @@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({ liveTerminalIds, pendingCreateInput: pendingTerminalCreateInput, query: terminalsQuery, + queryKey: terminalsQueryKey, removeTerminalFromCache, standaloneTerminalIds, terminals, @@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({ const [_hoveredTabKey, setHoveredTabKey] = useState(null); const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(null); + const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } = + useWorkspaceTabRename({ + client, + normalizedServerId, + queryClient, + terminalsData: terminalsQuery.data, + terminalsQueryKey, + }); const tabByKey = useMemo(() => { const map = new Map(); @@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} @@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({ handleCopyResumeCommand, handleCopyAgentId, handleReloadAgent, + handleRenameTab, handleCloseTabsToLeftInPane, handleCloseTabsToRightInPane, handleCloseOtherTabsInPane, @@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTab={handleCloseTabById} onCloseTabsAbove={handleCloseTabsToLeft} onCloseTabsBelow={handleCloseTabsToRight} @@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} @@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({ onClose={closeImportSheet} onImportedAgent={handleImportedAgent} /> + ) diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 43bf39d3a..6c1c2e108 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => { const onCopyResumeCommand = vi.fn(); const onCopyAgentId = vi.fn(); const onReloadAgent = vi.fn(); + const onRenameTab = vi.fn(); const onCloseTab = vi.fn(); const onCloseTabsBefore = vi.fn(); const onCloseTabsAfter = vi.fn(); @@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Rename", "Close to the left", "Close to the right", "Close other tabs", @@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Rename", "Close tabs above", "Close tabs below", "Close other tabs", @@ -74,7 +79,7 @@ describe("buildWorkspaceTabMenuEntries", () => { ]); }); - it("omits agent copy actions for non-agent tabs", () => { + it("omits agent copy actions and rename for draft tabs", () => { const entries = buildWorkspaceTabMenuEntries({ surface: "mobile", tab: { @@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe( false, ); + expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false); expect(entries.some((entry) => entry.kind === "separator")).toBe(false); }); @@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -128,4 +136,128 @@ describe("buildWorkspaceTabMenuEntries", () => { }), ); }); + + it("invokes onRenameTab when the rename entry is selected for agent tabs", () => { + const onRenameTab = vi.fn(); + const tab = createAgentTab(); + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-agent_123", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); + if (!renameEntry || renameEntry.kind !== "item") { + throw new Error("Rename entry missing"); + } + renameEntry.onSelect(); + + expect(onRenameTab).toHaveBeenCalledWith(tab); + }); + + it("includes rename as the first entry for terminal tabs", () => { + const onRenameTab = vi.fn(); + const terminalTab: WorkspaceTabDescriptor = { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab: terminalTab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-terminal_abc", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label); + expect(labels[0]).toBe("Rename"); + expect(labels).not.toContain("Copy resume command"); + expect(labels).not.toContain("Copy agent id"); + expect(labels).not.toContain("Reload agent"); + + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); + if (!renameEntry || renameEntry.kind !== "item") { + throw new Error("Rename entry missing"); + } + renameEntry.onSelect(); + expect(onRenameTab).toHaveBeenCalledWith(terminalTab); + }); + + it("uses the same rename entry shape for agent and terminal tabs", () => { + const terminalTab: WorkspaceTabDescriptor = { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; + const menuTestIDBase = "workspace-tab-context"; + const sharedInput = { + surface: "desktop" as const, + index: 0, + tabCount: 1, + menuTestIDBase, + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }; + + const agentEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: createAgentTab() }); + const terminalEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: terminalTab }); + + const agentRename = agentEntries.find( + (entry) => entry.kind === "item" && entry.key === "rename", + ); + const terminalRename = terminalEntries.find( + (entry) => entry.kind === "item" && entry.key === "rename", + ); + if (!agentRename || agentRename.kind !== "item") throw new Error("Agent rename missing"); + if (!terminalRename || terminalRename.kind !== "item") + throw new Error("Terminal rename missing"); + + expect({ + key: agentRename.key, + label: agentRename.label, + icon: agentRename.icon, + testID: agentRename.testID, + }).toEqual({ + key: terminalRename.key, + label: terminalRename.label, + icon: terminalRename.icon, + testID: terminalRename.testID, + }); + + const agentSeparator = agentEntries + .slice(agentEntries.indexOf(agentRename) + 1) + .find((entry) => entry.kind === "separator"); + const terminalSeparator = terminalEntries + .slice(terminalEntries.indexOf(terminalRename) + 1) + .find((entry) => entry.kind === "separator"); + expect(agentSeparator?.key).toBe("rename-separator"); + expect(terminalSeparator?.key).toBe("rename-separator"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index da57221a5..123135763 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,7 +8,14 @@ export type WorkspaceTabMenuEntry = kind: "item"; key: string; label: string; - icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x"; + icon?: + | "copy" + | "rotate-cw" + | "arrow-left-to-line" + | "arrow-right-to-line" + | "copy-x" + | "pencil" + | "x"; hint?: string; tooltip?: string; disabled?: boolean; @@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsBefore: (tabId: string) => Promise | void; onCloseTabsAfter: (tabId: string) => Promise | void; @@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; @@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries( onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries( void onCopyAgentId(agentId); }, }); + } + + if (tab.target.kind === "agent" || tab.target.kind === "terminal") { + entries.push({ + kind: "item", + key: "rename", + label: "Rename", + icon: "pencil", + testID: `${menuTestIDBase}-rename`, + onSelect: () => { + onRenameTab(tab); + }, + }); entries.push({ kind: "separator", - key: "copy-separator", + key: "rename-separator", }); } @@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions( onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, onReloadAgent: input.onReloadAgent, + onRenameTab: input.onRenameTab, onCloseTab: input.onCloseTab, onCloseTabsBefore: input.onCloseTabsToLeft, onCloseTabsAfter: input.onCloseTabsToRight, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 1feff96e3..b844c6125 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -15,6 +15,7 @@ import { } from "@server/shared/terminal-input-mode"; import { type PendingTerminalModifiers, + isAppleHandheldPlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, @@ -84,6 +85,14 @@ const isMac = (/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") || /Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? "")); +const isAppleHandheld = + typeof navigator !== "undefined" && + isAppleHandheldPlatform({ + userAgent: navigator.userAgent, + platform: (navigator as Navigator & { platform?: string }).platform, + maxTouchPoints: navigator.maxTouchPoints, + }); + const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; @@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime { metaKey: event.metaKey, pendingModifiers: this.pendingModifiers, enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(), + isAppleHandheld, }) ) { return true; diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 07dff7b1c..5d39d55cf 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { hasPendingTerminalModifiers, + isAppleHandheldPlatform, isTerminalModifierDomKey, mapTerminalDataToKey, mergeTerminalModifiers, @@ -10,6 +11,13 @@ import { shouldInterceptDomTerminalKey, } from "./terminal-keys"; +const IPAD_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15"; +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const IPHONE_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; + describe("terminal key helpers", () => { it("normalizes supported DOM keys", () => { expect(normalizeDomTerminalKey("Esc")).toBe("Escape"); @@ -157,6 +165,140 @@ describe("terminal key helpers", () => { ).toBe(false); }); + it("intercepts plain Ctrl+C on iPad so xterm's keyCode-13 quirk never reaches the PTY (#1049)", () => { + // See COMPAT(xterm-ipad-ctrl-c) in terminal-keys.ts. + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(true); + // Uppercase variant in case Caps Lock is on. + expect( + shouldInterceptDomTerminalKey({ + key: "C", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(true); + }); + + it("does not intercept other Ctrl+letter combos on iPad (xterm handles them correctly)", () => { + for (const key of ["b", "d", "z", "a", "r", "l"]) { + expect( + shouldInterceptDomTerminalKey({ + key, + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + } + }); + + it("does not intercept Ctrl+C on real macOS / Windows / Linux", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: false, + }), + ).toBe(false); + }); + + it("does not intercept Cmd+C on iPad (Cmd-based shortcuts stay with the OS)", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: true, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + }); + + it("does not intercept Ctrl+Shift+C / Ctrl+Alt+C on iPad", () => { + // Only bare Ctrl+C is affected by the WebKit quirk; modified variants stay with xterm. + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + shiftKey: false, + altKey: true, + metaKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + isAppleHandheld: true, + }), + ).toBe(false); + }); + + it("detects iPad masquerading as macOS via maxTouchPoints", () => { + expect( + isAppleHandheldPlatform({ userAgent: IPAD_UA, platform: "MacIntel", maxTouchPoints: 5 }), + ).toBe(true); + }); + + it("detects iPhone/iPod by UA", () => { + expect( + isAppleHandheldPlatform({ userAgent: IPHONE_UA, platform: "iPhone", maxTouchPoints: 5 }), + ).toBe(true); + }); + + it("does not flag real macOS desktop as a handheld", () => { + expect( + isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 0 }), + ).toBe(false); + }); + + it("does not flag macOS when maxTouchPoints == 1 (some trackpad contexts)", () => { + expect( + isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 1 }), + ).toBe(false); + }); + + it("tolerates null/undefined navigator-style inputs", () => { + expect(isAppleHandheldPlatform({ userAgent: null, platform: null, maxTouchPoints: null })).toBe( + false, + ); + expect( + isAppleHandheldPlatform({ + userAgent: undefined, + platform: undefined, + maxTouchPoints: undefined, + }), + ).toBe(false); + }); + it("detects pending modifier state", () => { expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false); expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 4a70cb312..a25d4e08a 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers) return modifiers.ctrl || modifiers.shift || modifiers.alt; } +interface AppleHandheldDetectionInput { + userAgent: string | null | undefined; + platform: string | null | undefined; + maxTouchPoints: number | null | undefined; +} + +// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish +// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS. +export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean { + const userAgent = input.userAgent ?? ""; + const platform = input.platform ?? ""; + const touchPoints = input.maxTouchPoints ?? 0; + if (/iPad|iPhone|iPod/.test(userAgent)) { + return true; + } + if (/Mac/i.test(platform) && touchPoints > 1) { + return true; + } + return false; +} + export function shouldInterceptDomTerminalKey(args: { key: string; ctrlKey: boolean; @@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: { metaKey: boolean; pendingModifiers: PendingTerminalModifiers; enhancedInputActive?: boolean; + isAppleHandheld?: boolean; }): boolean { if (hasPendingTerminalModifiers(args.pendingModifiers)) { return true; @@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: { if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) { return Boolean(args.enhancedInputActive); } + // COMPAT(xterm-ipad-ctrl-c): WebKit sends keyCode=13 for hardware-kbd Ctrl+C on iPad, so + // xterm.js emits \r instead of \x03. Upstream: xtermjs/xterm.js#5721, targeting xterm.js 7.0.0. + // Drop this block and the isAppleHandheld plumbing once @xterm/xterm is bumped past it. + if ( + args.isAppleHandheld && + args.ctrlKey && + !args.metaKey && + !args.altKey && + !args.shiftKey && + (args.key === "c" || args.key === "C") + ) { + return true; + } return false; } diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 943752e4e..f153df13f 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -392,9 +392,15 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD const env: NodeJS.ProcessEnv = { ...envWithHome(options.home), // Status should reflect local persisted config + pid file, not inherited daemon env overrides. + // This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available. PASEO_LISTEN: undefined, PASEO_HOSTNAMES: undefined, PASEO_ALLOWED_HOSTS: undefined, + PASEO_RELAY_ENABLED: undefined, + PASEO_RELAY_ENDPOINT: undefined, + PASEO_RELAY_PUBLIC_ENDPOINT: undefined, + PASEO_RELAY_USE_TLS: undefined, + PASEO_RELAY_PUBLIC_USE_TLS: undefined, }; const home = resolvePaseoHome(env); const config = loadConfig(home, { env }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 14aced0cc..cd238fce7 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -1,6 +1,8 @@ import { Command } from "commander"; import chalk from "chalk"; import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { tryConnectToDaemon } from "../../utils/client.js"; +import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; interface PairOptions { @@ -22,6 +24,35 @@ export async function runPairCommand(options: PairOptions): Promise { } const paseoHome = resolvePaseoHome(); + const state = resolveLocalDaemonState({ home: paseoHome }); + const host = resolveTcpHostFromListen(state.listen); + + // Try to get the pairing offer from the running daemon first. + if (host) { + const client = await tryConnectToDaemon({ host, timeout: 1500 }); + if (client) { + const supportsDaemonStatusRpc = + client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; + if (supportsDaemonStatusRpc) { + try { + const offer = await client.getDaemonPairingOffer(); + await client.close().catch(() => {}); + outputPairingResult( + { relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null }, + options, + ); + return; + } catch { + // COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while + // old daemons lack daemonStatusRpc. Remove once the daemon floor is past + // v0.1.76; pairing should come from daemon.get_pairing_offer. + } + } + await client.close().catch(() => {}); + } + } + + // Fall back to local pairing offer generation. const config = loadConfig(paseoHome); const pairing = await generateLocalPairingOffer({ paseoHome, @@ -34,6 +65,13 @@ export async function runPairCommand(options: PairOptions): Promise { includeQr: true, }); + outputPairingResult(pairing, options); +} + +function outputPairingResult( + pairing: { relayEnabled: boolean; url: string | null; qr: string | null }, + options: PairOptions, +): void { if (!pairing.relayEnabled || !pairing.url) { console.error(chalk.red("Relay pairing is disabled for this daemon config.")); console.error(chalk.yellow("Enable relay and run this command again.")); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index dbc41c423..6752099be 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -10,6 +10,7 @@ interface ProviderBinaryStatus { label: string; path: string | null; version: string | null; + source?: "daemon" | "local"; } interface DaemonStatus { @@ -99,7 +100,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema { return "red"; } if (item.key.startsWith(" ")) { - if (item.value === "not found") return "red"; + if (item.value === "not found" || item.value === "not found (daemon)") return "red"; if (item.value.endsWith("(--version failed)")) return "yellow"; return "green"; } @@ -149,7 +150,13 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { rows.push({ key: "", value: "" }); rows.push({ key: "Providers", value: "" }); for (const provider of status.providers) { - if (!provider.path) { + if (provider.source === "daemon") { + if (!provider.path) { + rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" }); + } else { + rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` }); + } + } else if (!provider.path) { rows.push({ key: ` ${provider.label}`, value: "not found" }); } else if (!provider.version) { rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` }); @@ -210,6 +217,7 @@ interface DaemonProbeResult { runningAgents?: number; idleAgents?: number; daemonNodeOverride?: string; + daemonProviders?: ProviderBinaryStatus[]; note?: string; } @@ -231,12 +239,32 @@ async function probeDaemonOverWebsocket(args: { } const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; + const supportsDaemonStatusRpc = + client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); const runningAgents = agents.filter((a) => a.status === "running").length; const idleAgents = agents.filter((a) => a.status === "idle").length; + let daemonProviders: ProviderBinaryStatus[] | undefined; + if (supportsDaemonStatusRpc) { + try { + const statusPayload = await client.getDaemonStatus(); + const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); + daemonProviders = statusPayload.providers.map((p) => ({ + label: labelMap.get(p.provider) ?? p.provider, + path: p.available ? "available" : null, + version: p.available ? null : (p.error ?? null), + source: "daemon" as const, + })); + } catch { + // COMPAT(daemon-rpc-rollout): fall back to CLI-side provider resolution while + // old daemons lack daemonStatusRpc. Remove once the daemon floor is past + // v0.1.76; status should come from daemon.get_status. + } + } + if (!state.running) { return { connectedDaemon: "reachable", @@ -244,6 +272,7 @@ async function probeDaemonOverWebsocket(args: { runningAgents, idleAgents, daemonNodeOverride: "unknown (API reachable, PID unresolved)", + daemonProviders, note: state.pidInfo ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, @@ -255,6 +284,7 @@ async function probeDaemonOverWebsocket(args: { daemonVersion, runningAgents, idleAgents, + daemonProviders, }; } catch { return { @@ -278,6 +308,7 @@ interface ProbeMergeState { daemonVersion: string | null; runningAgents: number | null; idleAgents: number | null; + daemonProviders: ProviderBinaryStatus[] | undefined; note: string | undefined; } @@ -290,6 +321,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit { app.dock?.setBadge(devWorktreeName); } - setupDarwinPaintRefresh(mainWindow); + setupDarwinCompositorWatchdog(mainWindow); setupWindowResizeEvents(mainWindow); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); diff --git a/packages/desktop/src/window/compositor-watchdog/index.test.ts b/packages/desktop/src/window/compositor-watchdog/index.test.ts new file mode 100644 index 000000000..3dc4b524d --- /dev/null +++ b/packages/desktop/src/window/compositor-watchdog/index.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { shouldRecoverFromFrameStall } from "."; + +describe("compositor-watchdog", () => { + describe("shouldRecoverFromFrameStall", () => { + const recoverable = { + stalledChecks: 3, + recovering: false, + msSinceLastRecovery: 120_000, + consecutiveRecoveries: 0, + }; + + it("recovers once the stall threshold is reached", () => { + expect(shouldRecoverFromFrameStall(recoverable)).toBe(true); + }); + + it("waits until the stall threshold is reached", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, stalledChecks: 2 })).toBe(false); + }); + + it("does not recover while a recovery is already in progress", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, recovering: true })).toBe(false); + }); + + it("respects the cooldown between recoveries", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, msSinceLastRecovery: 30_000 })).toBe( + false, + ); + }); + + it("stops recovering after the consecutive-recovery cap", () => { + expect(shouldRecoverFromFrameStall({ ...recoverable, consecutiveRecoveries: 3 })).toBe(false); + }); + }); +}); diff --git a/packages/desktop/src/window/compositor-watchdog/index.ts b/packages/desktop/src/window/compositor-watchdog/index.ts new file mode 100644 index 000000000..654abde39 --- /dev/null +++ b/packages/desktop/src/window/compositor-watchdog/index.ts @@ -0,0 +1,158 @@ +import { app, type BrowserWindow, powerMonitor } from "electron"; + +// COMPAT(darwinCompositorWatchdog): added in v0.1.78, target removal after +// 2026-11-19. Workaround for Electron/Chromium macOS display-sleep compositor +// stalls; re-test when Electron/Chromium is upgraded. + +// How often the main process probes the renderer for frame production. +const FRAME_PROBE_INTERVAL_MS = 2000; +// A probed frame must arrive within this window or the probe counts as stalled. +const FRAME_PROBE_DEADLINE_MS = 300; +// Consecutive stalled probes before the watchdog restarts the GPU process (~6 s). +const FRAME_STALL_CHECKS_TO_RECOVER = 3; +// Minimum gap between GPU-process restarts. +const COMPOSITOR_RECOVERY_COOLDOWN_MS = 60_000; +// Grace period for Chromium to relaunch the GPU process before probing resumes. +const GPU_RELAUNCH_GRACE_MS = 5_000; +// Stop restarting the GPU process after this many tries without frames returning. +const MAX_CONSECUTIVE_RECOVERIES = 3; + +// Resolves { producedFrame, visibilityState } for the renderer. The frame is +// requested with requestAnimationFrame; setTimeout (not vsync-driven) bounds the +// wait so the probe always resolves even when frame production has stopped. +const FRAME_PROBE_SOURCE = `new Promise((resolve) => { + let settled = false; + const finish = (producedFrame) => { + if (settled) return; + settled = true; + resolve({ producedFrame, visibilityState: document.visibilityState }); + }; + requestAnimationFrame(() => finish(true)); + setTimeout(() => finish(false), ${FRAME_PROBE_DEADLINE_MS}); +})`; + +interface FrameStallState { + stalledChecks: number; + recovering: boolean; + msSinceLastRecovery: number; + consecutiveRecoveries: number; +} + +export function shouldRecoverFromFrameStall(state: FrameStallState): boolean { + return ( + state.stalledChecks >= FRAME_STALL_CHECKS_TO_RECOVER && + !state.recovering && + state.msSinceLastRecovery >= COMPOSITOR_RECOVERY_COOLDOWN_MS && + state.consecutiveRecoveries < MAX_CONSECUTIVE_RECOVERIES + ); +} + +function findGpuProcessPid(): number | null { + for (const metric of app.getAppMetrics()) { + if (metric.type === "GPU") { + return metric.pid; + } + } + return null; +} + +// macOS display sleep can leave Chromium's GPU-process display link (the vsync +// source that drives frame production) stuck on a stale display. The compositor +// then stops producing frames and the window looks frozen: unresponsive to +// clicks and keys even though the renderer and every process stay alive. This +// watchdog polls the renderer for frame production and, on a sustained stall, +// restarts the GPU process so Chromium rebuilds the display link. +export function setupDarwinCompositorWatchdog(win: BrowserWindow): void { + if (process.platform !== "darwin") { + return; + } + + // Keep producing frames while occluded so the probe is not fooled by throttling. + win.webContents.setBackgroundThrottling(false); + + let stalledChecks = 0; + let recovering = false; + let lastRecoveryAt = 0; + let consecutiveRecoveries = 0; + let screenLocked = false; + + const recoverCompositor = async () => { + recovering = true; + lastRecoveryAt = Date.now(); + consecutiveRecoveries += 1; + stalledChecks = 0; + const gpuPid = findGpuProcessPid(); + console.warn( + `[compositor-watchdog] Desktop window stopped producing frames; restarting GPU process ` + + `(pid=${gpuPid ?? "unknown"}, attempt ${consecutiveRecoveries}) to recover`, + ); + if (gpuPid !== null) { + try { + process.kill(gpuPid, "SIGKILL"); + } catch (error) { + console.warn("[compositor-watchdog] Could not restart GPU process", error); + } + } + await new Promise((resolve) => setTimeout(resolve, GPU_RELAUNCH_GRACE_MS)); + recovering = false; + }; + + const probeFrameProduction = async () => { + if (win.isDestroyed() || recovering) { + return; + } + // A freeze is only meaningful, and only distinguishable from a normal idle + // window, while the window is actually on screen. A locked screen, a + // minimized window, or a hidden one legitimately stops producing frames. + if (screenLocked || !win.isVisible() || win.isMinimized()) { + stalledChecks = 0; + return; + } + + let result: { producedFrame?: unknown; visibilityState?: unknown } | null; + try { + result = await win.webContents.executeJavaScript(FRAME_PROBE_SOURCE); + } catch { + return; + } + if (!result || result.visibilityState !== "visible") { + stalledChecks = 0; + return; + } + if (result.producedFrame === true) { + stalledChecks = 0; + consecutiveRecoveries = 0; + return; + } + + stalledChecks += 1; + if ( + shouldRecoverFromFrameStall({ + stalledChecks, + recovering, + msSinceLastRecovery: Date.now() - lastRecoveryAt, + consecutiveRecoveries, + }) + ) { + void recoverCompositor(); + } + }; + + const probeTimer = setInterval(() => void probeFrameProduction(), FRAME_PROBE_INTERVAL_MS); + const handleScreenLocked = () => { + screenLocked = true; + stalledChecks = 0; + }; + const handleScreenUnlocked = () => { + screenLocked = false; + stalledChecks = 0; + }; + powerMonitor.on("lock-screen", handleScreenLocked); + powerMonitor.on("unlock-screen", handleScreenUnlocked); + + win.once("closed", () => { + clearInterval(probeTimer); + powerMonitor.off("lock-screen", handleScreenLocked); + powerMonitor.off("unlock-screen", handleScreenUnlocked); + }); +} diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index 176ef6c90..56697685d 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -229,59 +229,6 @@ export function setupWindowResizeEvents(win: BrowserWindow): void { }); } -function refreshChromiumSurface(win: BrowserWindow): void { - if (win.isDestroyed()) { - return; - } - - win.webContents.invalidate(); - if (win.isMaximized() || win.isFullScreen()) { - return; - } - - const [width, height] = win.getSize(); - win.setSize(width + 1, height); - setTimeout(() => { - if (!win.isDestroyed()) { - win.setSize(width, height); - } - }, 32); -} - -export function setupDarwinPaintRefresh(win: BrowserWindow): void { - if (process.platform !== "darwin") { - return; - } - - win.webContents.setBackgroundThrottling(false); - - const requestSurfaceRefresh = () => { - if (!win.isDestroyed()) { - win.webContents.invalidate(); - } - }; - const handleChildProcessGone = ( - _event: Electron.Event, - details: { type?: string; reason?: string }, - ) => { - if (details.type !== "GPU") { - return; - } - - console.warn("[window] GPU process gone:", details.reason); - refreshChromiumSurface(win); - }; - - win.on("restore", requestSurfaceRefresh); - win.on("show", requestSurfaceRefresh); - app.on("child-process-gone", handleChildProcessGone); - win.once("closed", () => { - win.off("restore", requestSurfaceRefresh); - win.off("show", requestSurfaceRefresh); - app.off("child-process-gone", handleChildProcessGone); - }); -} - export function buildStandardContextMenuItems( contents: WebContents, params: Electron.ContextMenuParams, diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts index 8cd863570..ea8970115 100644 --- a/packages/relay/src/encrypted-channel.test.ts +++ b/packages/relay/src/encrypted-channel.test.ts @@ -35,6 +35,10 @@ function createMockTransportPair(): [Transport, Transport] { return [transportA, transportB]; } +async function waitForAsyncDelivery(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)); +} + describe("EncryptedChannel", () => { it("establishes encrypted channel between daemon and client", async () => { const [daemonTransport, clientTransport] = createMockTransportPair(); @@ -96,7 +100,7 @@ describe("EncryptedChannel", () => { await clientChannel.send("Second message from client"); // Wait for async delivery - await new Promise((r) => setTimeout(r, 50)); + await waitForAsyncDelivery(); expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]); expect(clientMessages).toEqual(["Hello from daemon"]); @@ -194,4 +198,75 @@ describe("EncryptedChannel", () => { await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message"); }); + + it("accepts duplicate hello from the same client without re-keying", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + + const daemonKeyPair = generateKeyPair(); + const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey); + const daemonMessages: (string | ArrayBuffer)[] = []; + + let clientOpenedResolve: (() => void) | null = null; + const clientOpened = new Promise((resolve) => { + clientOpenedResolve = resolve; + }); + + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + + const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, { + onopen: () => clientOpenedResolve?.(), + }); + + await daemonChannelPromise; + await clientOpened; + + const firstHello = (clientTransport.send as ReturnType).mock.calls.find( + ([data]) => typeof data === "string" && data.includes('"type":"e2ee_hello"'), + )?.[0]; + expect(typeof firstHello).toBe("string"); + + daemonTransport.onmessage?.(firstHello as string); + await waitForAsyncDelivery(); + + expect(daemonTransport.close).not.toHaveBeenCalled(); + + await clientChannel.send("still encrypted with original key"); + await waitForAsyncDelivery(); + + expect(daemonMessages).toEqual(["still encrypted with original key"]); + }); + + it("closes an open daemon channel when a different client key sends hello", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + + const daemonKeyPair = generateKeyPair(); + const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey); + + let clientOpenedResolve: (() => void) | null = null; + const clientOpened = new Promise((resolve) => { + clientOpenedResolve = resolve; + }); + + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair); + + await createClientChannel(clientTransport, daemonPubKeyB64, { + onopen: () => clientOpenedResolve?.(), + }); + + await daemonChannelPromise; + await clientOpened; + + const attackerKeyPair = generateKeyPair(); + const attackerHello = JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(attackerKeyPair.publicKey), + }); + + daemonTransport.onmessage?.(attackerHello); + await waitForAsyncDelivery(); + + expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch"); + }); }); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 2d2c59f1b..c9baa07e2 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -92,6 +92,8 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { const HANDSHAKE_RETRY_MS = 1000; const MAX_PENDING_SENDS = 200; +const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008; +const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch"; interface TimeoutWithUnref { unref(): void; @@ -420,16 +422,14 @@ export class EncryptedChannel { return; } - // Different key implies a new client connection (common with relays - // where the daemon's socket stays open while the client reconnects). - // Re-key and re-send "ready". Drop any queued sends to avoid leaking - // messages between logical client sessions. - this.state = "handshaking"; - this.sharedKey = nextSharedKey; - this.pendingSends = []; - this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); - this.state = "open"; - await this.flushPendingSends(); + // A different key on an already-open encrypted channel is not an + // authenticated reconnect. Close and require a fresh transport instead of + // allowing the relay to switch this channel to an attacker-chosen key. + this.state = "closed"; + this.transport.close( + REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE, + REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON, + ); } close(code = 1000, reason = "Normal closure"): void { @@ -452,8 +452,9 @@ export class EncryptedChannel { function keysEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) return false; + let difference = 0; for (let i = 0; i < a.byteLength; i += 1) { - if (a[i] !== b[i]) return false; + difference |= a[i] ^ b[i]; } - return true; + return difference === 0; } diff --git a/packages/server/package.json b/packages/server/package.json index 42e30123a..d3832ba9a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,6 +22,11 @@ "types": "./dist/server/utils/tool-call-parsers.d.ts", "source": "./src/utils/tool-call-parsers.ts", "default": "./dist/server/utils/tool-call-parsers.js" + }, + "./utils/branch-slug": { + "types": "./dist/server/utils/branch-slug.d.ts", + "source": "./src/utils/branch-slug.ts", + "default": "./dist/server/utils/branch-slug.js" } }, "publishConfig": { @@ -58,12 +63,12 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.2.133", + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-coding-agent": "^0.75.3", "@getpaseo/highlight": "0.1.78", "@getpaseo/relay": "0.1.78", "@isaacs/ttlcache": "^2.1.4", - "@mariozechner/pi-agent-core": "^0.70.2", - "@mariozechner/pi-ai": "^0.70.2", - "@mariozechner/pi-coding-agent": "^0.70.2", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", "@sctg/sentencepiece-js": "^1.1.0", diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index f79d7014f..6fb456990 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1750,6 +1750,119 @@ test("requests checkout pull via RPC", async () => { }); }); +test("renames a branch via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.renameBranch({ + cwd: "/tmp/project", + branch: "feature/new-name", + requestId: "req-rename-branch", + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout.rename_branch.request"; + cwd: string; + branch: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout.rename_branch.request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.branch).toBe("feature/new-name"); + expect(request.message.requestId).toBe("req-rename-branch"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.rename_branch.response", + payload: { + requestId: "req-rename-branch", + success: true, + cwd: "/tmp/project", + currentBranch: "feature/new-name", + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: "req-rename-branch", + success: true, + cwd: "/tmp/project", + currentBranch: "feature/new-name", + error: null, + }); +}); + +test("returns renameBranch business failures", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.renameBranch({ + cwd: "/tmp/project", + branch: "already-exists", + requestId: "req-rename-branch-fail", + }); + + expect(mock.sent).toHaveLength(1); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.rename_branch.response", + payload: { + requestId: "req-rename-branch-fail", + success: false, + cwd: "/tmp/project", + currentBranch: null, + error: { code: "NOT_ALLOWED", message: "Branch already exists" }, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: "req-rename-branch-fail", + success: false, + cwd: "/tmp/project", + currentBranch: null, + error: { code: "NOT_ALLOWED", message: "Branch already exists" }, + }); +}); + test("resubscribes checkout diff streams after reconnect", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 3baae77fb..9ce4a06ea 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -5,7 +5,9 @@ import { AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, + CheckoutRenameBranchResponseSchema, parseServerInfoStatusPayload, + RenameTerminalResponseSchema, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, SessionInboundMessageSchema, @@ -60,6 +62,8 @@ import type { GetProvidersSnapshotResponseMessage, RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, + DaemonGetStatusResponse, + DaemonGetPairingOfferResponse, ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, @@ -285,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; +export type RenameBranchResult = z.infer["payload"]; type StashSavePayload = StashSaveResponse["payload"]; type StashPopPayload = StashPopResponse["payload"]; type StashListPayload = StashListResponse["payload"]; @@ -317,6 +322,8 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; +type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; +type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type ReadProjectConfigPayload = Extract< SessionOutboundMessage, { type: "read_project_config_response" } @@ -351,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract< type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; +export type RenameTerminalResult = z.infer["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type CloseItemsPayload = CloseItemsResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; @@ -614,6 +622,16 @@ export interface UpdateScheduleOptions { expiresAt?: string | null; requestId?: string; } +export interface RenameBranchInput { + cwd: string; + branch: string; + requestId?: string; +} +export interface RenameTerminalInput { + terminalId: string; + title: string; + requestId?: string; +} type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"]; type OpenInEditorPayload = OpenInEditorResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; @@ -2929,6 +2947,19 @@ export class DaemonClient { }); } + async renameBranch(input: RenameBranchInput): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "checkout.rename_branch.request", + cwd: input.cwd, + branch: input.branch, + }, + responseType: "checkout.rename_branch.response", + timeout: 30000, + }); + } + async stashSave( cwd: string, options?: { branch?: string }, @@ -3298,6 +3329,28 @@ export class DaemonClient { }); } + async getDaemonStatus(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "daemon.get_status.request", + }, + responseType: "daemon.get_status.response", + timeout: 10000, + }); + } + + async getDaemonPairingOffer(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "daemon.get_pairing_offer.request", + }, + responseType: "daemon.get_pairing_offer.response", + timeout: 10000, + }); + } + async patchDaemonConfig( config: MutableDaemonConfigPatch, requestId?: string, @@ -3635,6 +3688,19 @@ export class DaemonClient { }); } + async renameTerminal(input: RenameTerminalInput): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "terminal.rename.request", + terminalId: input.terminalId, + title: input.title, + }, + responseType: "terminal.rename.response", + timeout: 10000, + }); + } + async subscribeTerminal( terminalId: string, requestId?: string, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c4e28641d..2c5a2aae8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager } from "./agent-manager.js"; +import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -1457,6 +1457,111 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); }); +test("setGeneratedTitleIfUnset preserves an existing user title", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setTitle(snapshot.id, "User title"); + await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("User title"); +}); + +test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000129", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); +}); + +test("setGeneratedTitleIfUnset ignores blank generated titles", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000130", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); + + const stateEvents: ManagedAgent[] = []; + manager.subscribe( + (event) => { + if (event.type === "agent_state") { + stateEvents.push(event.agent); + } + }, + { agentId: snapshot.id, replayState: false }, + ); + + await manager.setGeneratedTitleIfUnset(snapshot.id, " "); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBeNull(); + expect(after?.updatedAt).toBe(before?.updatedAt); + expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt); + expect(stateEvents).toEqual([]); +}); + +test("setGeneratedTitleIfUnset throws for an unknown agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + await expect( + manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"), + ).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'"); +}); + test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9ab21b890..570ecbbab 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1228,6 +1228,23 @@ export class AgentManager { this.emitState(agent, { persist: false }); } + async setGeneratedTitleIfUnset(agentId: string, title: string): Promise { + const agent = this.requireAgent(agentId); + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + return; + } + + const registry = this.requireRegistry(); + const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle); + if (!persisted) { + return; + } + + agent.updatedAt = new Date(persisted.updatedAt); + this.emitState(agent, { persist: false }); + } + async setLabels(agentId: string, labels: Record): Promise { const agent = this.requireAgent(agentId); agent.labels = { ...agent.labels, ...labels }; diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 016c2e821..f71da3348 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -158,7 +158,7 @@ export async function generateAndApplyAgentMetadata( if (needs.needsTitle && typeof result.title === "string") { const normalizedTitle = normalizeAutoTitle(result.title); if (normalizedTitle) { - await options.agentManager.setTitle(options.agentId, normalizedTitle); + await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle); } } } diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 1959510c3..2e075f675 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -39,8 +39,8 @@ function createDeps( describe("agent metadata generator auto-title", () => { it("caps generated auto titles at 40 characters before persisting", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25); const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] @@ -56,13 +56,16 @@ describe("agent metadata generator auto-title", () => { deps: createDeps(generateStructured), }); - expect(setTitle).toHaveBeenCalledTimes(1); - expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS)); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith( + "agent-1", + "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS), + ); }); it("does not generate an auto title when an explicit title is provided", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] >; @@ -78,12 +81,12 @@ describe("agent metadata generator auto-title", () => { }); expect(generateStructured).not.toHaveBeenCalled(); - expect(setTitle).not.toHaveBeenCalled(); + expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled(); }); it("generates titles independently from workspace branch naming", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi .fn() .mockResolvedValue({ title: "Generated title" }) as NonNullable< @@ -108,7 +111,10 @@ describe("agent metadata generator auto-title", () => { persistSession: false, }), ); - expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith( + "agent-suppressed-branch", + "Generated title", + ); }); it.each([ @@ -170,8 +176,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt: writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); } - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] >; diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index f1db9e77a..61e56e435 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -321,6 +321,42 @@ describe("AgentStorage", () => { ); }); + test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => { + const agentId = "agent-generated-title-race"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + await storage.setTitle(agentId, "User title"); + + const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title"); + + expect(result).toBeNull(); + const record = await storage.get(agentId); + expect(record?.title).toBe("User title"); + }); + + test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => { + const agentId = "agent-generated-title-concurrent"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + + await Promise.all([ + storage.setGeneratedTitleIfUnset(agentId, "Title A"), + storage.setGeneratedTitleIfUnset(agentId, "Title B"), + ]); + + const record = await storage.get(agentId); + expect(["Title A", "Title B"]).toContain(record?.title); + }); + + test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => { + const agentId = "agent-generated-title-empty"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + + const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title"); + + expect(written?.title).toBe("Generated title"); + const record = await storage.get(agentId); + expect(record?.title).toBe("Generated title"); + }); + test("applySnapshot accepts explicit title overrides", async () => { const agentId = "agent-override"; await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" }); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 6c05f61a0..c747395e5 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -115,44 +115,51 @@ export class AgentStorage { async upsert(record: StoredAgentRecord): Promise { await this.load(); + await this.queueRecordWrite(record); + } + + private queueRecordWrite(record: StoredAgentRecord): Promise { const agentId = record.id; const prev = this.pendingWrites.get(agentId) ?? Promise.resolve(); const next = prev.then(async () => { if (this.deleting.has(agentId)) { - return; + return undefined; } - const nextPath = this.buildRecordPath(record); - const previousPath = this.pathById.get(agentId); - - await fs.mkdir(path.dirname(nextPath), { recursive: true }); - await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); - this.addIndexedPath(agentId, nextPath); - - if (previousPath && previousPath !== nextPath) { - try { - await fs.unlink(previousPath); - } catch { - // ignore cleanup errors - } - this.removeIndexedPath(agentId, previousPath); - } - - this.cache.set(agentId, record); - this.pathById.set(agentId, nextPath); - return; + await this.writeRecord(record); + return undefined; }); - this.pendingWrites.set( - agentId, - next.finally(() => { - if (this.pendingWrites.get(agentId) === next) { - this.pendingWrites.delete(agentId); - } - }), - ); + const tracked = next.finally(() => { + if (this.pendingWrites.get(agentId) === tracked) { + this.pendingWrites.delete(agentId); + } + }); - await next; + this.pendingWrites.set(agentId, tracked); + return tracked; + } + + private async writeRecord(record: StoredAgentRecord): Promise { + const agentId = record.id; + const nextPath = this.buildRecordPath(record); + const previousPath = this.pathById.get(agentId); + + await fs.mkdir(path.dirname(nextPath), { recursive: true }); + await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); + this.addIndexedPath(agentId, nextPath); + + if (previousPath && previousPath !== nextPath) { + try { + await fs.unlink(previousPath); + } catch { + // ignore cleanup errors + } + this.removeIndexedPath(agentId, previousPath); + } + + this.cache.set(agentId, record); + this.pathById.set(agentId, nextPath); } beginDelete(agentId: string): void { @@ -225,6 +232,40 @@ export class AgentStorage { await this.upsert({ ...record, title }); } + async setGeneratedTitleIfUnset( + agentId: string, + title: string, + ): Promise { + await this.load(); + await this.waitForPendingWrite(agentId); + const record = this.cache.get(agentId) ?? null; + if (!record) { + throw new Error(`Agent ${agentId} not found`); + } + if (record.title) { + return null; + } + + // Re-drain pending writes: a concurrent setTitle may have queued between the + // first drain and here. After waiting, re-read the cache before writing. + await this.waitForPendingWrite(agentId); + const latestRecord = this.cache.get(agentId) ?? null; + if (!latestRecord) { + throw new Error(`Agent ${agentId} not found`); + } + if (latestRecord.title) { + return null; + } + + const nextRecord = { + ...latestRecord, + title, + updatedAt: new Date().toISOString(), + }; + await this.queueRecordWrite(nextRecord); + return nextRecord; + } + async flush(): Promise { await this.load().catch(() => undefined); const writes = Array.from(this.pendingWrites.values()); diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 7370e6082..9ee49887e 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -154,7 +154,7 @@ async function createTopLevelAgent(args?: Partial): Promise { title: "MCP parity parent", provider: "claude/claude-test-model", initialPrompt: "say done and stop", - mode: "bypassPermissions", + settings: { modeId: "bypassPermissions" }, background: true, }); parentAgentId = str(parentPayload.agentId); @@ -339,7 +339,7 @@ describe("Suite A: Core Fixes", () => { test("create_agent accepts provider features over MCP", async () => { let agentId: string | null = null; try { - agentId = await createTopLevelAgent({ features: { test_feature: true } }); + agentId = await createTopLevelAgent({ settings: { features: { test_feature: true } } }); const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId); expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true }); @@ -356,7 +356,7 @@ describe("Suite A: Core Fixes", () => { try { agentId = await createChildAgent({ provider: "claude/claude-test-model", - features: { test_feature: true }, + settings: { features: { test_feature: true } }, }); const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId); expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true }); @@ -369,14 +369,13 @@ describe("Suite A: Core Fixes", () => { } }); - test("set_agent_feature updates provider features over MCP", async () => { + test("update_agent updates provider features over MCP", async () => { let agentId: string | null = null; try { - agentId = await createTopLevelAgent({ features: { test_feature: false } }); - const updated = await callToolStructured(topLevelClient, "set_agent_feature", { + agentId = await createTopLevelAgent({ settings: { features: { test_feature: false } } }); + const updated = await callToolStructured(topLevelClient, "update_agent", { agentId, - featureId: "test_feature", - value: true, + settings: { features: { test_feature: true } }, }); expect(updated.success).toBe(true); const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId); @@ -390,15 +389,18 @@ describe("Suite A: Core Fixes", () => { } }); - test("list_provider_features returns draft provider features over MCP", async () => { - const payload = await callToolStructured(topLevelClient, "list_provider_features", { + test("inspect_provider returns draft provider features over MCP", async () => { + const payload = await callToolStructured(topLevelClient, "inspect_provider", { provider: "claude", cwd: parentAgentCwd, - model: "claude-test-model", - featureValues: { test_feature: true }, + settings: { + model: "claude-test-model", + features: { test_feature: true }, + }, }); expect(payload.provider).toBe("claude"); + expect(payload.selectedModel).toBe("claude-test-model"); expect(recordArr(payload.features)).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ab27332e5..c1f59cc8f 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -117,7 +117,9 @@ function buildAgentManagerSpies() { createAgent: vi.fn(), waitForAgentEvent: vi.fn(), recordUserMessage: vi.fn(), - setAgentMode: vi.fn(), + setAgentMode: vi.fn().mockResolvedValue(undefined), + setAgentModel: vi.fn().mockResolvedValue(undefined), + setAgentThinkingOption: vi.fn().mockResolvedValue(undefined), setAgentFeature: vi.fn().mockResolvedValue(undefined), setLabels: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined), @@ -446,7 +448,7 @@ describe("create_agent MCP tool", () => { const missingTitle = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", initialPrompt: "test", }); @@ -455,7 +457,7 @@ describe("create_agent MCP tool", () => { const tooLong = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", title: "x".repeat(61), initialPrompt: "test", @@ -465,7 +467,7 @@ describe("create_agent MCP tool", () => { const ok = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", title: "Short title", initialPrompt: "test", @@ -479,7 +481,7 @@ describe("create_agent MCP tool", () => { const tool = registeredTool(server, "create_agent"); const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, provider: "codex/gpt-5.4", title: "Short title", }); @@ -510,7 +512,7 @@ describe("create_agent MCP tool", () => { provider: "codex/gpt-5.4", initialPrompt: "Do work", background: true, - features: { fast_mode: true }, + settings: { features: { fast_mode: true } }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -536,7 +538,7 @@ describe("create_agent MCP tool", () => { const missingProvider = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", initialPrompt: "test", }); @@ -549,7 +551,7 @@ describe("create_agent MCP tool", () => { const providerWithoutModel = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "codex", initialPrompt: "test", @@ -558,7 +560,7 @@ describe("create_agent MCP tool", () => { const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "codex/", initialPrompt: "test", @@ -567,7 +569,7 @@ describe("create_agent MCP tool", () => { const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "/gpt-5.4", initialPrompt: "test", @@ -577,7 +579,7 @@ describe("create_agent MCP tool", () => { await expect( tool.handler({ cwd: existingCwd, - mode: "default", + settings: { modeId: "default" }, title: "Short title", provider: "codex/gpt-5.4", model: "gpt-5.4", @@ -722,10 +724,9 @@ describe("create_agent MCP tool", () => { await tool.handler({ cwd: existingCwd, title: "Config test", - mode: "auto", initialPrompt: "Do work", provider: "codex/gpt-5.4", - thinking: "think-hard", + settings: { modeId: "auto", thinkingOptionId: "think-hard" }, labels: { source: "mcp" }, }); @@ -1269,7 +1270,7 @@ describe("create_agent MCP tool", () => { const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, title: "Custom provider agent", - mode: "default", + settings: { modeId: "default" }, provider: "zai/custom-model", initialPrompt: "Do work", }); @@ -1360,7 +1361,7 @@ describe("create_agent MCP tool", () => { provider: "codex/gpt-5.4", initialPrompt: "Do work", background: true, - features: { fast_mode: true }, + settings: { features: { fast_mode: true } }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -1403,7 +1404,7 @@ describe("create_agent MCP tool", () => { await tool.handler({ cwd: existingCwd, title: "Injected config test", - mode: "auto", + settings: { modeId: "auto" }, provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -1428,7 +1429,7 @@ describe("create_agent MCP tool", () => { cwd: existingCwd, title: "Bad mode", provider: "opencode/gpt-5.4", - mode: "bypassPermissions", + settings: { modeId: "bypassPermissions" }, initialPrompt: "Do work", }), ).rejects.toThrow( @@ -1567,7 +1568,7 @@ describe("create_agent MCP tool", () => { await tool.handler({ title: "Child", provider: "opencode/gpt-5.4", - mode: "build", + settings: { modeId: "build" }, initialPrompt: "Do work", }); @@ -1579,17 +1580,31 @@ describe("create_agent MCP tool", () => { }); }); -describe("set_agent_feature MCP tool", () => { +describe("update_agent MCP tool", () => { const logger = createTestLogger(); - it("sets a provider feature on an existing agent", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); + it("does not register the replaced feature-specific MCP tool", async () => { + const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); - const tool = registeredTool(server, "set_agent_feature"); + + expect(lookupTool(server, "set_agent_feature")).toBeUndefined(); + }); + + it("updates runtime settings before metadata", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" })); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "update_agent"); const input = { agentId: "agent-1", - featureId: "fast_mode", - value: true, + name: "Updated agent", + labels: { role: "worker" }, + settings: { + modeId: "full-access", + model: "gpt-5.4", + thinkingOptionId: "high", + features: { fast_mode: true }, + }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -1597,9 +1612,39 @@ describe("set_agent_feature MCP tool", () => { const response = await tool.handler(input); + expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access"); + expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4"); + expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high"); expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true); + expect(spies.agentStorage.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: "agent-1", + title: "Updated agent", + }), + ); + expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" }); expect(response.structuredContent).toEqual({ success: true }); }); + + it("does not update metadata when runtime settings fail", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature")); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "update_agent"); + + await expect( + tool.handler({ + agentId: "agent-1", + name: "Should not persist", + labels: { role: "worker" }, + settings: { features: { fast_mode: true } }, + }), + ).rejects.toThrow("unsupported feature"); + + expect(spies.agentStorage.get).not.toHaveBeenCalled(); + expect(spies.agentStorage.upsert).not.toHaveBeenCalled(); + expect(spies.agentManager.setLabels).not.toHaveBeenCalled(); + }); }); describe("create_schedule MCP tool", () => { @@ -2033,10 +2078,17 @@ describe("provider listing MCP tool", () => { }); }); -describe("model listing MCP tool", () => { +describe("provider MCP tools", () => { const logger = createTestLogger(); - it("lists provider features for a draft agent configuration", async () => { + it("does not register the replaced feature-specific provider discovery MCP tool", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + + expect(lookupTool(server, "list_provider_features")).toBeUndefined(); + }); + + it("inspects provider features for a draft agent configuration", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.listDraftFeatures.mockResolvedValue([ { @@ -2046,19 +2098,29 @@ describe("model listing MCP tool", () => { value: false, }, ]); + const providerRegistry = { + codex: createProviderDefinition({ + id: "codex", + label: "Codex", + description: "OpenAI coding agent", + modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }], + }), + }; const server = await createAgentMcpServer({ agentManager, agentStorage, + providerRegistry, logger, }); - const tool = registeredTool(server, "list_provider_features"); + const tool = registeredTool(server, "inspect_provider"); const input = { - provider: "codex", + provider: "codex/gpt-5.4", cwd: "~/repo", - modeId: "full-access", - model: "gpt-5.4", - thinkingOptionId: "high", - featureValues: { fast_mode: true }, + settings: { + modeId: "full-access", + thinkingOptionId: "high", + features: { fast_mode: true }, + }, }; const parsed = await tool.inputSchema.safeParseAsync(input); @@ -2076,6 +2138,12 @@ describe("model listing MCP tool", () => { }); expect(response.structuredContent).toEqual({ provider: "codex", + label: "Codex", + description: "OpenAI coding agent", + enabled: true, + status: "available", + modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }], + selectedModel: "gpt-5.4", features: [ { type: "toggle", @@ -2118,6 +2186,38 @@ describe("model listing MCP tool", () => { ); expect(fetchModels).not.toHaveBeenCalled(); }); + + it("inspect_provider rejects disabled providers without fetching models", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const fetchModels = vi.fn().mockResolvedValue([ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + }, + ]); + const providerRegistry = { + codex: createProviderDefinition({ + id: "codex", + label: "Codex", + enabled: false, + fetchModels, + }), + }; + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerRegistry, + logger, + }); + const tool = registeredTool(server, "inspect_provider"); + + await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow( + "Provider 'codex' is disabled", + ); + expect(fetchModels).not.toHaveBeenCalled(); + }); }); describe("speak MCP tool", () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 89387363d..577e9f495 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -62,6 +62,7 @@ import { AgentModelSchema, AgentProviderEnum, AgentStatusEnum, + ProviderModeSchema, ProviderSummarySchema, parseDurationString, resolveRequiredProviderModel, @@ -587,6 +588,55 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, { message: "provider must be provider/model, for example codex/gpt-5.4" }, ); + const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim() + .min(1, "provider is required") + .refine( + (value) => { + if (!value.includes("/")) { + return true; + } + try { + resolveRequiredProviderModel(value); + return true; + } catch { + return false; + } + }, + { message: "provider must be provider or provider/model, for example codex/gpt-5.4" }, + ); + const CreateAgentSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Session mode to configure before the first run."), + thinkingOptionId: z.string().optional().describe("Thinking option ID."), + features: z + .record(z.unknown()) + .optional() + .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), + }) + .strict(); + const UpdateAgentSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Session mode ID."), + model: z.string().nullable().optional().describe("Model ID. Pass null to clear."), + thinkingOptionId: z + .string() + .nullable() + .optional() + .describe("Thinking option ID. Pass null to clear."), + features: z + .record(z.unknown()) + .optional() + .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), + }) + .strict(); + const InspectProviderSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Draft session mode ID."), + model: z.string().optional().describe("Draft model ID."), + thinkingOptionId: z.string().optional().describe("Draft thinking option ID."), + features: z.record(z.unknown()).optional().describe("Draft provider feature values."), + }) + .strict(); const agentToAgentInputSchema = { cwd: z .string() @@ -601,23 +651,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom provider: ProviderModelInputSchema.describe( "Required provider/model pair, for example codex/gpt-5.4.", ), - thinking: z.string().optional().describe("Thinking option ID"), - features: z - .record(z.unknown()) - .optional() - .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: CreateAgentSettingsInputSchema.optional().describe( + "Initial runtime settings for the new agent.", + ), initialPrompt: z .string() .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), - mode: z - .string() - .optional() - .describe( - "Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.", - ), background: z .boolean() .optional() @@ -647,21 +689,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom provider: ProviderModelInputSchema.describe( "Required provider/model pair, for example codex/gpt-5.4.", ), - thinking: z.string().optional().describe("Thinking option ID"), - features: z - .record(z.unknown()) - .optional() - .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: CreateAgentSettingsInputSchema.optional().describe( + "Initial runtime settings for the new agent.", + ), initialPrompt: z .string() .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), - mode: z - .string() - .optional() - .describe("Optional session mode to configure before the first run."), worktreeName: z .string() .optional() @@ -700,13 +736,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict(); - const listProviderFeaturesInputSchema = { - provider: AgentProviderEnum, - cwd: z.string().describe("Working directory used to resolve provider feature availability."), - modeId: z.string().optional(), - model: z.string().optional(), - thinkingOptionId: z.string().optional(), - featureValues: z.record(z.unknown()).optional(), + const inspectProviderInputSchema = { + provider: ProviderOrProviderModelInputSchema.describe( + "Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).", + ), + cwd: z + .string() + .optional() + .describe("Working directory used to resolve provider feature availability."), + settings: InspectProviderSettingsInputSchema.optional().describe( + "Draft provider settings used to compute available features.", + ), }; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { @@ -758,7 +798,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background: boolean; normalizedTitle: string | null; model: string | undefined; - thinking: string | undefined; + thinkingOptionId: string | undefined; features: Record | undefined; labels: Record | undefined; notifyOnFinish: boolean; @@ -805,6 +845,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom throw new Error(`Parent agent ${parentAgentId} not found`); } const provider = resolvedProviderModel.provider; + const settings = callerArgs.settings; const resolvedCwd = resolveChildAgentCwd({ parentCwd: parentAgent.cwd, requestedCwd: callerArgs.cwd, @@ -812,7 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom allowCustomCwd: callerContext?.allowCustomCwd ?? true, }); const resolvedMode = resolveAndValidateCreateAgentMode({ - requestedMode: callerArgs.mode, + requestedMode: settings?.modeId, targetProvider: provider, parent: { provider: parentAgent.provider, @@ -828,8 +869,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background: callerArgs.background ?? false, normalizedTitle: callerArgs.title.trim(), model: resolvedProviderModel.model, - thinking: callerArgs.thinking, - features: callerArgs.features, + thinkingOptionId: settings?.thinkingOptionId, + features: settings?.features, labels: callerArgs.labels, notifyOnFinish: callerArgs.notifyOnFinish ?? false, resolvedCwd, @@ -843,9 +884,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ): Promise => { const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); - const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; + const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } = + topLevelArgs; const resolvedMode = resolveAndValidateCreateAgentMode({ - requestedMode: mode, + requestedMode: settings?.modeId, targetProvider: resolvedProviderModel.provider, parent: null, availableModes: getAvailableModeIds(resolvedProviderModel.provider), @@ -902,8 +944,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background: topLevelArgs.background ?? false, normalizedTitle: topLevelArgs.title.trim(), model: resolvedProviderModel.model, - thinking: topLevelArgs.thinking, - features: topLevelArgs.features, + thinkingOptionId: settings?.thinkingOptionId, + features: settings?.features, labels: topLevelArgs.labels, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, resolvedCwd, @@ -946,7 +988,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom background, normalizedTitle, model, - thinking, + thinkingOptionId, features, labels, notifyOnFinish, @@ -968,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom modeId: resolvedMode, title: normalizedTitle ?? undefined, model, - thinkingOptionId: thinking, + thinkingOptionId, featureValues: features, }, undefined, @@ -1057,29 +1099,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( - "set_agent_feature", - { - title: "Set agent feature", - description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.", - inputSchema: { - agentId: z.string(), - featureId: z.string().trim().min(1), - value: z.unknown(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, featureId, value }) => { - await agentManager.setAgentFeature(agentId, featureId, value); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - server.registerTool( "wait_for_agent", { @@ -1440,17 +1459,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "update_agent", { title: "Update agent", - description: "Update an agent name and/or labels.", + description: "Update an agent name, labels, and/or runtime settings.", inputSchema: { agentId: z.string(), name: z.string().optional(), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: UpdateAgentSettingsInputSchema.optional().describe( + "Runtime settings to apply to the agent.", + ), }, outputSchema: { success: z.boolean(), }, }, - async ({ agentId, name, labels }) => { + async ({ agentId, name, labels, settings }) => { + if (settings?.modeId !== undefined) { + await agentManager.setAgentMode(agentId, settings.modeId); + } + if (settings?.model !== undefined) { + await agentManager.setAgentModel(agentId, settings.model); + } + if (settings?.thinkingOptionId !== undefined) { + await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); + } + if (settings?.features) { + for (const [featureId, value] of Object.entries(settings.features)) { + await agentManager.setAgentFeature(agentId, featureId, value); + } + } + const trimmedName = name?.trim(); if (trimmedName) { const record = await agentStorage.get(agentId); @@ -2022,30 +2059,63 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ); server.registerTool( - "list_provider_features", + "inspect_provider", { - title: "List provider features", + title: "Inspect provider", description: - "List provider-specific features available for a draft agent configuration, such as Codex fast_mode.", - inputSchema: listProviderFeaturesInputSchema, + "Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.", + inputSchema: inspectProviderInputSchema, outputSchema: { provider: AgentProviderEnum, + label: z.string().nullable().optional(), + description: z.string().nullable().optional(), + enabled: z.boolean(), + status: z.string(), + modes: z.array(ProviderModeSchema).nullish(), + selectedModel: z.string().nullable(), features: z.array(AgentFeatureSchema), }, }, - async ({ provider, cwd, modeId, model, thinkingOptionId, featureValues }) => { - const features = await agentManager.listDraftFeatures({ + async ({ provider, cwd, settings }) => { + const resolvedProviderModel = resolveScheduleProviderAndModel({ provider, - cwd: expandUserPath(cwd), - ...(modeId ? { modeId } : {}), - ...(model ? { model } : {}), - ...(thinkingOptionId ? { thinkingOptionId } : {}), - ...(featureValues ? { featureValues } : {}), + defaultProvider: provider, + }); + const providerId = resolvedProviderModel.provider; + if (!providerRegistry) { + throw new Error("Provider registry is not configured"); + } + const definition = providerRegistry[providerId]; + if (!definition) { + throw new Error(`Provider ${providerId} is not configured`); + } + const summary = await resolveProviderSummary(definition, childLogger); + if (!definition.enabled) { + throw new Error(`Provider '${providerId}' is disabled`); + } + if (summary.status !== "available") { + throw new Error(summary.error ?? `Provider '${providerId}' is unavailable`); + } + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const selectedModel = settings?.model ?? resolvedProviderModel.model; + const features = await agentManager.listDraftFeatures({ + provider: providerId, + cwd: resolvedCwd, + ...(settings?.modeId ? { modeId: settings.modeId } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), + ...(settings?.features ? { featureValues: settings.features } : {}), }); return { content: [], structuredContent: ensureValidJson({ - provider, + provider: providerId, + label: summary.label, + description: summary.description, + enabled: summary.enabled, + status: summary.status, + modes: summary.modes, + selectedModel: selectedModel ?? null, features, }), }; diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 0cbc2a4df..1f3e9fe30 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1438,6 +1438,26 @@ describe("ACPAgentSession slash commands", () => { }); describe("ACPAgentSession", () => { + test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => { + const logger = createTestLogger(); + const trace = vi.spyOn(logger, "trace"); + const session = createSessionWithConfig({ provider: "kiro" }, logger); + + await expect( + session.extNotification("_kiro.dev/session/initialized", { + sessionId: "session-1", + }), + ).resolves.toBeUndefined(); + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "kiro", + method: "_kiro.dev/session/initialized", + sessionId: "session-1", + }), + "provider.acp.extension_notification", + ); + }); + test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => { const session = createSession(); const events: Array<{ type: string; item?: { type: string; text?: string } }> = []; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index a9f7924c5..dbf2e3d02 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1667,6 +1667,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + async extNotification(method: string, params: Record): Promise { + this.logger.trace( + { + agentId: this.agentId, + provider: this.provider, + sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined, + method, + rawEvent: params, + }, + "provider.acp.extension_notification", + ); + } + async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { const raw = await fs.readFile(params.path, "utf8"); if (!params.line && !params.limit) { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index be2fec564..8f4ae15a1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1461,4 +1461,64 @@ describe("ClaudeAgentSession context window usage", () => { expect(timelineEvents).toEqual([]); expect(events.some((event) => event.type === "turn_completed")).toBe(true); }); + + test("result.result is not duplicated when assistant text already streamed with zero token usage", async () => { + const queryFactory = createQueryFactoryForTurns([ + [ + { + type: "system", + subtype: "init", + session_id: "session-third-party", + permissionMode: "default", + }, + { + type: "assistant", + message: { + id: "assistant-third-party-1", + role: "assistant", + content: [{ type: "text", text: "Here is the answer." }], + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + session_id: "session-third-party", + uuid: "assistant-third-party-event-1", + }, + { + type: "result", + subtype: "success", + result: "Here is the answer.", + is_error: false, + duration_ms: 100, + duration_api_ms: 80, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + cache_read_input_tokens: 0, + output_tokens: 0, + }, + permission_denials: [], + uuid: "result-third-party-1", + session_id: "session-third-party", + }, + ], + ]); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const result = await session.run("turn"); + await session.close(); + + expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index f84f66ea9..8d88d7929 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1568,6 +1568,7 @@ class ClaudeAgentSession implements AgentSession { private pendingInterruptAbort = false; private lastForegroundPromptText: string | null = null; private foregroundHasVisibleActivity = false; + private activeTurnHasAssistantText = false; private lastContextWindowUsedTokens: number | undefined; private lastContextWindowMaxTokens: number | undefined; private lastStreamRequestInputTokens: number | undefined; @@ -1692,6 +1693,7 @@ class ClaudeAgentSession implements AgentSession { const turnId = this.createTurnId("foreground"); this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; + this.activeTurnHasAssistantText = false; this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); @@ -2630,6 +2632,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; this.cancelCurrentTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); } @@ -2645,9 +2648,11 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; this.cancelCurrentTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); } else if (this.autonomousTurn) { this.autonomousTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("autonomous turn terminal"); } } @@ -2660,6 +2665,7 @@ class ClaudeAgentSession implements AgentSession { this.autonomousTurn = { id: this.createTurnId("autonomous"), }; + this.activeTurnHasAssistantText = false; this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.syncTurnState("autonomous turn started"); } @@ -2670,6 +2676,7 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("autonomous turn completed"); } @@ -2898,7 +2905,6 @@ class ClaudeAgentSession implements AgentSession { if (events.length === 0) { return; } - if ( this.pendingInterruptAbort && message.type === "result" && @@ -2909,6 +2915,11 @@ class ClaudeAgentSession implements AgentSession { this.logger.debug("Suppressing stale Claude interrupt terminal result"); return; } + if ( + events.some((event) => event.type === "timeline" && event.item.type === "assistant_message") + ) { + this.activeTurnHasAssistantText = true; + } if ( this.activeForegroundTurnId && events.some( @@ -3231,12 +3242,12 @@ class ClaudeAgentSession implements AgentSession { if (message.subtype === "success") { // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // run client-side in the Claude CLI with no model turn — output_tokens - // is 0 and the user-visible text is carried in `result`. Surface it as - // an assistant message so the turn doesn't end silently. Normal turns - // have output_tokens > 0 and their text is already in the stream. + // is 0 and the user-visible text is carried in `result`. Surface it only + // when the turn has not already emitted assistant text so zero-token + // accounting from provider gateways does not duplicate streamed output. const resultText = typeof message.result === "string" ? message.result.trim() : ""; const outputTokens = message.usage?.output_tokens; - if (resultText.length > 0 && outputTokens === 0) { + if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) { events.push({ type: "timeline", provider: "claude", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 89ec1ccbe..8abafe00c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1352,6 +1352,40 @@ describe("Codex app-server provider", () => { }); }); + test("does not synthesize a parent sub-agent failure from child error state alone", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "call-sub-agent-transient-child-error", + tool: "spawnAgent", + status: "completed", + prompt: "Validate the child agent result.", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: "error", message: "Sub-agent failed" }, + }, + }, + }); + + expect(events.at(-1)?.item).toMatchObject({ + type: "tool_call", + callId: "call-sub-agent-transient-child-error", + name: "Sub-agent", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "Sub-agent", + description: "Validate the child agent result.", + }, + }); + }); + test("loads Codex persisted history from the app-server thread", async () => { const session = createSession(); const requests: Array<{ method: string; params: unknown }> = []; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bdac82a7d..ffdb13c81 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -52,7 +52,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; @@ -375,8 +375,61 @@ function mergeCodexConfiguredDefaults( }; } +function codexMicrosoftStorePackageRoot(): string | null { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return null; + } + return path.join(localAppData, "Packages"); +} + +async function findCodexMicrosoftStoreBinary(): Promise { + if (process.platform !== "win32") { + return null; + } + + const packageRoot = codexMicrosoftStorePackageRoot(); + if (!packageRoot) { + return null; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(packageRoot, { withFileTypes: true }); + } catch { + return null; + } + + const codexPackages = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_")) + .map((entry) => entry.name) + .sort(); + + for (const packageName of codexPackages) { + const candidate = path.join( + packageRoot, + packageName, + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + "codex.exe", + ); + if (await probeExecutable(candidate)) { + return candidate; + } + } + + return null; +} + +async function findDefaultCodexBinary(): Promise { + return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); +} + async function resolveCodexBinary(): Promise { - const found = await findExecutable("codex"); + const found = await findDefaultCodexBinary(); if (found) { return found; } @@ -5299,13 +5352,13 @@ export class CodexAppServerAgentClient implements AgentClient { if (command?.mode === "replace") { return await isCommandAvailable(command.argv[0]); } - return await isCommandAvailable("codex"); + return (await findDefaultCodexBinary()) !== null; } async getDiagnostic(): Promise<{ diagnostic: string }> { try { const available = await this.isAvailable(); - const resolvedBinary = await findExecutable("codex"); + const resolvedBinary = await findDefaultCodexBinary(); const entries: Array<{ label: string; value: string }> = [ { label: "Binary", @@ -5448,6 +5501,8 @@ export const __codexAppServerInternals = { CodexAppServerClient, codexModelSupportsFastMode, CodexAppServerAgentSession, + findCodexMicrosoftStoreBinary, + findDefaultCodexBinary, formatCodexQuestionPrompts, mapCodexQuestionRequestToToolCall, mapCodexPatchNotificationToToolCall, diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index ac14899fd..4271f4059 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -212,6 +212,64 @@ describe("codex tool-call mapper", () => { }); }); + it("does not fail a collabAgentToolCall from child error state alone", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "collabAgentToolCall", + id: "call-sub-agent-transient-child-error", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the Codex stream path.", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: "error", message: "Sub-agent failed" }, + }, + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "call-sub-agent-transient-child-error", + name: "Sub-agent", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "Sub-agent", + description: "Inspect the Codex stream path.", + log: "", + actions: [], + }, + }); + }); + + it("still fails a collabAgentToolCall from an explicitly failed child state", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "collabAgentToolCall", + id: "call-sub-agent-child-failed", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the Codex stream path.", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: "failed", message: "Child failed" }, + }, + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "call-sub-agent-child-failed", + name: "Sub-agent", + status: "failed", + error: { message: "Sub-agent failed" }, + detail: { + type: "sub_agent", + subAgentType: "Sub-agent", + description: "Inspect the Codex stream path.", + log: "", + actions: [], + }, + }); + }); + it("maps mcp read_file completion with detail", () => { const item = mapCodexToolCallFromThreadItem( { diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 2c21288b7..cc6e0d4ca 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -544,6 +544,14 @@ function readStatus(value: unknown): string | undefined { return typeof value.status === "string" ? value.status : undefined; } +function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] { + const normalized = status.trim().toLowerCase(); + if (normalized === "error" || normalized === "errored") { + return "running"; + } + return normalizeToolCallStatus(status, null, null); +} + function resolveCollabAgentStatus( item: z.infer, ): ToolCallTimelineItem["status"] { @@ -551,10 +559,15 @@ function resolveCollabAgentStatus( return "failed"; } + const parentStatus = normalizeToolCallStatus(item.status, null, null); + if (parentStatus === "failed") { + return "failed"; + } + const childStatuses = Object.values(item.agentsStates ?? {}) .map(readStatus) .filter((status): status is string => typeof status === "string" && status.trim().length > 0) - .map((status) => normalizeToolCallStatus(status, null, null)); + .map(normalizeCollabAgentChildStatus); if (childStatuses.some((status) => status === "failed")) { return "failed"; @@ -566,7 +579,7 @@ function resolveCollabAgentStatus( return childStatuses.every((status) => status === "completed") ? "completed" : "running"; } - return normalizeToolCallStatus(item.status, item.error ?? null, null); + return parentStatus; } function buildMcpToolName(server: string | undefined, tool: string): string { diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts index b75bdf2b0..92dc2b982 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, test, vi } from "vitest"; -import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai"; +import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import pino from "pino"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts index 79a6528d9..edaee537d 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.ts @@ -25,9 +25,9 @@ import { type ResolvedCommand, type Skill, type WriteToolInput, -} from "@mariozechner/pi-coding-agent"; -import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; +} from "@earendil-works/pi-coding-agent"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; import { z } from "zod"; import { diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts index c52ff093c..140eb6b09 100644 --- a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts +++ b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts @@ -30,7 +30,7 @@ export interface PiSessionRecoveryResult { } // COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal -// 2026-11-13, once upstream @mariozechner/pi-ai recognizes this overflow. +// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow. const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i; const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [ diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index 3f371e542..d5f2c9564 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -9,10 +9,11 @@ import { AgentManager } from "../agent-manager.js"; import { AgentStorage } from "../agent-storage.js"; import { ClaudeAgentClient } from "./claude/agent.js"; -import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; const originalEnv = { + LOCALAPPDATA: process.env.LOCALAPPDATA, PATH: process.env.PATH, PATHEXT: process.env.PATHEXT, }; @@ -31,7 +32,19 @@ function isolatePathTo(dir: string): void { } } +function isolateCodexDefaultDiscoveryTo(dir: string): void { + isolatePathTo(dir); + if (process.platform === "win32") { + process.env.LOCALAPPDATA = dir; + } +} + afterEach(() => { + if (originalEnv.LOCALAPPDATA === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA; + } process.env.PATH = originalEnv.PATH; process.env.PATHEXT = originalEnv.PATHEXT; for (const dir of tempDirs.splice(0)) { @@ -42,12 +55,51 @@ afterEach(() => { describe("default provider availability", () => { test("Codex reports unavailable when the default command cannot be resolved", async () => { const binDir = makeTempDir("provider-availability-codex-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const client = new CodexAppServerAgentClient(createTestLogger()); await expect(client.isAvailable()).resolves.toBe(false); }); + test("Codex reports available from a Microsoft Store install path when PATH misses codex", async () => { + const originalPlatform = process.platform; + const originalLocalAppData = process.env.LOCALAPPDATA; + const root = makeTempDir("provider-availability-codex-store-"); + const emptyPathDir = join(root, "empty-path"); + const codexBinDir = join( + root, + "Packages", + "OpenAI.Codex_abc123", + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + ); + const codexExe = join(codexBinDir, "codex.exe"); + mkdirSync(emptyPathDir, { recursive: true }); + mkdirSync(codexBinDir, { recursive: true }); + copyFileSync(process.execPath, codexExe); + Object.defineProperty(process, "platform", { value: "win32", writable: true }); + process.env.LOCALAPPDATA = root; + isolatePathTo(emptyPathDir); + process.env.PATHEXT = ".EXE"; + + try { + const client = new CodexAppServerAgentClient(createTestLogger()); + + await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe); + await expect(client.isAvailable()).resolves.toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + } + }); + test("Claude reports unavailable when the default command cannot be resolved", async () => { const binDir = makeTempDir("provider-availability-claude-"); isolatePathTo(binDir); @@ -66,7 +118,7 @@ describe("default provider availability", () => { test("AgentManager reports Codex unavailable without throwing", async () => { const binDir = makeTempDir("provider-availability-manager-bin-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const workdir = makeTempDir("provider-availability-manager-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ @@ -88,7 +140,7 @@ describe("default provider availability", () => { test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => { const binDir = makeTempDir("provider-availability-resume-bin-"); - isolatePathTo(binDir); + isolateCodexDefaultDiscoveryTo(binDir); const workdir = makeTempDir("provider-availability-resume-work-"); const storage = new AgentStorage(join(workdir, "agents"), createTestLogger()); const manager = new AgentManager({ diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a4554bb5e..4f509e40a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -936,6 +936,16 @@ export async function createPaseoDaemon( workspaceGitService, github, config.pushNotificationSender, + { + listen: formatListenTarget(boundListenTarget ?? listenTarget), + relay: { + enabled: relayEnabled, + endpoint: relayEndpoint, + publicEndpoint: relayPublicEndpoint, + useTls: relayUseTls, + publicUseTls: relayPublicUseTls, + }, + }, ); if (relayEnabled) { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 4f32ab8e1..82d7fd94d 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -112,6 +112,7 @@ const checkoutGitMocks = vi.hoisted(() => ({ mergeToBase: vi.fn(), pullCurrentBranch: vi.fn(), pushCurrentBranch: vi.fn(), + renameCurrentBranch: vi.fn(), resolveBranchCheckout: vi.fn(), warmCheckoutShortstatInBackground: vi.fn(), })); @@ -203,6 +204,7 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => { mergeToBase: checkoutGitMocks.mergeToBase, pullCurrentBranch: checkoutGitMocks.pullCurrentBranch, pushCurrentBranch: checkoutGitMocks.pushCurrentBranch, + renameCurrentBranch: checkoutGitMocks.renameCurrentBranch, resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout, warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground, }; @@ -927,6 +929,16 @@ function createWorkspaceGitSnapshot( }; } +function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType }): { + setTerminalTitle: ReturnType; + subscribeTerminalsChanged: ReturnType; +} { + return { + setTerminalTitle: options?.setTerminalTitle ?? vi.fn(), + subscribeTerminalsChanged: vi.fn(() => () => {}), + }; +} + afterEach(() => { vi.clearAllMocks(); }); @@ -3158,6 +3170,208 @@ describe("session checkout switch branch handling", () => { }); }); +describe("session checkout rename branch handling", () => { + test("rejects invalid branch slugs without renaming", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "Feature Name", + requestId: "request-rename-invalid", + }); + + expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: false, + currentBranch: null, + error: { + code: "UNKNOWN", + message: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }, + requestId: "request-rename-invalid", + }, + }); + }); + + test("reports null current branch when branch rename fails", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists")); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-rename-failure", + }); + + expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/new-name", + ); + expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: false, + currentBranch: null, + error: { + code: "UNKNOWN", + message: "branch already exists", + }, + requestId: "request-rename-failure", + }, + }); + }); + + test("forces workspace git refresh after renaming the current branch", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/new-name", + isDirty: false, + }, + }), + ), + peekSnapshot: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/old-name" }, + }), + ), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.renameCurrentBranch.mockResolvedValue({ + previousBranch: "feature/old-name", + currentBranch: "feature/new-name", + }); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-rename-success", + }); + + expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/new-name", + ); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "rename-branch", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: true, + currentBranch: "feature/new-name", + error: null, + requestId: "request-rename-success", + }, + }); + }); +}); + +describe("session terminal rename handling", () => { + test("rejects an empty terminal title without calling the terminal manager", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub(); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: " ", + requestId: "request-empty-title", + }); + + expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-empty-title", + success: false, + error: "Title is required", + }, + }); + }); + + test("reports when the terminal manager cannot find the terminal", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub({ + setTerminalTitle: vi.fn(() => false), + }); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "missing-terminal", + title: "Renamed terminal", + requestId: "request-missing-terminal", + }); + + expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith( + "missing-terminal", + "Renamed terminal", + ); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-missing-terminal", + success: false, + error: "Terminal not found", + }, + }); + }); + + test("trims and sets a valid terminal title", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub({ + setTerminalTitle: vi.fn(() => true), + }); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: " Renamed terminal ", + requestId: "request-title-success", + }); + + expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal"); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-title-success", + success: true, + error: null, + }, + }); + }); +}); + describe("session branch suggestions handling", () => { test("lists branch suggestions through the workspace git service", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2e6a383d6..46a0978cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -20,6 +20,7 @@ import { type FileExplorerRequest, type FileDownloadTokenRequest, type GitSetupOptions, + type CheckoutRenameBranchRequest, type StartWorkspaceScriptRequest, type CloseItemsRequest, type SubscribeCheckoutDiffRequest, @@ -47,6 +48,8 @@ import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js" import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; +import { getPidLockInfo } from "./pid-lock.js"; +import { generateLocalPairingOffer } from "./pairing-offer.js"; import { DictationStreamManager, type DictationStreamOutboundMessage, @@ -192,7 +195,9 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, + renameCurrentBranch, } from "../utils/checkout-git.js"; +import { validateBranchSlug } from "../utils/branch-slug.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; @@ -306,8 +311,8 @@ type GitMutationRefreshReason = | "disable-pr-auto-merge" | "create-pr" | "switch-branch" - | "create-branch" | "rename-branch" + | "create-branch" | "stash-push" | "stash-pop" | "create-worktree"; @@ -429,6 +434,7 @@ type ProcessingPhase = "idle" | "transcribing"; interface WorkspaceGitWatchTarget { cwd: string; + workspaceId: string; watchers: FSWatcher[]; debounceTimer: ReturnType | null; refreshPromise: Promise | null; @@ -595,6 +601,18 @@ export interface SessionOptions { agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; isDev?: boolean; + serverId?: string; + daemonVersion?: string; + daemonRuntimeConfig?: { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + } | null; + }; } export type SessionLifecycleIntent = @@ -805,6 +823,9 @@ export class Session { private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerOverrides: Record | undefined; private readonly isDev: boolean; + private readonly serverId: string | undefined; + private readonly daemonVersion: string | undefined; + private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -850,6 +871,9 @@ export class Session { agentProviderRuntimeSettings, providerOverrides, isDev, + serverId, + daemonVersion, + daemonRuntimeConfig, } = options; this.clientId = clientId; this.appVersion = appVersion ?? null; @@ -901,6 +925,9 @@ export class Session { this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.providerOverrides = providerOverrides; this.isDev = isDev === true; + this.serverId = serverId; + this.daemonVersion = daemonVersion; + this.daemonRuntimeConfig = daemonRuntimeConfig; this.abortController = new AbortController(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, @@ -1864,6 +1891,10 @@ export class Session { payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() }, }); return undefined; + case "daemon.get_status.request": + return this.handleDaemonGetStatusRequest(msg); + case "daemon.get_pairing_offer.request": + return this.handleDaemonGetPairingOfferRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", @@ -2019,12 +2050,8 @@ export class Session { return undefined; case "checkout_switch_branch_request": return this.handleCheckoutSwitchBranchRequest(msg); - case "stash_save_request": - return this.handleStashSaveRequest(msg); - case "stash_pop_request": - return this.handleStashPopRequest(msg); - case "stash_list_request": - return this.handleStashListRequest(msg); + case "checkout.rename_branch.request": + return this.handleCheckoutRenameBranchRequest(msg); case "checkout_commit_request": return this.handleCheckoutCommitRequest(msg); case "checkout_merge_request": @@ -2047,6 +2074,12 @@ export class Session { return this.handlePullRequestTimelineRequest(msg); case "github_search_request": return this.handleGitHubSearchRequest(msg); + case "stash_save_request": + return this.handleStashSaveRequest(msg); + case "stash_pop_request": + return this.handleStashPopRequest(msg); + case "stash_list_request": + return this.handleStashListRequest(msg); default: return undefined; } @@ -3813,6 +3846,86 @@ export class Session { } } + private async handleDaemonGetStatusRequest( + msg: Extract, + ): Promise { + try { + const pidInfo = await getPidLockInfo(this.paseoHome); + const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({ + provider: p.provider, + available: p.available, + error: p.error ?? null, + })); + this.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: pidInfo?.startedAt ?? null, + listen: this.daemonRuntimeConfig?.listen ?? null, + relay: this.daemonRuntimeConfig?.relay ?? null, + providers, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle daemon status request"); + this.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: null, + relay: null, + providers: [], + }, + }); + } + } + + private async handleDaemonGetPairingOfferRequest( + msg: Extract, + ): Promise { + try { + const relay = this.daemonRuntimeConfig?.relay; + const pairing = await generateLocalPairingOffer({ + paseoHome: this.paseoHome, + relayEnabled: relay?.enabled ?? true, + relayEndpoint: relay?.endpoint, + relayPublicEndpoint: relay?.publicEndpoint, + relayUseTls: relay?.useTls, + relayPublicUseTls: relay?.publicUseTls, + includeQr: true, + logger: this.sessionLogger, + }); + this.emit({ + type: "daemon.get_pairing_offer.response", + payload: { + requestId: msg.requestId, + url: pairing.url ?? "", + qr: pairing.qr ?? null, + relayEnabled: pairing.relayEnabled, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request"); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: "daemon.get_pairing_offer.request", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleListAvailableProvidersRequest( msg: Extract, ): Promise { @@ -4826,16 +4939,35 @@ export class Session { target.lastBranchName = workspace?.name ?? null; } + private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void { + const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd)); + if (!target) { + return; + } + + const previousBranchName = target.lastBranchName; + if (branchName === previousBranchName) { + return; + } + + target.lastBranchName = branchName; + this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName); + } + private syncWorkspaceGitObservers(workspaces: Iterable): void { for (const workspace of workspaces) { this.syncWorkspaceGitObserver(workspace.workspaceDirectory, { isGit: workspace.projectKind === "git", + workspaceId: workspace.id, }); this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace); } } - private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void { + private syncWorkspaceGitObserver( + cwd: string, + options: { isGit: boolean; workspaceId: string }, + ): void { const normalizedCwd = normalizePersistedWorkspaceId(cwd); if (!options.isGit) { this.removeWorkspaceGitSubscription(normalizedCwd); @@ -4846,9 +4978,22 @@ export class Session { return; } + const target: WorkspaceGitWatchTarget = { + cwd: normalizedCwd, + workspaceId: options.workspaceId, + watchers: [], + debounceTimer: null, + refreshPromise: null, + refreshQueued: false, + latestDescriptorStateKey: null, + lastBranchName: null, + }; + this.workspaceGitWatchTargets.set(normalizedCwd, target); + const subscription = this.workspaceGitService.registerWorkspace( { cwd: normalizedCwd }, (snapshot) => { + this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null); void this.emitWorkspaceUpdateForCwd(normalizedCwd); this.emitCheckoutStatusUpdate(normalizedCwd, snapshot); }, @@ -4955,6 +5100,58 @@ export class Session { } } + private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise { + const { cwd, branch, requestId } = msg; + const validation = validateBranchSlug(branch); + + if (!validation.valid) { + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")), + requestId, + }, + }); + return; + } + + try { + const result = await renameCurrentBranch(cwd, branch); + await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + + // Push a workspace_update immediately so the sidebar/header reflect + // the new branch name without waiting for the background git watcher. + await this.emitWorkspaceUpdateForCwd(cwd); + + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: true, + currentBranch: result.currentBranch, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + // --------------------------------------------------------------------------- // Stash handlers // --------------------------------------------------------------------------- @@ -6672,7 +6869,10 @@ export class Session { private async emitWorkspaceUpdateForCwd( cwd: string, - options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, + options?: { + skipReconcile?: boolean; + dedupeGitState?: boolean; + }, ): Promise { const workspaces = await this.workspaceRegistry.list(); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 38e47afd0..be7cec138 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; +import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; +import { ScriptRouteStore } from "./script-proxy.js"; import { Session, type SessionOptions } from "./session.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceGitListener, WorkspaceGitRuntimeSnapshot, @@ -24,7 +27,7 @@ interface SessionInternals { lastEmittedByWorkspaceId: Map; }; buildWorkspaceDescriptorMap: () => Promise>; - syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void; + syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void; listAgentPayloads: () => Promise; } @@ -92,7 +95,15 @@ function createWorkspaceRuntimeSnapshot( }; } -function createSessionForWorkspaceGitWatchTests(): { +function createSessionForWorkspaceGitWatchTests(options?: { + onBranchChanged?: ( + workspaceId: string, + oldBranch: string | null, + newBranch: string | null, + ) => void; + scriptRouteStore?: ScriptRouteStore; + scriptRuntimeStore?: WorkspaceScriptRuntimeStore; +}): { session: Session; emitted: Array<{ type: string; payload: unknown }>; projects: Map>; @@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): { stt: null, tts: null, terminalManager: null, + scriptRouteStore: options?.scriptRouteStore, + scriptRuntimeStore: options?.scriptRuntimeStore, + onBranchChanged: options?.onBranchChanged, + getDaemonTcpPort: () => 6767, }); asInternals(session).listAgentPayloads = async () => []; @@ -305,7 +320,7 @@ describe("workspace git watch targets", () => { sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( { cwd: REPO_CWD }, @@ -364,7 +379,7 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); emitted.length = 0; subscriptions[0]?.listener( @@ -407,6 +422,74 @@ describe("workspace git watch targets", () => { await session.cleanup(); }); + test("updates running service script URLs when the git branch changes", async () => { + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "app.old-branch.paseo.localhost", + port: 4321, + workspaceId: "ws-10", + projectSlug: "paseo", + scriptName: "app", + }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: "ws-10", + scriptName: "app", + type: "service", + lifecycle: "running", + terminalId: "term-app", + exitCode: null, + }); + + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + onRoutesChanged: vi.fn(), + }); + const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests( + { + scriptRouteStore: routeStore, + scriptRuntimeStore: runtimeStore, + onBranchChanged: handleBranchChange, + }, + ); + const sessionAny = session as unknown as SessionInternals; + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: "/tmp/repo", + name: "old-branch", + }); + + sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true, workspaceId: "ws-10" }); + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "new-branch", + }, + }), + ); + + expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([ + expect.objectContaining({ + hostname: "app.new-branch.paseo.localhost", + projectSlug: "paseo", + scriptName: "app", + }), + ]); + expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([ + expect.objectContaining({ + scriptName: "app", + hostname: "app.new-branch.paseo.localhost", + proxyUrl: "http://app.new-branch.paseo.localhost:6767", + }), + ]); + + await session.cleanup(); + }); + test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); @@ -427,7 +510,7 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); emitted.length = 0; subscriptions[0]?.listener( diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f81438e9f..93088f5e6 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer { private readonly externalSessionsByKey: Map = new Map(); private readonly serverId: string; private readonly daemonVersion: string; + private readonly daemonRuntimeConfig: + | { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + }; + } + | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; @@ -416,6 +428,16 @@ export class VoiceAssistantWebSocketServer { workspaceGitService?: WorkspaceGitService, github?: GitHubService, pushNotificationSender?: PushNotificationSender, + daemonRuntimeConfig?: { + listen: string | null; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + }; + }, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -423,6 +445,7 @@ export class VoiceAssistantWebSocketServer { throw new MissingDaemonVersionError(); } this.daemonVersion = daemonVersion.trim(); + this.daemonRuntimeConfig = daemonRuntimeConfig; this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); @@ -921,6 +944,9 @@ export class VoiceAssistantWebSocketServer { agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, isDev: this.isDev, + serverId: this.serverId, + daemonVersion: this.daemonVersion, + daemonRuntimeConfig: this.daemonRuntimeConfig, }); connection = { @@ -1053,6 +1079,8 @@ export class VoiceAssistantWebSocketServer { providersSnapshot: true, // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. checkoutGithubSetAutoMerge: true, + // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. + daemonStatusRpc: true, }, }; } diff --git a/packages/server/src/shared/messages.rename-entities.test.ts b/packages/server/src/shared/messages.rename-entities.test.ts new file mode 100644 index 000000000..f032f553b --- /dev/null +++ b/packages/server/src/shared/messages.rename-entities.test.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">; + +function schemaWithoutMessageTypes( + schema: { options: SessionMessageOption[] }, + excludedTypes: string[], +) { + const excluded = new Set(excludedTypes); + const options = schema.options.filter((option) => !excluded.has(option.shape.type.value)); + + return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]); +} + +describe("rename entity message schemas", () => { + test("new client schema still parses old daemon checkout and terminal responses", () => { + const checkoutResponse = SessionOutboundMessageSchema.parse({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "main", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + const terminalResponse = SessionOutboundMessageSchema.parse({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + + expect(checkoutResponse).toEqual({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "main", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + expect(terminalResponse).toEqual({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + }); + + test("old unions without rename variants reject rename messages and still parse existing messages", () => { + const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [ + "terminal.rename.request", + "checkout.rename_branch.request", + ]); + const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [ + "terminal.rename.response", + "checkout.rename_branch.response", + ]); + + expect( + legacyInboundSchema.safeParse({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: "Server logs", + requestId: "request-terminal-rename", + }).success, + ).toBe(false); + expect( + legacyInboundSchema.safeParse({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-branch-rename", + }).success, + ).toBe(false); + expect( + legacyOutboundSchema.safeParse({ + type: "terminal.rename.response", + payload: { + requestId: "request-terminal-rename", + success: true, + error: null, + }, + }).success, + ).toBe(false); + expect( + legacyOutboundSchema.safeParse({ + type: "checkout.rename_branch.response", + payload: { + requestId: "request-branch-rename", + success: true, + cwd: "/tmp/repo", + currentBranch: "feature/new-name", + error: null, + }, + }).success, + ).toBe(false); + + expect( + legacyInboundSchema.parse({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "main", + requestId: "request-switch", + }), + ).toEqual({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "main", + requestId: "request-switch", + }); + expect( + legacyOutboundSchema.parse({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }), + ).toEqual({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index fc4eeafaf..76207e2f4 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -979,6 +979,16 @@ export const WaitForFinishRequestSchema = z.object({ timeoutMs: z.number().int().positive().optional(), }); +export const DaemonGetStatusRequestSchema = z.object({ + type: z.literal("daemon.get_status.request"), + requestId: z.string(), +}); + +export const DaemonGetPairingOfferRequestSchema = z.object({ + type: z.literal("daemon.get_pairing_offer.request"), + requestId: z.string(), +}); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -1379,6 +1389,13 @@ export const CheckoutSwitchBranchRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutRenameBranchRequestSchema = z.object({ + type: z.literal("checkout.rename_branch.request"), + cwd: z.string(), + branch: z.string(), + requestId: z.string(), +}); + export const StashSaveRequestSchema = z.object({ type: z.literal("stash_save_request"), cwd: z.string(), @@ -1696,6 +1713,13 @@ export const CreateTerminalRequestSchema = z.object({ requestId: z.string(), }); +export const RenameTerminalRequestSchema = z.object({ + type: z.literal("terminal.rename.request"), + terminalId: z.string(), + title: z.string(), + requestId: z.string(), +}); + export const StartWorkspaceScriptRequestSchema = z.object({ type: z.literal("start_workspace_script_request"), workspaceId: z.string(), @@ -1764,6 +1788,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, + DaemonGetStatusRequestSchema, + DaemonGetPairingOfferRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -1806,6 +1832,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusRequestSchema, PullRequestTimelineRequestSchema, CheckoutSwitchBranchRequestSchema, + CheckoutRenameBranchRequestSchema, StashSaveRequestSchema, StashPopRequestSchema, StashListRequestSchema, @@ -1833,6 +1860,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, + RenameTerminalRequestSchema, StartWorkspaceScriptRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, @@ -2026,6 +2054,8 @@ export const ServerInfoStatusPayloadSchema = z .object({ providersSnapshot: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(), + // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. + daemonStatusRpc: z.boolean().optional(), }) .optional(), }) @@ -2586,6 +2616,50 @@ export const GetDaemonConfigResponseMessageSchema = z.object({ .passthrough(), }); +export const DaemonGetStatusResponseSchema = z.object({ + type: z.literal("daemon.get_status.response"), + payload: z + .object({ + requestId: z.string(), + serverId: z.string(), + version: z.string().nullable().optional(), + pid: z.number(), + nodePath: z.string(), + startedAt: z.string().nullable().optional(), + listen: z.string().nullable(), + relay: z + .object({ + enabled: z.boolean(), + endpoint: z.string(), + publicEndpoint: z.string(), + useTls: z.boolean(), + publicUseTls: z.boolean(), + }) + .nullable() + .optional(), + providers: z.array( + z.object({ + provider: z.string(), + available: z.boolean(), + error: z.string().nullable().optional(), + }), + ), + }) + .passthrough(), +}); + +export const DaemonGetPairingOfferResponseSchema = z.object({ + type: z.literal("daemon.get_pairing_offer.response"), + payload: z + .object({ + requestId: z.string(), + url: z.string(), + qr: z.string().nullable().optional(), + relayEnabled: z.boolean(), + }) + .passthrough(), +}); + export const SetDaemonConfigResponseMessageSchema = z.object({ type: z.literal("set_daemon_config_response"), payload: z @@ -3047,6 +3121,17 @@ export const CheckoutSwitchBranchResponseSchema = z.object({ }), }); +export const CheckoutRenameBranchResponseSchema = z.object({ + type: z.literal("checkout.rename_branch.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + cwd: z.string(), + currentBranch: z.string().nullable(), + error: CheckoutErrorSchema.nullable(), + }), +}); + const StashEntrySchema = z.object({ index: z.number().int().min(0), message: z.string(), @@ -3401,6 +3486,15 @@ export const CreateTerminalResponseSchema = z.object({ }), }); +export const RenameTerminalResponseSchema = z.object({ + type: z.literal("terminal.rename.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SubscribeTerminalResponseSchema = z.object({ type: z.literal("subscribe_terminal_response"), payload: z.union([ @@ -3481,6 +3575,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ClearAgentAttentionResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, + DaemonGetStatusResponseSchema, + DaemonGetPairingOfferResponseSchema, GetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema, @@ -3512,6 +3608,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusResponseSchema, PullRequestTimelineResponseSchema, CheckoutSwitchBranchResponseSchema, + CheckoutRenameBranchResponseSchema, StashSaveResponseSchema, StashPopResponseSchema, StashListResponseSchema, @@ -3537,6 +3634,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListTerminalsResponseSchema, TerminalsChangedSchema, CreateTerminalResponseSchema, + RenameTerminalResponseSchema, SubscribeTerminalResponseSchema, KillTerminalResponseSchema, CaptureTerminalResponseSchema, @@ -3643,6 +3741,8 @@ export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; +export type DaemonGetStatusResponse = z.infer; +export type DaemonGetPairingOfferResponse = z.infer; export type GetProvidersSnapshotResponseMessage = z.infer< typeof GetProvidersSnapshotResponseMessageSchema >; @@ -3782,6 +3882,8 @@ export type PullRequestTimelineItem = z.infer; export type CheckoutSwitchBranchRequest = z.infer; export type CheckoutSwitchBranchResponse = z.infer; +export type CheckoutRenameBranchRequest = z.infer; +export type CheckoutRenameBranchResponse = z.infer; export type StashSaveRequest = z.infer; export type StashSaveResponse = z.infer; export type StashPopRequest = z.infer; @@ -3835,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; +export type RenameTerminalRequest = z.infer; +export type RenameTerminalResponse = z.infer; export type StartWorkspaceScriptRequest = z.infer; export type StartWorkspaceScriptResponse = z.infer< typeof StartWorkspaceScriptResponseMessageSchema diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 689340bd3..6104e3937 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -384,3 +384,35 @@ it("emits empty snapshot when last terminal is removed", async () => { unsubscribe(); }); + +it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + title: "Existing title", + }); + const snapshots: Array> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + })), + ); + }); + + expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false); + expect(session.getTitle()).toBe("Existing title"); + expect(session.getState().title).toBe("Existing title"); + expect(snapshots).toEqual([]); + + unsubscribe(); +}); + +it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + + expect(manager.setTerminalTitle(session.id, "x")).toBe(true); + expect(session.getTitle()).toBe("x"); +}); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 2a01b2252..dad8a1e4e 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -30,6 +30,7 @@ export interface TerminalManager { registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; getTerminalState(id: string): Promise; + setTerminalTitle(id: string, title: string): boolean; killTerminal(id: string): void; killTerminalAndWait( id: string, @@ -211,6 +212,16 @@ export function createTerminalManager(): TerminalManager { return terminalsById.get(id)?.getStateSnapshot() ?? null; }, + setTerminalTitle(id: string, title: string): boolean { + const session = terminalsById.get(id); + if (!session) { + return false; + } + + session.setTitle(title); + return true; + }, + killTerminal(id: string): void { removeSessionById(id, { kill: true }); }, diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 103a6b4b3..c2e226b88 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -4,6 +4,7 @@ import type { CreateTerminalRequest, KillTerminalRequest, ListTerminalsRequest, + RenameTerminalRequest, SessionInboundMessage, SessionOutboundMessage, SubscribeTerminalRequest, @@ -64,7 +65,8 @@ type TerminalDispatchableMessage = | UnsubscribeTerminalRequest | TerminalInput | KillTerminalRequest - | CaptureTerminalRequest; + | CaptureTerminalRequest + | RenameTerminalRequest; const TERMINAL_MESSAGE_TYPES: ReadonlySet = new Set([ "subscribe_terminals_request", @@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet = "terminal_input", "kill_terminal_request", "capture_terminal_request", + "terminal.rename.request", ]); export class TerminalSessionController { @@ -145,6 +148,8 @@ export class TerminalSessionController { return this.handleKillTerminalRequest(msg); case "capture_terminal_request": return this.handleCaptureTerminalRequest(msg); + case "terminal.rename.request": + return this.handleRenameTerminalRequest(msg); default: return undefined; } @@ -430,6 +435,32 @@ export class TerminalSessionController { } } + private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise { + const respond = (success: boolean, error: string | null): void => { + this.emit({ + type: "terminal.rename.response", + payload: { requestId: msg.requestId, success, error }, + }); + }; + + const title = msg.title.trim(); + if (title.length === 0) { + respond(false, "Title is required"); + return; + } + if (title.length > 200) { + respond(false, "Title is too long"); + return; + } + if (!this.terminalManager) { + respond(false, "Terminal manager not available"); + return; + } + + const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title); + respond(renamed, renamed ? null : "Terminal not found"); + } + private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index f20258ca1..b6b49c691 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,15 +1,19 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { + buildTerminalEnvironment, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, humanizeProcessTitle, normalizeProcessTitle, + resolveZshShellIntegrationDir, type TerminalSession, } from "./terminal.js"; import { chmodSync, + cpSync, + existsSync, mkdtempSync, mkdirSync, realpathSync, @@ -17,8 +21,92 @@ import { statSync, writeFileSync, } from "node:fs"; +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; + +const hasZsh = existsSync("/bin/zsh"); + +type TerminalRow = ReturnType["grid"][number]; + +function rowToText(row: TerminalRow): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +// Extract text from a single row +function getRowText(state: ReturnType, rowIndex: number): string { + return rowToText(state.grid[rowIndex]); +} + +// Extract all visible lines as array (trimmed, empty lines included) +function getLines(state: ReturnType): string[] { + return state.grid.map(rowToText); +} + +// Wait for terminal state to match expected lines +async function waitForLines( + session: TerminalSession, + expectedLines: string[], + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const lines = getLines(session.getState()); + let matches = true; + for (let i = 0; i < expectedLines.length; i++) { + if (lines[i] !== expectedLines[i]) { + matches = false; + break; + } + } + if (matches) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const actual = getLines(session.getState()).slice(0, expectedLines.length); + throw new Error( + `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, + ); +} + +async function waitForState( + session: TerminalSession, + predicate: (state: ReturnType) => boolean, + timeoutMs = 5000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const state = session.getState(); + if (predicate(state)) { + return state; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error("Timeout waiting for terminal state predicate to match"); +} + +async function waitForTitle( + session: TerminalSession, + predicate: (title: string | undefined) => boolean, + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const title = session.getTitle(); + if (predicate(title)) { + return title; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + throw new Error("Timeout waiting for terminal title predicate to match"); +} if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; @@ -28,6 +116,7 @@ const sessions: TerminalSession[] = []; const temporaryDirs: string[] = []; afterEach(async () => { + vi.useRealTimers(); for (const session of sessions) { session.kill(); } @@ -45,6 +134,17 @@ function trackSession(session: TerminalSession): TerminalSession { return session; } +async function waitForScheduledTimers(expectedTimerCount: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (vi.getTimerCount() === expectedTimerCount) { + return; + } + await waitForImmediate(); + } + + throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`); +} + describe("createTerminal", () => { it("keeps full process titles while stripping path prefixes", () => { expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev"); @@ -205,6 +305,544 @@ describe("createTerminal", () => { }); }); +describe.skipIf(isPlatform("win32"))("send input", () => { + it("executes a simple echo command", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + // Wait for initial prompt, then send command + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo hello\r" }); + + // After running "echo hello", terminal should show: + // Line 0: "$ echo hello" + // Line 1: "hello" + // Line 2: "$" + await waitForLines(session, ["$ echo hello", "hello", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo hello"); + expect(getRowText(state, 1)).toBe("hello"); + expect(getRowText(state, 2)).toBe("$"); + }); + + it("captures output from pwd in specified cwd", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "pwd\r" }); + + await waitForLines(session, ["$ pwd", "/tmp", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ pwd"); + expect(getRowText(state, 1)).toBe("/tmp"); + expect(getRowText(state, 2)).toBe("$"); + }); +}); + +describe.skipIf(isPlatform("win32"))("terminal title", () => { + it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + mkdirSync(realZdotdir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); + + const session = trackSession( + await createTerminal({ + cwd: homeDir, + command: "/bin/zsh", + args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); + }); + + it("emits the initial title from command args to title listeners", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForTitle(session, (title) => title === "npm run dev"); + await waitForState(session, (state) => state.title === "npm run dev"); + + expect(seenTitles).toContain("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); + expect(session.getState().title).toBe("npm run dev"); + + unsubscribeTitle(); + }); + + it("emits OSC title updates to title listeners", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + expect(seenTitles).toContain("Build Log"); + expect(session.getTitle()).toBe("Build Log"); + expect(session.getState().title).toBe("Build Log"); + + unsubscribeTitle(); + }); + + it("keeps preset titles instead of applying OSC title updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + title: "typecheck", + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(session.getTitle()).toBe("typecheck"); + expect(session.getState().title).toBe("typecheck"); + }); + + it("emits command completion from VS Code OSC 633 without visible output", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-command-finished.sh"); + writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([7]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + + unsubscribeCommandFinished(); + }); + + it("ignores malformed VS Code OSC 633 command completion payloads", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); + writeFileSync( + scriptPath, + "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", + ); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([3]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + + unsubscribeCommandFinished(); + }); + + it("debounces rapid title changes and emits only the final title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const seenMessages: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + const unsubscribeMessages = session.subscribe((message) => { + if (message.type === "titleChange") { + seenMessages.push(message.title); + } + }); + + await waitForLines(session, ["$"]); + session.send({ + type: "input", + data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + }); + + await waitForTitle(session, (title) => title === "Final"); + + expect(seenTitles).toEqual(["Final"]); + expect(seenMessages).toEqual(["Final"]); + + unsubscribeMessages(); + unsubscribeTitle(); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + await waitForLines(session, ["$"]); + await waitForTitle(session, (title) => title === "~/dev/faro"); + + session.send({ type: "input", data: "sleep 1\r" }); + + await waitForTitle(session, (title) => title === "sleep 1"); + await waitForTitle(session, (title) => title === "~/dev/faro", 4000); + }); + + it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-")); + temporaryDirs.push(homeDir); + writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n"); + + const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources"); + const inaccessiblePackagedIntegrationDir = join( + fakeAppRoot, + "app.asar", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + const unpackedIntegrationDir = join( + fakeAppRoot, + "app.asar.unpacked", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + mkdirSync(unpackedIntegrationDir, { recursive: true }); + cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true }); + writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder"); + + const env = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: homeDir, + }, + zshShellIntegrationDir: inaccessiblePackagedIntegrationDir, + }); + + const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], { + cwd: homeDir, + env, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> "); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "false\r" }); + + await waitForState(session, () => commandCompletions.includes(1)); + + expect(commandCompletions).toEqual([1]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + + unsubscribeCommandFinished(); + }); + + it("clears already scheduled OSC title debounce timers when setting a user title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + vi.useFakeTimers(); + session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" }); + await waitForScheduledTimers(1); + + session.setTitle("User terminal"); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); + + it("ignores later OSC title updates after setting a user title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + session.setTitle("User terminal"); + session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); + + it("trims user-set titles and treats empty titles as no-ops", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + + session.setTitle(" "); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await waitForTitle(session, (title) => title === "Build Log"); + + session.setTitle(" User terminal "); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); +}); + +describe.skipIf(isPlatform("win32"))("colors", () => { + it("captures ANSI 16 color codes (mode 1)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[31m = ANSI red (color 1) + session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fg).toBe(1); // ANSI red = 1 + expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors + + // The "$" after RED should have default color + expect(outputRow[3].char).toBe("$"); + expect(outputRow[3].fg).toBe(undefined); + expect(outputRow[3].fgMode).toBe(undefined); + }); + + it("captures true color RGB (mode 3)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;2;255;128;64m = true color RGB(255, 128, 64) + session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check R cell + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color + + // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 + const expectedPacked = (255 << 16) | (128 << 8) | 64; + expect(outputRow[0].fg).toBe(expectedPacked); + }); + + it("captures background colors", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[41m = ANSI red background + session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("B"); + expect(outputRow[0].bg).toBe(1); // ANSI red = 1 + expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors + }); +}); + describe("resize", () => { it("updates terminal dimensions on resize", async () => { const session = trackSession( diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 56f54697c..4cd54e0cd 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -57,6 +57,7 @@ export interface TerminalSession { getStateSnapshot(): TerminalStateSnapshot; getReplayPreamble(): string; getTitle(): string | undefined; + setTitle(title: string): void; getExitInfo(): TerminalExitInfo | null; kill(): void; killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise; @@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; let pendingInput = ""; let inputFlushImmediate: ReturnType | null = null; let stateRevision = 0; const inputModeTracker = new TerminalInputModeTracker(); + let titleChangeSubscription: { dispose(): void } | null = null; // Create xterm.js headless terminal const terminal = new Terminal({ @@ -598,14 +601,40 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (params.length === 0 || (params.length === 1 && params[0] === 0)) { ptyProcess.write("\x1b[?62;4;22c"); @@ -637,9 +666,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + if (titleMode === "auto") { + titleChangeSubscription = terminal.onTitleChange((nextTitle) => { if (disposed || killed) { return; } @@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { titleDebounceTimer = null; emitTitleChange(pendingTitle); + pendingTitle = undefined; }, TERMINAL_TITLE_DEBOUNCE_MS); }); } @@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { // no-op; kill is intentionally best-effort and synchronous in the public interface. diff --git a/packages/server/src/utils/branch-slug.test.ts b/packages/server/src/utils/branch-slug.test.ts new file mode 100644 index 000000000..343bbd16b --- /dev/null +++ b/packages/server/src/utils/branch-slug.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { slugify, validateBranchSlug } from "./branch-slug.js"; + +describe("branch slug utilities", () => { + it("normalizes display names to lowercase branch slugs", () => { + expect(slugify("My Feature")).toBe("my-feature"); + }); + + it("collapses punctuation and whitespace to a single hyphen", () => { + expect(slugify("My___Feature! @#$ Next")).toBe("my-feature-next"); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(" --- My Feature !!! ")).toBe("my-feature"); + }); + + it("enforces the 50 character slug limit", () => { + const slug = slugify("a".repeat(60)); + + expect(slug).toBe("a".repeat(50)); + }); + + it("validates branch slugs with clear messages", () => { + expect(validateBranchSlug("my-feature")).toEqual({ valid: true }); + expect(validateBranchSlug("")).toEqual({ + valid: false, + error: "Branch name cannot be empty", + }); + expect(validateBranchSlug("My Feature")).toEqual({ + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }); + expect(validateBranchSlug("-my-feature")).toEqual({ + valid: false, + error: "Branch name cannot start or end with a hyphen", + }); + }); + + it("is exported through the package subpath", () => { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const packageJson = JSON.parse( + readFileSync(join(currentDir, "..", "..", "package.json"), "utf8"), + ) as { + exports?: Record; + }; + + expect(packageJson.exports?.["./utils/branch-slug"]).toEqual({ + types: "./dist/server/utils/branch-slug.d.ts", + source: "./src/utils/branch-slug.ts", + default: "./dist/server/utils/branch-slug.js", + }); + }); + + it("does not import server-only modules", () => { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(currentDir, "branch-slug.ts"), "utf8"); + + expect(source).not.toMatch( + /from\s+["'](?:node:)?(?:fs|path|child_process)["']|from\s+["']node:/, + ); + }); +}); diff --git a/packages/server/src/utils/branch-slug.ts b/packages/server/src/utils/branch-slug.ts new file mode 100644 index 000000000..1713372fe --- /dev/null +++ b/packages/server/src/utils/branch-slug.ts @@ -0,0 +1,61 @@ +/** + * Validate that a string is a valid git branch name slug. + * Must be lowercase alphanumeric with hyphens and forward slashes only. + */ +export function validateBranchSlug(slug: string): { + valid: boolean; + error?: string; +} { + if (!slug || slug.length === 0) { + return { valid: false, error: "Branch name cannot be empty" }; + } + + if (slug.length > 100) { + return { valid: false, error: "Branch name too long (max 100 characters)" }; + } + + const validPattern = /^[a-z0-9-/]+$/; + if (!validPattern.test(slug)) { + return { + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; + } + + if (slug.startsWith("-") || slug.endsWith("-")) { + return { + valid: false, + error: "Branch name cannot start or end with a hyphen", + }; + } + + if (slug.includes("--")) { + return { valid: false, error: "Branch name cannot have consecutive hyphens" }; + } + + return { valid: true }; +} + +export const MAX_SLUG_LENGTH = 50; + +/** + * Convert a string to kebab-case for branch names. + */ +export function slugify(input: string): string { + const slug = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + if (slug.length <= MAX_SLUG_LENGTH) { + return slug; + } + + const truncated = slug.slice(0, MAX_SLUG_LENGTH); + const lastHyphen = truncated.lastIndexOf("-"); + if (lastHyphen > MAX_SLUG_LENGTH / 2) { + return truncated.slice(0, lastHyphen); + } + return truncated.replace(/-+$/, ""); +} diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 1fc062657..6141109e2 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -16,8 +16,12 @@ vi.mock("child_process", async () => { const [command, commandArgs] = args; if (command === "git" && Array.isArray(commandArgs)) { const normalizedArgs = commandArgs.map((arg) => String(arg)); + // `runGitCommand` always prepends `-c core.quotepath=false`; skip it to + // find the actual git subcommand. + const subcommandIndex = + normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 2 : 0; const isTrackedTextDiff = - normalizedArgs[0] === "diff" && + normalizedArgs[subcommandIndex] === "diff" && normalizedArgs.includes("HEAD") && !normalizedArgs.includes("--numstat") && !normalizedArgs.includes("--no-index") && diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 44c5d08cf..a5a8db146 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { existsSync, mkdtempSync, @@ -34,6 +34,7 @@ import { resolveBranchCheckout, resolveRepositoryDefaultBranch, parseWorktreeList, + renameCurrentBranch, isPaseoWorktreePath, isDescendantPath, warmCheckoutShortstatInBackground, @@ -263,6 +264,48 @@ describe("checkout git utilities", () => { expect(branch).toBe("feature/rebase-test"); }); + it("renames the checked out branch and returns concrete branch names", async () => { + execSync("git checkout -b feature/old-name", { cwd: repoDir }); + + const result = await renameCurrentBranch(repoDir, "feature/new-name"); + + const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim(); + expect(currentBranch).toBe("feature/new-name"); + expect(result).toEqual({ + previousBranch: "feature/old-name", + currentBranch: "feature/new-name", + }); + expect(() => + execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }), + ).toThrow(); + expect( + execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/new-name"); + }); + + it("fails when renaming the checked out branch to an existing branch", async () => { + execSync("git branch feature/new-name", { cwd: repoDir }); + execSync("git checkout -b feature/old-name", { cwd: repoDir }); + + await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow(); + + expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe( + "feature/old-name", + ); + expect( + execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/old-name"); + expect( + execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/new-name"); + }); + it("handles status/diff/commit in a normal repo", async () => { writeFileSync(join(repoDir, "file.txt"), "updated\n"); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 383f6d770..b889b8f08 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix"; const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; -const DEFAULT_MAX_DEPTH = 6; -const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; +const DEFAULT_MAX_DEPTH = 12; +const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 2ff2dd14c..bef9b73be 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -77,7 +77,9 @@ export function runGitCommand( logger.trace(traceContext, "Spawning git command"); } - const child = spawnProcess("git", args, { + // `core.quotepath=false` makes git emit raw UTF-8 paths instead of + // octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`). + const child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], { cwd: options.cwd, envOverlay, shell: false, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 80aefb2d0..24538a603 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -30,6 +30,9 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; +import { validateBranchSlug } from "./branch-slug.js"; + +export { slugify, validateBranchSlug } from "./branch-slug.js"; const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV = { @@ -740,72 +743,6 @@ export async function getGitCommonDir(cwd: string): Promise { return commonDir; } -/** - * Validate that a string is a valid git branch name slug - * Must be lowercase, alphanumeric, hyphens only - */ -export function validateBranchSlug(slug: string): { - valid: boolean; - error?: string; -} { - if (!slug || slug.length === 0) { - return { valid: false, error: "Branch name cannot be empty" }; - } - - if (slug.length > 100) { - return { valid: false, error: "Branch name too long (max 100 characters)" }; - } - - // Check for valid characters: lowercase letters, numbers, hyphens, forward slashes - const validPattern = /^[a-z0-9-/]+$/; - if (!validPattern.test(slug)) { - return { - valid: false, - error: - "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - - // Cannot start or end with hyphen - if (slug.startsWith("-") || slug.endsWith("-")) { - return { - valid: false, - error: "Branch name cannot start or end with a hyphen", - }; - } - - // Cannot have consecutive hyphens - if (slug.includes("--")) { - return { valid: false, error: "Branch name cannot have consecutive hyphens" }; - } - - return { valid: true }; -} - -const MAX_SLUG_LENGTH = 50; - -/** - * Convert string to kebab-case for branch names - */ -export function slugify(input: string): string { - const slug = input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - if (slug.length <= MAX_SLUG_LENGTH) { - return slug; - } - - // Truncate at word boundary (hyphen) if possible - const truncated = slug.slice(0, MAX_SLUG_LENGTH); - const lastHyphen = truncated.lastIndexOf("-"); - if (lastHyphen > MAX_SLUG_LENGTH / 2) { - return truncated.slice(0, lastHyphen); - } - return truncated.replace(/-+$/, ""); -} - const WORKTREE_PROJECT_HASH_LENGTH = 8; function deriveShortAlphanumericHash(value: string): string { diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 29d747329..54f000800 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -15,20 +15,19 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may ### Agents -| Tool | Function | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt, provider features, or a new git worktree. | -| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | -| `send_agent_prompt` | Send a task to a running agent. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata. | -| `cancel_agent` | Abort an agent's current run but keep the agent alive. | -| `archive_agent` | Soft-delete an agent and remove it from the active list. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name or labels. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | -| `set_agent_feature` | Set a provider-specific feature on an existing agent, for example Codex `fast_mode`. | +| Tool | Function | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. | +| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | +| `send_agent_prompt` | Send a task to a running agent. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata. | +| `cancel_agent` | Abort an agent's current run but keep the agent alive. | +| `archive_agent` | Soft-delete an agent and remove it from the active list. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `set_agent_mode` | Switch an agent's session mode. | ### Terminals @@ -53,11 +52,11 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may ### Providers -| Tool | Function | -| ------------------------ | ------------------------------------------------------------------------------------------- | -| `list_providers` | List configured agent providers, availability, and modes. | -| `list_models` | List models for an agent provider. | -| `list_provider_features` | List provider-specific features for a draft agent configuration, such as Codex `fast_mode`. | +| Tool | Function | +| ------------------ | ----------------------------------------------------------------- | +| `list_providers` | List configured agent providers, availability, and modes. | +| `list_models` | List models for an agent provider. | +| `inspect_provider` | Inspect compact provider capabilities and draft feature settings. | ### Worktrees diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 1115202a9..c40be9f72 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -20,25 +20,29 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo. ## Agents -**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `features`. Returns `{ agentId, … }`. +**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `settings`. Returns `{ agentId, … }`. -Provider features are provider-specific. For Codex fast mode, pass `features: { "fast_mode": true }` when creating the agent. +Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`. **`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget. -**`set_agent_feature`** — `{ agentId, featureId, value }`. Use for provider-specific toggles on an existing agent, for example `{ agentId, featureId: "fast_mode", value: true }` for Codex. +**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. **`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`. **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list. -## Provider features +## Provider discovery -**`list_provider_features`** — query provider-specific features before setting them. Required: `provider`, `cwd`. Optional: `model`, `modeId`, `thinkingOptionId`, `featureValues`. +**`list_providers`** — compact provider availability and modes. -Only set feature IDs returned by `list_provider_features`. For Codex fast mode, look for `fast_mode` and pass `features: { "fast_mode": true }` to `create_agent`. +**`list_models`** — full model list for one provider. Use only when you need model IDs or thinking options; the list can be large. + +**`inspect_provider`** — compact provider capability and feature inspection. Required: `provider`; pass `cwd` when you are not in an agent-scoped session. Optional: `settings` with draft `model`, `modeId`, `thinkingOptionId`, and `features`. + +Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look for `fast_mode` and pass `settings: { features: { "fast_mode": true } }` to `create_agent` or `update_agent`. ## Heartbeats