From 2608506973f3d081727c99fbcbad7504e7c3d330 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:58:32 -0500 Subject: [PATCH 1/5] Add browser-compatible agent loop packages --- common/config/rush/pnpm-lock.yaml | 982 +++++++++++++++++- packages/acp-agent/package.json | 3 + .../src/agent-prompt-cancellation.test.ts | 54 +- .../src/agent-session-lifecycle.test.ts | 45 +- packages/acp-agent/src/agent.ts | 673 +----------- packages/acp-agent/src/index.ts | 4 +- packages/agent-core/config/rig.json | 4 + packages/agent-core/eslint.config.cjs | 3 + packages/agent-core/package.json | 30 + packages/agent-core/src/agent.ts | 410 ++++++++ packages/agent-core/src/index.ts | 5 + packages/agent-core/src/interfaces.ts | 78 ++ packages/agent-core/src/prompt-content.ts | 92 ++ packages/agent-core/src/prompt-errors.test.ts | 24 + packages/agent-core/src/prompt-errors.ts | 157 +++ packages/agent-core/src/session-cleanup.ts | 106 ++ packages/agent-core/tsconfig.json | 8 + packages/agent-core/vitest.config.ts | 3 + .../config/rig.json | 4 + .../eslint.config.cjs | 3 + .../mcp-workspace-webcontainer/package.json | 29 + .../mcp-workspace-webcontainer/src/index.ts | 2 + .../mcp-workspace-webcontainer/src/runtime.ts | 162 +++ .../mcp-workspace-webcontainer/src/sidecar.ts | 203 ++++ .../mcp-workspace-webcontainer/tsconfig.json | 9 + .../vitest.config.ts | 7 + packages/session-file-system/config/rig.json | 4 + .../session-file-system/eslint.config.cjs | 3 + packages/session-file-system/package.json | 28 + packages/session-file-system/src/index.ts | 73 ++ .../src/session-file-system.test.ts | 43 + packages/session-file-system/tsconfig.json | 8 + packages/session-file-system/vitest.config.ts | 3 + .../session-local-storage/config/rig.json | 4 + .../session-local-storage/eslint.config.cjs | 3 + packages/session-local-storage/package.json | 28 + packages/session-local-storage/src/index.ts | 66 ++ .../src/session-local-storage.test.ts | 61 ++ packages/session-local-storage/tsconfig.json | 9 + .../session-local-storage/vitest.config.ts | 3 + packages/session-memory/config/rig.json | 4 + packages/session-memory/eslint.config.cjs | 3 + packages/session-memory/package.json | 28 + packages/session-memory/src/index.ts | 37 + .../session-memory/src/session-memory.test.ts | 25 + packages/session-memory/tsconfig.json | 8 + packages/session-memory/vitest.config.ts | 3 + packages/tools-mcp-node/config/rig.json | 4 + packages/tools-mcp-node/eslint.config.cjs | 3 + packages/tools-mcp-node/package.json | 31 + packages/tools-mcp-node/src/config.test.ts | 65 ++ packages/tools-mcp-node/src/config.ts | 44 + packages/tools-mcp-node/src/index.ts | 173 +++ packages/tools-mcp-node/tsconfig.json | 8 + packages/tools-mcp-node/vitest.config.ts | 3 + packages/web-agent/config/rig.json | 4 + packages/web-agent/eslint.config.cjs | 3 + packages/web-agent/package.json | 34 + packages/web-agent/src/dependencies.ts | 34 + packages/web-agent/src/index.ts | 4 + packages/web-agent/src/tool-provider.ts | 49 + packages/web-agent/src/webpack-entry.ts | 1 + packages/web-agent/tsconfig.json | 9 + packages/web-agent/webpack.config.cjs | 43 + rush.json | 28 + 65 files changed, 3408 insertions(+), 676 deletions(-) create mode 100644 packages/agent-core/config/rig.json create mode 100644 packages/agent-core/eslint.config.cjs create mode 100644 packages/agent-core/package.json create mode 100644 packages/agent-core/src/agent.ts create mode 100644 packages/agent-core/src/index.ts create mode 100644 packages/agent-core/src/interfaces.ts create mode 100644 packages/agent-core/src/prompt-content.ts create mode 100644 packages/agent-core/src/prompt-errors.test.ts create mode 100644 packages/agent-core/src/prompt-errors.ts create mode 100644 packages/agent-core/src/session-cleanup.ts create mode 100644 packages/agent-core/tsconfig.json create mode 100644 packages/agent-core/vitest.config.ts create mode 100644 packages/mcp-workspace-webcontainer/config/rig.json create mode 100644 packages/mcp-workspace-webcontainer/eslint.config.cjs create mode 100644 packages/mcp-workspace-webcontainer/package.json create mode 100644 packages/mcp-workspace-webcontainer/src/index.ts create mode 100644 packages/mcp-workspace-webcontainer/src/runtime.ts create mode 100644 packages/mcp-workspace-webcontainer/src/sidecar.ts create mode 100644 packages/mcp-workspace-webcontainer/tsconfig.json create mode 100644 packages/mcp-workspace-webcontainer/vitest.config.ts create mode 100644 packages/session-file-system/config/rig.json create mode 100644 packages/session-file-system/eslint.config.cjs create mode 100644 packages/session-file-system/package.json create mode 100644 packages/session-file-system/src/index.ts create mode 100644 packages/session-file-system/src/session-file-system.test.ts create mode 100644 packages/session-file-system/tsconfig.json create mode 100644 packages/session-file-system/vitest.config.ts create mode 100644 packages/session-local-storage/config/rig.json create mode 100644 packages/session-local-storage/eslint.config.cjs create mode 100644 packages/session-local-storage/package.json create mode 100644 packages/session-local-storage/src/index.ts create mode 100644 packages/session-local-storage/src/session-local-storage.test.ts create mode 100644 packages/session-local-storage/tsconfig.json create mode 100644 packages/session-local-storage/vitest.config.ts create mode 100644 packages/session-memory/config/rig.json create mode 100644 packages/session-memory/eslint.config.cjs create mode 100644 packages/session-memory/package.json create mode 100644 packages/session-memory/src/index.ts create mode 100644 packages/session-memory/src/session-memory.test.ts create mode 100644 packages/session-memory/tsconfig.json create mode 100644 packages/session-memory/vitest.config.ts create mode 100644 packages/tools-mcp-node/config/rig.json create mode 100644 packages/tools-mcp-node/eslint.config.cjs create mode 100644 packages/tools-mcp-node/package.json create mode 100644 packages/tools-mcp-node/src/config.test.ts create mode 100644 packages/tools-mcp-node/src/config.ts create mode 100644 packages/tools-mcp-node/src/index.ts create mode 100644 packages/tools-mcp-node/tsconfig.json create mode 100644 packages/tools-mcp-node/vitest.config.ts create mode 100644 packages/web-agent/config/rig.json create mode 100644 packages/web-agent/eslint.config.cjs create mode 100644 packages/web-agent/package.json create mode 100644 packages/web-agent/src/dependencies.ts create mode 100644 packages/web-agent/src/index.ts create mode 100644 packages/web-agent/src/tool-provider.ts create mode 100644 packages/web-agent/src/webpack-entry.ts create mode 100644 packages/web-agent/tsconfig.json create mode 100644 packages/web-agent/webpack.config.cjs diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 86081c8..a3fdc26 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@ai-sdk/openai': specifier: ^2.0.0 version: 2.0.106(zod@3.25.76) + '@fledgling/agent-core': + specifier: workspace:* + version: link:../agent-core '@fledgling/common': specifier: workspace:* version: link:../common @@ -30,9 +33,15 @@ importers: '@fledgling/mcp-workspace': specifier: workspace:* version: link:../mcp-workspace + '@fledgling/session-file-system': + specifier: workspace:* + version: link:../session-file-system '@fledgling/session-log': specifier: workspace:* version: link:../session-log + '@fledgling/tools-mcp-node': + specifier: workspace:* + version: link:../tools-mcp-node ai: specifier: ^5.0.0 version: 5.0.193(zod@3.25.76) @@ -48,13 +57,13 @@ importers: version: 22.19.19 '@vitest/coverage-v8': specifier: ^3.1.1 - version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) typescript: specifier: ~5.9.0 version: 5.9.3 vitest: specifier: ^3.1.1 - version: 3.2.6(@types/node@22.19.19) + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) ../../packages/acp-host-log: dependencies: @@ -90,6 +99,40 @@ importers: specifier: ~5.9.0 version: 5.9.3 + ../../packages/agent-core: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.4.3 + version: 0.4.9 + '@fledgling/common': + specifier: workspace:* + version: link:../common + '@fledgling/context-builder': + specifier: workspace:* + version: link:../context-builder + ai: + specifier: ^5.0.0 + version: 5.0.193(zod@3.25.76) + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + ../../packages/common: devDependencies: '@fledgling/heft-rig': @@ -122,13 +165,13 @@ importers: version: 22.19.19 '@vitest/coverage-v8': specifier: ^3.1.1 - version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) typescript: specifier: ~5.9.0 version: 5.9.3 vitest: specifier: ^3.1.1 - version: 3.2.6(@types/node@22.19.19) + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) ../../packages/mcp-workspace: dependencies: @@ -155,6 +198,93 @@ importers: specifier: ~5.9.0 version: 5.9.3 + ../../packages/mcp-workspace-webcontainer: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.21.0 + version: 1.29.0(zod@3.25.76) + '@webcontainer/api': + specifier: ^1.5.1 + version: 1.6.4 + zod: + specifier: ^3.25.0 + version: 3.25.76 + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + + ../../packages/session-file-system: + dependencies: + '@fledgling/agent-core': + specifier: workspace:* + version: link:../agent-core + '@fledgling/common': + specifier: workspace:* + version: link:../common + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + + ../../packages/session-local-storage: + dependencies: + '@fledgling/agent-core': + specifier: workspace:* + version: link:../agent-core + '@fledgling/common': + specifier: workspace:* + version: link:../common + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + ../../packages/session-log: dependencies: '@fledgling/common': @@ -172,13 +302,124 @@ importers: version: 22.19.19 '@vitest/coverage-v8': specifier: ^3.1.1 - version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + + ../../packages/session-memory: + dependencies: + '@fledgling/agent-core': + specifier: workspace:* + version: link:../agent-core + '@fledgling/common': + specifier: workspace:* + version: link:../common + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + + ../../packages/tools-mcp-node: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.4.3 + version: 0.4.9 + '@ai-sdk/mcp': + specifier: 0.0.18 + version: 0.0.18(zod@3.25.76) + '@fledgling/agent-core': + specifier: workspace:* + version: link:../agent-core + '@fledgling/mcp-workspace': + specifier: workspace:* + version: link:../mcp-workspace + ai: + specifier: ^5.0.0 + version: 5.0.193(zod@3.25.76) + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + + ../../packages/web-agent: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.4.3 + version: 0.4.9 + '@ai-sdk/mcp': + specifier: 0.0.18 + version: 0.0.18(zod@3.25.76) + '@fledgling/agent-core': + specifier: workspace:* + version: link:../agent-core + '@fledgling/mcp-workspace-webcontainer': + specifier: workspace:* + version: link:../mcp-workspace-webcontainer + '@fledgling/session-local-storage': + specifier: workspace:* + version: link:../session-local-storage + ai: + specifier: ^5.0.0 + version: 5.0.193(zod@3.25.76) + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) typescript: specifier: ~5.9.0 version: 5.9.3 vitest: specifier: ^3.1.1 - version: 3.2.6(@types/node@22.19.19) + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + webpack: + specifier: ^5.99.9 + version: 5.107.2(webpack-cli@6.0.1) + webpack-cli: + specifier: ^6.0.1 + version: 6.0.1(webpack@5.107.2) ../../rigs/heft-rig: dependencies: @@ -196,7 +437,7 @@ importers: version: 1.3.2(@rushstack/heft@1.2.7(@types/node@22.19.19))(@types/node@22.19.19) '@vitest/coverage-v8': specifier: ^3.1.1 - version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) eslint: specifier: ~9.39.4 version: 9.39.4 @@ -205,7 +446,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.1 - version: 3.2.6(@types/node@22.19.19) + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) packages: @@ -265,6 +506,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@discoveryjs/json-ext@0.6.3': + resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} + engines: {node: '>=14.17.0'} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -500,6 +745,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -894,10 +1142,95 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@webcontainer/api@1.6.4': + resolution: {integrity: sha512-r9sHCXg1FcC1AMgppGwAc0vYWaQhqvg282cnsuPbJEzYnWifAdCVvg+8ngJUEHyHcomhJJp+/zuytite4ITHLw==} + + '@webpack-cli/configtest@3.0.1': + resolution: {integrity: sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==} + engines: {node: '>=18.12.0'} + peerDependencies: + webpack: ^5.82.0 + webpack-cli: 6.x.x + + '@webpack-cli/info@3.0.1': + resolution: {integrity: sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==} + engines: {node: '>=18.12.0'} + peerDependencies: + webpack: ^5.82.0 + webpack-cli: 6.x.x + + '@webpack-cli/serve@3.0.1': + resolution: {integrity: sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==} + engines: {node: '>=18.12.0'} + peerDependencies: + webpack: ^5.82.0 + webpack-cli: 6.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -922,9 +1255,17 @@ packages: ajv: optional: true + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1010,6 +1351,11 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1028,6 +1374,14 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1052,6 +1406,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1064,6 +1421,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1074,6 +1439,13 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1166,6 +1538,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.366: + resolution: {integrity: sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1179,6 +1554,15 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.22.1: + resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} + engines: {node: '>=10.13.0'} + + envinfo@7.21.0: + resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} + engines: {node: '>=4'} + hasBin: true + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -1198,6 +1582,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1219,6 +1606,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1241,6 +1632,10 @@ packages: eslint-plugin-tsdoc@0.5.2: resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1279,6 +1674,10 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -1294,6 +1693,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -1338,6 +1741,10 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1362,6 +1769,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1370,6 +1781,10 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -1524,6 +1939,11 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1535,6 +1955,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -1611,6 +2035,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1656,6 +2084,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1679,6 +2111,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} @@ -1739,10 +2175,22 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1786,6 +2234,9 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1835,10 +2286,17 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1886,14 +2344,26 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1959,6 +2429,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2016,6 +2490,10 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2028,10 +2506,18 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -2077,6 +2563,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -2117,6 +2607,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2155,6 +2649,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2242,6 +2743,58 @@ packages: resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} engines: {node: '>=6'} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -2330,6 +2883,12 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2414,6 +2973,42 @@ packages: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'} + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + webpack-cli@6.0.1: + resolution: {integrity: sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==} + engines: {node: '>=18.12.0'} + hasBin: true + peerDependencies: + webpack: ^5.82.0 + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + + webpack-merge@6.0.1: + resolution: {integrity: sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==} + engines: {node: '>=18.0.0'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack@5.107.2: + resolution: {integrity: sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2440,6 +3035,9 @@ packages: engines: {node: '>=8'} hasBin: true + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2527,6 +3125,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@discoveryjs/json-ext@0.6.3': {} + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -2689,6 +3289,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -3087,7 +3692,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.19))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3102,7 +3707,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/node@22.19.19) + vitest: 3.2.6(@types/node@22.19.19)(terser@5.48.0) transitivePeerDependencies: - supports-color @@ -3114,13 +3719,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.19))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.19)(terser@5.48.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@22.19.19) + vite: 7.3.5(@types/node@22.19.19)(terser@5.48.0) '@vitest/pretty-format@3.2.6': dependencies: @@ -3148,11 +3753,112 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@webcontainer/api@1.6.4': {} + + '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.107.2)': + dependencies: + webpack: 5.107.2(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack@5.107.2) + + '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.107.2)': + dependencies: + webpack: 5.107.2(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack@5.107.2) + + '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1)(webpack@5.107.2)': + dependencies: + webpack: 5.107.2(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack@5.107.2) + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-import-phases@1.0.4(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3171,10 +3877,19 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@2.1.1: + dependencies: + ajv: 8.20.0 + ajv-formats@3.0.1: dependencies: ajv: 8.20.0 + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3289,6 +4004,8 @@ snapshots: balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.33: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -3320,6 +4037,16 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.366 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-from@1.1.2: {} + bytes@3.1.2: {} cac@6.7.14: {} @@ -3343,6 +4070,8 @@ snapshots: callsites@3.1.0: {} + caniuse-lite@1.0.30001793: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -3358,6 +4087,14 @@ snapshots: check-error@2.1.3: {} + chrome-trace-event@1.0.4: {} + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3366,6 +4103,10 @@ snapshots: colorette@2.0.20: {} + commander@12.1.0: {} + + commander@2.20.3: {} + concat-map@0.0.1: {} content-disposition@1.1.0: {} @@ -3447,6 +4188,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.366: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3457,6 +4200,13 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.22.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + envinfo@7.21.0: {} + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -3539,6 +4289,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3589,6 +4341,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -3630,6 +4384,11 @@ snapshots: - supports-color - typescript + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -3694,6 +4453,8 @@ snapshots: dependencies: estraverse: 5.3.0 + estraverse@4.3.0: {} + estraverse@5.3.0: {} estree-walker@3.0.3: @@ -3704,6 +4465,8 @@ snapshots: etag@1.8.1: {} + events@3.3.0: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -3770,6 +4533,8 @@ snapshots: fast-uri@3.1.2: {} + fastest-levenshtein@1.0.16: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3797,6 +4562,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3807,6 +4577,8 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 + flat@5.0.2: {} + flatted@3.4.2: {} for-each@0.3.5: @@ -3955,6 +4727,11 @@ snapshots: import-lazy@4.0.0: {} + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -3965,6 +4742,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 + interpret@3.1.1: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -4040,6 +4819,10 @@ snapshots: is-number@7.0.0: {} + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + is-promise@4.0.0: {} is-regex@1.2.1: @@ -4085,6 +4868,8 @@ snapshots: isexe@2.0.0: {} + isobject@3.0.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4121,6 +4906,12 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jest-worker@27.5.1: + dependencies: + '@types/node': 22.19.19 + merge-stream: 2.0.0 + supports-color: 8.1.1 + jju@1.4.0: {} jose@6.2.3: {} @@ -4174,11 +4965,19 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + loader-runner@4.3.2: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4217,6 +5016,8 @@ snapshots: merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -4254,6 +5055,8 @@ snapshots: negotiator@1.0.0: {} + neo-async@2.6.2: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -4261,6 +5064,8 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-releases@2.0.47: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4322,14 +5127,24 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -4399,6 +5214,10 @@ snapshots: pkce-challenge@5.0.1: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} postcss@8.5.15: @@ -4452,6 +5271,10 @@ snapshots: real-require@1.0.0: {} + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -4474,8 +5297,14 @@ snapshots: require-from-string@2.0.2: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -4562,6 +5391,13 @@ snapshots: safer-buffer@2.1.2: {} + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1 + ajv-keywords: 5.1.0(ajv@8.20.0) + secure-json-parse@4.1.0: {} semver@6.3.1: {} @@ -4621,6 +5457,10 @@ snapshots: setprototypeof@1.2.0: {} + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4665,6 +5505,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + split2@4.2.0: {} sprintf-js@1.0.3: {} @@ -4766,6 +5613,23 @@ snapshots: tapable@1.1.3: {} + tapable@2.3.3: {} + + terser-webpack-plugin@5.6.1(webpack@5.107.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.107.2(webpack-cli@6.0.1) + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 @@ -4859,19 +5723,25 @@ snapshots: unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.19.19): + vite-node@3.2.4(@types/node@22.19.19)(terser@5.48.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@22.19.19) + vite: 7.3.5(@types/node@22.19.19)(terser@5.48.0) transitivePeerDependencies: - '@types/node' - jiti @@ -4886,7 +5756,7 @@ snapshots: - tsx - yaml - vite@7.3.5(@types/node@22.19.19): + vite@7.3.5(@types/node@22.19.19)(terser@5.48.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -4897,12 +5767,13 @@ snapshots: optionalDependencies: '@types/node': 22.19.19 fsevents: 2.3.3 + terser: 5.48.0 - vitest@3.2.6(@types/node@22.19.19): + vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.19)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.19)(terser@5.48.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -4920,8 +5791,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@22.19.19) - vite-node: 3.2.4(@types/node@22.19.19) + vite: 7.3.5(@types/node@22.19.19)(terser@5.48.0) + vite-node: 3.2.4(@types/node@22.19.19)(terser@5.48.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 @@ -4944,6 +5815,77 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + webpack-cli@6.0.1(webpack@5.107.2): + dependencies: + '@discoveryjs/json-ext': 0.6.3 + '@webpack-cli/configtest': 3.0.1(webpack-cli@6.0.1)(webpack@5.107.2) + '@webpack-cli/info': 3.0.1(webpack-cli@6.0.1)(webpack@5.107.2) + '@webpack-cli/serve': 3.0.1(webpack-cli@6.0.1)(webpack@5.107.2) + colorette: 2.0.20 + commander: 12.1.0 + cross-spawn: 7.0.6 + envinfo: 7.21.0 + fastest-levenshtein: 1.0.16 + import-local: 3.2.0 + interpret: 3.1.1 + rechoir: 0.8.0 + webpack: 5.107.2(webpack-cli@6.0.1) + webpack-merge: 6.0.1 + + webpack-merge@6.0.1: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-sources@3.5.0: {} + + webpack@5.107.2(webpack-cli@6.0.1): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.22.1 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(webpack@5.107.2) + watchpack: 2.5.1 + webpack-sources: 3.5.0 + optionalDependencies: + webpack-cli: 6.0.1(webpack@5.107.2) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4994,6 +5936,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wildcard@2.0.1: {} + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/packages/acp-agent/package.json b/packages/acp-agent/package.json index 4fc151f..44f707c 100644 --- a/packages/acp-agent/package.json +++ b/packages/acp-agent/package.json @@ -21,10 +21,13 @@ "@agentclientprotocol/sdk": "^0.4.3", "@ai-sdk/mcp": "0.0.18", "@ai-sdk/openai": "^2.0.0", + "@fledgling/agent-core": "workspace:*", "@fledgling/common": "workspace:*", "@fledgling/context-builder": "workspace:*", "@fledgling/mcp-workspace": "workspace:*", + "@fledgling/session-file-system": "workspace:*", "@fledgling/session-log": "workspace:*", + "@fledgling/tools-mcp-node": "workspace:*", "ai": "^5.0.0" }, "devDependencies": { diff --git a/packages/acp-agent/src/agent-prompt-cancellation.test.ts b/packages/acp-agent/src/agent-prompt-cancellation.test.ts index af7ceea..d79add5 100644 --- a/packages/acp-agent/src/agent-prompt-cancellation.test.ts +++ b/packages/acp-agent/src/agent-prompt-cancellation.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@fledgling/common"; -import { SessionStore } from "@fledgling/session-log"; +import { FileSystemSessionManager } from "@fledgling/session-file-system"; import type { FledglingAgent, FledglingAgentDependencies } from "./agent.js"; @@ -324,14 +324,14 @@ describe("FledglingAgent prompt cancellation", () => { }); it("replays stored user and assistant messages when loading a session", async () => { - const { agent, sessionId, sessionStore, sessionUpdates, tempDir } = await createTestAgent(); - await sessionStore.append({ - ...sessionStore.createEventBase(sessionId), + const { agent, sessionId, sessionManager, sessionUpdates, tempDir } = await createTestAgent(); + await sessionManager.appendEvent({ + ...sessionManager.createEventBase(sessionId), type: "message.user", text: "previous user" }); - await sessionStore.append({ - ...sessionStore.createEventBase(sessionId), + await sessionManager.appendEvent({ + ...sessionManager.createEventBase(sessionId), type: "message.assistant", text: "previous assistant" }); @@ -358,13 +358,17 @@ describe("FledglingAgent prompt cancellation", () => { it("rejects session creation when injected tool setup fails", async () => { const { FledglingAgent } = await import("./agent.js"); - const sessionStore = await createTempSessionStore(); + const sessionStore = await createTempSessionManager(); const agent = new FledglingAgent(createFakeConnection() as never, { - createSessionTools: vi.fn(async () => { - throw new Error("setup failed"); - }), - sessionStore: sessionStore.store, - runModelTurn: vi.fn() + toolProvider: { + createSessionTools: vi.fn(async () => { + throw new Error("setup failed"); + }) + }, + sessionManager: sessionStore.manager, + modelTurnRunner: { + runModelTurn: vi.fn() + } }); await expect(agent.newSession({ cwd: sessionStore.tempDir, mcpServers: [] })).rejects.toThrow("setup failed"); @@ -374,19 +378,23 @@ describe("FledglingAgent prompt cancellation", () => { readonly agent: FledglingAgent; readonly sessionId: string; readonly sessionFile: string; - readonly sessionStore: SessionStore; + readonly sessionManager: FileSystemSessionManager; readonly sessionUpdates: FakeSessionUpdate[]; readonly tempDir: string; readonly streamText: ReturnType; }> { - const { store: sessionStore, sessionFile, tempDir: createdTempDir } = await createTempSessionStore(); + const { manager: sessionManager, sessionFile, tempDir: createdTempDir } = await createTempSessionManager(); const sessionUpdates: FakeSessionUpdate[] = []; const streamText = vi.fn(); const { FledglingAgent } = await import("./agent.js"); const agent = new FledglingAgent(createFakeConnection(sessionUpdates) as never, { - createSessionTools: vi.fn(async () => ({ mcpClients: [], tools: {} })), - sessionStore, - runModelTurn: streamText + toolProvider: { + createSessionTools: vi.fn(async () => ({ clients: [], tools: {} })) + }, + sessionManager, + modelTurnRunner: { + runModelTurn: streamText + } } satisfies FledglingAgentDependencies); const session = await agent.newSession({ cwd: createdTempDir, mcpServers: [] }); @@ -394,7 +402,7 @@ describe("FledglingAgent prompt cancellation", () => { agent, sessionId: session.sessionId, sessionFile, - sessionStore, + sessionManager, sessionUpdates, tempDir: createdTempDir, streamText @@ -418,8 +426,8 @@ function createFakeConnection(sessionUpdates: FakeSessionUpdate[] = []): { sessi }; } -async function createTempSessionStore(): Promise<{ - readonly store: SessionStore; +async function createTempSessionManager(): Promise<{ + readonly manager: FileSystemSessionManager; readonly sessionFile: string; readonly tempDir: string; }> { @@ -428,7 +436,11 @@ async function createTempSessionStore(): Promise<{ process.env.FLEDGLING_CONFIG = path.join(createdTempDir, "missing-config.json"); const sessionFile = path.join(createdTempDir, "session.jsonl"); process.env.FLEDGLING_SESSION_FILE = sessionFile; - return { store: new SessionStore(createdTempDir, sessionFile), sessionFile, tempDir: createdTempDir }; + return { + manager: new FileSystemSessionManager(createdTempDir, sessionFile), + sessionFile, + tempDir: createdTempDir + }; } function createControlledStream(parts: readonly StreamPart[]): ControlledStream { diff --git a/packages/acp-agent/src/agent-session-lifecycle.test.ts b/packages/acp-agent/src/agent-session-lifecycle.test.ts index 720a349..86f4f25 100644 --- a/packages/acp-agent/src/agent-session-lifecycle.test.ts +++ b/packages/acp-agent/src/agent-session-lifecycle.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { FileSystemSessionManager } from "@fledgling/session-file-system"; + describe("FledglingAgent session lifecycle", () => { const originalConfig = process.env.FLEDGLING_CONFIG; const originalSessionFile = process.env.FLEDGLING_SESSION_FILE; @@ -38,26 +40,33 @@ describe("FledglingAgent session lifecycle", () => { }; const clients = [firstClient, secondClient]; - vi.doMock("@ai-sdk/mcp", () => ({ - experimental_createMCPClient: vi.fn(async () => { - const client = clients.shift(); - if (!client) { - throw new Error("unexpected client request"); - } + const { FledglingAgent } = await import("./agent.js"); + const agent = new FledglingAgent( + { + sessionUpdate: vi.fn(async () => {}) + } as never, + { + sessionManager: new FileSystemSessionManager(tempDir, process.env.FLEDGLING_SESSION_FILE), + toolProvider: { + createSessionTools: vi.fn(async () => { + const client = clients.shift(); + if (!client) { + throw new Error("unexpected client request"); + } - return client; - }) - })); - vi.doMock("@ai-sdk/mcp/mcp-stdio", () => ({ - Experimental_StdioMCPTransport: class Experimental_StdioMCPTransport { - public constructor(_options: unknown) {} + return { + clients: [client], + tools: {} + }; + }) + }, + modelTurnRunner: { + runModelTurn: vi.fn(() => ({ + fullStream: (async function* () {})() + })) + } } - })); - - const { FledglingAgent } = await import("./agent.js"); - const agent = new FledglingAgent({ - sessionUpdate: vi.fn(async () => {}) - } as never); + ); const mcpServers = [{ name: "workspace", command: "workspace-command", args: [], env: [] }]; const session = await agent.newSession({ cwd: tempDir, mcpServers }); diff --git a/packages/acp-agent/src/agent.ts b/packages/acp-agent/src/agent.ts index 532f139..e59b739 100644 --- a/packages/acp-agent/src/agent.ts +++ b/packages/acp-agent/src/agent.ts @@ -1,601 +1,53 @@ -import * as acp from "@agentclientprotocol/sdk"; -import type { experimental_MCPClient as MCPClient } from "@ai-sdk/mcp"; import { createOpenAI, type OpenAIProvider } from "@ai-sdk/openai"; -import type { SessionErrorEvent } from "@fledgling/common"; -import { buildContext } from "@fledgling/context-builder"; -import { SessionStore } from "@fledgling/session-log"; -import { stepCountIs, streamText, type CoreMessage, type LanguageModel, type ToolSet } from "ai"; - -import { createSessionTools } from "./mcp-session-tools.js"; import { - extractContextHint, - extractPromptText, - messageContentToText, - stringifyToolOutput, - toRawObject -} from "./prompt-content.js"; -import { SessionCleanup } from "./session-cleanup.js"; - -type SessionEventBase = ReturnType; - -export interface SessionStoreLike { - createId(): string; - createEventBase(sessionId: string): SessionEventBase; - append(event: Parameters[0]): Promise; - load(sessionId: string): Promise>>; -} - -interface SessionState { - readonly id: string; - readonly cwd: string | undefined; - readonly history: CoreMessage[]; - readonly mcpClients: MCPClient[]; - readonly tools: ToolSet; - readonly toolCallNames: Map; - pendingPrompt: AbortController | undefined; - promptQueue: Promise; -} - -type ModelStreamPart = - | { - readonly type: "text-delta"; - readonly text: string; - } - | { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; - } - | { - readonly type: "tool-result"; - readonly toolCallId: string; - readonly output: unknown; - } - | { - readonly type: "tool-error"; - readonly toolCallId: string; - readonly error: unknown; - }; - -interface ModelTurnRequest { - readonly messages: CoreMessage[]; - readonly tools: ToolSet; - readonly abortSignal: AbortSignal; -} - -interface ModelTurnResult { - readonly fullStream: AsyncIterable; -} - -type PromptErrorKind = SessionErrorEvent["kind"]; -type PromptErrorPhase = SessionErrorEvent["phase"]; - -interface NormalizedPromptError { - readonly kind: PromptErrorKind; - readonly phase: PromptErrorPhase; - readonly message: string; - readonly recoverable: boolean; - readonly errorName: string | undefined; - readonly errorCode: string | undefined; -} - -export interface FledglingAgentDependencies { - readonly createSessionTools: typeof createSessionTools; - readonly sessionStore: SessionStoreLike; - readonly runModelTurn: (request: ModelTurnRequest) => ModelTurnResult; -} + type FledglingAgentDependencies, + type IModelTurnRunner, + type ModelTurnRequest, + type ModelTurnResult, + type ModelStreamPart +} from "@fledgling/agent-core"; +import { FileSystemSessionManager } from "@fledgling/session-file-system"; +import { NodeMcpToolProvider } from "@fledgling/tools-mcp-node"; +import { stepCountIs, streamText, type LanguageModel, type ToolSet } from "ai"; + +export { + FledglingAgent, + type FledglingAgentDependencies, + type IModelTurnRunner, + type IRuntimeLogger, + type ISessionManager, + type IToolProvider, + type ModelStreamPart, + type ModelTurnRequest, + type ModelTurnResult +} from "@fledgling/agent-core"; const DEFAULT_SYSTEM_PROMPT: string = "You are Fledgling, a small ACP-native assistant. Answer directly. Use tools when they are available and useful. If the user asks you to inspect, create, modify, delete, search, or execute something in the workspace, use the relevant workspace tool instead of only describing what you would do. If the user asks you to write content to a file, call the file-writing tool. Do not claim you cannot access files when a relevant workspace tool is available. Tool results may include Fledgling context hints that describe identity, retention, and prompt placement for future context assembly."; -export class FledglingAgent implements acp.Agent { - readonly #connection: acp.AgentSideConnection; - readonly #sessions: Map = new Map(); - readonly #dependencies: FledglingAgentDependencies; - readonly #sessionCleanup: SessionCleanup = new SessionCleanup( - () => this.#sessions.values(), - () => this.#sessions.clear() - ); - - public constructor( - connection: acp.AgentSideConnection, - dependencies: FledglingAgentDependencies = createDefaultDependencies() - ) { - this.#connection = connection; - this.#dependencies = dependencies; - } - - public async initialize(_params: acp.InitializeRequest): Promise { - return { - protocolVersion: acp.PROTOCOL_VERSION, - agentCapabilities: { - loadSession: true, - mcpCapabilities: { - http: true, - sse: true - } - } - }; - } - - public async newSession(params: acp.NewSessionRequest): Promise { - const { mcpClients, tools } = await this.#dependencies.createSessionTools(params.cwd, params.mcpServers); - const session: SessionState = { - id: this.#dependencies.sessionStore.createId(), - cwd: params.cwd, - history: [], - mcpClients, - tools, - toolCallNames: new Map(), - pendingPrompt: undefined, - promptQueue: Promise.resolve() - }; - - this.#sessions.set(session.id, session); - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "session.created", - cwd: params.cwd, - mcpServers: params.mcpServers.map((server) => server.name) +export class VercelAiSdkModelTurnRunner implements IModelTurnRunner { + public runModelTurn(request: ModelTurnRequest): ModelTurnResult { + const openai = createOpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL + }); + const modelName = process.env.OPENAI_MODEL ?? "gpt-4.1-mini"; + const model = selectOpenAiModel(openai, modelName); + + const result = streamText({ + model, + system: process.env.FLEDGLING_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT, + messages: request.messages, + tools: request.tools, + toolChoice: getToolChoice(request.tools), + stopWhen: stepCountIs(5), + abortSignal: request.abortSignal }); return { - sessionId: session.id + fullStream: result.fullStream as AsyncIterable }; } - - public async loadSession(params: acp.LoadSessionRequest): Promise { - const events = await this.#dependencies.sessionStore.load(params.sessionId); - const context = buildContext(events, { mode: "replay" }); - const { mcpClients, tools } = await this.#dependencies.createSessionTools(params.cwd, params.mcpServers); - const existingSession = this.#sessions.get(params.sessionId); - const session: SessionState = { - id: params.sessionId, - cwd: params.cwd, - history: context.messages.map((message) => ({ role: message.role, content: message.content }) as CoreMessage), - mcpClients, - tools, - toolCallNames: new Map(), - pendingPrompt: undefined, - promptQueue: Promise.resolve() - }; - - if (existingSession) { - await this.#sessionCleanup.closeSession(existingSession, "session-replaced"); - } - - this.#sessions.set(session.id, session); - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "session.loaded", - cwd: params.cwd, - source: "session.load" - }); - await replaySessionHistory(this.#connection, session); - - return {}; - } - - public async authenticate(_params: acp.AuthenticateRequest): Promise { - return {}; - } - - public async setSessionMode(_params: acp.SetSessionModeRequest): Promise { - return {}; - } - - public async prompt(params: acp.PromptRequest): Promise { - const session = this.#sessions.get(params.sessionId); - if (!session) { - throw new Error(`Unknown ACP session: ${sanitizeErrorMessage(params.sessionId)}`); - } - - const response = session.promptQueue - .catch(() => undefined) - .then(() => { - if (this.#sessions.get(session.id) !== session) { - return { stopReason: "cancelled" } satisfies acp.PromptResponse; - } - - return this.#runPrompt(session, params); - }); - session.promptQueue = response.then( - () => undefined, - () => undefined - ); - - return response; - } - - async #runPrompt(session: SessionState, params: acp.PromptRequest): Promise { - const userText = extractPromptText(params); - const promptController = new AbortController(); - session.pendingPrompt = promptController; - - let assistantText = ""; - - try { - session.history.push({ role: "user", content: userText }); - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "message.user", - text: userText - }); - - let result: ModelTurnResult; - try { - result = this.#dependencies.runModelTurn({ - messages: session.history, - tools: session.tools, - abortSignal: promptController.signal - }); - } catch (error: unknown) { - await this.#recordPromptError(session, params.sessionId, { - error, - kind: "model_start_failed", - phase: "model_start", - assistantTextPersisted: false - }); - throw createPromptRpcError(error, "model_start"); - } - - try { - for await (const part of result.fullStream) { - if (process.env.FLEDGLING_DEBUG_STREAM === "1") { - console.error(JSON.stringify({ streamPart: part.type })); - } - - switch (part.type) { - case "text-delta": { - assistantText += part.text; - await this.#connection.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: part.text - } - } - }); - break; - } - - case "tool-call": { - session.toolCallNames.set(part.toolCallId, part.toolName); - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "tool.call", - toolCallId: part.toolCallId, - toolName: part.toolName, - title: part.toolName, - rawInput: part.input - }); - await this.#connection.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.toolCallId, - title: part.toolName, - kind: "other", - status: "pending", - rawInput: toRawObject(part.input) - } - }); - break; - } - - case "tool-result": { - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "tool.result", - toolCallId: part.toolCallId, - toolName: session.toolCallNames.get(part.toolCallId), - status: "completed", - text: stringifyToolOutput(part.output), - rawOutput: part.output, - contextHint: extractContextHint(part.output) - }); - await this.#connection.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.toolCallId, - status: "completed", - content: [ - { - type: "content", - content: { - type: "text", - text: stringifyToolOutput(part.output) - } - } - ], - rawOutput: toRawObject(part.output) - } - }); - break; - } - - case "tool-error": { - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "tool.result", - toolCallId: part.toolCallId, - toolName: session.toolCallNames.get(part.toolCallId), - status: "failed", - text: stringifyToolOutput(part.error), - rawOutput: toRawObject(part.error), - contextHint: undefined - }); - await this.#connection.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.toolCallId, - status: "failed", - content: [ - { - type: "content", - content: { - type: "text", - text: stringifyToolOutput(part.error) - } - } - ], - rawOutput: toRawObject(part.error) - } - }); - break; - } - } - } - } catch (error: unknown) { - if (promptController.signal.aborted) { - return { stopReason: "cancelled" }; - } - - const assistantTextPersisted = assistantText.length > 0; - if (assistantTextPersisted) { - await this.#persistAssistantMessage(session, assistantText); - } - - await this.#recordPromptError(session, params.sessionId, { - error, - kind: "model_stream_failed", - phase: "model_stream", - assistantTextPersisted - }); - throw createPromptRpcError(error, "model_stream"); - } - - await this.#persistAssistantMessage(session, assistantText); - - return { - stopReason: "end_turn" - }; - } catch (error: unknown) { - if (promptController.signal.aborted) { - return { stopReason: "cancelled" }; - } - - throw error; - } finally { - if (session.pendingPrompt === promptController) { - session.pendingPrompt = undefined; - } - } - } - - async #persistAssistantMessage(session: SessionState, assistantText: string): Promise { - session.history.push({ role: "assistant", content: assistantText }); - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "message.assistant", - text: assistantText - }); - } - - async #recordPromptError( - session: SessionState, - sessionId: string, - options: { - readonly error: unknown; - readonly kind: PromptErrorKind; - readonly phase: PromptErrorPhase; - readonly assistantTextPersisted: boolean; - } - ): Promise { - const normalized = normalizePromptError(options.error, options.kind, options.phase); - await this.#connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `\n\n[Fledgling error: ${formatPromptErrorKind(normalized.kind)}. ${normalized.message}]` - } - } - }); - await this.#dependencies.sessionStore.append({ - ...this.#dependencies.sessionStore.createEventBase(session.id), - type: "session.error", - kind: normalized.kind, - phase: normalized.phase, - message: normalized.message, - recoverable: normalized.recoverable, - assistantTextPersisted: options.assistantTextPersisted, - errorName: normalized.errorName, - errorCode: normalized.errorCode - }); - } - - public async cancel(_params: acp.CancelNotification): Promise { - this.#sessions.get(_params.sessionId)?.pendingPrompt?.abort(); - } - - public closeAllSessions(reason: string): Promise { - return this.#sessionCleanup.closeAll(reason); - } -} - -function normalizePromptError(error: unknown, kind: PromptErrorKind, phase: PromptErrorPhase): NormalizedPromptError { - return { - kind, - phase, - message: sanitizeErrorMessage(extractErrorMessage(error)), - recoverable: true, - errorName: extractErrorName(error), - errorCode: extractErrorCode(error) - }; -} - -function createPromptRpcError(error: unknown, phase: "model_start" | "model_stream"): Error { - const normalized = normalizePromptError( - error, - phase === "model_start" ? "model_start_failed" : "model_stream_failed", - phase - ); - return new Error(`Fledgling ${formatPromptErrorKind(normalized.kind)}: ${normalized.message}`); -} - -function formatPromptErrorKind(kind: PromptErrorKind): string { - switch (kind) { - case "model_start_failed": - return "model start failed"; - - case "model_stream_failed": - return "model stream failed"; - - case "prompt_cleanup_failed": - return "prompt cleanup failed"; - } -} - -function extractErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === "string") { - return error; - } - - if (error && typeof error === "object" && "message" in error) { - const message = (error as { readonly message?: unknown }).message; - if (typeof message === "string") { - return message; - } - } - - return "Unknown error"; -} - -function extractErrorName(error: unknown): string | undefined { - if (error instanceof Error) { - return error.name; - } - - if (error && typeof error === "object" && "name" in error) { - const name = (error as { readonly name?: unknown }).name; - return typeof name === "string" ? sanitizeErrorMessage(name) : undefined; - } - - return undefined; -} - -function extractErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== "object" || !("code" in error)) { - return undefined; - } - - const code = (error as { readonly code?: unknown }).code; - if (typeof code === "string" || typeof code === "number") { - return sanitizeErrorMessage(String(code)); - } - - return undefined; -} - -function sanitizeErrorMessage(message: string): string { - const withoutControlCharacters = replaceControlCharacters(stripAnsiEscapeSequences(message)); - const normalized = withoutControlCharacters.replace(/\s+/g, " ").trim() || "Unknown error"; - const redacted = normalized - .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") - .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "sk-[redacted]") - .replace(/\b(api[_-]?key|token|secret|password)=\S+/gi, "$1=[redacted]"); - - return redacted.length > 500 ? `${redacted.slice(0, 497)}...` : redacted; -} - -function stripAnsiEscapeSequences(message: string): string { - let stripped = ""; - for (let index = 0; index < message.length; index++) { - const code = message.charCodeAt(index); - if (code !== 0x1b) { - stripped += message[index]; - continue; - } - - const next = message.charCodeAt(index + 1); - if (next === 0x5b) { - index += 2; - while (index < message.length) { - const sequenceCode = message.charCodeAt(index); - if (sequenceCode >= 0x40 && sequenceCode <= 0x7e) { - break; - } - - index++; - } - continue; - } - - if (next >= 0x40 && next <= 0x5f) { - index++; - } - } - - return stripped; -} - -function replaceControlCharacters(message: string): string { - let replaced = ""; - for (let index = 0; index < message.length; index++) { - const code = message.charCodeAt(index); - replaced += isUnsafeControlCharacter(code) ? " " : message[index]; - } - - return replaced; -} - -function isUnsafeControlCharacter(code: number): boolean { - return code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) || (code >= 0x7f && code <= 0x9f); -} - -async function replaySessionHistory(connection: acp.AgentSideConnection, session: SessionState): Promise { - for (const message of session.history) { - if (message.role !== "user" && message.role !== "assistant") { - continue; - } - - const text = messageContentToText(message.content); - if (!text) { - continue; - } - - await connection.sessionUpdate({ - sessionId: session.id, - update: { - sessionUpdate: message.role === "user" ? "user_message_chunk" : "agent_message_chunk", - content: { - type: "text", - text - } - } - }); - } } function getToolChoice(tools: ToolSet): "auto" | { type: "tool"; toolName: string } { @@ -611,37 +63,26 @@ function getToolChoice(tools: ToolSet): "auto" | { type: "tool"; toolName: strin return { type: "tool", toolName }; } -function createDefaultDependencies(): FledglingAgentDependencies { - return { - createSessionTools, - sessionStore: new SessionStore(), - runModelTurn: runDefaultModelTurn - }; +function selectOpenAiModel(openai: OpenAIProvider, modelName: string): LanguageModel { + return process.env.FLEDGLING_OPENAI_API === "responses" ? openai.responses(modelName) : openai.chat(modelName); } -function runDefaultModelTurn(request: ModelTurnRequest): ModelTurnResult { - const openai = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY, - baseURL: process.env.OPENAI_BASE_URL - }); - const modelName = process.env.OPENAI_MODEL ?? "gpt-4.1-mini"; - const model = selectOpenAiModel(openai, modelName); - - const result = streamText({ - model, - system: process.env.FLEDGLING_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT, - messages: request.messages, - tools: request.tools, - toolChoice: getToolChoice(request.tools), - stopWhen: stepCountIs(5), - abortSignal: request.abortSignal - }); - +export function createDefaultDependencies(): FledglingAgentDependencies { return { - fullStream: result.fullStream as AsyncIterable + toolProvider: new NodeMcpToolProvider(), + sessionManager: new FileSystemSessionManager(), + modelTurnRunner: new VercelAiSdkModelTurnRunner(), + logger: { + debug(value: unknown): void { + console.error(JSON.stringify(value)); + }, + warn(record: unknown): void { + console.warn(record); + }, + error(record: unknown): void { + console.error(record); + } + }, + debugStream: process.env.FLEDGLING_DEBUG_STREAM === "1" }; } - -function selectOpenAiModel(openai: OpenAIProvider, modelName: string): LanguageModel { - return process.env.FLEDGLING_OPENAI_API === "responses" ? openai.responses(modelName) : openai.chat(modelName); -} diff --git a/packages/acp-agent/src/index.ts b/packages/acp-agent/src/index.ts index 1873876..cfb5ea2 100644 --- a/packages/acp-agent/src/index.ts +++ b/packages/acp-agent/src/index.ts @@ -2,7 +2,7 @@ import { Readable, Writable } from "node:stream"; import * as acp from "@agentclientprotocol/sdk"; -import { FledglingAgent } from "./agent.js"; +import { createDefaultDependencies, FledglingAgent } from "./agent.js"; import { registerProcessLifecycle } from "./process-lifecycle.js"; const input: WritableStream = Writable.toWeb(process.stdout); @@ -15,6 +15,6 @@ registerProcessLifecycle(() => activeAgent); // eslint-disable-next-line no-new -- AgentSideConnection owns the stdio lifecycle. new acp.AgentSideConnection((connection) => { - activeAgent = new FledglingAgent(connection); + activeAgent = new FledglingAgent(connection, createDefaultDependencies()); return activeAgent; }, stream); diff --git a/packages/agent-core/config/rig.json b/packages/agent-core/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/agent-core/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/agent-core/eslint.config.cjs b/packages/agent-core/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/agent-core/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json new file mode 100644 index 0000000..29041de --- /dev/null +++ b/packages/agent-core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@fledgling/agent-core", + "version": "0.0.0", + "private": true, + "description": "Browser-safe ACP agent loop core for Fledgling.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.4.3", + "@fledgling/common": "workspace:*", + "@fledgling/context-builder": "workspace:*", + "ai": "^5.0.0" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts new file mode 100644 index 0000000..2ee85f5 --- /dev/null +++ b/packages/agent-core/src/agent.ts @@ -0,0 +1,410 @@ +import * as acp from "@agentclientprotocol/sdk"; +import { buildContext } from "@fledgling/context-builder"; +import type { CoreMessage, ToolSet } from "ai"; + +import type { FledglingAgentDependencies, IClosable } from "./interfaces.js"; +import { + createPromptRpcError, + formatPromptErrorKind, + normalizePromptError, + sanitizeErrorMessage +} from "./prompt-errors.js"; +import { + extractContextHint, + extractPromptText, + messageContentToText, + stringifyToolOutput, + toRawObject +} from "./prompt-content.js"; +import { SessionCleanup } from "./session-cleanup.js"; + +interface SessionState { + readonly id: string; + readonly cwd: string | undefined; + readonly history: CoreMessage[]; + readonly clients: IClosable[]; + readonly tools: ToolSet; + readonly toolCallNames: Map; + pendingPrompt: AbortController | undefined; + promptQueue: Promise; +} + +export class FledglingAgent implements acp.Agent { + readonly #connection: acp.AgentSideConnection; + readonly #sessions: Map = new Map(); + readonly #dependencies: FledglingAgentDependencies; + readonly #sessionCleanup: SessionCleanup; + + public constructor(connection: acp.AgentSideConnection, dependencies: FledglingAgentDependencies) { + this.#connection = connection; + this.#dependencies = dependencies; + this.#sessionCleanup = new SessionCleanup( + () => this.#sessions.values(), + () => this.#sessions.clear(), + dependencies.logger + ); + } + + public async initialize(_params: acp.InitializeRequest): Promise { + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + mcpCapabilities: { + http: true, + sse: true + } + } + }; + } + + public async newSession(params: acp.NewSessionRequest): Promise { + const { clients, tools } = await this.#dependencies.toolProvider.createSessionTools({ + cwd: params.cwd, + mcpServers: params.mcpServers + }); + const session: SessionState = { + id: this.#dependencies.sessionManager.createSessionId(), + cwd: params.cwd, + history: [], + clients, + tools, + toolCallNames: new Map(), + pendingPrompt: undefined, + promptQueue: Promise.resolve() + }; + + this.#sessions.set(session.id, session); + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "session.created", + cwd: params.cwd, + mcpServers: params.mcpServers.map((server) => server.name) + }); + + return { + sessionId: session.id + }; + } + + public async loadSession(params: acp.LoadSessionRequest): Promise { + const events = await this.#dependencies.sessionManager.loadEvents(params.sessionId); + const context = buildContext(events, { mode: "replay" }); + const { clients, tools } = await this.#dependencies.toolProvider.createSessionTools({ + cwd: params.cwd, + mcpServers: params.mcpServers + }); + const existingSession = this.#sessions.get(params.sessionId); + const session: SessionState = { + id: params.sessionId, + cwd: params.cwd, + history: context.messages.map((message) => ({ role: message.role, content: message.content }) as CoreMessage), + clients, + tools, + toolCallNames: new Map(), + pendingPrompt: undefined, + promptQueue: Promise.resolve() + }; + + if (existingSession) { + await this.#sessionCleanup.closeSession(existingSession, "session-replaced"); + } + + this.#sessions.set(session.id, session); + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "session.loaded", + cwd: params.cwd, + source: "session.load" + }); + await replaySessionHistory(this.#connection, session); + + return {}; + } + + public async authenticate(_params: acp.AuthenticateRequest): Promise { + return {}; + } + + public async setSessionMode(_params: acp.SetSessionModeRequest): Promise { + return {}; + } + + public async prompt(params: acp.PromptRequest): Promise { + const session = this.#sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown ACP session: ${sanitizeErrorMessage(params.sessionId)}`); + } + + const response = session.promptQueue + .catch(() => undefined) + .then(() => { + if (this.#sessions.get(session.id) !== session) { + return { stopReason: "cancelled" } satisfies acp.PromptResponse; + } + + return this.#runPrompt(session, params); + }); + session.promptQueue = response.then( + () => undefined, + () => undefined + ); + + return response; + } + + async #runPrompt(session: SessionState, params: acp.PromptRequest): Promise { + const userText = extractPromptText(params); + const promptController = new AbortController(); + session.pendingPrompt = promptController; + + let assistantText = ""; + + try { + session.history.push({ role: "user", content: userText }); + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "message.user", + text: userText + }); + + let result: ReturnType; + try { + result = this.#dependencies.modelTurnRunner.runModelTurn({ + messages: session.history, + tools: session.tools, + abortSignal: promptController.signal + }); + } catch (error: unknown) { + await this.#recordPromptError(session, params.sessionId, { + error, + kind: "model_start_failed", + phase: "model_start", + assistantTextPersisted: false + }); + throw createPromptRpcError(error, "model_start"); + } + + try { + for await (const part of result.fullStream) { + if (this.#dependencies.debugStream) { + this.#dependencies.logger?.debug?.({ streamPart: part.type }); + } + + switch (part.type) { + case "text-delta": { + assistantText += part.text; + await this.#connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: part.text + } + } + }); + break; + } + + case "tool-call": { + session.toolCallNames.set(part.toolCallId, part.toolName); + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "tool.call", + toolCallId: part.toolCallId, + toolName: part.toolName, + title: part.toolName, + rawInput: part.input + }); + await this.#connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.toolCallId, + title: part.toolName, + kind: "other", + status: "pending", + rawInput: toRawObject(part.input) + } + }); + break; + } + + case "tool-result": { + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "tool.result", + toolCallId: part.toolCallId, + toolName: session.toolCallNames.get(part.toolCallId), + status: "completed", + text: stringifyToolOutput(part.output), + rawOutput: part.output, + contextHint: extractContextHint(part.output) + }); + await this.#connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.toolCallId, + status: "completed", + content: [ + { + type: "content", + content: { + type: "text", + text: stringifyToolOutput(part.output) + } + } + ], + rawOutput: toRawObject(part.output) + } + }); + break; + } + + case "tool-error": { + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "tool.result", + toolCallId: part.toolCallId, + toolName: session.toolCallNames.get(part.toolCallId), + status: "failed", + text: stringifyToolOutput(part.error), + rawOutput: toRawObject(part.error), + contextHint: undefined + }); + await this.#connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.toolCallId, + status: "failed", + content: [ + { + type: "content", + content: { + type: "text", + text: stringifyToolOutput(part.error) + } + } + ], + rawOutput: toRawObject(part.error) + } + }); + break; + } + } + } + } catch (error: unknown) { + if (promptController.signal.aborted) { + return { stopReason: "cancelled" }; + } + + const assistantTextPersisted = assistantText.length > 0; + if (assistantTextPersisted) { + await this.#persistAssistantMessage(session, assistantText); + } + + await this.#recordPromptError(session, params.sessionId, { + error, + kind: "model_stream_failed", + phase: "model_stream", + assistantTextPersisted + }); + throw createPromptRpcError(error, "model_stream"); + } + + await this.#persistAssistantMessage(session, assistantText); + + return { + stopReason: "end_turn" + }; + } catch (error: unknown) { + if (promptController.signal.aborted) { + return { stopReason: "cancelled" }; + } + + throw error; + } finally { + if (session.pendingPrompt === promptController) { + session.pendingPrompt = undefined; + } + } + } + + async #persistAssistantMessage(session: SessionState, assistantText: string): Promise { + session.history.push({ role: "assistant", content: assistantText }); + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "message.assistant", + text: assistantText + }); + } + + async #recordPromptError( + session: SessionState, + sessionId: string, + options: { + readonly error: unknown; + readonly kind: "model_start_failed" | "model_stream_failed" | "prompt_cleanup_failed"; + readonly phase: "model_start" | "model_stream" | "cleanup"; + readonly assistantTextPersisted: boolean; + } + ): Promise { + const normalized = normalizePromptError(options.error, options.kind, options.phase); + await this.#connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `\n\n[Fledgling error: ${formatPromptErrorKind(normalized.kind)}. ${normalized.message}]` + } + } + }); + await this.#dependencies.sessionManager.appendEvent({ + ...this.#dependencies.sessionManager.createEventBase(session.id), + type: "session.error", + kind: normalized.kind, + phase: normalized.phase, + message: normalized.message, + recoverable: normalized.recoverable, + assistantTextPersisted: options.assistantTextPersisted, + errorName: normalized.errorName, + errorCode: normalized.errorCode + }); + } + + public async cancel(_params: acp.CancelNotification): Promise { + this.#sessions.get(_params.sessionId)?.pendingPrompt?.abort(); + } + + public closeAllSessions(reason: string): Promise { + return this.#sessionCleanup.closeAll(reason); + } +} + +async function replaySessionHistory(connection: acp.AgentSideConnection, session: SessionState): Promise { + for (const message of session.history) { + if (message.role !== "user" && message.role !== "assistant") { + continue; + } + + const text = messageContentToText(message.content); + if (!text) { + continue; + } + + await connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: message.role === "user" ? "user_message_chunk" : "agent_message_chunk", + content: { + type: "text", + text + } + } + }); + } +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts new file mode 100644 index 0000000..bf4dd88 --- /dev/null +++ b/packages/agent-core/src/index.ts @@ -0,0 +1,5 @@ +export * from "./agent.js"; +export * from "./interfaces.js"; +export * from "./prompt-content.js"; +export * from "./prompt-errors.js"; +export * from "./session-cleanup.js"; diff --git a/packages/agent-core/src/interfaces.ts b/packages/agent-core/src/interfaces.ts new file mode 100644 index 0000000..4655833 --- /dev/null +++ b/packages/agent-core/src/interfaces.ts @@ -0,0 +1,78 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type { SessionEvent } from "@fledgling/common"; +import type { CoreMessage, ToolSet } from "ai"; + +export type SessionEventBase = Pick; + +export interface IClosable { + close(): Promise | void; +} + +export interface ISessionManager { + createSessionId(): string; + createEventBase(sessionId: string): SessionEventBase; + appendEvent(event: SessionEvent): Promise; + loadEvents(sessionId: string): Promise; +} + +export type ModelStreamPart = + | { + readonly type: "text-delta"; + readonly text: string; + } + | { + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + } + | { + readonly type: "tool-result"; + readonly toolCallId: string; + readonly output: unknown; + } + | { + readonly type: "tool-error"; + readonly toolCallId: string; + readonly error: unknown; + }; + +export interface ModelTurnRequest { + readonly messages: CoreMessage[]; + readonly tools: ToolSet; + readonly abortSignal: AbortSignal; +} + +export interface ModelTurnResult { + readonly fullStream: AsyncIterable; +} + +export interface IModelTurnRunner { + runModelTurn(request: ModelTurnRequest): ModelTurnResult; +} + +export interface SessionTools { + readonly clients: IClosable[]; + readonly tools: ToolSet; +} + +export interface IToolProvider { + createSessionTools(request: { + readonly cwd: string | undefined; + readonly mcpServers: acp.McpServer[]; + }): Promise; +} + +export interface IRuntimeLogger { + debug?(record: unknown): void; + warn(record: unknown): void; + error(record: unknown): void; +} + +export interface FledglingAgentDependencies { + readonly toolProvider: IToolProvider; + readonly sessionManager: ISessionManager; + readonly modelTurnRunner: IModelTurnRunner; + readonly logger?: IRuntimeLogger; + readonly debugStream?: boolean; +} diff --git a/packages/agent-core/src/prompt-content.ts b/packages/agent-core/src/prompt-content.ts new file mode 100644 index 0000000..9cbdfce --- /dev/null +++ b/packages/agent-core/src/prompt-content.ts @@ -0,0 +1,92 @@ +import type { PromptRequest } from "@agentclientprotocol/sdk"; +import type { CoreMessage } from "ai"; + +export function messageContentToText(content: CoreMessage["content"]): string { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") { + return part; + } + + if (typeof part === "object" && "text" in part && typeof part.text === "string") { + return part.text; + } + + return JSON.stringify(part); + }) + .join("\n"); + } + + return JSON.stringify(content); +} + +export function extractPromptText(params: PromptRequest): string { + const prompt = params.prompt; + + if (typeof prompt === "string") { + return prompt; + } + + if (Array.isArray(prompt)) { + return prompt + .map((part) => { + if (typeof part === "string") { + return part; + } + + if (typeof part === "object" && "text" in part && typeof part.text === "string") { + return part.text; + } + + return JSON.stringify(part); + }) + .join("\n"); + } + + return JSON.stringify(prompt); +} + +export function stringifyToolOutput(output: unknown): string { + if (typeof output === "string") { + return output; + } + + return JSON.stringify(output, null, 2); +} + +export function extractContextHint(output: unknown): unknown { + if (!output || typeof output !== "object") { + return undefined; + } + + if ("structuredContent" in output) { + const structuredContent = (output as { readonly structuredContent?: unknown }).structuredContent; + if (structuredContent && typeof structuredContent === "object" && "contextHint" in structuredContent) { + return (structuredContent as { readonly contextHint?: unknown }).contextHint; + } + } + + if ("_meta" in output) { + const meta = (output as { readonly _meta?: unknown })._meta; + if (meta && typeof meta === "object" && "house.pape.fledgling/context-hint" in meta) { + return (meta as { readonly ["house.pape.fledgling/context-hint"]?: unknown })[ + "house.pape.fledgling/context-hint" + ]; + } + } + + return undefined; +} + +export function toRawObject(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + + return { value }; +} diff --git a/packages/agent-core/src/prompt-errors.test.ts b/packages/agent-core/src/prompt-errors.test.ts new file mode 100644 index 0000000..81c1d99 --- /dev/null +++ b/packages/agent-core/src/prompt-errors.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { createPromptRpcError, normalizePromptError } from "./prompt-errors.js"; + +describe("prompt error helpers", () => { + it("sanitizes model errors before durable reporting", () => { + const error = new Error("\u001B[31mfailed\nBearer abc.def.ghi sk-testsecret123"); + const normalized = normalizePromptError(error, "model_start_failed", "model_start"); + + expect(normalized).toMatchObject({ + kind: "model_start_failed", + phase: "model_start", + message: "failed Bearer [redacted] sk-[redacted]", + recoverable: true, + errorName: "Error" + }); + }); + + it("creates sanitized RPC errors", () => { + expect(createPromptRpcError(new Error("token=secret-value"), "model_stream").message).toBe( + "Fledgling model stream failed: token=[redacted]" + ); + }); +}); diff --git a/packages/agent-core/src/prompt-errors.ts b/packages/agent-core/src/prompt-errors.ts new file mode 100644 index 0000000..d7400bd --- /dev/null +++ b/packages/agent-core/src/prompt-errors.ts @@ -0,0 +1,157 @@ +import type { SessionErrorEvent } from "@fledgling/common"; + +export type PromptErrorKind = SessionErrorEvent["kind"]; +export type PromptErrorPhase = SessionErrorEvent["phase"]; + +export interface NormalizedPromptError { + readonly kind: PromptErrorKind; + readonly phase: PromptErrorPhase; + readonly message: string; + readonly recoverable: boolean; + readonly errorName: string | undefined; + readonly errorCode: string | undefined; +} + +export function normalizePromptError( + error: unknown, + kind: PromptErrorKind, + phase: PromptErrorPhase +): NormalizedPromptError { + return { + kind, + phase, + message: sanitizeErrorMessage(extractErrorMessage(error)), + recoverable: true, + errorName: extractErrorName(error), + errorCode: extractErrorCode(error) + }; +} + +export function createPromptRpcError(error: unknown, phase: "model_start" | "model_stream"): Error { + const normalized = normalizePromptError( + error, + phase === "model_start" ? "model_start_failed" : "model_stream_failed", + phase + ); + return new Error(`Fledgling ${formatPromptErrorKind(normalized.kind)}: ${normalized.message}`); +} + +export function formatPromptErrorKind(kind: PromptErrorKind): string { + switch (kind) { + case "model_start_failed": + return "model start failed"; + + case "model_stream_failed": + return "model stream failed"; + + case "prompt_cleanup_failed": + return "prompt cleanup failed"; + } +} + +export function sanitizeErrorMessage(message: string): string { + const withoutControlCharacters = replaceControlCharacters(stripAnsiEscapeSequences(message)); + const normalized = withoutControlCharacters.replace(/\s+/g, " ").trim() || "Unknown error"; + const redacted = normalized + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "sk-[redacted]") + .replace(/\b(api[_-]?key|token|secret|password)=\S+/gi, "$1=[redacted]"); + + return redacted.length > 500 ? `${redacted.slice(0, 497)}...` : redacted; +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + if (error && typeof error === "object" && "message" in error) { + const message = (error as { readonly message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + + return "Unknown error"; +} + +function extractErrorName(error: unknown): string | undefined { + if (error instanceof Error) { + return error.name; + } + + if (error && typeof error === "object" && "name" in error) { + const name = (error as { readonly name?: unknown }).name; + return typeof name === "string" ? sanitizeErrorMessage(name) : undefined; + } + + return undefined; +} + +function extractErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("code" in error)) { + return undefined; + } + + const code = (error as { readonly code?: unknown }).code; + if (typeof code === "string" || typeof code === "number") { + return sanitizeErrorMessage(String(code)); + } + + return undefined; +} + +function stripAnsiEscapeSequences(message: string): string { + let stripped = ""; + for (let index = 0; index < message.length; index++) { + const code = message.charCodeAt(index); + if (code !== 0x1b) { + stripped += message[index]; + continue; + } + + const next = message.charCodeAt(index + 1); + if (next === 0x5b) { + index += 2; + while (index < message.length) { + const sequenceCode = message.charCodeAt(index); + if (sequenceCode >= 0x40 && sequenceCode <= 0x7e) { + break; + } + + index++; + } + continue; + } + + if (next >= 0x40 && next <= 0x5f) { + index++; + } + } + + return stripped; +} + +function replaceControlCharacters(message: string): string { + let replaced = ""; + for (let index = 0; index < message.length; index++) { + const code = message.charCodeAt(index); + replaced += isUnsafeControlCharacter(code) ? " " : message[index]; + } + + return replaced; +} + +function isUnsafeControlCharacter(code: number): boolean { + return ( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + (code >= 0x7f && code <= 0x9f) + ); +} diff --git a/packages/agent-core/src/session-cleanup.ts b/packages/agent-core/src/session-cleanup.ts new file mode 100644 index 0000000..15a4502 --- /dev/null +++ b/packages/agent-core/src/session-cleanup.ts @@ -0,0 +1,106 @@ +import type { IClosable, IRuntimeLogger } from "./interfaces.js"; + +export interface PromptAbortControllerLike { + abort(): void; +} + +export interface SessionCleanupState { + readonly id: string; + readonly clients: readonly IClosable[]; + readonly pendingPrompt: PromptAbortControllerLike | undefined; +} + +export interface McpCloseFailureRecord { + readonly level: "warn"; + readonly event: "mcp_close_failed"; + readonly sessionId: string | undefined; + readonly reason: string; + readonly error: string; +} + +const defaultLogger: IRuntimeLogger = { + warn(record: unknown): void { + console.warn(JSON.stringify(record)); + }, + error(record: unknown): void { + console.error(JSON.stringify(record)); + } +}; + +export class SessionCleanup { + readonly #getSessions: () => Iterable; + readonly #clearSessions: () => void; + readonly #logger: IRuntimeLogger; + #closeAllPromise: Promise | undefined; + + public constructor( + getSessions: () => Iterable, + clearSessions: () => void, + logger: IRuntimeLogger = defaultLogger + ) { + this.#getSessions = getSessions; + this.#clearSessions = clearSessions; + this.#logger = logger; + } + + public closeAll(reason: string): Promise { + this.#closeAllPromise ??= this.#closeAll(reason); + return this.#closeAllPromise; + } + + public async closeSession(session: SessionCleanupState, reason: string): Promise { + session.pendingPrompt?.abort(); + await closeClients(session.clients, reason, session.id, this.#logger); + } + + async #closeAll(reason: string): Promise { + const closeAttempts: Promise[] = []; + + for (const session of this.#getSessions()) { + closeAttempts.push(this.closeSession(session, reason)); + } + + await Promise.allSettled(closeAttempts); + this.#clearSessions(); + } +} + +export async function closeClients( + clients: readonly IClosable[], + reason: string, + sessionId: string | undefined = undefined, + logger: IRuntimeLogger = defaultLogger +): Promise { + await Promise.allSettled( + clients.map(async (client) => { + try { + await client.close(); + } catch (error: unknown) { + logger.warn({ + level: "warn", + event: "mcp_close_failed", + sessionId, + reason, + error: serializeError(error) + } satisfies McpCloseFailureRecord); + } + }) + ); +} + +export function serializeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + try { + const serialized: unknown = JSON.stringify(error); + return typeof serialized === "string" ? serialized : String(error); + } catch { + return String(error); + } +} diff --git a/packages/agent-core/tsconfig.json b/packages/agent-core/tsconfig.json new file mode 100644 index 0000000..7eedcec --- /dev/null +++ b/packages/agent-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/agent-core/vitest.config.ts b/packages/agent-core/vitest.config.ts new file mode 100644 index 0000000..1175b9f --- /dev/null +++ b/packages/agent-core/vitest.config.ts @@ -0,0 +1,3 @@ +import { createVitestConfig } from "@fledgling/heft-rig/vitest-base.mjs"; + +export default createVitestConfig(import.meta.url); diff --git a/packages/mcp-workspace-webcontainer/config/rig.json b/packages/mcp-workspace-webcontainer/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/mcp-workspace-webcontainer/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/mcp-workspace-webcontainer/eslint.config.cjs b/packages/mcp-workspace-webcontainer/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/mcp-workspace-webcontainer/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/mcp-workspace-webcontainer/package.json b/packages/mcp-workspace-webcontainer/package.json new file mode 100644 index 0000000..300c625 --- /dev/null +++ b/packages/mcp-workspace-webcontainer/package.json @@ -0,0 +1,29 @@ +{ + "name": "@fledgling/mcp-workspace-webcontainer", + "version": "0.0.0", + "private": true, + "description": "Browser MCP workspace sidecar backed by WebContainer-compatible runtimes.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.21.0", + "@webcontainer/api": "^1.5.1", + "zod": "^3.25.0" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/mcp-workspace-webcontainer/src/index.ts b/packages/mcp-workspace-webcontainer/src/index.ts new file mode 100644 index 0000000..a70d94f --- /dev/null +++ b/packages/mcp-workspace-webcontainer/src/index.ts @@ -0,0 +1,2 @@ +export * from "./runtime.js"; +export * from "./sidecar.js"; diff --git a/packages/mcp-workspace-webcontainer/src/runtime.ts b/packages/mcp-workspace-webcontainer/src/runtime.ts new file mode 100644 index 0000000..e175108 --- /dev/null +++ b/packages/mcp-workspace-webcontainer/src/runtime.ts @@ -0,0 +1,162 @@ +import type { WebContainer } from "@webcontainer/api"; + +export interface WorkspaceFileEntry { + readonly type: "file"; + readonly name: string; + readonly path: string; + readonly sizeBytes: number; +} + +export interface WorkspaceDirectoryEntry { + readonly type: "directory"; + readonly name: string; + readonly path: string; +} + +export type WorkspaceEntry = WorkspaceFileEntry | WorkspaceDirectoryEntry; + +export interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly timedOut: boolean; + readonly truncated: boolean; +} + +export interface IWorkspaceRuntime { + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + listDirectory(path: string): Promise; + searchText(query: string, path: string): Promise; + runCommand(command: string, cwd: string, timeoutMs: number, maxOutputBytes: number): Promise; +} + +export interface SearchMatch { + readonly path: string; + readonly line: number; + readonly text: string; +} + +export class WebContainerWorkspaceRuntime implements IWorkspaceRuntime { + readonly #container: WebContainer; + + public constructor(container: WebContainer) { + this.#container = container; + } + + public async readFile(path: string): Promise { + return this.#container.fs.readFile(normalizeWorkspacePath(path), "utf8"); + } + + public async writeFile(path: string, content: string): Promise { + await this.#container.fs.writeFile(normalizeWorkspacePath(path), content); + } + + public async listDirectory(path: string): Promise { + const root = normalizeWorkspacePath(path); + const entries = await this.#container.fs.readdir(root, { withFileTypes: true }); + return entries.map((entry) => { + const entryPath = joinWorkspacePath(root, entry.name); + if (entry.isDirectory()) { + return { + type: "directory", + name: entry.name, + path: entryPath + }; + } + + return { + type: "file", + name: entry.name, + path: entryPath, + sizeBytes: 0 + }; + }); + } + + public async searchText(query: string, path: string): Promise { + const matches: SearchMatch[] = []; + await this.#searchDirectory(normalizeWorkspacePath(path), query, matches); + return matches; + } + + public async runCommand( + command: string, + cwd: string, + timeoutMs: number, + maxOutputBytes: number + ): Promise { + const started = new AbortController(); + const timeout = setTimeout(() => started.abort(), timeoutMs); + + try { + const process = await this.#container.spawn("jsh", ["-c", command], { + cwd: normalizeWorkspacePath(cwd) + }); + const stdout = await readProcessOutput(process.output, maxOutputBytes); + const exitCode = await process.exit; + + return { + exitCode, + stdout, + stderr: "", + timedOut: started.signal.aborted, + truncated: stdout.length >= maxOutputBytes + }; + } finally { + clearTimeout(timeout); + } + } + + async #searchDirectory(path: string, query: string, matches: SearchMatch[]): Promise { + for (const entry of await this.#container.fs.readdir(path, { withFileTypes: true })) { + const entryPath = joinWorkspacePath(path, entry.name); + if (entry.isDirectory()) { + await this.#searchDirectory(entryPath, query, matches); + continue; + } + + const content = await this.#container.fs.readFile(entryPath, "utf8"); + appendSearchMatches(matches, entryPath, content, query); + } + } +} + +async function readProcessOutput(output: ReadableStream, maxOutputBytes: number): Promise { + let text = ""; + const reader = output.getReader(); + + try { + for (;;) { + const result = await reader.read(); + if (result.done) { + return text.slice(0, maxOutputBytes); + } + + text += result.value; + if (text.length >= maxOutputBytes) { + return text.slice(0, maxOutputBytes); + } + } + } finally { + reader.releaseLock(); + } +} + +function appendSearchMatches(matches: SearchMatch[], path: string, content: string, query: string): void { + const lines = content.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + if (line.includes(query)) { + matches.push({ path, line: index + 1, text: line }); + } + } +} + +function normalizeWorkspacePath(path: string): string { + const normalized = path.replaceAll("\\", "/").replace(/^\/+/, ""); + return normalized.length === 0 || normalized === "." ? "." : normalized; +} + +function joinWorkspacePath(parent: string, child: string): string { + return parent === "." ? child : `${parent}/${child}`; +} diff --git a/packages/mcp-workspace-webcontainer/src/sidecar.ts b/packages/mcp-workspace-webcontainer/src/sidecar.ts new file mode 100644 index 0000000..31bd197 --- /dev/null +++ b/packages/mcp-workspace-webcontainer/src/sidecar.ts @@ -0,0 +1,203 @@ +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +import type { IWorkspaceRuntime } from "./runtime.js"; + +interface ContextHint { + readonly kind: "durable_resource" | "command_output"; + readonly identity?: string; + readonly contentHash?: string; + readonly tokenEstimate?: number; + readonly placement: "session_context" | "latest_evidence"; + readonly retention: "discard_after_turn" | "retain_until_changed"; + readonly priority?: number; + readonly routingTags?: string[]; +} + +export interface WebWorkspaceSidecar { + readonly clientTransport: InMemoryTransport; + close(): Promise; +} + +export async function createWebContainerWorkspaceSidecar(runtime: IWorkspaceRuntime): Promise { + const server = new McpServer( + { + name: "fledgling-webcontainer-workspace", + version: "0.0.0" + }, + { + instructions: + "Browser workspace tools run through a WebContainer-backed MCP sidecar. Use read_file before edits; command output is latest evidence." + } + ); + registerWebWorkspaceTools(server, runtime); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + return { + clientTransport, + async close(): Promise { + await server.close(); + await clientTransport.close(); + } + }; +} + +export function registerWebWorkspaceTools(server: McpServer, runtime: IWorkspaceRuntime): void { + server.registerTool( + "workspace.read_file", + { + title: "Read file", + description: "Read a UTF-8 text file in the browser workspace.", + inputSchema: { + path: z.string() + }, + annotations: { + readOnlyHint: true + } + }, + async ({ path }) => { + const content = await runtime.readFile(path); + const contextHint = fileContextHint(path, content); + return toolResult(`Read ${path}.\n\n${content}`, { path, content, contextHint }, contextHint); + } + ); + + server.registerTool( + "workspace.write_file", + { + title: "Write file", + description: "Write a UTF-8 text file in the browser workspace.", + inputSchema: { + path: z.string(), + content: z.string() + }, + annotations: { + destructiveHint: true + } + }, + async ({ path, content }) => { + await runtime.writeFile(path, content); + const contextHint = fileContextHint(path, content); + return toolResult(`Wrote ${path}.`, { path, contentHash: hashText(content), contextHint }, contextHint); + } + ); + + server.registerTool( + "workspace.list_directory", + { + title: "List directory", + description: "List files and directories in the browser workspace.", + inputSchema: { + path: z.string().default(".") + }, + annotations: { + readOnlyHint: true + } + }, + async ({ path }) => { + const entries = await runtime.listDirectory(path); + const text = entries.map((entry) => `${entry.type}\t${entry.path}`).join("\n"); + return toolResult(text.length > 0 ? text : "Directory is empty.", { path, entries }, undefined); + } + ); + + server.registerTool( + "workspace.search_text", + { + title: "Search text", + description: "Search text across files in the browser workspace.", + inputSchema: { + query: z.string(), + path: z.string().default(".") + }, + annotations: { + readOnlyHint: true + } + }, + async ({ query, path }) => { + const matches = await runtime.searchText(query, path); + const text = matches.map((match) => `${match.path}:${match.line}: ${match.text}`).join("\n"); + return toolResult(text.length > 0 ? text : "No matches.", { query, path, matches }, undefined); + } + ); + + server.registerTool( + "workspace.run_command", + { + title: "Run command", + description: "Run a shell command in the browser workspace sidecar.", + inputSchema: { + command: z.string(), + cwd: z.string().default("."), + timeoutMs: z.number().int().positive().default(30_000), + maxOutputBytes: z.number().int().positive().default(20_000) + }, + annotations: { + destructiveHint: true + } + }, + async ({ command, cwd, timeoutMs, maxOutputBytes }) => { + const result = await runtime.runCommand(command, cwd, timeoutMs, maxOutputBytes); + const output = `exitCode: ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`; + const contextHint = commandContextHint(command, output); + return toolResult(output, { command, cwd, ...result, contextHint }, contextHint); + } + ); +} + +function toolResult( + text: string, + structuredContent: Record, + contextHint: ContextHint | undefined +): CallToolResult { + const meta = contextHint ? { "house.pape.fledgling/context-hint": contextHint } : undefined; + return { + content: [{ type: "text", text }], + structuredContent, + _meta: meta + }; +} + +function fileContextHint(path: string, content: string): ContextHint { + return { + kind: "durable_resource", + identity: `webcontainer://${path}`, + contentHash: hashText(content), + tokenEstimate: estimateTokens(content), + placement: "session_context", + retention: "retain_until_changed", + priority: 60, + routingTags: ["file", path] + }; +} + +function commandContextHint(command: string, output: string): ContextHint { + return { + kind: "command_output", + identity: `webcontainer-command://${hashText(command)}`, + contentHash: hashText(output), + tokenEstimate: estimateTokens(output), + placement: "latest_evidence", + retention: "discard_after_turn", + priority: 90, + routingTags: ["command", "shell"] + }; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function hashText(text: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index++) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, "0")}`; +} diff --git a/packages/mcp-workspace-webcontainer/tsconfig.json b/packages/mcp-workspace-webcontainer/tsconfig.json new file mode 100644 index 0000000..83592d4 --- /dev/null +++ b/packages/mcp-workspace-webcontainer/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/mcp-workspace-webcontainer/vitest.config.ts b/packages/mcp-workspace-webcontainer/vitest.config.ts new file mode 100644 index 0000000..99f4bce --- /dev/null +++ b/packages/mcp-workspace-webcontainer/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node" + } +}); diff --git a/packages/session-file-system/config/rig.json b/packages/session-file-system/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/session-file-system/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/session-file-system/eslint.config.cjs b/packages/session-file-system/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/session-file-system/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/session-file-system/package.json b/packages/session-file-system/package.json new file mode 100644 index 0000000..987a0c3 --- /dev/null +++ b/packages/session-file-system/package.json @@ -0,0 +1,28 @@ +{ + "name": "@fledgling/session-file-system", + "version": "0.0.0", + "private": true, + "description": "Filesystem-backed JSONL Fledgling session manager.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@fledgling/agent-core": "workspace:*", + "@fledgling/common": "workspace:*" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/session-file-system/src/index.ts b/packages/session-file-system/src/index.ts new file mode 100644 index 0000000..5f16de6 --- /dev/null +++ b/packages/session-file-system/src/index.ts @@ -0,0 +1,73 @@ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +import type { ISessionManager, SessionEventBase } from "@fledgling/agent-core"; +import type { SessionEvent } from "@fledgling/common"; + +export class FileSystemSessionManager implements ISessionManager { + readonly #root: string; + readonly #sessionFile: string | undefined; + + public constructor( + root: string = defaultSessionStoreRoot(), + sessionFile: string | undefined = process.env.FLEDGLING_SESSION_FILE + ) { + this.#root = root; + this.#sessionFile = sessionFile ? path.resolve(sessionFile) : undefined; + } + + public createSessionId(): string { + return randomUUID(); + } + + public createEventBase(sessionId: string): SessionEventBase { + return { + eventId: randomUUID(), + sessionId, + timestamp: new Date().toISOString() + }; + } + + public async appendEvent(event: SessionEvent): Promise { + const sessionPath = this.#sessionPath(event.sessionId); + await mkdir(path.dirname(sessionPath), { recursive: true }); + await appendFile(sessionPath, `${JSON.stringify(event)}\n`, "utf8"); + } + + public async loadEvents(sessionId: string): Promise { + let raw: string; + try { + raw = await readFile(this.#sessionPath(sessionId), "utf8"); + } catch (error: unknown) { + throw new Error(`Unknown ACP session: ${sessionId}`, { cause: error }); + } + + const events = raw + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as SessionEvent); + + if (events.some((event) => event.sessionId !== sessionId)) { + throw new Error(`Stored ACP session contains events for another session: ${sessionId}`); + } + + return events; + } + + #sessionPath(sessionId: string): string { + if (this.#sessionFile) { + return this.#sessionFile; + } + + return path.join(this.#root, `${sessionIdToFileName(sessionId)}.jsonl`); + } +} + +export function defaultSessionStoreRoot(): string { + return path.resolve(process.env.FLEDGLING_SESSION_DIR ?? path.join(process.cwd(), ".fledgling", "sessions")); +} + +function sessionIdToFileName(sessionId: string): string { + return sessionId.replaceAll(/[^A-Za-z0-9._-]/g, "_"); +} diff --git a/packages/session-file-system/src/session-file-system.test.ts b/packages/session-file-system/src/session-file-system.test.ts new file mode 100644 index 0000000..149fb78 --- /dev/null +++ b/packages/session-file-system/src/session-file-system.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { FileSystemSessionManager } from "./index.js"; + +let tempDir: string | undefined; + +describe("FileSystemSessionManager", () => { + afterEach(async () => { + const cleanupDir = tempDir; + tempDir = undefined; + if (cleanupDir) { + await rm(cleanupDir, { recursive: true, force: true }); + } + }); + + it("appends and loads JSONL session events", async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "fledgling-session-file-system-test-")); + const sessionFile = path.join(tempDir, "session.jsonl"); + const manager = new FileSystemSessionManager(tempDir, sessionFile); + const sessionId = manager.createSessionId(); + const event = { + ...manager.createEventBase(sessionId), + type: "message.user", + text: "hello" + } as const; + + await manager.appendEvent(event); + + await expect(manager.loadEvents(sessionId)).resolves.toEqual([event]); + expect(await readFile(sessionFile, "utf8")).toContain('"type":"message.user"'); + }); + + it("rejects unknown sessions", async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "fledgling-session-file-system-test-")); + const manager = new FileSystemSessionManager(tempDir); + + await expect(manager.loadEvents("missing")).rejects.toThrow("Unknown ACP session: missing"); + }); +}); diff --git a/packages/session-file-system/tsconfig.json b/packages/session-file-system/tsconfig.json new file mode 100644 index 0000000..7eedcec --- /dev/null +++ b/packages/session-file-system/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/session-file-system/vitest.config.ts b/packages/session-file-system/vitest.config.ts new file mode 100644 index 0000000..1175b9f --- /dev/null +++ b/packages/session-file-system/vitest.config.ts @@ -0,0 +1,3 @@ +import { createVitestConfig } from "@fledgling/heft-rig/vitest-base.mjs"; + +export default createVitestConfig(import.meta.url); diff --git a/packages/session-local-storage/config/rig.json b/packages/session-local-storage/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/session-local-storage/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/session-local-storage/eslint.config.cjs b/packages/session-local-storage/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/session-local-storage/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/session-local-storage/package.json b/packages/session-local-storage/package.json new file mode 100644 index 0000000..2baf34b --- /dev/null +++ b/packages/session-local-storage/package.json @@ -0,0 +1,28 @@ +{ + "name": "@fledgling/session-local-storage", + "version": "0.0.0", + "private": true, + "description": "localStorage-backed Fledgling session manager.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@fledgling/agent-core": "workspace:*", + "@fledgling/common": "workspace:*" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/session-local-storage/src/index.ts b/packages/session-local-storage/src/index.ts new file mode 100644 index 0000000..7b95510 --- /dev/null +++ b/packages/session-local-storage/src/index.ts @@ -0,0 +1,66 @@ +import type { ISessionManager, SessionEventBase } from "@fledgling/agent-core"; +import type { SessionEvent } from "@fledgling/common"; + +export interface LocalStorageSessionManagerOptions { + readonly storage?: Storage; + readonly keyPrefix?: string; +} + +export class LocalStorageSessionManager implements ISessionManager { + readonly #storage: Storage; + readonly #keyPrefix: string; + + public constructor(options: LocalStorageSessionManagerOptions = {}) { + this.#storage = options.storage ?? globalThis.localStorage; + this.#keyPrefix = options.keyPrefix ?? "fledgling:sessions:"; + } + + public createSessionId(): string { + return createId(); + } + + public createEventBase(sessionId: string): SessionEventBase { + return { + eventId: createId(), + sessionId, + timestamp: new Date().toISOString() + }; + } + + public async appendEvent(event: SessionEvent): Promise { + const events = this.#loadExistingEvents(event.sessionId); + events.push(event); + this.#storage.setItem(this.#sessionKey(event.sessionId), JSON.stringify(events)); + } + + public async loadEvents(sessionId: string): Promise { + const raw = this.#storage.getItem(this.#sessionKey(sessionId)); + if (!raw) { + throw new Error(`Unknown ACP session: ${sessionId}`); + } + + const events = JSON.parse(raw) as SessionEvent[]; + if (events.some((event) => event.sessionId !== sessionId)) { + throw new Error(`Stored ACP session contains events for another session: ${sessionId}`); + } + + return events; + } + + #loadExistingEvents(sessionId: string): SessionEvent[] { + const raw = this.#storage.getItem(this.#sessionKey(sessionId)); + return raw ? (JSON.parse(raw) as SessionEvent[]) : []; + } + + #sessionKey(sessionId: string): string { + return `${this.#keyPrefix}${sessionId.replaceAll(/[^A-Za-z0-9._-]/g, "_")}`; + } +} + +function createId(): string { + if (typeof globalThis.crypto.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + return `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} diff --git a/packages/session-local-storage/src/session-local-storage.test.ts b/packages/session-local-storage/src/session-local-storage.test.ts new file mode 100644 index 0000000..4091e5e --- /dev/null +++ b/packages/session-local-storage/src/session-local-storage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { LocalStorageSessionManager } from "./index.js"; + +class MemoryStorage implements Storage { + readonly #items: Map = new Map(); + + public get length(): number { + return this.#items.size; + } + + public clear(): void { + this.#items.clear(); + } + + // eslint-disable-next-line @rushstack/no-new-null -- Storage.getItem is a DOM API. + public getItem(key: string): string | null { + return this.#items.get(key) ?? null; + } + + // eslint-disable-next-line @rushstack/no-new-null -- Storage.key is a DOM API. + public key(index: number): string | null { + return this.keys()[index] ?? null; + } + + public removeItem(key: string): void { + this.#items.delete(key); + } + + public setItem(key: string, value: string): void { + this.#items.set(key, value); + } + + public keys(): string[] { + return [...this.#items.keys()]; + } +} + +describe("LocalStorageSessionManager", () => { + it("appends and loads session events with key prefix isolation", async () => { + const storage = new MemoryStorage(); + const manager = new LocalStorageSessionManager({ storage, keyPrefix: "test:" }); + const sessionId = manager.createSessionId(); + const event = { + ...manager.createEventBase(sessionId), + type: "message.user", + text: "hello" + } as const; + + await manager.appendEvent(event); + + await expect(manager.loadEvents(sessionId)).resolves.toEqual([event]); + expect(storage.keys()).toEqual([`test:${sessionId}`]); + }); + + it("rejects unknown sessions", async () => { + const manager = new LocalStorageSessionManager({ storage: new MemoryStorage() }); + + await expect(manager.loadEvents("missing")).rejects.toThrow("Unknown ACP session: missing"); + }); +}); diff --git a/packages/session-local-storage/tsconfig.json b/packages/session-local-storage/tsconfig.json new file mode 100644 index 0000000..83592d4 --- /dev/null +++ b/packages/session-local-storage/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/session-local-storage/vitest.config.ts b/packages/session-local-storage/vitest.config.ts new file mode 100644 index 0000000..1175b9f --- /dev/null +++ b/packages/session-local-storage/vitest.config.ts @@ -0,0 +1,3 @@ +import { createVitestConfig } from "@fledgling/heft-rig/vitest-base.mjs"; + +export default createVitestConfig(import.meta.url); diff --git a/packages/session-memory/config/rig.json b/packages/session-memory/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/session-memory/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/session-memory/eslint.config.cjs b/packages/session-memory/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/session-memory/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/session-memory/package.json b/packages/session-memory/package.json new file mode 100644 index 0000000..0cea896 --- /dev/null +++ b/packages/session-memory/package.json @@ -0,0 +1,28 @@ +{ + "name": "@fledgling/session-memory", + "version": "0.0.0", + "private": true, + "description": "In-memory Fledgling session manager.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@fledgling/agent-core": "workspace:*", + "@fledgling/common": "workspace:*" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/session-memory/src/index.ts b/packages/session-memory/src/index.ts new file mode 100644 index 0000000..dda961a --- /dev/null +++ b/packages/session-memory/src/index.ts @@ -0,0 +1,37 @@ +import type { ISessionManager, SessionEventBase } from "@fledgling/agent-core"; +import type { SessionEvent } from "@fledgling/common"; + +export class MemorySessionManager implements ISessionManager { + readonly #eventsBySessionId: Map = new Map(); + + public createSessionId(): string { + return createId(); + } + + public createEventBase(sessionId: string): SessionEventBase { + return { + eventId: createId(), + sessionId, + timestamp: new Date().toISOString() + }; + } + + public async appendEvent(event: SessionEvent): Promise { + const events = this.#eventsBySessionId.get(event.sessionId) ?? []; + events.push(event); + this.#eventsBySessionId.set(event.sessionId, events); + } + + public async loadEvents(sessionId: string): Promise { + const events = this.#eventsBySessionId.get(sessionId); + if (!events) { + throw new Error(`Unknown ACP session: ${sessionId}`); + } + + return [...events]; + } +} + +function createId(): string { + return globalThis.crypto?.randomUUID?.() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} diff --git a/packages/session-memory/src/session-memory.test.ts b/packages/session-memory/src/session-memory.test.ts new file mode 100644 index 0000000..cd65399 --- /dev/null +++ b/packages/session-memory/src/session-memory.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { MemorySessionManager } from "./index.js"; + +describe("MemorySessionManager", () => { + it("appends and loads session events", async () => { + const manager = new MemorySessionManager(); + const sessionId = manager.createSessionId(); + const event = { + ...manager.createEventBase(sessionId), + type: "message.user", + text: "hello" + } as const; + + await manager.appendEvent(event); + + await expect(manager.loadEvents(sessionId)).resolves.toEqual([event]); + }); + + it("rejects unknown sessions", async () => { + const manager = new MemorySessionManager(); + + await expect(manager.loadEvents("missing")).rejects.toThrow("Unknown ACP session: missing"); + }); +}); diff --git a/packages/session-memory/tsconfig.json b/packages/session-memory/tsconfig.json new file mode 100644 index 0000000..7eedcec --- /dev/null +++ b/packages/session-memory/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/session-memory/vitest.config.ts b/packages/session-memory/vitest.config.ts new file mode 100644 index 0000000..1175b9f --- /dev/null +++ b/packages/session-memory/vitest.config.ts @@ -0,0 +1,3 @@ +import { createVitestConfig } from "@fledgling/heft-rig/vitest-base.mjs"; + +export default createVitestConfig(import.meta.url); diff --git a/packages/tools-mcp-node/config/rig.json b/packages/tools-mcp-node/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/tools-mcp-node/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/tools-mcp-node/eslint.config.cjs b/packages/tools-mcp-node/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/tools-mcp-node/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/tools-mcp-node/package.json b/packages/tools-mcp-node/package.json new file mode 100644 index 0000000..97695aa --- /dev/null +++ b/packages/tools-mcp-node/package.json @@ -0,0 +1,31 @@ +{ + "name": "@fledgling/tools-mcp-node", + "version": "0.0.0", + "private": true, + "description": "Node MCP tool provider for Fledgling.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.4.3", + "@ai-sdk/mcp": "0.0.18", + "@fledgling/agent-core": "workspace:*", + "@fledgling/mcp-workspace": "workspace:*", + "ai": "^5.0.0" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/tools-mcp-node/src/config.test.ts b/packages/tools-mcp-node/src/config.test.ts new file mode 100644 index 0000000..7593806 --- /dev/null +++ b/packages/tools-mcp-node/src/config.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { loadConfig } from "./config.js"; + +let tempDir: string | undefined; + +describe("loadMcpServersFromConfig", () => { + const originalConfig = process.env.FLEDGLING_CONFIG; + + afterEach(async () => { + restoreEnv("FLEDGLING_CONFIG", originalConfig); + + const cleanupDir = tempDir; + tempDir = undefined; + if (cleanupDir) { + await rm(cleanupDir, { recursive: true, force: true }); + } + }); + + it("loads MCP servers from Fledgling config files", async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "fledgling-tools-mcp-node-test-")); + const configFile = path.join(tempDir, "config.json"); + process.env.FLEDGLING_CONFIG = configFile; + await writeFile( + configFile, + JSON.stringify({ + mcpServers: { + workspace: { + command: "node", + args: ["server.js"], + env: { + A: "B" + } + } + } + }), + "utf8" + ); + + expect(await loadConfig()).toEqual({ + mcpServers: { + workspace: { + command: "node", + args: ["server.js"], + env: { + A: "B" + } + } + } + }); + }); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + + process.env[name] = value; +} diff --git a/packages/tools-mcp-node/src/config.ts b/packages/tools-mcp-node/src/config.ts new file mode 100644 index 0000000..fce24c2 --- /dev/null +++ b/packages/tools-mcp-node/src/config.ts @@ -0,0 +1,44 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export interface FledglingConfig { + readonly mcpServers?: Record; +} + +export type McpOrigin = "acp_client" | "config" | "first_party"; + +export interface ResolvedMcpServer { + readonly name: string; + readonly origin: McpOrigin; + readonly config: McpServerConfig; +} + +export type McpServerConfig = + | { + readonly type: "firstPartyWorkspace"; + } + | { + readonly type: "stdio"; + readonly command: string; + readonly args?: string[]; + readonly cwd?: string; + readonly env?: Record; + } + | { + readonly type: "http" | "sse"; + readonly url: string; + readonly headers?: Record; + }; + +export async function loadConfig(): Promise { + const configPath = process.env.FLEDGLING_CONFIG + ? path.resolve(process.env.FLEDGLING_CONFIG) + : path.resolve(process.cwd(), "fledgling.config.json"); + + if (!existsSync(configPath)) { + return {}; + } + + return JSON.parse(await readFile(configPath, "utf8")) as FledglingConfig; +} diff --git a/packages/tools-mcp-node/src/index.ts b/packages/tools-mcp-node/src/index.ts new file mode 100644 index 0000000..1f40aeb --- /dev/null +++ b/packages/tools-mcp-node/src/index.ts @@ -0,0 +1,173 @@ +import { fileURLToPath } from "node:url"; + +import * as acp from "@agentclientprotocol/sdk"; +import { + experimental_createMCPClient as createMCPClient, + type experimental_MCPClient as MCPClient +} from "@ai-sdk/mcp"; +import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio"; +import { closeClients, type IToolProvider, type SessionTools } from "@fledgling/agent-core"; +import type { ToolSet } from "ai"; + +import { loadConfig, type FledglingConfig, type McpServerConfig, type ResolvedMcpServer } from "./config.js"; + +export class NodeMcpToolProvider implements IToolProvider { + readonly #configPromise: Promise; + + public constructor(configPromise: Promise = loadConfig()) { + this.#configPromise = configPromise; + } + + public async createSessionTools(request: { + readonly cwd: string | undefined; + readonly mcpServers: acp.McpServer[]; + }): Promise { + const entries = await this.#resolveMcpServers(request.mcpServers); + const clients: MCPClient[] = []; + const tools: ToolSet = {}; + + try { + for (const entry of entries) { + const client = await createMCPClient({ + name: `fledgling-${entry.origin}-${entry.name}`, + transport: await createTransport(entry.config, request.cwd) + }); + clients.push(client); + + const serverTools = await client.tools(); + for (const [toolName, tool] of Object.entries(serverTools)) { + const exposedToolName = toExposedToolName(entry.name, toolName); + if (exposedToolName in tools) { + throw new Error(`MCP tool name collision after sanitization: ${exposedToolName}`); + } + + tools[exposedToolName] = tool as unknown as ToolSet[string]; + } + } + } catch (error: unknown) { + await closeClients(clients, "session-tools-setup-failed"); + throw error; + } + + return { clients, tools }; + } + + async #resolveMcpServers(clientMcpServers: acp.McpServer[]): Promise { + const resolved = new Map(); + + for (const server of clientMcpServers) { + const name = sanitizeToolName(server.name); + resolved.set(name, { + name, + origin: "acp_client", + config: fromAcpMcpServer(server) + }); + } + + const config = await this.#configPromise; + for (const [serverName, serverConfig] of Object.entries(config.mcpServers ?? {})) { + const name = sanitizeToolName(serverName); + if (resolved.has(name)) { + console.error( + JSON.stringify({ + level: "warn", + message: "Skipping configured MCP server because ACP client provided the same server name.", + serverName: name + }) + ); + continue; + } + + resolved.set(name, { + name, + origin: serverConfig.type === "firstPartyWorkspace" ? "first_party" : "config", + config: serverConfig + }); + } + + return [...resolved.values()]; + } +} + +function fromAcpMcpServer(server: acp.McpServer): McpServerConfig { + if ("type" in server) { + return { + type: server.type, + url: server.url, + headers: Object.fromEntries(server.headers.map((header: acp.HttpHeader) => [header.name, header.value])) + }; + } + + return { + type: "stdio", + command: server.command, + args: server.args, + env: Object.fromEntries(server.env.map((entry: acp.EnvVariable) => [entry.name, entry.value])) + }; +} + +async function createTransport( + config: McpServerConfig, + sessionCwd: string | undefined +): Promise[0]["transport"]> { + switch (config.type) { + case "firstPartyWorkspace": { + return new Experimental_StdioMCPTransport({ + command: process.execPath, + args: [fileURLToPath(import.meta.resolve("@fledgling/mcp-workspace"))], + cwd: sessionCwd ?? process.cwd(), + env: { + FLEDGLING_WORKSPACE_ROOT: sessionCwd ?? process.cwd() + }, + stderr: "inherit" + }); + } + + case "stdio": { + return new Experimental_StdioMCPTransport({ + command: config.command, + args: config.args, + cwd: expandConfigString(config.cwd, sessionCwd), + env: expandEnv(config.env, sessionCwd), + stderr: "inherit" + }); + } + + case "http": + case "sse": + return { + type: config.type, + url: config.url, + headers: config.headers + }; + } +} + +function expandEnv( + env: Record | undefined, + sessionCwd: string | undefined +): Record | undefined { + if (!env) { + return undefined; + } + + return Object.fromEntries( + Object.entries(env).map(([key, value]) => [key, expandConfigString(value, sessionCwd) ?? value]) + ); +} + +function expandConfigString(value: string | undefined, sessionCwd: string | undefined): string | undefined { + return value?.replaceAll("${sessionCwd}", sessionCwd ?? process.cwd()); +} + +function sanitizeToolName(toolName: string): string { + return toolName.replaceAll(/[^A-Za-z0-9_-]/g, "_").slice(0, 64); +} + +function toExposedToolName(serverName: string, toolName: string): string { + const localToolName = toolName.startsWith(`${serverName}.`) + ? toolName.slice(serverName.length + 1) + : toolName; + + return sanitizeToolName(`${serverName}_${localToolName}`); +} diff --git a/packages/tools-mcp-node/tsconfig.json b/packages/tools-mcp-node/tsconfig.json new file mode 100644 index 0000000..7eedcec --- /dev/null +++ b/packages/tools-mcp-node/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/tools-mcp-node/vitest.config.ts b/packages/tools-mcp-node/vitest.config.ts new file mode 100644 index 0000000..1175b9f --- /dev/null +++ b/packages/tools-mcp-node/vitest.config.ts @@ -0,0 +1,3 @@ +import { createVitestConfig } from "@fledgling/heft-rig/vitest-base.mjs"; + +export default createVitestConfig(import.meta.url); diff --git a/packages/web-agent/config/rig.json b/packages/web-agent/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/web-agent/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/web-agent/eslint.config.cjs b/packages/web-agent/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/web-agent/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/web-agent/package.json b/packages/web-agent/package.json new file mode 100644 index 0000000..7c122e6 --- /dev/null +++ b/packages/web-agent/package.json @@ -0,0 +1,34 @@ +{ + "name": "@fledgling/web-agent", + "version": "0.0.0", + "private": true, + "description": "Browser composition package for the Fledgling ACP agent loop.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "bundle": "webpack --config webpack.config.cjs", + "_phase:bundle": "webpack --config webpack.config.cjs" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.4.3", + "@ai-sdk/mcp": "0.0.18", + "@fledgling/agent-core": "workspace:*", + "@fledgling/mcp-workspace-webcontainer": "workspace:*", + "@fledgling/session-local-storage": "workspace:*", + "ai": "^5.0.0" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "typescript": "~5.9.0", + "vitest": "^3.1.1", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } +} diff --git a/packages/web-agent/src/dependencies.ts b/packages/web-agent/src/dependencies.ts new file mode 100644 index 0000000..74cc0ee --- /dev/null +++ b/packages/web-agent/src/dependencies.ts @@ -0,0 +1,34 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import { + type FledglingAgentDependencies, + type IModelTurnRunner, + FledglingAgent +} from "@fledgling/agent-core"; +import { + createWebContainerWorkspaceSidecar, + type IWorkspaceRuntime +} from "@fledgling/mcp-workspace-webcontainer"; +import { LocalStorageSessionManager } from "@fledgling/session-local-storage"; + +import { WebMcpToolProvider } from "./tool-provider.js"; + +export interface WebAgentOptions { + readonly workspaceRuntime: IWorkspaceRuntime; + readonly modelTurnRunner: IModelTurnRunner; + readonly storage?: Storage; +} + +export function createWebAgentDependencies(options: WebAgentOptions): FledglingAgentDependencies { + return { + sessionManager: new LocalStorageSessionManager({ storage: options.storage }), + toolProvider: new WebMcpToolProvider({ + createSidecar: () => createWebContainerWorkspaceSidecar(options.workspaceRuntime) + }), + modelTurnRunner: options.modelTurnRunner, + logger: console + }; +} + +export function createWebAgent(connection: acp.AgentSideConnection, options: WebAgentOptions): FledglingAgent { + return new FledglingAgent(connection, createWebAgentDependencies(options)); +} diff --git a/packages/web-agent/src/index.ts b/packages/web-agent/src/index.ts new file mode 100644 index 0000000..a746f10 --- /dev/null +++ b/packages/web-agent/src/index.ts @@ -0,0 +1,4 @@ +export * from "@fledgling/agent-core"; +export * from "@fledgling/mcp-workspace-webcontainer"; +export * from "./dependencies.js"; +export * from "./tool-provider.js"; diff --git a/packages/web-agent/src/tool-provider.ts b/packages/web-agent/src/tool-provider.ts new file mode 100644 index 0000000..3c6b80f --- /dev/null +++ b/packages/web-agent/src/tool-provider.ts @@ -0,0 +1,49 @@ +import { + experimental_createMCPClient as createMCPClient, + type experimental_MCPClient as MCPClient, + type MCPTransport +} from "@ai-sdk/mcp"; +import type { IToolProvider, SessionTools } from "@fledgling/agent-core"; +import type { WebWorkspaceSidecar } from "@fledgling/mcp-workspace-webcontainer"; +import type { ToolSet } from "ai"; + +export interface WebMcpToolProviderOptions { + readonly createSidecar: () => Promise; +} + +class WebMcpClientHandle { + readonly #client: MCPClient; + readonly #sidecar: WebWorkspaceSidecar; + + public constructor(client: MCPClient, sidecar: WebWorkspaceSidecar) { + this.#client = client; + this.#sidecar = sidecar; + } + + public async close(): Promise { + await this.#client.close(); + await this.#sidecar.close(); + } +} + +export class WebMcpToolProvider implements IToolProvider { + readonly #createSidecar: () => Promise; + + public constructor(options: WebMcpToolProviderOptions) { + this.#createSidecar = options.createSidecar; + } + + public async createSessionTools(): Promise { + const sidecar = await this.#createSidecar(); + const client = await createMCPClient({ + name: "fledgling-web-agent", + transport: sidecar.clientTransport as MCPTransport + }); + const tools: ToolSet = await client.tools(); + + return { + clients: [new WebMcpClientHandle(client, sidecar)], + tools + }; + } +} diff --git a/packages/web-agent/src/webpack-entry.ts b/packages/web-agent/src/webpack-entry.ts new file mode 100644 index 0000000..8e893de --- /dev/null +++ b/packages/web-agent/src/webpack-entry.ts @@ -0,0 +1 @@ +export * from "./index.js"; diff --git a/packages/web-agent/tsconfig.json b/packages/web-agent/tsconfig.json new file mode 100644 index 0000000..83592d4 --- /dev/null +++ b/packages/web-agent/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/web-agent/webpack.config.cjs b/packages/web-agent/webpack.config.cjs new file mode 100644 index 0000000..7dc4c66 --- /dev/null +++ b/packages/web-agent/webpack.config.cjs @@ -0,0 +1,43 @@ +const { builtinModules } = require("node:module"); +const path = require("node:path"); +const webpack = require("webpack"); + +const nodeBuiltins = new Set( + builtinModules.flatMap((moduleName) => [moduleName, `node:${moduleName}`]) +); + +class FailOnNodeBuiltinPlugin { + apply(compiler) { + compiler.hooks.normalModuleFactory.tap("FailOnNodeBuiltinPlugin", (factory) => { + factory.hooks.beforeResolve.tap("FailOnNodeBuiltinPlugin", (request) => { + if (request && nodeBuiltins.has(request.request)) { + throw new Error(`Browser bundle imported Node builtin '${request.request}' from ${request.context}`); + } + }); + }); + } +} + +module.exports = { + mode: "production", + target: "web", + entry: path.resolve(__dirname, "lib/webpack-entry.js"), + output: { + path: path.resolve(__dirname, "dist"), + filename: "fledgling-web-agent.js", + library: { + name: "FledglingWebAgent", + type: "umd" + }, + clean: true + }, + resolve: { + extensions: [".js"] + }, + plugins: [ + new FailOnNodeBuiltinPlugin(), + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify("production") + }) + ] +}; diff --git a/rush.json b/rush.json index 4672aa0..8f963f4 100644 --- a/rush.json +++ b/rush.json @@ -14,6 +14,10 @@ "packageName": "@fledgling/acp-agent", "projectFolder": "packages/acp-agent" }, + { + "packageName": "@fledgling/agent-core", + "projectFolder": "packages/agent-core" + }, { "packageName": "@fledgling/common", "projectFolder": "packages/common" @@ -26,6 +30,30 @@ "packageName": "@fledgling/session-log", "projectFolder": "packages/session-log" }, + { + "packageName": "@fledgling/session-memory", + "projectFolder": "packages/session-memory" + }, + { + "packageName": "@fledgling/session-local-storage", + "projectFolder": "packages/session-local-storage" + }, + { + "packageName": "@fledgling/session-file-system", + "projectFolder": "packages/session-file-system" + }, + { + "packageName": "@fledgling/tools-mcp-node", + "projectFolder": "packages/tools-mcp-node" + }, + { + "packageName": "@fledgling/web-agent", + "projectFolder": "packages/web-agent" + }, + { + "packageName": "@fledgling/mcp-workspace-webcontainer", + "projectFolder": "packages/mcp-workspace-webcontainer" + }, { "packageName": "@fledgling/acp-host-log", "projectFolder": "packages/acp-host-log" From d702dc025551a6db482d89a97245368535e34bbf Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:09:50 -0500 Subject: [PATCH 2/5] Use Web Crypto for browser session IDs --- packages/session-local-storage/src/index.ts | 17 ++++++++++++++--- packages/session-memory/src/index.ts | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/session-local-storage/src/index.ts b/packages/session-local-storage/src/index.ts index 7b95510..e0a5b64 100644 --- a/packages/session-local-storage/src/index.ts +++ b/packages/session-local-storage/src/index.ts @@ -58,9 +58,20 @@ export class LocalStorageSessionManager implements ISessionManager { } function createId(): string { - if (typeof globalThis.crypto.randomUUID === "function") { - return globalThis.crypto.randomUUID(); + const crypto = globalThis.crypto; + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); } - return `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; + if (typeof crypto.getRandomValues === "function") { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] % 16) + 64; + bytes[8] = (bytes[8] % 64) + 128; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex + .slice(8, 10) + .join("")}-${hex.slice(10, 16).join("")}`; + } + + throw new Error("Web Crypto is required to create ACP session IDs"); } diff --git a/packages/session-memory/src/index.ts b/packages/session-memory/src/index.ts index dda961a..3471f97 100644 --- a/packages/session-memory/src/index.ts +++ b/packages/session-memory/src/index.ts @@ -33,5 +33,20 @@ export class MemorySessionManager implements ISessionManager { } function createId(): string { - return globalThis.crypto?.randomUUID?.() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const crypto = globalThis.crypto; + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + if (typeof crypto.getRandomValues === "function") { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] % 16) + 64; + bytes[8] = (bytes[8] % 64) + 128; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex + .slice(8, 10) + .join("")}-${hex.slice(10, 16).join("")}`; + } + + throw new Error("Web Crypto is required to create ACP session IDs"); } From 06410151c7aa05bfa66cf3be9b258531299a2c68 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:16:24 -0500 Subject: [PATCH 3/5] Add webcontainer runtime smoke tests --- .../src/runtime.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 packages/mcp-workspace-webcontainer/src/runtime.test.ts diff --git a/packages/mcp-workspace-webcontainer/src/runtime.test.ts b/packages/mcp-workspace-webcontainer/src/runtime.test.ts new file mode 100644 index 0000000..6966917 --- /dev/null +++ b/packages/mcp-workspace-webcontainer/src/runtime.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { WebContainerWorkspaceRuntime } from "./runtime.js"; + +class FakeDirent { + public readonly name: string; + readonly #directory: boolean; + + public constructor(name: string, directory: boolean) { + this.name = name; + this.#directory = directory; + } + + public isDirectory(): boolean { + return this.#directory; + } +} + +describe("WebContainerWorkspaceRuntime", () => { + it("normalizes paths for file and directory operations", async () => { + const reads: string[] = []; + const writes: [string, string][] = []; + const runtime = new WebContainerWorkspaceRuntime({ + fs: { + async readFile(path: string): Promise { + reads.push(path); + return `content:${path}`; + }, + async writeFile(path: string, content: string): Promise { + writes.push([path, content]); + }, + async readdir(path: string): Promise { + expect(path).toBe("."); + return [new FakeDirent("src", true), new FakeDirent("README.md", false)]; + } + } + } as never); + + await expect(runtime.readFile("/src\\index.ts")).resolves.toBe("content:src/index.ts"); + await runtime.writeFile("/README.md", "hello"); + await expect(runtime.listDirectory("/")).resolves.toEqual([ + { type: "directory", name: "src", path: "src" }, + { type: "file", name: "README.md", path: "README.md", sizeBytes: 0 } + ]); + + expect(reads).toEqual(["src/index.ts"]); + expect(writes).toEqual([["README.md", "hello"]]); + }); + + it("searches nested text files", async () => { + const files = new Map([ + ["README.md", "hello\nneedle here"], + ["src/index.ts", "export const value = 'needle';"] + ]); + const directories = new Map([ + [".", [new FakeDirent("src", true), new FakeDirent("README.md", false)]], + ["src", [new FakeDirent("index.ts", false)]] + ]); + const runtime = new WebContainerWorkspaceRuntime({ + fs: { + async readFile(path: string): Promise { + return files.get(path) ?? ""; + }, + async writeFile(): Promise {}, + async readdir(path: string): Promise { + return directories.get(path) ?? []; + } + } + } as never); + + await expect(runtime.searchText("needle", ".")).resolves.toEqual([ + { path: "src/index.ts", line: 1, text: "export const value = 'needle';" }, + { path: "README.md", line: 2, text: "needle here" } + ]); + }); +}); From 3c1a0ae98d51918cc81fdd4f1ef8babd12c02c96 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:47:00 -0500 Subject: [PATCH 4/5] Address browser agent review feedback --- common/config/rush/pnpm-lock.yaml | 3 - packages/acp-agent/package.json | 1 - .../src/runtime.test.ts | 33 +++++- .../mcp-workspace-webcontainer/src/runtime.ts | 80 +++++++++++-- packages/session-local-storage/src/index.ts | 17 ++- .../src/session-local-storage.test.ts | 18 ++- packages/tools-mcp-node/src/config.test.ts | 30 +++++ packages/tools-mcp-node/src/index.ts | 11 +- packages/web-agent/package.json | 2 + packages/web-agent/src/tool-provider.ts | 6 +- packages/web-agent/src/web-agent.test.ts | 111 ++++++++++++++++++ packages/web-agent/vitest.config.ts | 7 ++ 12 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 packages/web-agent/src/web-agent.test.ts create mode 100644 packages/web-agent/vitest.config.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a3fdc26..3cf7c4a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -36,9 +36,6 @@ importers: '@fledgling/session-file-system': specifier: workspace:* version: link:../session-file-system - '@fledgling/session-log': - specifier: workspace:* - version: link:../session-log '@fledgling/tools-mcp-node': specifier: workspace:* version: link:../tools-mcp-node diff --git a/packages/acp-agent/package.json b/packages/acp-agent/package.json index 44f707c..4008efa 100644 --- a/packages/acp-agent/package.json +++ b/packages/acp-agent/package.json @@ -26,7 +26,6 @@ "@fledgling/context-builder": "workspace:*", "@fledgling/mcp-workspace": "workspace:*", "@fledgling/session-file-system": "workspace:*", - "@fledgling/session-log": "workspace:*", "@fledgling/tools-mcp-node": "workspace:*", "ai": "^5.0.0" }, diff --git a/packages/mcp-workspace-webcontainer/src/runtime.test.ts b/packages/mcp-workspace-webcontainer/src/runtime.test.ts index 6966917..4f9b2ba 100644 --- a/packages/mcp-workspace-webcontainer/src/runtime.test.ts +++ b/packages/mcp-workspace-webcontainer/src/runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { WebContainerWorkspaceRuntime } from "./runtime.js"; @@ -73,4 +73,35 @@ describe("WebContainerWorkspaceRuntime", () => { { path: "README.md", line: 2, text: "needle here" } ]); }); + + it("aborts commands that exceed the timeout", async () => { + const kill = vi.fn(); + const runtime = new WebContainerWorkspaceRuntime({ + fs: { + async readFile(): Promise { + return ""; + }, + async writeFile(): Promise {}, + async readdir(): Promise { + return []; + } + }, + async spawn(): Promise { + return { + output: new ReadableStream(), + exit: new Promise(() => undefined), + kill + }; + } + } as never); + + await expect(runtime.runCommand("sleep 10", ".", 1, 1000)).resolves.toEqual({ + exitCode: 124, + stdout: "", + stderr: "Command timed out", + timedOut: true, + truncated: false + }); + expect(kill).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/mcp-workspace-webcontainer/src/runtime.ts b/packages/mcp-workspace-webcontainer/src/runtime.ts index e175108..05c1946 100644 --- a/packages/mcp-workspace-webcontainer/src/runtime.ts +++ b/packages/mcp-workspace-webcontainer/src/runtime.ts @@ -86,23 +86,38 @@ export class WebContainerWorkspaceRuntime implements IWorkspaceRuntime { timeoutMs: number, maxOutputBytes: number ): Promise { - const started = new AbortController(); - const timeout = setTimeout(() => started.abort(), timeoutMs); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const process = await this.#container.spawn("jsh", ["-c", command], { cwd: normalizeWorkspacePath(cwd) }); - const stdout = await readProcessOutput(process.output, maxOutputBytes); - const exitCode = await process.exit; + try { + const stdout = await readProcessOutput(process.output, maxOutputBytes, controller.signal); + const exitCode = await waitForProcessExit(process.exit, controller.signal); - return { - exitCode, - stdout, - stderr: "", - timedOut: started.signal.aborted, - truncated: stdout.length >= maxOutputBytes - }; + return { + exitCode, + stdout, + stderr: "", + timedOut: false, + truncated: stdout.length >= maxOutputBytes + }; + } catch (error: unknown) { + if (!controller.signal.aborted) { + throw error; + } + + killProcess(process); + return { + exitCode: 124, + stdout: "", + stderr: "Command timed out", + timedOut: true, + truncated: false + }; + } } finally { clearTimeout(timeout); } @@ -122,13 +137,17 @@ export class WebContainerWorkspaceRuntime implements IWorkspaceRuntime { } } -async function readProcessOutput(output: ReadableStream, maxOutputBytes: number): Promise { +async function readProcessOutput( + output: ReadableStream, + maxOutputBytes: number, + abortSignal: AbortSignal +): Promise { let text = ""; const reader = output.getReader(); try { for (;;) { - const result = await reader.read(); + const result = await readWithAbort(reader, abortSignal); if (result.done) { return text.slice(0, maxOutputBytes); } @@ -143,6 +162,41 @@ async function readProcessOutput(output: ReadableStream, maxOutputBytes: } } +function waitForProcessExit(exit: Promise, abortSignal: AbortSignal): Promise { + return raceAbort(exit, abortSignal); +} + +async function readWithAbort( + reader: ReadableStreamDefaultReader, + abortSignal: AbortSignal +): Promise> { + if (abortSignal.aborted) { + throw new DOMException("Command timed out", "AbortError"); + } + + return raceAbort(reader.read(), abortSignal); +} + +function raceAbort(promise: Promise, abortSignal: AbortSignal): Promise { + if (abortSignal.aborted) { + return Promise.reject(new DOMException("Command timed out", "AbortError")); + } + + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + abortSignal.addEventListener("abort", () => reject(new DOMException("Command timed out", "AbortError")), { + once: true + }); + }) + ]); +} + +function killProcess(process: unknown): void { + const candidate = process as { kill?: () => void }; + candidate.kill?.(); +} + function appendSearchMatches(matches: SearchMatch[], path: string, content: string, query: string): void { const lines = content.split(/\r?\n/); for (const [index, line] of lines.entries()) { diff --git a/packages/session-local-storage/src/index.ts b/packages/session-local-storage/src/index.ts index e0a5b64..dcf3110 100644 --- a/packages/session-local-storage/src/index.ts +++ b/packages/session-local-storage/src/index.ts @@ -11,7 +11,7 @@ export class LocalStorageSessionManager implements ISessionManager { readonly #keyPrefix: string; public constructor(options: LocalStorageSessionManagerOptions = {}) { - this.#storage = options.storage ?? globalThis.localStorage; + this.#storage = options.storage ?? getDefaultStorage(); this.#keyPrefix = options.keyPrefix ?? "fledgling:sessions:"; } @@ -57,6 +57,21 @@ export class LocalStorageSessionManager implements ISessionManager { } } +function getDefaultStorage(): Storage { + try { + const storage = (globalThis as { readonly localStorage?: Storage }).localStorage; + if (storage !== undefined) { + return storage; + } + } catch { + // Fall through to the actionable error below. + } + + throw new Error( + "LocalStorageSessionManager requires browser localStorage; pass an explicit Storage implementation in restricted or non-window environments." + ); +} + function createId(): string { const crypto = globalThis.crypto; if (typeof crypto.randomUUID === "function") { diff --git a/packages/session-local-storage/src/session-local-storage.test.ts b/packages/session-local-storage/src/session-local-storage.test.ts index 4091e5e..2073b86 100644 --- a/packages/session-local-storage/src/session-local-storage.test.ts +++ b/packages/session-local-storage/src/session-local-storage.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { LocalStorageSessionManager } from "./index.js"; @@ -58,4 +58,20 @@ describe("LocalStorageSessionManager", () => { await expect(manager.loadEvents("missing")).rejects.toThrow("Unknown ACP session: missing"); }); + + it("throws an actionable error when default localStorage is unavailable", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + vi.stubGlobal("localStorage", undefined); + + try { + expect(() => new LocalStorageSessionManager()).toThrow( + "LocalStorageSessionManager requires browser localStorage" + ); + } finally { + vi.unstubAllGlobals(); + if (descriptor) { + Object.defineProperty(globalThis, "localStorage", descriptor); + } + } + }); }); diff --git a/packages/tools-mcp-node/src/config.test.ts b/packages/tools-mcp-node/src/config.test.ts index 7593806..ce412b4 100644 --- a/packages/tools-mcp-node/src/config.test.ts +++ b/packages/tools-mcp-node/src/config.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { loadConfig } from "./config.js"; +import { NodeMcpToolProvider } from "./index.js"; let tempDir: string | undefined; @@ -53,6 +54,35 @@ describe("loadMcpServersFromConfig", () => { } }); }); + + it("rejects ACP server names that collide after sanitization", async () => { + const provider = new NodeMcpToolProvider(Promise.resolve({})); + + await expect( + provider.createSessionTools({ + cwd: undefined, + mcpServers: [ + { name: "one.two", command: "node", args: [], env: [] }, + { name: "one_two", command: "node", args: [], env: [] } + ] + }) + ).rejects.toThrow("MCP server name collision after sanitization: one_two"); + }); + + it("rejects configured server names that collide after sanitization", async () => { + const provider = new NodeMcpToolProvider( + Promise.resolve({ + mcpServers: { + "one.two": { type: "stdio", command: "node" }, + one_two: { type: "stdio", command: "node" } + } + }) + ); + + await expect(provider.createSessionTools({ cwd: undefined, mcpServers: [] })).rejects.toThrow( + "Configured MCP server name collision after sanitization: one_two" + ); + }); }); function restoreEnv(name: string, value: string | undefined): void { diff --git a/packages/tools-mcp-node/src/index.ts b/packages/tools-mcp-node/src/index.ts index 1f40aeb..566dfe8 100644 --- a/packages/tools-mcp-node/src/index.ts +++ b/packages/tools-mcp-node/src/index.ts @@ -57,6 +57,10 @@ export class NodeMcpToolProvider implements IToolProvider { for (const server of clientMcpServers) { const name = sanitizeToolName(server.name); + if (resolved.has(name)) { + throw new Error(`MCP server name collision after sanitization: ${name}`); + } + resolved.set(name, { name, origin: "acp_client", @@ -67,7 +71,8 @@ export class NodeMcpToolProvider implements IToolProvider { const config = await this.#configPromise; for (const [serverName, serverConfig] of Object.entries(config.mcpServers ?? {})) { const name = sanitizeToolName(serverName); - if (resolved.has(name)) { + const existing = resolved.get(name); + if (existing?.origin === "acp_client") { console.error( JSON.stringify({ level: "warn", @@ -78,6 +83,10 @@ export class NodeMcpToolProvider implements IToolProvider { continue; } + if (existing) { + throw new Error(`Configured MCP server name collision after sanitization: ${name}`); + } + resolved.set(name, { name, origin: serverConfig.type === "firstPartyWorkspace" ? "first_party" : "config", diff --git a/packages/web-agent/package.json b/packages/web-agent/package.json index 7c122e6..aa20f79 100644 --- a/packages/web-agent/package.json +++ b/packages/web-agent/package.json @@ -10,6 +10,8 @@ "scripts": { "build": "heft build --clean", "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage", "bundle": "webpack --config webpack.config.cjs", "_phase:bundle": "webpack --config webpack.config.cjs" }, diff --git a/packages/web-agent/src/tool-provider.ts b/packages/web-agent/src/tool-provider.ts index 3c6b80f..7887fea 100644 --- a/packages/web-agent/src/tool-provider.ts +++ b/packages/web-agent/src/tool-provider.ts @@ -4,6 +4,7 @@ import { type MCPTransport } from "@ai-sdk/mcp"; import type { IToolProvider, SessionTools } from "@fledgling/agent-core"; +import type * as acp from "@agentclientprotocol/sdk"; import type { WebWorkspaceSidecar } from "@fledgling/mcp-workspace-webcontainer"; import type { ToolSet } from "ai"; @@ -33,7 +34,10 @@ export class WebMcpToolProvider implements IToolProvider { this.#createSidecar = options.createSidecar; } - public async createSessionTools(): Promise { + public async createSessionTools(_request: { + readonly cwd: string | undefined; + readonly mcpServers: acp.McpServer[]; + }): Promise { const sidecar = await this.#createSidecar(); const client = await createMCPClient({ name: "fledgling-web-agent", diff --git a/packages/web-agent/src/web-agent.test.ts b/packages/web-agent/src/web-agent.test.ts new file mode 100644 index 0000000..32617ed --- /dev/null +++ b/packages/web-agent/src/web-agent.test.ts @@ -0,0 +1,111 @@ +import type { IModelTurnRunner, ModelTurnRequest } from "@fledgling/agent-core"; +import { describe, expect, it, vi } from "vitest"; + +import type { IWorkspaceRuntime } from "@fledgling/mcp-workspace-webcontainer"; + +import { createWebAgent } from "./index.js"; + +class MemoryStorage implements Storage { + readonly #items: Map = new Map(); + + public get length(): number { + return this.#items.size; + } + + public clear(): void { + this.#items.clear(); + } + + // eslint-disable-next-line @rushstack/no-new-null -- Storage.getItem is a DOM API. + public getItem(key: string): string | null { + return this.#items.get(key) ?? null; + } + + // eslint-disable-next-line @rushstack/no-new-null -- Storage.key is a DOM API. + public key(index: number): string | null { + return [...this.#items.keys()][index] ?? null; + } + + public removeItem(key: string): void { + this.#items.delete(key); + } + + public setItem(key: string, value: string): void { + this.#items.set(key, value); + } +} + +describe("createWebAgent", () => { + it("runs initialize, newSession, and prompt through the browser entrypoint", async () => { + const sessionUpdates: unknown[] = []; + const runModelTurn = vi.fn(() => ({ + fullStream: (async function* () { + yield { type: "text-delta", text: "pong" } as const; + })() + })); + const agent = createWebAgent( + { + async sessionUpdate(params: unknown): Promise { + sessionUpdates.push(params); + } + } as never, + { + workspaceRuntime: createFakeWorkspaceRuntime(), + modelTurnRunner: { runModelTurn }, + storage: new MemoryStorage() + } + ); + + const initializeResponse = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} } as never); + expect(typeof initializeResponse.protocolVersion).toBe("number"); + const session = await agent.newSession({ cwd: ".", mcpServers: [] }); + await expect(agent.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "ping" }] })).resolves.toEqual({ + stopReason: "end_turn" + }); + + expect(runModelTurn).toHaveBeenCalledOnce(); + const [[modelRequest]] = runModelTurn.mock.calls as [[ModelTurnRequest]]; + expect(Object.keys(modelRequest.tools)).toEqual( + expect.arrayContaining([ + "workspace.read_file", + "workspace.write_file", + "workspace.list_directory", + "workspace.search_text", + "workspace.run_command" + ]) + ); + expect(sessionUpdates).toEqual([ + { + sessionId: session.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "pong" } + } + } + ]); + }); +}); + +function createFakeWorkspaceRuntime(): IWorkspaceRuntime { + return { + async readFile(path: string): Promise { + return `content:${path}`; + }, + async writeFile(): Promise {}, + async listDirectory(): Promise<[]> { + return []; + }, + async searchText(): Promise<[]> { + return []; + }, + async runCommand() { + return { + exitCode: 0, + stdout: "", + stderr: "", + timedOut: false, + truncated: false + }; + } + }; +} diff --git a/packages/web-agent/vitest.config.ts b/packages/web-agent/vitest.config.ts new file mode 100644 index 0000000..99f4bce --- /dev/null +++ b/packages/web-agent/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node" + } +}); From edaea76f880eeddd2b43211c543f9d290c266251 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:05:17 -0500 Subject: [PATCH 5/5] Address follow-up browser agent review feedback --- .../mcp-workspace-webcontainer/src/runtime.ts | 16 +++++------ packages/session-local-storage/src/index.ts | 6 +++- .../src/session-local-storage.test.ts | 16 +++++++++++ packages/session-memory/src/index.ts | 11 +++++++- .../session-memory/src/session-memory.test.ts | 18 +++++++++++- packages/tools-mcp-node/src/config.test.ts | 2 ++ packages/web-agent/src/tool-provider.ts | 28 ++++++++++++------- 7 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/mcp-workspace-webcontainer/src/runtime.ts b/packages/mcp-workspace-webcontainer/src/runtime.ts index 05c1946..7d67d7f 100644 --- a/packages/mcp-workspace-webcontainer/src/runtime.ts +++ b/packages/mcp-workspace-webcontainer/src/runtime.ts @@ -182,14 +182,14 @@ function raceAbort(promise: Promise, abortSignal: AbortSignal): Promise return Promise.reject(new DOMException("Command timed out", "AbortError")); } - return Promise.race([ - promise, - new Promise((_resolve, reject) => { - abortSignal.addEventListener("abort", () => reject(new DOMException("Command timed out", "AbortError")), { - once: true - }); - }) - ]); + let removeAbortListener: () => void = () => undefined; + const abortPromise = new Promise((_resolve, reject) => { + const onAbort = (): void => reject(new DOMException("Command timed out", "AbortError")); + abortSignal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => abortSignal.removeEventListener("abort", onAbort); + }); + + return Promise.race([promise, abortPromise]).finally(() => removeAbortListener()); } function killProcess(process: unknown): void { diff --git a/packages/session-local-storage/src/index.ts b/packages/session-local-storage/src/index.ts index dcf3110..1766203 100644 --- a/packages/session-local-storage/src/index.ts +++ b/packages/session-local-storage/src/index.ts @@ -73,7 +73,11 @@ function getDefaultStorage(): Storage { } function createId(): string { - const crypto = globalThis.crypto; + const crypto = (globalThis as { readonly crypto?: Crypto }).crypto; + if (!crypto) { + throw new Error("Web Crypto is required to create ACP session IDs"); + } + if (typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } diff --git a/packages/session-local-storage/src/session-local-storage.test.ts b/packages/session-local-storage/src/session-local-storage.test.ts index 2073b86..26e01ed 100644 --- a/packages/session-local-storage/src/session-local-storage.test.ts +++ b/packages/session-local-storage/src/session-local-storage.test.ts @@ -74,4 +74,20 @@ describe("LocalStorageSessionManager", () => { } } }); + + it("throws an actionable error when Web Crypto is unavailable", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + vi.stubGlobal("crypto", undefined); + + try { + expect(() => + new LocalStorageSessionManager({ storage: new MemoryStorage() }).createSessionId() + ).toThrow("Web Crypto is required to create ACP session IDs"); + } finally { + vi.unstubAllGlobals(); + if (descriptor) { + Object.defineProperty(globalThis, "crypto", descriptor); + } + } + }); }); diff --git a/packages/session-memory/src/index.ts b/packages/session-memory/src/index.ts index 3471f97..21bf0c4 100644 --- a/packages/session-memory/src/index.ts +++ b/packages/session-memory/src/index.ts @@ -1,6 +1,11 @@ import type { ISessionManager, SessionEventBase } from "@fledgling/agent-core"; import type { SessionEvent } from "@fledgling/common"; +interface WebCryptoLike { + randomUUID?: () => string; + getRandomValues?: (array: T) => T; +} + export class MemorySessionManager implements ISessionManager { readonly #eventsBySessionId: Map = new Map(); @@ -33,7 +38,11 @@ export class MemorySessionManager implements ISessionManager { } function createId(): string { - const crypto = globalThis.crypto; + const crypto = (globalThis as { readonly crypto?: WebCryptoLike }).crypto; + if (!crypto) { + throw new Error("Web Crypto is required to create ACP session IDs"); + } + if (typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } diff --git a/packages/session-memory/src/session-memory.test.ts b/packages/session-memory/src/session-memory.test.ts index cd65399..fedc069 100644 --- a/packages/session-memory/src/session-memory.test.ts +++ b/packages/session-memory/src/session-memory.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MemorySessionManager } from "./index.js"; @@ -22,4 +22,20 @@ describe("MemorySessionManager", () => { await expect(manager.loadEvents("missing")).rejects.toThrow("Unknown ACP session: missing"); }); + + it("throws an actionable error when Web Crypto is unavailable", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + vi.stubGlobal("crypto", undefined); + + try { + expect(() => new MemorySessionManager().createSessionId()).toThrow( + "Web Crypto is required to create ACP session IDs" + ); + } finally { + vi.unstubAllGlobals(); + if (descriptor) { + Object.defineProperty(globalThis, "crypto", descriptor); + } + } + }); }); diff --git a/packages/tools-mcp-node/src/config.test.ts b/packages/tools-mcp-node/src/config.test.ts index ce412b4..de53bdc 100644 --- a/packages/tools-mcp-node/src/config.test.ts +++ b/packages/tools-mcp-node/src/config.test.ts @@ -31,6 +31,7 @@ describe("loadMcpServersFromConfig", () => { JSON.stringify({ mcpServers: { workspace: { + type: "stdio", command: "node", args: ["server.js"], env: { @@ -45,6 +46,7 @@ describe("loadMcpServersFromConfig", () => { expect(await loadConfig()).toEqual({ mcpServers: { workspace: { + type: "stdio", command: "node", args: ["server.js"], env: { diff --git a/packages/web-agent/src/tool-provider.ts b/packages/web-agent/src/tool-provider.ts index 7887fea..9ff546f 100644 --- a/packages/web-agent/src/tool-provider.ts +++ b/packages/web-agent/src/tool-provider.ts @@ -39,15 +39,23 @@ export class WebMcpToolProvider implements IToolProvider { readonly mcpServers: acp.McpServer[]; }): Promise { const sidecar = await this.#createSidecar(); - const client = await createMCPClient({ - name: "fledgling-web-agent", - transport: sidecar.clientTransport as MCPTransport - }); - const tools: ToolSet = await client.tools(); - - return { - clients: [new WebMcpClientHandle(client, sidecar)], - tools - }; + let client: MCPClient | undefined; + + try { + client = await createMCPClient({ + name: "fledgling-web-agent", + transport: sidecar.clientTransport as MCPTransport + }); + const tools: ToolSet = await client.tools(); + + return { + clients: [new WebMcpClientHandle(client, sidecar)], + tools + }; + } catch (error: unknown) { + await client?.close(); + await sidecar.close(); + throw error; + } } }