diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ffb07fc1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure shell scripts always use LF line endings +*.sh text eol=lf + diff --git a/.gitignore b/.gitignore index 18f34849..9d4f74c4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ dist/ *.tsbuildinfo .DS_Store .env + +test-collab-project/* +!test-collab-project/.gitkeep +.vscode/extension-host-data-1/ +.vscode/extension-host-data-2/ diff --git a/.vscode/README.md b/.vscode/README.md new file mode 100644 index 00000000..a895c33e --- /dev/null +++ b/.vscode/README.md @@ -0,0 +1,34 @@ +# VS Code Debug Configuration + +## Node.js Version Manager Support + +This project uses a Node.js wrapper script (`node-wrapper.sh`) to ensure debugging works regardless of how Node.js is installed on your system. + +### Supported Node Version Managers + +- **nvm** (Node Version Manager) +- **volta** +- System-installed Node.js + +### How It Works + +The `node-wrapper.sh` script automatically detects and loads your Node version manager before running Node.js. This ensures VS Code can find Node.js even when launched from the Dock/Finder on macOS or Start Menu on Windows. + +### Troubleshooting + +If you're still having issues with Node.js not being found: + +1. **Ensure Node.js is installed**: Run `node --version` in your terminal +2. **Check the wrapper is executable**: It should be executable by default, but you can verify with: + ```bash + chmod +x .vscode/node-wrapper.sh + ``` +3. **Alternative: Launch VS Code from terminal**: + ```bash + code . + ``` + This ensures VS Code inherits your shell's PATH environment. + +### For Other Version Managers + +If you use a different Node version manager (e.g., `fnm`, `asdf`), you can modify `node-wrapper.sh` to add support for it. diff --git a/.vscode/launch-extension-host.js b/.vscode/launch-extension-host.js new file mode 100644 index 00000000..c22e3946 --- /dev/null +++ b/.vscode/launch-extension-host.js @@ -0,0 +1,36 @@ +/** + * Spawns a second Extension Development Host window (separate process). + * Used for chat testing: run "Run VS Code Extension" first, then this config. + * Requires env: WORKSPACE_FOLDER, VSCODE_EXEC_PATH. Args: folder path, user-data-dir path (relative to workspace). + */ +import { spawn } from 'child_process'; +import path from 'path'; + +const workspaceFolder = process.env.WORKSPACE_FOLDER || process.cwd(); +const execPath = process.env.VSCODE_EXEC_PATH; +const folderArg = process.argv[2]; +const userDataDirArg = process.argv[3]; + +if (!execPath || !folderArg || !userDataDirArg) { + console.error('Usage: node launch-extension-host.js '); + console.error('Requires env: WORKSPACE_FOLDER, VSCODE_EXEC_PATH'); + process.exit(1); +} + +const folder = path.resolve(workspaceFolder, folderArg); +const userDataDir = path.resolve(workspaceFolder, userDataDirArg); +const extPath = path.resolve(workspaceFolder, 'packages/open-collaboration-vscode'); + +// Always use local server (testing only) +const spawnEnv = { + ...process.env, + DEVELOPMENT: 'true', + OCT_SERVER_URL: 'http://localhost:8100', +}; + +const child = spawn(execPath, [ + folder, + '--extensionDevelopmentPath=' + extPath, + '--user-data-dir=' + userDataDir, +], { detached: true, stdio: 'ignore', env: spawnEnv }); +child.unref(); diff --git a/.vscode/launch.json b/.vscode/launch.json index 6db8a5eb..0e0ef71e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,104 +1,170 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "attach", - "name": "Attach to Service Process", - "port": "${input:attachPort}", - - }, - { - "type": "node", - "request": "launch", - "name": "Launch Server", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/packages/open-collaboration-server/lib/app.js", - "args": [ - "--hostname=0.0.0.0" - ], - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/packages/open-collaboration-server/lib/**/*.js", - ], - "env": { - "OCT_ACTIVATE_SIMPLE_LOGIN": "true", - "OCT_REDIRECT_URL_WHITELIST": "http://localhost:5173/", - } - }, - { - "name": "Run VS Code Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode" - ], - "env": { - "DEVELOPMENT": "true" - }, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/packages/open-collaboration-vscode/dist/**/*.js" - ] - }, - { - "name": "Run VS Code Web Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode", - "--extensionDevelopmentKind=web" - ], - // VS Code cannot debug the web extension directly, which is why we don't configure source maps here - // Instead, use VS Code browser dev tools (Help -> Toggle Developer Tools) to debug the web extension - }, - { - "name": "Launch Service Process", - "type": "node", - "request": "launch", - "console": "integratedTerminal", - "outFiles": [ - "${workspaceFolder}/packages/open-collaboration-service-process/lib/**/*.js" - ], - "sourceMaps": true, - "program": "${workspaceFolder}/packages/open-collaboration-service-process/lib/process.js", - "args": [ - "--server-address=http://localhost:8100", - "--auth-token=12312" - ] - }, - { - "name": "Debug Monaco Examples", - "request": "launch", - "type": "chrome", - "url": "http://localhost:5173/", - "webRoot": "${workspaceFolder}/packages/open-collaboration-monaco", - }, - { - "name": "Vitest: Run Selected File", - "type": "node", - "request": "launch", - "autoAttachChildProcesses": true, - "skipFiles": ["/**", "**/node_modules/**", "!**/node_modules/vscode-*/**"], - "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", - "args": ["run", "${relativeFile}"], - "console": "integratedTerminal", - "smartStep": true, - "sourceMaps": true, - "outFiles": [] - } - ], - "inputs": [ - { - "id": "attachPort", - "type": "promptString", - "description": "What port should the application use for debugging?", - "default": "23698" - } - ] + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Server", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/packages/open-collaboration-server/lib/app.js", + "args": ["--hostname=0.0.0.0"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/packages/open-collaboration-server/lib/**/*.js" + ], + "env": { + "OCT_ACTIVATE_SIMPLE_LOGIN": "true", + "OCT_REDIRECT_URL_WHITELIST": "http://localhost:5173/" + } + }, + // Chat test: run "Run VS Code Extension", then "Spawn Second Extension Instance" to get two windows (protocol + vscode). + { + "name": "Run VS Code Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode", + "--user-data-dir=${workspaceFolder}/.vscode/extension-host-data-1" + ], + "env": { + "DEVELOPMENT": "true" + }, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/packages/open-collaboration-vscode/dist/**/*.js" + ] + }, + { + "name": "Spawn Second Extension Instance", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "program": "${workspaceFolder}/.vscode/launch-extension-host.js", + "args": [ + "packages/open-collaboration-vscode", + ".vscode/extension-host-data-2" + ], + "env": { + "WORKSPACE_FOLDER": "${workspaceFolder}", + "VSCODE_EXEC_PATH": "${execPath}", + "OCT_SERVER_URL": "http://localhost:8100" + }, + "console": "integratedTerminal" + }, + { + "name": "Run VS Code Web Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode", + "--extensionDevelopmentKind=web" + ] + // VS Code cannot debug the web extension directly, which is why we don't configure source maps here + // Instead, use VS Code browser dev tools (Help -> Toggle Developer Tools) to debug the web extension + }, + { + "name": "Launch Service Process", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "console": "integratedTerminal", + "outFiles": [ + "${workspaceFolder}/packages/open-collaboration-service-process/lib/**/*.js" + ], + "sourceMaps": true, + "program": "${workspaceFolder}/packages/open-collaboration-service-process/lib/process.js", + "args": ["--server-address=http://localhost:8100", "--auth-token=12312"] + }, + { + "name": "Debug Monaco Examples", + "request": "launch", + "type": "chrome", + "url": "http://localhost:5173/", + "webRoot": "${workspaceFolder}/packages/open-collaboration-monaco" + }, + { + "name": "Vitest: Run Selected File", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "autoAttachChildProcesses": true, + "skipFiles": [ + "/**", + "**/node_modules/**", + "!**/node_modules/vscode-*/**" + ], + "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", + "args": ["run", "${relativeFile}"], + "console": "integratedTerminal", + "smartStep": true, + "sourceMaps": true, + "outFiles": [] + }, + { + "name": "Debug Agent", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/packages/open-collaboration-agent/lib/main.js", + "args": [ + "--room", + "${input:roomToken}", + "--server", + "https://api.open-collab.tools" + // Optional: Add "--acp-agent", "npx @zed-industries/claude-code-acp" to override default ACP agent + ], + "cwd": "${input:workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/packages/open-collaboration-agent/lib/**/*.js" + ], + "env": { + "NODE_ENV": "development" + }, + "envFile": "${workspaceFolder}/.env" + }, + { + "name": "Debug Agent (Local)", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/node_modules/.bin/tsx", + "args": [ + "watch", + "${workspaceFolder}/packages/open-collaboration-agent/src/main.ts", + "--room", + "${input:roomToken}", + "--server", + "http://localhost:8100" + // Optional: Add "--acp-agent", "npx @zed-industries/claude-code-acp" to override default ACP agent + ], + "cwd": "${input:workspaceFolder}", + "console": "integratedTerminal", + "restart": true, + "env": { + "NODE_ENV": "development" + }, + "envFile": "${workspaceFolder}/.env" + } + ], + "inputs": [ + { + "id": "roomToken", + "type": "promptString", + "description": "Enter the room token to join" + }, + { + "id": "workspaceFolder", + "type": "promptString", + "description": "Enter the workspace folder to use", + "default": "${workspaceFolder}" + } + ] } diff --git a/.vscode/node-wrapper.sh b/.vscode/node-wrapper.sh new file mode 100755 index 00000000..2f9a995d --- /dev/null +++ b/.vscode/node-wrapper.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Node.js wrapper for VS Code debugging +# This script ensures Node.js is available regardless of version manager (nvm, volta, etc.) + +# Try to source nvm if it exists +if [ -s "$HOME/.nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/.nvm" + source "$NVM_DIR/nvm.sh" +fi + +# Try to source volta if it exists +if [ -d "$HOME/.volta" ]; then + export VOLTA_HOME="$HOME/.volta" + export PATH="$VOLTA_HOME/bin:$PATH" +fi + +# Execute node with all arguments passed to this script +exec node "$@" + diff --git a/eslint.config.mjs b/eslint.config.mjs index dbd1caa9..ed4f83be 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,6 +38,16 @@ export default [{ '**/*env.d.ts' ], }, ...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended'), { + // Node-based helper scripts (e.g. .vscode/*.js) need Node globals like `process` and `console`. + files: ['**/*.js', '**/*.mjs', '**/*.cjs'], + languageOptions: { + globals: { + ...globals.node + }, + ecmaVersion: 2022, + sourceType: 'module' + } +}, { files: [ '**/src/**/*.ts', '**/src/**/*.tsx', diff --git a/package-lock.json b/package-lock.json index c54b1cd6..64cc0cac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,124 +38,13 @@ "npm": ">=10.2.3" } }, - "node_modules/@ai-sdk/anthropic": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-1.2.12.tgz", - "integrity": "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==", + "node_modules/@agentclientprotocol/sdk": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.13.1.tgz", + "integrity": "sha512-6byvu+F/xc96GBkdAx4hq6/tB3vT63DSBO4i3gYCz8nuyZMerVFna2Gkhm8EHNpZX0J9DjUxzZCW+rnHXUg0FA==", "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.0.0" - } - }, - "node_modules/@ai-sdk/openai": { - "version": "1.3.24", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz", - "integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.0.0" - } - }, - "node_modules/@ai-sdk/provider": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", - "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/provider-utils": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", - "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "nanoid": "^3.3.8", - "secure-json-parse": "^2.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.23.8" - } - }, - "node_modules/@ai-sdk/provider-utils/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/@ai-sdk/react": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", - "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/ui-utils": "1.2.11", - "swr": "^2.2.5", - "throttleit": "2.1.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@ai-sdk/ui-utils": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", - "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - }, "peerDependencies": { - "zod": "^3.23.8" + "zod": "^3.25.0 || ^4.0.0" } }, "node_modules/@ampproject/remapping": { @@ -1427,15 +1316,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2163,12 +2043,6 @@ "@types/node": "*" } }, - "node_modules/@types/diff-match-patch": { - "version": "1.0.36", - "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", - "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3378,32 +3252,6 @@ "node": ">= 14" } }, - "node_modules/ai": { - "version": "4.3.19", - "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.19.tgz", - "integrity": "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/react": "1.2.12", - "@ai-sdk/ui-utils": "1.2.11", - "@opentelemetry/api": "1.9.0", - "jsondiffpatch": "0.6.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3466,6 +3314,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4072,6 +3947,44 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -4653,15 +4566,6 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4672,12 +4576,6 @@ "node": ">=8" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "license": "Apache-2.0" - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6743,6 +6641,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6898,6 +6803,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -7461,12 +7379,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7501,35 +7413,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsondiffpatch": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", - "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", - "license": "MIT", - "dependencies": { - "@types/diff-match-patch": "^1.0.36", - "chalk": "^5.3.0", - "diff-match-patch": "^1.0.5" - }, - "bin": { - "jsondiffpatch": "bin/jsondiffpatch.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/jsondiffpatch/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -8231,6 +8114,58 @@ "node": ">=20" } }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -9070,6 +9005,13 @@ "node": ">=4.2.0" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -9301,6 +9243,32 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -9657,12 +9625,6 @@ "node": ">=20.0.0" } }, - "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", - "license": "BSD-3-Clause" - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -9980,6 +9942,19 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -10570,19 +10545,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/swr": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.7.tgz", - "integrity": "sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -10766,18 +10728,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/throttleit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", - "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -10881,6 +10831,16 @@ "node": ">=6" } }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11156,6 +11116,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/underscore": { "version": "1.13.7", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", @@ -11228,15 +11195,6 @@ "dev": true, "license": "MIT" }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11983,22 +11941,11 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "packages/open-collaboration-agent": { "version": "0.3.0", "license": "MIT", "dependencies": { - "@ai-sdk/anthropic": "^1.2.10", - "@ai-sdk/openai": "^1.3.21", - "ai": "^4.3.14", + "@agentclientprotocol/sdk": "^0.13.1", "commander": "~13.1.0", "dotenv": "^16.5.0", "open-collaboration-protocol": "~0.3.0", @@ -12007,18 +11954,754 @@ "bin": { "oct-agent": "bin/agent" }, + "devDependencies": { + "@vitest/ui": "^2.0.0", + "nodemon": "^3.0.0", + "vitest": "^2.0.0" + }, "engines": { "node": ">=20.10.0", "npm": ">=10.2.3" } }, - "packages/open-collaboration-agent/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "packages/open-collaboration-agent/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/ui": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", + "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "fflate": "^0.8.2", + "flatted": "^3.3.1", + "pathe": "^1.1.2", + "sirv": "^3.0.0", + "tinyglobby": "^0.2.10", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "2.1.9" + } + }, + "packages/open-collaboration-agent/node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/open-collaboration-agent/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/open-collaboration-agent/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/open-collaboration-agent/node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/open-collaboration-agent/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/open-collaboration-agent/node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/open-collaboration-agent/node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "packages/open-collaboration-monaco": { diff --git a/packages/open-collaboration-agent/README.md b/packages/open-collaboration-agent/README.md new file mode 100644 index 00000000..5286e446 --- /dev/null +++ b/packages/open-collaboration-agent/README.md @@ -0,0 +1,193 @@ +# Open Collaboration Agent + +An AI agent for Open Collaboration Tools (OCT) sessions that runs in your local workspace and proposes changes to the collaborative session for review. + +## Setup + +1. Build the project: + + ```bash + npm run build + ``` + +2. Configure your ACP agent: The oct-agent connects to an external ACP-capable agent (e.g. Claude Code via `npx @zed-industries/claude-code-acp`). API keys and model selection are configured in that ACP agent’s environment, not in the oct-agent package. + +3. Create a collaboration session in your IDE and copy the room ID. + +## Development Usage + +The agent runs from your **local workspace** where you have your project files. This allows the agent to access your full project context. + +## Starting the Agent + +### Option 1: From VSCode Extension (Recommended) + +If you're using VSCode with the Open Collaboration Tools extension: + +1. Open your project workspace in VSCode +2. Create or join an OCT room +3. Command Palette (Cmd+Shift+P) → "Open Collaboration Tools: Start Agent" +4. The agent starts automatically in a terminal with correct configuration + +This is the easiest way to start the agent as it: +- Automatically uses the correct room ID and server URL +- Starts the agent in your workspace directory +- Handles development vs production environments + +### Option 2: Manual CLI Execution + +**From your project workspace:** + +```bash +cd /path/to/your/project +node /path/to/oct-project/packages/open-collaboration-agent/bin/agent -r {room-id} +``` + +**Options:** + +- `-r, --room `: Room ID to join (required) +- `-s, --server `: OCT server URL (default: `https://api.open-collab.tools/`) +- `--acp-agent `: Command to run the ACP agent (default: `npx @zed-industries/claude-code-acp`). Use this to connect any ACP-capable agent. +- `--config `: Path to an `oct-agent.config.json` file (see [Configuration](#configuration) below). Defaults to `./oct-agent.config.json` in the current working directory. + +### Example + +```bash +cd ~/my-project +node ~/oct-tools/packages/open-collaboration-agent/bin/agent -r my-room-id +``` + +## Configuration + +The agent reads an optional `oct-agent.config.json` file from the working directory (or from a custom path via `--config`). The file is **fully optional** — if it is missing, the agent falls back to conservative built-in defaults. Configuration is intentionally **agent-agnostic**: the same file works with any ACP-capable adapter (Claude Code, Gemini CLI, Codex-ACP, …). Adapter-specific markdown files such as `CLAUDE.md` or `GEMINI.md` are **not** part of the OCT agent contract. + +### Full example + +```json +{ + "toolWhitelist": { + "allowedKinds": ["read", "edit"], + "allowedToolNames": ["mcp__acp__Read", "mcp__acp__Edit", "mcp__acp__Write"] + }, + "systemPrompt": [ + "You are operating inside a collaborative OCT session.", + "All file changes are delivered to other participants as reviewable diff proposals, not persisted directly.", + "When a request affects multiple code locations or multiple files, apply all relevant edits in a single run." + ] +} +``` + +### `toolWhitelist` + +Controls which ACP tool calls the bridge is allowed to approve when the agent requests permission. A tool call is allowed if **either** its declared ACP `kind` is in `allowedKinds`, **or** its tool name (as reported by the adapter) is in `allowedToolNames`. Anything else is denied. + +| Field | Type | Default | Description | +| ------------------ | ---------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `allowedKinds` | `string[]` | `["read", "edit"]` | ACP-standard tool kinds. Common values: `read`, `edit`, `delete`, `move`, `search`, `execute`. | +| `allowedToolNames` | `string[]` | `["mcp__acp__Read", "mcp__acp__Edit", "mcp__acp__Write"]` | Adapter-specific tool names used as a fallback when an adapter does not set a standard `kind`. Override per adapter if used. | + +Enabling additional capabilities (for example workspace search via Glob/Grep, or shell execution) is **opt-in**: + +```json +{ + "toolWhitelist": { + "allowedKinds": ["read", "edit", "search"] + } +} +``` + +### `systemPrompt` + +An optional instruction string (or array of strings) that is prepended to every prompt sent to the ACP agent as a standard ACP `text` content block — **before** the user prompt and any `resource_link` blocks. Because it travels through the regular ACP protocol, every adapter receives the same instructions. + +- **Type:** `string | string[]` +- **Default:** unset (no system prompt is sent) +- When an array is provided, entries are joined with newlines, so you can keep one instruction per line in the config. +- Empty strings, whitespace-only values, and non-string array entries are ignored. + +Example use cases: + +- Tell the agent that it is operating inside a collaborative review session and that edits are surfaced as diff proposals. +- Encourage the agent to perform multi-file or multi-location edits in a single run when the request warrants it. +- Constrain output style (e.g. "respond in English", "do not add code comments"). + +```json +{ + "systemPrompt": [ + "You are operating inside a collaborative OCT session.", + "All file changes are delivered to other participants as reviewable diff proposals." + ] +} +``` + +## Workspace Context (IMPORTANT) + +The agent MUST run in the workspace directory because: + +- **Reads files from local filesystem**: Uses `fs.readFileSync(absolutePath, 'utf8')` +- **No remote file streaming**: Files are NOT sent over the network +- **`process.cwd()` is workspace root**: All relative paths resolve from workspace +- **File writes sync via OCT**: Changes are synchronized to the session (Yjs CRDT) + +### Deployment Scenarios + +**✅ Supported: Local Agent** +- Agent runs on same machine as workspace +- Has direct filesystem access +- Typical for personal development or single-developer workflows + +**❌ Not Supported: Remote Agent** +- Agent runs on different machine than workspace +- Cannot access workspace files directly +- See `REMOTE_AGENT_CHALLENGES.md` for details + +### How It Works + +- The agent has access to all files in your local project directory +- File reads come from your local filesystem +- File writes are proposed via the OCT session as reviewable diffs (visible to all participants) +- Changes are not applied automatically – participants accept or reject them in their editor + +## Using the Agent + +1. **Authentication:** + + - Open the login URL shown in the terminal + - Use simple login (choose a username like `agent`) + - In your host workspace, allow the agent user to enter the session + +2. **Triggering the Agent:** + + Write a line starting with `@agent` (or whatever username you chose) followed by your prompt: + + ```typescript + // @agent Write a factorial function + ``` + +3. **Execute the Prompt:** + + Press Enter at the end of the line and wait for the agent to respond... ✨ + +4. **Collaboration:** + + - The agent proposes file changes as diffs in the OCT session + - Participants review the proposed changes and accept or reject them in their editor + - When the agent only replies with text (no file changes), the response is delivered through the chat without an extra confirmation message + +## How It Works + +``` +Local Workspace → oct-agent (process.cwd()) + ↓ + ACP Agent (e.g. Claude Code) + ↓ + Local File Operations + ↓ + OCT Session: Proposed Diffs + ↓ + Participants Review & Accept Changes +``` + +## ACP + +The agent connects to an external ACP-capable agent (Agent Client Protocol), such as Claude Code via `npx @zed-industries/claude-code-acp`. You can use any ACP adapter by overriding `--acp-agent`. The ACP agent has access to your full local workspace while synchronizing changes back to the OCT session. diff --git a/packages/open-collaboration-agent/package.json b/packages/open-collaboration-agent/package.json index b6359f22..5a2d8a66 100644 --- a/packages/open-collaboration-agent/package.json +++ b/packages/open-collaboration-agent/package.json @@ -28,17 +28,25 @@ "oct-agent": "./bin/agent" }, "scripts": { - "build": "tsc" + "build": "tsc", + "watch": "tsc -w", + "dev": "tsx watch src/main.ts", + "dev:nodemon": "nodemon --exec tsx src/main.ts --ext ts,js,json", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "@ai-sdk/anthropic": "^1.2.10", - "@ai-sdk/openai": "^1.3.21", - "ai": "^4.3.14", + "@agentclientprotocol/sdk": "^0.13.1", "commander": "~13.1.0", "dotenv": "^16.5.0", "open-collaboration-protocol": "~0.3.0", "open-collaboration-yjs": "~0.3.0" }, + "devDependencies": { + "nodemon": "^3.0.0", + "vitest": "^2.0.0", + "@vitest/ui": "^2.0.0" + }, "keywords": [ "collaboration", "live-share", @@ -67,4 +75,4 @@ "node": ">=20.10.0", "npm": ">=10.2.3" } -} +} \ No newline at end of file diff --git a/packages/open-collaboration-agent/src/acp-bridge.ts b/packages/open-collaboration-agent/src/acp-bridge.ts new file mode 100644 index 00000000..9f7575d0 --- /dev/null +++ b/packages/open-collaboration-agent/src/acp-bridge.ts @@ -0,0 +1,1188 @@ +// ****************************************************************************** +// Copyright 2025 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { DocumentSyncOperations } from './document-operations.js'; +import type { TextDiffChange } from 'open-collaboration-protocol'; +import { AgentNotification, AgentRequest, AgentResponse, ClientRequest, ContentBlock, InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, PermissionOption, PromptResponse, ReadTextFileRequest, RequestId, RequestPermissionRequest, SessionNotification, SessionUpdate, ToolCall, ToolCallUpdate, WriteTextFileRequest } from '@agentclientprotocol/sdk'; + +/** + * Configuration for the tool whitelist + */ +export interface ToolWhitelistConfig { + allowedKinds: string[]; + allowedToolNames: string[]; +} + +/** + * Optional workspace context that can be injected into the ACP prompt in an + * agent-agnostic way (as additional text / resource_link blocks). Kept opt-in + * so the default bridge behavior stays minimal. + */ +export interface WorkspaceContextConfig { + /** + * If true, prepend a textual snapshot of the workspace tree to the prompt. + * Defaults to false when omitted. + */ + includeTree?: boolean; + /** + * Maximum directory depth used when rendering the workspace tree. + * Only applies when `includeTree` is true. Defaults to 2. + */ + maxDepth?: number; + /** + * Additional files (relative to the workspace root or absolute) that should + * be attached to the prompt as `resource_link` blocks alongside the active + * document. + */ + extraFiles?: string[]; +} + +/** + * Agent configuration loaded from oct-agent.config.json + */ +export interface AgentConfig { + toolWhitelist: ToolWhitelistConfig; + /** + * Optional system-prompt instructions delivered to the ACP agent before + * the user prompt. Provided as a single string or as an array of strings + * that are joined with newlines. Agent-agnostic: forwarded as a regular + * ACP `text` content block, never as an adapter-specific markdown file. + */ + systemPrompt?: string | string[]; + /** + * Optional workspace context configuration. When set, the bridge attaches + * additional ACP content blocks (text + resource_links) to each prompt. + */ + workspaceContext?: WorkspaceContextConfig; +} + +/** + * Default configuration used when no config file is found. + * `systemPrompt` and `workspaceContext` are intentionally left unset so the + * bridge behaves identically to previous releases unless a user opts in. + */ +const DEFAULT_AGENT_CONFIG: AgentConfig = { + toolWhitelist: { + allowedKinds: ['read', 'edit'], + allowedToolNames: ['mcp__acp__Read', 'mcp__acp__Edit', 'mcp__acp__Write'] + } +}; + +/** + * Normalize a raw `systemPrompt` value from a config file. Accepts a string + * or a string array; everything else is ignored. Returns undefined when no + * valid prompt is configured so callers can cheaply skip the injection. + */ +function normalizeSystemPrompt(raw: unknown): string | string[] | undefined { + if (typeof raw === 'string' && raw.length > 0) { + return raw; + } + if (Array.isArray(raw)) { + const filtered = raw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); + return filtered.length > 0 ? filtered : undefined; + } + return undefined; +} + +/** + * Normalize a raw `workspaceContext` value from a config file. Only known + * fields are picked up; unknown fields are ignored to keep forward + * compatibility predictable. + */ +function normalizeWorkspaceContext(raw: unknown): WorkspaceContextConfig | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const obj = raw as Record; + const includeTree = typeof obj.includeTree === 'boolean' ? obj.includeTree : undefined; + const maxDepth = typeof obj.maxDepth === 'number' && Number.isFinite(obj.maxDepth) && obj.maxDepth >= 0 + ? Math.floor(obj.maxDepth) + : undefined; + const extraFiles = Array.isArray(obj.extraFiles) + ? obj.extraFiles.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) + : undefined; + + if (includeTree === undefined && maxDepth === undefined && (!extraFiles || extraFiles.length === 0)) { + return undefined; + } + + const normalized: WorkspaceContextConfig = {}; + if (includeTree !== undefined) normalized.includeTree = includeTree; + if (maxDepth !== undefined) normalized.maxDepth = maxDepth; + if (extraFiles && extraFiles.length > 0) normalized.extraFiles = extraFiles; + return normalized; +} + +/** + * Load agent configuration from file or return defaults + */ +function loadAgentConfig(configPath?: string): AgentConfig { + const searchPath = configPath || path.join(process.cwd(), 'oct-agent.config.json'); + try { + const content = fs.readFileSync(searchPath, 'utf8'); + const parsed = JSON.parse(content); + console.log(`[ACP] Loaded config from ${searchPath}`); + const config: AgentConfig = { + toolWhitelist: { + allowedKinds: parsed.toolWhitelist?.allowedKinds ?? DEFAULT_AGENT_CONFIG.toolWhitelist.allowedKinds, + allowedToolNames: parsed.toolWhitelist?.allowedToolNames ?? DEFAULT_AGENT_CONFIG.toolWhitelist.allowedToolNames + } + }; + const systemPrompt = normalizeSystemPrompt(parsed.systemPrompt); + if (systemPrompt !== undefined) { + config.systemPrompt = systemPrompt; + } + const workspaceContext = normalizeWorkspaceContext(parsed.workspaceContext); + if (workspaceContext !== undefined) { + config.workspaceContext = workspaceContext; + } + return config; + } catch { + console.log(`[ACP] No config file found at ${searchPath}, using defaults`); + return DEFAULT_AGENT_CONFIG; + } +} + +/** + * Extract tool name from a tool call (supports multiple agent formats) + */ +function extractToolName(toolCall: ToolCallUpdate): string | undefined { + // Claude-Code-ACP format: _meta.claudeCode.toolName + const claudeToolName = (toolCall._meta as any)?.claudeCode?.toolName; + if (claudeToolName) return claudeToolName; + // Additional agent formats can be added here + return undefined; +} + +/** + * Extract tool kind from the title (e.g. "Write /path/to/file" -> "edit") + * This is used as a fallback when kind is not set + */ +function extractKindFromTitle(title?: string): string | undefined { + if (!title) return undefined; + const firstWord = title.split(' ')[0]?.toLowerCase(); + // Map common title prefixes to ACP kinds + const titleToKind: Record = { + 'write': 'edit', + 'read': 'read', + 'edit': 'edit', + 'delete': 'delete', + 'move': 'move', + 'rename': 'move', + 'search': 'search', + 'execute': 'execute', + 'run': 'execute', + 'bash': 'execute', + }; + return titleToKind[firstWord]; +} + +/** + * Check if a tool call is allowed based on the configuration + */ +function isAllowedToolCall(toolCall: ToolCallUpdate, config: AgentConfig): boolean { + // 1. Check kind (ACP standard field) + if (toolCall.kind && config.toolWhitelist.allowedKinds.includes(toolCall.kind)) { + return true; + } + // 2. Check tool name from _meta (agent-specific) + const toolName = extractToolName(toolCall); + if (toolName && config.toolWhitelist.allowedToolNames.includes(toolName)) { + return true; + } + // 3. Fallback: extract kind from title (e.g. "Write /path" -> "edit") + const kindFromTitle = extractKindFromTitle(toolCall.title ?? undefined); + if (kindFromTitle && config.toolWhitelist.allowedKinds.includes(kindFromTitle)) { + return true; + } + return false; +} + +/** + * ACP Bridge for connecting external agents via Agent Client Protocol + * + * This implementation communicates directly with ACP agents using JSON-RPC over stdio, + * without using the full ACP SDK to keep the implementation simple and maintainable. + */ +export class ACPBridge { + private childProcess?: ChildProcess; + private isConnected = false; + private sessionId?: string; + private pendingPrompts = new Map void; + reject: (error: Error) => void; + accumulatedText?: string; // Accumulate text chunks for this request + currentDocPath?: string; // Track the document path for this request + }>(); + private messageBuffer = ''; + private requestIdCounter = 0; + private pendingToolCalls = new Map(); + private pendingProposals = new Map(); + private config: AgentConfig; + + constructor( + private readonly acpAgentCommand: string, + private documentOps?: DocumentSyncOperations, + configPath?: string + ) { + this.config = loadAgentConfig(configPath); + } + + /** + * Start the ACP bridge by spawning the agent process and establishing connection + */ + async start(): Promise { + if (this.isConnected) { + throw new Error('ACP bridge is already started'); + } + + console.log(`🚀 Starting ACP agent: ${this.acpAgentCommand}`); + + // Parse the command (handle 'npx @zed-industries/claude-code-acp' format) + const parts = this.acpAgentCommand.split(/\s+/); + const command = parts[0]; + const args = parts.slice(1); + + // Spawn the child process + this.childProcess = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + shell: true, + }); + + // Handle process errors + this.childProcess.on('error', (error) => { + console.error('❌ ACP agent process error:', error); + this.isConnected = false; + }); + + // Handle process exit + this.childProcess.on('exit', (code, signal) => { + console.error(`⚠️ ACP agent process exited with code ${code}, signal ${signal}`); + this.isConnected = false; + if (code !== 0 && code !== null) { + // Reject all pending prompts + for (const { reject } of this.pendingPrompts.values()) { + reject(new Error(`ACP agent process exited with code ${code}`)); + } + this.pendingPrompts.clear(); + } + }); + + // Handle stderr (for logging) + this.childProcess.stderr?.on('data', (data) => { + const message = data.toString(); + // Only log non-empty messages to avoid noise + if (message.trim()) { + console.error(`[ACP Agent] ${message.trim()}`); + } + }); + + // Handle stdout - parse JSON-RPC messages + this.childProcess.stdout?.on('data', (data: Buffer) => { + const rawData = data.toString(); + console.error(`[ACP] Raw stdout data received (${rawData.length} bytes): ${rawData.substring(0, 200)}`); + this.messageBuffer += rawData; + this.processMessageBuffer(); + }); + + // Wait a short moment for the agent process to be ready + // Some agents need time to initialize before accepting requests + await new Promise(resolve => setTimeout(resolve, 500)); + + // Initialize ACP connection + await this.initializeACP(); + + this.isConnected = true; + console.log('✅ ACP bridge connected'); + } + + /** + * Initialize ACP connection: initialize and create session + */ + private async initializeACP(): Promise { + // Send initialize request + const initRequestId = this.getNextRequestId(); + const initResponse = await this.sendRequest({ + jsonrpc: '2.0', + id: String(initRequestId), + method: 'initialize', + params: { + protocolVersion: 1, // ACP protocol version (must be a number) + clientInfo: { + name: 'oct-agent', + version: '0.3.0', + }, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }, + }); + + if ('error' in initResponse) { + throw new Error(`Failed to initialize: ${initResponse.error.message || 'Unknown error'}`); + } + const result = initResponse.result as InitializeResponse; + console.error(`[ACP] Initialized with protocol version: ${result?.protocolVersion || 'unknown'}`); + console.info('Init Response: ', initResponse); + + // Try to create a session, but handle the case where session/new method doesn't exist + try { + const sessionRequestId = this.getNextRequestId(); + + // Build session params - use local workspace + const sessionParams: NewSessionRequest = { + mcpServers: [], + cwd: process.cwd() + }; + + console.error(`[ACP] Creating session with local workspace: ${process.cwd()}`); + + const sessionResponse = await this.sendRequest({ + jsonrpc: '2.0', + id: String(sessionRequestId), + method: 'session/new', + params: sessionParams, + }); + + if ('error' in sessionResponse) { + throw new Error(`Failed to create session: ${sessionResponse.error.message || 'Unknown error'}`); + } + + console.error('[ACP] Session created: ', JSON.stringify(sessionResponse, null, 2)); + const result = sessionResponse.result as NewSessionResponse; + this.sessionId = result?.sessionId; + if (this.sessionId) { + console.error(`[ACP] Created session: ${this.sessionId}`); + } else { + console.error('[ACP] Session creation returned no session ID, continuing without explicit session'); + } + } catch (error: any) { + // If session/new method doesn't exist, that's okay - some ACP agents + // may not require explicit session creation + if (error.message?.includes('Method not found') || error.message?.includes('session/new')) { + console.error('[ACP] session/new method not available, continuing without explicit session'); + } else { + // Re-throw other errors + throw error; + } + } + } + + /** + * Get next request ID + */ + private getNextRequestId(): number { + return ++this.requestIdCounter; + } + + /** + * Send a JSON-RPC request and wait for response + */ + private sendRequest(request: ClientRequest & { params: T; jsonrpc?: string }): Promise { + return new Promise((resolve, reject) => { + const requestId = request.id; + const timeout = setTimeout(() => { + this.pendingPrompts.delete(String(requestId)); + reject(new Error(`Request ${requestId} timed out`)); + }, 30000); // 30 second timeout + + // Store response handler BEFORE sending request + this.pendingPrompts.set(String(requestId), { + resolve: (response: AgentResponse) => { + clearTimeout(timeout); + resolve(response); + }, + reject: (error: Error) => { + clearTimeout(timeout); + reject(error); + }, + }); + + // Send request + this.sendMessage(request); + }); + } + + /** + * Process incoming message buffer, parsing newline-delimited JSON messages + */ + private processMessageBuffer(): void { + const lines = this.messageBuffer.split('\n'); + // Keep the last incomplete line in the buffer + this.messageBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const message = JSON.parse(line); + console.error(`[ACP] Received: ${line.trim()}`); + + // Route based on message type + if (message.id !== undefined && message.method === undefined) { + // Response to our request + this.handleResponse(message); + } else { + // Request/notification from agent + this.handleAgentRequest(message); + } + } catch (error) { + console.error(`[ACP] Failed to parse message: ${line}`, error); + } + } + } + + /** + * Handle responses to requests sent by the bridge + */ + private handleResponse(message: AgentResponse): void { + // This is a response (has id but no method) + const requestId = String(message.id); + const pending = this.pendingPrompts.get(requestId); + if (pending) { + this.pendingPrompts.delete(requestId); + + // Flush buffered proposals before resolving, so that the peer + // receives a single consolidated diff per file for this prompt cycle. + void this.flushPendingProposals().then((proposalCount) => { + const editsProposed = proposalCount > 0; + if ('error' in message) { + pending.reject(new Error(message.error.message || 'ACP request failed')); + } else if ('result' in message) { + const result = message.result as PromptResponse; + const accumulatedText = pending.accumulatedText || ''; + + if (accumulatedText) { + if (accumulatedText.trim()) { + void this.documentOps?.getConnection().chat.sendMessage(accumulatedText); + } + pending.resolve({ + type: 'agent/response', + content: accumulatedText, + stopReason: result.stopReason, + editsProposed, + }); + } else if (result.stopReason) { + pending.resolve({ + type: 'agent/response', + content: '', + stopReason: result.stopReason, + editsProposed, + }); + } else { + // Wrap raw result so callers can rely on a consistent shape + // (including the editsProposed flag) regardless of whether + // the agent emitted text or only performed edits. + pending.resolve({ + type: 'agent/response', + content: '', + stopReason: result.stopReason, + editsProposed, + result, + }); + } + } + }); + } + } + + /** + * Flush all buffered proposals as proposeChanges broadcasts. + * Called after a prompt cycle completes so that multiple writes to the same + * file are collapsed into a single diff on the peer side. + * + * @returns The number of proposals that were flushed (0 if none). + */ + private async flushPendingProposals(): Promise { + if (this.pendingProposals.size === 0) { + return 0; + } + + const connection = this.documentOps?.getConnection(); + if (!connection) { + console.error('[ACP] Cannot flush proposals: no connection available'); + this.pendingProposals.clear(); + return 0; + } + + const flushedCount = this.pendingProposals.size; + console.info(`[ACP] Flushing ${flushedCount} pending proposal(s)`); + for (const { octPath, currentContent, newContent } of this.pendingProposals.values()) { + try { + const currentLines = currentContent.split('\n'); + const lastLine = currentLines.length - 1; + const changes: TextDiffChange[] = [{ + range: { + start: { line: 0, character: 0 }, + end: { line: lastLine, character: currentLines[lastLine].length }, + }, + text: newContent, + }]; + await connection.editor.proposeChanges(octPath, changes); + console.info(`[ACP] Proposed changes via diff editor for: ${octPath}`); + } catch (error: any) { + console.error(`[ACP] Error proposing changes for ${octPath}: ${error.message}`); + } + } + this.pendingProposals.clear(); + return flushedCount; + } + + private isAgentRequest(message: AgentRequest | AgentNotification): message is AgentRequest { + return 'id' in message && 'method' in message && 'params' in message; + } + + private isAgentNotification(message: AgentRequest | AgentNotification): message is AgentNotification { + return 'method' in message && 'params' in message && !('id' in message); + } + + /** + * Handle permission requests from the agent + * This is a CLIENT method - the agent requests permission from the client + */ + private handlePermissionRequest(messageId: RequestId, params: RequestPermissionRequest): void { + console.info(`[ACP] Handling permission request: ${messageId}`); + const toolCallId = params.toolCall.toolCallId; + const options = params.options || []; + + // Helper: Send ACP-conformant permission response + const sendPermissionResponse = (optionId: string) => { + this.sendMessage({ + jsonrpc: '2.0', + id: messageId, + result: { + outcome: { + outcome: 'selected', + optionId + } + }, + }); + }; + + // Find allow and deny options from the provided options + const allowOption = options.find((opt: PermissionOption) => + opt.optionId === 'allow_once' || + opt.optionId === 'allow' + ) || options[0]; + const denyOption = options.find((opt: PermissionOption) => + opt.optionId === 'deny' || + opt.optionId === 'reject_once' + ) || options[options.length - 1]; + + // Get the full tool call data (from pendingToolCalls if available, otherwise from params) + // pendingToolCalls contains the complete data from tool_call_update (with title, kind, etc.) + const pendingToolCall = this.pendingToolCalls.get(toolCallId); + const toolCallForCheck = pendingToolCall?.toolCall ?? params.toolCall; + + // Security check: Only allow whitelisted tool calls + if (!isAllowedToolCall(toolCallForCheck, this.config)) { + const toolName = extractToolName(toolCallForCheck) || 'unknown'; + const toolKind = toolCallForCheck.kind || extractKindFromTitle(toolCallForCheck.title) || 'unknown'; + const toolTitle = toolCallForCheck.title || 'no title'; + console.error(`[ACP] Denying tool call ${toolCallId}: kind="${toolKind}", name="${toolName}", title="${toolTitle}" not in whitelist`); + sendPermissionResponse(denyOption?.optionId || 'deny'); + return; + } + + if (pendingToolCall) { + // Known tool call - approve (actual write happens via fs/write_text_file) + const selectedOptionId = allowOption?.optionId || 'allow_once'; + console.error(`[ACP] Approving known tool call ${toolCallId} with option ${selectedOptionId}`); + sendPermissionResponse(selectedOptionId); + this.pendingToolCalls.delete(toolCallId); + } else { + // Unknown tool call (permission request came before tool_call_update) + // Try to find docPath and approve if possible + console.error(`[ACP] Tool call ${toolCallId} not in pendingToolCalls, attempting fallback`); + + // Get docPath from pending prompts or active document + let currentDocPath: string | undefined; + const pendingEntries = Array.from(this.pendingPrompts.entries()); + if (pendingEntries.length > 0) { + const [, pending] = pendingEntries[pendingEntries.length - 1]; + currentDocPath = pending.currentDocPath; + } + if (!currentDocPath && this.documentOps) { + currentDocPath = this.documentOps.getActiveDocumentPath(); + } + + if (currentDocPath && this.documentOps) { + // Approve (actual write happens via fs/write_text_file) + const selectedOptionId = allowOption?.optionId || 'allow_once'; + console.error(`[ACP] Approving unknown tool call ${toolCallId} with fallback docPath ${currentDocPath}`); + sendPermissionResponse(selectedOptionId); + } else { + // Cannot determine where to apply - deny + console.error(`[ACP] Denying tool call ${toolCallId}: no docPath available`); + sendPermissionResponse(denyOption?.optionId || 'deny'); + } + } + } + + /** + * Handle tool call updates - convert to edits in current document + */ + private handleToolCallUpdate(update: ToolCall | ToolCallUpdate): void { + console.info(`[ACP] Handling tool call update: ${update}`); + const toolCallId = update.toolCallId; + // Get the current document path from the most recent pending prompt + const pendingEntries = Array.from(this.pendingPrompts.entries()); + console.info(`[ACP] Pending entries: ${pendingEntries}`); + let currentDocPath: string | undefined; + if (pendingEntries.length > 0) { + const [, pending] = pendingEntries[pendingEntries.length - 1]; + currentDocPath = pending.currentDocPath; + } + // Fallback to active document if available + if (!currentDocPath && this.documentOps) { + currentDocPath = this.documentOps.getActiveDocumentPath(); + } + if (currentDocPath) { + // Store the tool call for when permission is granted + this.pendingToolCalls.set(toolCallId, { + toolCall: update, + docPath: currentDocPath, + }); + console.error(`[ACP] Stored tool_call ${toolCallId} for document ${currentDocPath}`); + } else { + console.error('⚠️ Received tool_call but no document path available'); + } + } + + /** + * Handle agent message chunk updates - accumulate text + */ + private handleAgentMessageChunk(content: ContentBlock): void { + console.info(`[ACP] Handling agent message chunk: ${content}`); + if (content.type === 'text' && typeof content.text === 'string') { + // Keep the chat UI in writing mode while chunks are still streaming. + void this.documentOps?.getConnection().chat.isWriting(); + console.info(`[ACP] Received text chunk (${content.text.length} chars)`); + // Find the most recent pending prompt (for the current request) + const pendingEntries = Array.from(this.pendingPrompts.entries()); + if (pendingEntries.length > 0) { + const [requestId, pending] = pendingEntries[pendingEntries.length - 1]; + if (pending) { + // Accumulate the text chunk + if (!pending.accumulatedText) { + pending.accumulatedText = ''; + } + pending.accumulatedText += content.text; + console.error(`[ACP] Accumulated text chunk for request ${requestId}, total length: ${pending.accumulatedText.length}`); + } + } else { + console.error('⚠️ Received agent_message_chunk but no pending prompt found'); + } + } + } + + /** + * Handle session update notifications from the agent + */ + private handleSessionUpdate(params: SessionNotification): void { + const update: SessionUpdate = params.update; + const notificationSessionId = params.sessionId; + + // If we receive a sessionId in the notification but don't have one stored, store it + if (notificationSessionId && !this.sessionId) { + console.error(`[ACP] Received sessionId from notification: ${notificationSessionId}, storing it`); + this.sessionId = notificationSessionId; + } + + // Verify session ID matches (if we have one set) + if (this.sessionId && notificationSessionId && notificationSessionId !== this.sessionId) { + console.error(`⚠️ Received session/update for different session: ${notificationSessionId} (expected ${this.sessionId})`); + return; + } + + // Route to appropriate handler based on update type + if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') { + this.handleToolCallUpdate(update); + } else if (update.sessionUpdate === 'agent_message_chunk' && update.content) { + this.handleAgentMessageChunk(update.content); + } + } + + /** + * Handle requests and notifications from the agent + */ + private handleAgentRequest(message: AgentRequest | AgentNotification): void { + if (this.isAgentRequest(message)) { + // Route to appropriate handler based on method + if (message.method === 'fs/read_text_file' || message.method === 'fs/write_text_file') { + // Handle asynchronously but don't block + this.handleFileSystemRequest(message).catch((error) => { + console.error(`[ACP] Error handling file system request: ${error}`); + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: `Internal error: ${error.message || 'Unknown error'}`, + }, + }); + }); + } else if (message.method === 'session/request_permission') { + this.handlePermissionRequest(message.id, message.params as RequestPermissionRequest); + } + } else if (this.isAgentNotification(message)) { + if (message.method === 'session/update') { + this.handleSessionUpdate(message.params as SessionNotification); + } + } + } + + private getAbsoluteFilePath(message: AgentRequest): string | undefined { + const params = message.params as WriteTextFileRequest | ReadTextFileRequest; + const filePath = params.path; + + if (!filePath) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32602, + message: 'Missing required parameter: path', + }, + }); + return; + } + + // Normalize path relative to cwd + const normalizedPath = path.normalize(filePath); + const absolutePath = path.isAbsolute(normalizedPath) + ? normalizedPath + : path.resolve(process.cwd(), normalizedPath); + + // Security check: ensure path is within workspace + const workspaceRoot = path.normalize(process.cwd()); + if (!absolutePath.startsWith(workspaceRoot)) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32602, + message: `File path ${filePath} is not within workspace`, + }, + }); + return; + } + + return absolutePath; + } + + /** + * Handle File System requests from agent + * Read from local filesystem, write to OCT session for synchronization + */ + private async handleFileSystemRequest(message: AgentRequest): Promise { + console.info(`[ACP] Handling file system request: ${message.method}`); + if (!this.documentOps) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: 'DocumentOps not available', + }, + }); + return; + } + + if (message.method === 'fs/read_text_file') { + // Read file - try OCT document first, fallback to local filesystem + try { + const absolutePath = this.getAbsoluteFilePath(message); + if (!absolutePath) { + return; + } + + const activeDocPath = this.documentOps?.getActiveDocumentPath(); + const workspaceName = activeDocPath?.split('/')[0] ?? path.basename(process.cwd()); + const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); + + // Prefer the latest buffered proposal so an agent that re-reads + // the file between edits observes its own pending writes. + // Without this, sequential writes against the same file collapse + // to "last write wins" and earlier edits are lost. + const pending = this.pendingProposals.get(octPath); + let content: string | undefined = pending?.newContent + ?? this.documentOps?.getDocument(octPath); + if (content === undefined) { + content = fs.readFileSync(absolutePath, 'utf8'); + console.error(`[ACP] Read file from filesystem (not in OCT): ${absolutePath}`); + } else if (pending) { + console.error(`[ACP] Read file from pending proposal buffer: ${octPath}`); + } else { + console.error(`[ACP] Read file from OCT document: ${octPath}`); + } + + // Handle optional line and limit parameters + let resultContent = content; + const params = message.params as ReadTextFileRequest; + const lines = content.split('\n'); + const startLine = Math.max(0, (params.line ?? 0) - 1); // Convert to 0-based + const limit = params.limit ?? lines.length; + const endLine = Math.min(startLine + limit, lines.length); + resultContent = lines.slice(startLine, endLine).join('\n'); + console.info(`[ACP] Sending file system response: ${resultContent}`); + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + result: { + content: resultContent, + }, + }); + } catch (error: any) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: `File not found or not readable: ${error.message || 'Unknown error'}`, + }, + }); + } + } else if (message.method === 'fs/write_text_file') { + const absolutePath = this.getAbsoluteFilePath(message); + if (!absolutePath) { + return; + } + console.info(`[ACP] Writing file to OCT document: ${absolutePath}`); + const newContent = (message.params as WriteTextFileRequest).content; + if (newContent === undefined) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32602, + message: 'Missing required parameter: content', + }, + }); + return; + } + + const activeDocPath = this.documentOps.getActiveDocumentPath(); + const workspaceName = activeDocPath?.split('/')[0] ?? path.basename(process.cwd()); + const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); + console.info(`[ACP] OCT path: ${octPath} (workspaceName from ${activeDocPath ? 'activeDoc' : 'cwd'})`); + + // Prefer an already-buffered proposal (so multiple writes in a single + // prompt cycle accumulate correctly even for files that were not in + // OCT before this run). + const existing = this.pendingProposals.get(octPath); + let currentContent: string | undefined = + existing?.currentContent + ?? this.documentOps.getDocument(octPath); + console.info(`[ACP] Current content from OCT: ${currentContent !== undefined ? 'found' : 'not found'}`); + + // Multi-file support: if the document is not yet tracked by OCT (the + // host did not have it open), ask the host to open it so we can + // produce a regular diff proposal against its real content instead + // of silently writing to disk or skipping the edit altogether. + if (currentContent === undefined) { + try { + const opened = await this.documentOps.openAndWaitForContent(octPath); + if (opened !== undefined) { + currentContent = opened; + console.info(`[ACP] Opened document on host for proposal: ${octPath}`); + } + } catch (error: any) { + console.error(`[ACP] openAndWaitForContent failed for ${octPath}: ${error?.message ?? error}`); + } + } + + if (currentContent === undefined) { + // The host has no such file — treat this as a new-file proposal. + // We buffer an insert hunk against empty original content so the + // peer receives a regular proposeChanges broadcast (no silent + // disk write, no lost edit). + currentContent = ''; + console.info(`[ACP] No existing content for ${octPath}, proposing as new file (insert hunk)`); + } + + if (currentContent === newContent) { + console.info('[ACP] No changes detected, skipping write'); + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + result: null, + }); + return; + } + + // Buffer the proposal instead of sending immediately. + // Multiple writes to the same file during a single prompt cycle are + // collected and only the final state is sent once the prompt completes. + this.pendingProposals.set(octPath, { + octPath, + currentContent, + newContent, + }); + console.info(`[ACP] Queued proposal for: ${octPath} (${this.pendingProposals.size} pending)`); + + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + result: null, + }); + } + } + + /** + * Send a JSON-RPC message to the agent + */ + private sendMessage(message: any): void { + if (!this.childProcess?.stdin) { + throw new Error('ACP bridge is not connected'); + } + + const json = JSON.stringify(message) + '\n'; + console.error(`[ACP] Sending: ${json.trim()}`); + this.childProcess.stdin.write(json, 'utf8'); + } + + /** + * Stop the ACP bridge and terminate the agent process + */ + async stop(): Promise { + if (!this.isConnected) { + return; + } + + console.log('🛑 Stopping ACP bridge...'); + + // Reject all pending prompts + for (const { reject } of this.pendingPrompts.values()) { + reject(new Error('ACP bridge stopped')); + } + this.pendingPrompts.clear(); + + // Terminate child process + if (this.childProcess) { + this.childProcess.kill(); + this.childProcess = undefined; + } + + this.isConnected = false; + console.log('✅ ACP bridge stopped'); + } + + /** + * Build the system-prompt text from the agent configuration. + * Accepts a string or string[] (array entries are joined with newlines so + * users can keep one instruction per line in their config file) and + * returns undefined when no usable prompt is configured. The result is + * delivered to the agent as an ACP `text` content block by `sendTrigger`. + */ + private buildSystemPromptText(): string | undefined { + const sys = this.config.systemPrompt; + if (sys === undefined) { + return undefined; + } + const text = Array.isArray(sys) ? sys.join('\n') : sys; + const trimmed = text.trim(); + return trimmed.length > 0 ? text : undefined; + } + + /** + * Normalize whitespace for text matching (handles different newline counts) + */ + /** + * Get MIME type for a file based on its extension + */ + private getMimeType(filepath: string): string { + const ext = path.extname(filepath).toLowerCase(); + const mimeTypes: Record = { + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.txt': 'text/plain', + '.html': 'text/html', + '.css': 'text/css', + '.xml': 'text/xml', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.py': 'text/x-python', + '.java': 'text/x-java', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.hpp': 'text/x-c++', + '.go': 'text/x-go', + '.rs': 'text/x-rust', + '.sh': 'text/x-shellscript', + '.bash': 'text/x-shellscript', + }; + return mimeTypes[ext] || 'text/plain'; + } + + /** + * Send a prompt to the ACP agent using standard ACP protocol + * + * The prompt includes: + * - A text content block with the user's prompt + * - A resource_link content block identifying the currently active file + * + * Example output: + * ```json + * { + * "jsonrpc": "2.0", + * "id": 1, + * "method": "session/prompt", + * "params": { + * "sessionId": "session-123", + * "prompt": [ + * { + * "type": "text", + * "text": "Fix the bug in this function" + * }, + * { + * "type": "resource_link", + * "uri": "file:///path/to/file.ts", + * "name": "file.ts", + * "mimeType": "text/typescript", + * "size": 1024 + * } + * ] + * } + * } + * ``` + * + * @returns Promise that resolves with the agent's response + */ + async sendTrigger(trigger: { + id: string; + source: { + type: 'document'; + path: string; + line: number; + }; + content: { + prompt: string; + context?: string; + }; + }): Promise { + if (!this.isConnected) { + throw new Error('ACP bridge is not connected'); + } + + const requestId = this.getNextRequestId(); + + return new Promise((resolve, reject) => { + // Store the promise handlers using request ID, including the document path + // trigger.source.path is already an OCT path with workspace name prefix + this.pendingPrompts.set(String(requestId), { + resolve, + reject, + currentDocPath: trigger.source.path, // OCT document path for tool call correlation + }); + + // Build params - sessionId is optional if the agent doesn't require it + const promptContent: any[] = [ + { + type: 'text', + text: trigger.content.prompt, + }, + ]; + + // Prepend the configured system prompt as a regular ACP text block. + // This is intentionally agent-agnostic: every ACP adapter sees the + // same text content, so we never depend on adapter-specific files + // like CLAUDE.md / GEMINI.md. + const systemPromptText = this.buildSystemPromptText(); + if (systemPromptText) { + promptContent.unshift({ + type: 'text', + text: systemPromptText, + }); + console.error(`[ACP] Prepended systemPrompt (${systemPromptText.length} chars)`); + } + + // Add resource_link for the current document + try { + // Convert OCT path to absolute file path + // OCT paths include workspace name as prefix, so resolve from parent directory + const absolutePath = path.resolve(path.dirname(process.cwd()), trigger.source.path); + const filename = path.basename(absolutePath); + const mimeType = this.getMimeType(absolutePath); + + // Get file size (optional) + let fileSize: number | undefined; + try { + const stats = fs.statSync(absolutePath); + fileSize = stats.size; + } catch { + // File size is optional, continue without it + } + + // Add resource_link to prompt + const resourceLink: any = { + type: 'resource_link', + uri: `file://${absolutePath}`, + name: filename, + mimeType: mimeType, + }; + + // Add size if available + if (fileSize !== undefined) { + resourceLink.size = fileSize; + } + + promptContent.push(resourceLink); + console.error(`[ACP] Added resource_link for ${filename} (${mimeType})`); + } catch (error: any) { + console.error(`[ACP] Failed to create resource_link: ${error.message}`); + // Continue without resource_link if there's an error + } + + const params: any = { + prompt: promptContent, + }; + + // Only include sessionId if we have one + if (this.sessionId) { + params.sessionId = this.sessionId; + console.error(`[ACP] Sending prompt with sessionId: ${this.sessionId}`); + } else { + throw new Error('Cannot send session/prompt: sessionId is required but not set. Ensure session was created successfully.'); + } + + console.error(`[ACP] Sending prompt with params: ${JSON.stringify(params, null, 2)}`); + + // Send prompt request using standard ACP protocol + this.sendMessage({ + jsonrpc: '2.0', + id: requestId, + method: 'session/prompt', + params, + }); + }); + } + + /** + * Check if the bridge is connected + */ + get connected(): boolean { + return this.isConnected; + } +} diff --git a/packages/open-collaboration-agent/src/agent-util.ts b/packages/open-collaboration-agent/src/agent-util.ts deleted file mode 100644 index 3d2ba696..00000000 --- a/packages/open-collaboration-agent/src/agent-util.ts +++ /dev/null @@ -1,163 +0,0 @@ -// ****************************************************************************** -// Copyright 2025 TypeFox GmbH -// This program and the accompanying materials are made available under the -// terms of the MIT License, which is available in the project root. -// ****************************************************************************** - -import { Deferred } from 'open-collaboration-protocol'; -import type { IDocumentSync } from './document-sync.js'; - -/** - * Applies the text region changes returned by the LLM to the document. - */ -export function applyChanges(docPath: string, docContent: string, docLines: string[], changes: string[], documentSync: IDocumentSync): void { - // Create mutable copies of the document content and lines - let currentContent = docContent; - let currentLines = docLines; - - for (const change of changes) { - // Split the change text into lines - const changeLines = change.split('\n'); - - // Locate the change in the document with context - const location = locateChangeInDocument(currentLines, changeLines); - - if (location.endLine > location.startLine) { - // Calculate character offsets from line information - const startOffset = calculateOffset(currentContent, location.startLine); - const endOffset = calculateOffset(currentContent, location.endLine) - 1; - - // Apply the edit - documentSync.applyEdit(docPath, location.replacementText, startOffset, endOffset - startOffset); - - // Update our local document representation to reflect the change for subsequent edits - currentContent = - currentContent.substring(0, startOffset) + - location.replacementText + - currentContent.substring(endOffset); - - // Update the lines array - currentLines = currentContent.split('\n'); - } - } -} - -/** - * Displays a loading animation at the specified position in the document. - * @returns A promise that resolves when the animation is complete (aborted) - */ -export function animateLoadingIndicator(docPath: string, offset: number, documentSync: IDocumentSync, abortSignal: AbortSignal): Promise { - const deferred = new Deferred(); - const animationChars = ['|', '/', '-', '\\']; - let index = 0; - let currentChar: string | undefined = undefined; - let timer: NodeJS.Timeout | undefined = undefined; - - const updateChar = () => { - if (abortSignal.aborted) { - return; - } - - // Add the next character in the sequence - const nextChar = animationChars[index]; - documentSync.applyEdit(docPath, nextChar, offset, currentChar === undefined ? 0 : 1); - currentChar = nextChar; - index = (index + 1) % animationChars.length; - - // Schedule the next update - timer = setTimeout(updateChar, 250); - }; - - // Cleanup if aborted - abortSignal.addEventListener('abort', () => { - clearTimeout(timer); - if (currentChar !== undefined) { - documentSync.applyEdit(docPath, '', offset, 1); - currentChar = undefined; - } - deferred.resolve(); - }); - - // Start the animation - updateChar(); - - return deferred.promise; -} - -/** - * Locates where in the document the change should be applied by finding the best - * matching context and identifying the section to be replaced. - */ -function locateChangeInDocument(docLines: string[], changeLines: string[]): { - startLine: number, - endLine: number, - replacementText: string -} { - // Helper function to compare two string arrays (slices) - function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false; - } - } - return true; - } - - let docReplaceStartLine = 0; - let docReplaceEndLine = docLines.length; - let changeSliceStart = 0; - let changeSliceEnd = changeLines.length; - - // Find the longest prefix of changeLines that matches in docLines - for (let prefixLenInCl = Math.min(changeLines.length, docLines.length); prefixLenInCl >= 1; prefixLenInCl--) { - const prefixCl = changeLines.slice(0, prefixLenInCl); - for (let line = 0; line <= docLines.length - prefixLenInCl; line++) { - if (arraysEqual(docLines.slice(line, line + prefixLenInCl), prefixCl)) { - docReplaceStartLine = line + prefixLenInCl; - changeSliceStart = prefixLenInCl; - prefixLenInCl = 0; // Signal to break outer loop - break; // Break inner loop - } - } - } - - // Find the longest suffix of the remaining changeLines that matches in docLines - // The suffix must start at or after the end of the identified prefix context in docLines - const maxSuffixPossibleInCl = Math.min(changeLines.length - changeSliceStart, Math.max(0, docLines.length - docReplaceStartLine)); - for (let suffixLenInCl = maxSuffixPossibleInCl; suffixLenInCl >= 1; suffixLenInCl--) { - const suffixCl = changeLines.slice(changeLines.length - suffixLenInCl); - for (let line = docReplaceStartLine; line <= docLines.length - suffixLenInCl; line++) { - if (arraysEqual(docLines.slice(line, line + suffixLenInCl), suffixCl)) { - docReplaceEndLine = line; - changeSliceEnd = changeLines.length - suffixLenInCl; - suffixLenInCl = 0; // Signal to break outer loop - break; // Break inner loop - } - } - } - - const replacementText = changeLines.slice(changeSliceStart, changeSliceEnd).join('\n'); - - return { - startLine: docReplaceStartLine, - endLine: docReplaceEndLine, - replacementText: replacementText - }; -} - -/** - * Calculates the character offset in the document for a given line. - */ -function calculateOffset(text: string, line: number): number { - const lines = text.split('\n'); - let offset = 0; - - for (let i = 0; i < line; i++) { - offset += lines[i].length + 1; // +1 for the newline character - } - - return offset; -} diff --git a/packages/open-collaboration-agent/src/agent.ts b/packages/open-collaboration-agent/src/agent.ts index 9110c893..fb3eeb35 100644 --- a/packages/open-collaboration-agent/src/agent.ts +++ b/packages/open-collaboration-agent/src/agent.ts @@ -5,16 +5,19 @@ // ****************************************************************************** import { webcrypto } from 'node:crypto'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; import { ConnectionProvider, SocketIoTransportProvider, initializeProtocol } from 'open-collaboration-protocol'; import type { ConnectionProviderOptions, Peer } from 'open-collaboration-protocol'; -import { DocumentSync, DocumentChange } from './document-sync.js'; -import { executePrompt } from './prompt.js'; -import { animateLoadingIndicator, applyChanges } from './agent-util.js'; +import { DocumentSync, DocumentChange, type DocumentInsert } from './document-sync.js'; +import { DocumentSyncOperations } from './document-operations.js'; +import { ACPBridge } from './acp-bridge.js'; export interface AgentOptions { server: string room: string - model: string + acpAgent?: string + config?: string } export async function startCLIAgent(options: AgentOptions): Promise { @@ -59,9 +62,18 @@ export async function startCLIAgent(options: AgentOptions): Promise { // Connect to the room using the room token const connection = await connectionProvider.connect(joinResponse.roomToken); + // Store ACP bridge for cleanup. Declared with `let` because `cleanup` (below) + // closes over this binding and the bridge is created later in the function. + // eslint-disable-next-line prefer-const + let acpBridge: ACPBridge | undefined; + // Register signal handlers for graceful shutdown const cleanup = async () => { try { + // Stop ACP bridge if running + if (acpBridge) { + await acpBridge.stop(); + } const exitTimeout = setTimeout(() => { console.log('⚠️ Shutdown timeout reached, forcing exit'); process.exit(0); @@ -98,92 +110,439 @@ export async function startCLIAgent(options: AgentOptions): Promise { }); console.log(`✅ Received peer info: ${identity.name} (${identity.id})`); - runAgent(documentSync, identity, options); + // Run ACP agent (connects to external agent via ACP bridge) + acpBridge = await runACPAgent(documentSync, identity, options); +} + +export interface TriggerDetectionOptions { + agentName: string + documentSync: DocumentSync + documentOps: DocumentSyncOperations + onTrigger: (params: { + docPath: string + docContent: string + prompt: string + change?: DocumentInsert // Only present for document triggers (newline detection) + source: 'document' | 'chat' + sendChatResponse?: (message: string) => Promise // Only present for chat triggers + }) => Promise +} + +function levenshteinDistance(a: string, b: string): number { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +/** + * Searches the workspace for files matching a user-provided path. + * Uses suffix matching (e.g. "src/agent.ts" matches "packages/foo/src/agent.ts"), + * exact filename matching, and falls back to fuzzy matching via Levenshtein distance + * to catch typos (e.g. "text.txt" finds "test.txt"). + */ +function findMatchingFiles(rootDir: string, userPath: string, maxResults = 5): string[] { + const exactResults: string[] = []; + const fuzzyResults: Array<{ relativePath: string; distance: number }> = []; + const normalizedSearch = userPath.replace(/\\/g, '/'); + const searchFileName = path.basename(userPath).toLowerCase(); + const maxDistance = Math.max(2, Math.ceil(searchFileName.length / 3)); + + function walk(dir: string, depth: number) { + if (depth > 10) return; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, depth + 1); + } else { + const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, '/'); + if (relativePath.endsWith(normalizedSearch) || entry.name.toLowerCase() === searchFileName) { + exactResults.push(relativePath); + } else { + const distance = levenshteinDistance(entry.name.toLowerCase(), searchFileName); + if (distance <= maxDistance) { + fuzzyResults.push({ relativePath, distance }); + } + } + } + } + } catch { + // Ignore permission errors + } + } + + walk(rootDir, 0); + + if (exactResults.length > 0) { + exactResults.sort((a, b) => { + const aSuffix = a.endsWith(normalizedSearch); + const bSuffix = b.endsWith(normalizedSearch); + if (aSuffix && !bSuffix) return -1; + if (!aSuffix && bSuffix) return 1; + return a.length - b.length; + }); + return exactResults.slice(0, maxResults); + } + + fuzzyResults.sort((a, b) => a.distance - b.distance || a.relativePath.length - b.relativePath.length); + return fuzzyResults.slice(0, maxResults).map(r => r.relativePath); +} + +function isFileAtPath(filePath: string): boolean { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch { + return false; + } } -export function runAgent(documentSync: DocumentSync, identity: Peer, options: AgentOptions): void { +/** + * Sets up trigger detection for @agent mentions in documents + * Returns a cleanup function to stop monitoring + */ +export function setupTriggerDetection(options: TriggerDetectionOptions): () => void { type State = { executing: boolean documentChanged: boolean - animationAbort: AbortController | undefined + awaitingDocPath: boolean + pendingPrompt: string | undefined } const state: State = { executing: false, documentChanged: false, - animationAbort: undefined + awaitingDocPath: false, + pendingPrompt: undefined }; - documentSync.onActiveChange((documentPath: string) => { + const { agentName, documentSync, onTrigger } = options; + const trigger = `@${agentName}`; + + const activeChangeHandler = (documentPath: string) => { console.log(`Active document: ${documentPath}`); - }); + }; - const trigger = `@${identity.name}`; - documentSync.onDocumentChange(async (docPath: string, docContent: string, changes: DocumentChange[]) => { + const documentChangeHandler = async (docPath: string, docContent: string, changes: DocumentChange[]) => { + console.error(`[DEBUG] documentChangeHandler called for ${docPath}, changes: ${changes.length}`); if (state.executing) { // Don't start another execution while the previous one is running + console.error('[DEBUG] Already executing, skipping'); state.documentChanged = true; - if (state.animationAbort) { - state.animationAbort.abort(); - state.animationAbort = undefined; - } return; } + + console.error(`[DEBUG] Processing ${changes.length} changes`); for (const change of changes) { - if (change.type === 'insert' && change.text.endsWith('\n')) { - // Extract the line that was just completed + console.error(`[DEBUG] Change type: ${change.type}, ${change.type === 'insert' ? `text: "${change.text}"` : ''}`); + if (change.type === 'insert' && change.text === '\n') { + // A newline was inserted - check if the line before it contains the trigger const docLines = docContent.split('\n'); + // The line that was just completed is the one at change.position.line + // (before the newline was inserted) const completedLine = docLines[change.position.line]; + console.error(`[DEBUG] Newline inserted at line ${change.position.line}, checking line: "${completedLine}"`); + const triggerIndex = completedLine?.indexOf(trigger); if (triggerIndex !== undefined && triggerIndex !== -1) { // The trigger string was found in the completed line const prompt = completedLine.substring(triggerIndex + trigger.length).trim(); + console.error(`[DEBUG] Found trigger at index ${triggerIndex}, prompt: "${prompt}"`); if (prompt.length > 0) { - console.log(`Received prompt: "${prompt}"`); - // Create an AbortController for the loading animation - state.animationAbort = new AbortController(); + console.error(`Received prompt: "${prompt}"`); try { state.executing = true; - // Start the loading animation right after the trigger - const animationOffset = change.offset - (completedLine.length - triggerIndex - trigger.length); - const animation = animateLoadingIndicator(docPath, animationOffset, documentSync, state.animationAbort.signal); - - const changes = await executePrompt({ - document: docContent, + await onTrigger({ + docPath, + docContent, prompt, - promptOffset: change.offset, - model: options.model + change, + source: 'document', }); - - // Abort the animation - state.animationAbort?.abort(); - await animation; - - if (changes.length > 0) { - // Apply the changes to the document - console.log(`Applying ${changes.length} changes to ${docPath}`); - let currentContent = docContent; - let currentLines = docLines; - if (state.documentChanged) { - currentContent = documentSync.getActiveDocumentContent() ?? docContent; - currentLines = currentContent.split('\n'); - } - applyChanges(docPath, currentContent, currentLines, changes, documentSync); - } } catch (error) { - // Abort the animation in case of error - state.animationAbort?.abort(); console.error('Error executing prompt:', error); } finally { state.executing = false; state.documentChanged = false; - state.animationAbort = undefined; } break; } } } } + }; + + console.error(`[DEBUG] Registering document change handlers for trigger: ${trigger}`); + try { + documentSync.onActiveChange(activeChangeHandler); + documentSync.onDocumentChange(documentChangeHandler); + console.error('[DEBUG] Document change handlers registered successfully'); + } catch (error) { + console.error(`[DEBUG] Error registering handlers: ${error}`); + throw error; + } + + // Register chat message handler for @agent triggers in chat + const { documentOps } = options; + const connection = documentOps.getConnection(); + + const executeChatTrigger = async (docPath: string, docContent: string, prompt: string) => { + try { + state.executing = true; + await onTrigger({ + docPath, + docContent, + prompt, + source: 'chat', + sendChatResponse: (msg: string) => connection.chat.sendMessage(msg), + }); + } catch (error) { + console.error('Error executing chat trigger:', error); + await connection.chat.sendMessage(`Error processing your request: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + state.executing = false; + state.documentChanged = false; + } + }; + + const openDocumentAndExecute = async (octPath: string, pendingPrompt: string): Promise => { + const hostId = documentOps.getSessionInfo().hostId; + const docContent = await documentSync.openAndWaitForContent(hostId, octPath); + if (!docContent) { + await connection.chat.sendMessage('The document could not be loaded. Please try a different file path.'); + return false; + } + + state.awaitingDocPath = false; + state.pendingPrompt = undefined; + await executeChatTrigger(octPath, docContent, pendingPrompt); + return true; + }; + + const handleDocPathResponse = async (userPath: string) => { + const pendingPrompt = state.pendingPrompt!; + const workspaceRoot = process.cwd(); + const workspaceName = path.basename(workspaceRoot); + const absolutePath = path.resolve(workspaceRoot, userPath); + + if (!absolutePath.startsWith(workspaceRoot)) { + await connection.chat.sendMessage('The provided path is outside the workspace. Please provide a path within the project.'); + return; + } + + if (isFileAtPath(absolutePath)) { + const octPath = path.join(workspaceName, path.relative(workspaceRoot, absolutePath)); + await openDocumentAndExecute(octPath, pendingPrompt); + return; + } + + const matches = findMatchingFiles(workspaceRoot, userPath); + + if (matches.length === 1) { + const octPath = path.join(workspaceName, matches[0]); + await connection.chat.sendMessage(`I found "${matches[0]}". Opening it now...`); + await openDocumentAndExecute(octPath, pendingPrompt); + } else if (matches.length > 1) { + const suggestions = matches.map(m => ` - ${m}`).join('\n'); + await connection.chat.sendMessage( + `I couldn't find "${userPath}" at that exact path. Did you mean one of these?\n${suggestions}\nPlease provide the correct file path.` + ); + } else { + await connection.chat.sendMessage( + `I couldn't find "${userPath}" in the workspace. You could create a new file at this path, or provide a different file path.` + ); + } + }; + + const chatMessageHandler = async (origin: string, message: string) => { + console.error(`[DEBUG] chatMessageHandler called, message: "${message}"`); + + if (state.awaitingDocPath && state.pendingPrompt) { + if (message.includes(trigger)) { + state.awaitingDocPath = false; + state.pendingPrompt = undefined; + } else { + try { + await handleDocPathResponse(message.trim()); + } catch (error) { + console.error('Error handling document path response:', error); + await connection.chat.sendMessage( + 'Something went wrong while looking up the file. Please try again with a different path.' + ); + } + return; + } + } + + const triggerIndex = message.indexOf(trigger); + if (triggerIndex === -1) { + return; + } + + const prompt = message.substring(triggerIndex + trigger.length).trim(); + if (prompt.length === 0) { + console.error('[DEBUG] Chat trigger found but no prompt provided'); + return; + } + + if (state.executing) { + console.error('[DEBUG] Already executing, skipping chat trigger'); + await connection.chat.sendMessage('I am currently processing another request. Please wait.'); + return; + } + + const activeDoc = await documentSync.waitForActiveDocument(undefined, origin); + + if (!activeDoc) { + console.error('[DEBUG] No active document for chat trigger after waiting'); + await connection.chat.sendMessage( + 'No active document is currently open. Please provide the file path you\'d like me to work on.' + ); + state.awaitingDocPath = true; + state.pendingPrompt = prompt; + return; + } + + console.error(`[DEBUG] Chat trigger found, prompt: "${prompt}", docPath: ${activeDoc.path}`); + await executeChatTrigger(activeDoc.path, activeDoc.content, prompt); + }; + + connection.chat.onMessage(chatMessageHandler); + console.error('[DEBUG] Chat message handler registered successfully'); + + return () => { + state.awaitingDocPath = false; + state.pendingPrompt = undefined; + }; +} + +/** + * Run agent - connects to external agent via ACP bridge + * @returns The ACP bridge instance for cleanup + */ +export async function runACPAgent(documentSync: DocumentSync, identity: Peer, options: AgentOptions): Promise { + // Wait for host ID from DocumentSync + const hostId = await documentSync.waitForHostId(); + console.log(`✅ Received host ID: ${hostId}`); + + // Create document operations wrapper + const documentOps = new DocumentSyncOperations(documentSync, { + roomId: options.room, + agentId: identity.id, + agentName: identity.name, + hostId, + serverUrl: options.server }); + + // Create and start ACP bridge + // Default spawns npx @zed-industries/claude-code-acp; override with --acp-agent for other ACP adapters + const acpAgentCommand = options.acpAgent || 'npx @zed-industries/claude-code-acp'; + const acpBridge = new ACPBridge(acpAgentCommand, documentOps, options.config); + + try { + await acpBridge.start(); + + // Setup trigger detection with ACP handler + // Reuse the same trigger detection logic, but with ACP-specific processing + setupTriggerDetection({ + agentName: identity.name, + documentSync, + documentOps, + onTrigger: async ({ docPath, docContent, prompt, change, source, sendChatResponse }) => { + // Generate unique trigger ID + const triggerId = `trig-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + // Determine trigger line (1-indexed) - for chat triggers, use end of document + const triggerLine = change ? change.position.line + 1 : docContent.split('\n').length; + + // Convert to ACP trigger message format + const acpTriggerMessage = { + id: triggerId, + source: { + type: 'document' as const, + path: docPath, + line: triggerLine, + }, + content: { + prompt, + context: docContent, // Include full document context + }, + }; + + // Send trigger to ACP agent + console.error(`[ACP] Sending trigger ${triggerId} to ACP agent (source: ${source})`); + let response; + try { + response = await acpBridge.sendTrigger(acpTriggerMessage); + } catch (error: any) { + // Handle sessionId missing error specifically + if (error.message?.includes('sessionId is required')) { + const errorMessage = 'Error: ACP session not initialized. Please ensure the ACP agent started successfully.'; + console.error(`[ACP] ${errorMessage}`); + if (sendChatResponse) { + await sendChatResponse(errorMessage); + } else { + await documentOps.getConnection().chat.sendMessage(errorMessage); + } + + // Re-throw to be caught by outer catch block for logging + throw error; + } + // Re-throw other errors + throw error; + } + + // Log agent text response for observability. Chat delivery is already + // handled inside ACPBridge.handleResponse (which forwards accumulated + // text to the chat connection during the prompt cycle). + // The bridge resolves with a custom { type, content, editsProposed } + // shape on top of the typed AgentResponse, so we read the fields defensively. + const wrappedResponse = response as { + type?: string; + content?: unknown; + editsProposed?: boolean; + }; + if (wrappedResponse?.type === 'agent/response' && + typeof wrappedResponse.content === 'string' && + wrappedResponse.content.trim()) { + console.log(`[ACP Agent Response] ${wrappedResponse.content}`); + } + + // For document triggers, remove the trigger line + if (source === 'document' && change) { + // Remove the trigger line LAST (after all edits are applied) + const trigger = `@${identity.name}`; + documentOps.removeTriggerLine(docPath, trigger); + } + + // For chat triggers, only send a confirmation when the agent + // actually proposed document changes. Pure text responses are + // already delivered to the chat by ACPBridge.handleResponse, so + // an additional confirmation would be redundant and misleading + // when no changes were made. + if (source === 'chat' && sendChatResponse && wrappedResponse.editsProposed) { + await sendChatResponse( + 'Done! I have proposed changes to the document. Please review and accept them in the editor.' + ); + } + }, + }); + + console.log('✅ ACP agent mode initialized'); + return acpBridge; + } catch (error) { + console.error('❌ Failed to start ACP bridge:', error); + throw error; + } } diff --git a/packages/open-collaboration-agent/src/document-operations.ts b/packages/open-collaboration-agent/src/document-operations.ts new file mode 100644 index 00000000..11098275 --- /dev/null +++ b/packages/open-collaboration-agent/src/document-operations.ts @@ -0,0 +1,101 @@ +// ****************************************************************************** +// Copyright 2025 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import { ProtocolBroadcastConnection } from 'open-collaboration-protocol'; +import type { DocumentSync } from './document-sync.js'; + +/** + * Session information + */ +export interface SessionInfo { + roomId: string; + agentId: string; + agentName: string; + hostId: string; + serverUrl: string; +} + +/** + * Core document operations interface used by the ACP bridge. + */ +export interface DocumentOperations { + /** + * Get the full content of a document + */ + getDocument(path: string): string | undefined; + + /** + * Remove a line containing the trigger pattern + */ + removeTriggerLine(path: string, trigger: string): void; + + /** + * Get session information + */ + getSessionInfo(): SessionInfo; + + /** + * Get the currently active document path + */ + getActiveDocumentPath(): string | undefined; + + /** + * Ask the host to open a document and wait until its content is synced + * into the local Yjs store. Returns the synced content, or undefined if + * the host has no such document (or the sync timed out). + * + * The hostId is read from the stored session info — callers don't need + * to know the host peer's identity. + */ + openAndWaitForContent(path: string, timeoutMs?: number): Promise; +} + +/** + * Implementation of DocumentOperations backed by DocumentSync. + */ +export class DocumentSyncOperations implements DocumentOperations { + constructor( + private readonly documentSync: DocumentSync, + private readonly sessionInfo: SessionInfo + ) { } + + getConnection(): ProtocolBroadcastConnection { + return this.documentSync.getConnection(); + } + + getDocument(path: string): string | undefined { + return this.documentSync.getDocumentContent(path); + } + + removeTriggerLine(path: string, trigger: string): void { + const content = this.documentSync.getDocumentContent(path); + if (content === undefined) { + return; + } + + const lines = content.split('\n'); + const triggerLineIndex = lines.findIndex(line => line.includes(trigger)); + + if (triggerLineIndex !== -1) { + const triggerLineOffset = lines.slice(0, triggerLineIndex).reduce((acc, line) => acc + line.length + 1, 0); + const triggerLineLength = lines[triggerLineIndex].length + 1; // +1 for newline + + this.documentSync.applyEdit(path, '', triggerLineOffset, triggerLineLength); + } + } + + getSessionInfo(): SessionInfo { + return this.sessionInfo; + } + + getActiveDocumentPath(): string | undefined { + return this.documentSync.getActiveDocumentPath(); + } + + async openAndWaitForContent(path: string, timeoutMs?: number): Promise { + return this.documentSync.openAndWaitForContent(this.sessionInfo.hostId, path, timeoutMs); + } +} diff --git a/packages/open-collaboration-agent/src/document-sync.ts b/packages/open-collaboration-agent/src/document-sync.ts index a1534b5f..f0b2797a 100644 --- a/packages/open-collaboration-agent/src/document-sync.ts +++ b/packages/open-collaboration-agent/src/document-sync.ts @@ -31,8 +31,21 @@ export interface DocumentDelete { export type DocumentChange = DocumentInsert | DocumentDelete; +/** + * Source category that resolved the active document, used for diagnostic + * logging in {@link DocumentSync.waitForActiveDocument}. + * - `'sender'` — the chat sender's awareness state had a selection. + * - `'host'` — the host peer's awareness state had a selection. + * - `'peer'` — some other peer's awareness state had a selection. + * - `'yjs-fallback'` — no awareness selection was available, but a Y.Text in + * the local Yjs share store looked like a usable document. + * - `'none'` — no document could be resolved. + */ +type DocumentResolutionSource = 'sender' | 'host' | 'peer' | 'yjs-fallback' | 'none'; + export interface IDocumentSync { applyEdit(documentPath: string, text: string, offset: number, replacedLength: number): void; + getDocumentContent(documentPath: string): string | undefined; } export class DocumentSync implements IDocumentSync { @@ -45,6 +58,8 @@ export class DocumentSync implements IDocumentSync { private activeDocumentPath?: string; private hostId?: string; private documentInitialized = false; + private hostIdPromise: Promise; + private hostIdResolve?: (hostId: string) => void; private onDocumentContentChangeCallback?: (documentPath: string, content: string, changes: DocumentChange[]) => void; private onActiveDocumentChangeCallback?: (documentPath: string) => void; @@ -53,6 +68,11 @@ export class DocumentSync implements IDocumentSync { this.yjs = new Y.Doc(); this.yjsAwareness = new awarenessProtocol.Awareness(this.yjs); + // Create promise for host ID + this.hostIdPromise = new Promise((resolve) => { + this.hostIdResolve = resolve; + }); + // Set up the Yjs provider this.yjsProvider = new OpenCollaborationYjsProvider(connection, this.yjs, this.yjsAwareness, { resyncTimer: 10_000 @@ -67,36 +87,50 @@ export class DocumentSync implements IDocumentSync { // Listen for host's active document changes this.yjsAwareness.on('change', (_: any, origin: string) => { if (origin !== LOCAL_ORIGIN && this.hostId) { - const states = this.yjsAwareness.getStates() as Map; - for (const state of states.values()) { - // Only follow documents from the host - if (state.peer === this.hostId && state.selection) { - this.followDocument(state.selection.path); - break; - } - } + this.tryFollowPeerDocument(); + } else if (origin !== LOCAL_ORIGIN && !this.hostId) { + console.error('[DocumentSync] Awareness change received but hostId not yet set — event will be missed'); } }); // Get host information connection.peer.onInit((_, initData) => { this.hostId = initData.host.id; + console.error(`[DocumentSync] onInit: hostId=${initData.host.id}`); - // Now that we know the host, check if there's already a document to follow - const states = this.yjsAwareness.getStates() as Map; - for (const state of states.values()) { - if (state.peer === this.hostId && state.selection) { - this.followDocument(state.selection.path); - break; - } + // Resolve the host ID promise + if (this.hostIdResolve) { + this.hostIdResolve(initData.host.id); } + + // Re-trigger the Yjs provider sync now that peers are registered. + // The initial connect() call happens before Peer.Init, so all + // broadcasts (including the awarenessQuery) are silently dropped + // because getPublicKeys() returns empty at that point. + this.yjsProvider.connect(); + + // Check if there's already a document to follow + this.tryFollowPeerDocument(); }); } + getConnection(): ProtocolBroadcastConnection { + return this.connection; + } + + /** + * Waits for the host ID to be received from the connection + * @returns A promise that resolves with the host ID + */ + async waitForHostId(): Promise { + return this.hostIdPromise; + } + private followDocument(documentPath: string) { if (this.activeDocumentPath === documentPath) { return; } + console.error(`[DocumentSync] followDocument: "${documentPath}" (previous: "${this.activeDocumentPath ?? 'none'}")`); // Unsubscribe from previous document if any if (this.activeDocument) { @@ -183,6 +217,185 @@ export class DocumentSync implements IDocumentSync { return this.activeDocumentPath; } + /** + * Actively resolves an active document from awareness states, calling + * followDocument if needed, and waits for the Yjs content sync. + * + * Resolution priority: + * 1. The peer matching `preferredPeerId` (typically the chat sender). + * 2. The host peer. + * 3. Any other peer with a selection (most recently updated wins). + * 4. As a last-resort fallback, a non-empty `Y.Text` already present in + * the shared Yjs store whose key looks like a workspace path. + * + * This addresses the race condition where: + * - The awareness `change` event fires before `hostId` is set (skipped), AND + * - `peer.onInit` fires before awareness states arrive (nothing to follow), + * resulting in `followDocument` never being called. + */ + async waitForActiveDocument(timeoutMs = 5000, preferredPeerId?: string): Promise<{ path: string; content: string } | undefined> { + const pollIntervalMs = 100; + const deadline = Date.now() + timeoutMs; + let lastSource: DocumentResolutionSource = 'none'; + while (Date.now() < deadline) { + // Actively try to discover + follow a document from awareness + if (!this.activeDocumentPath) { + lastSource = this.tryFollowPeerDocument(preferredPeerId); + } + if (this.activeDocumentPath) { + const content = this.activeDocument?.toString(); + if (content && content.length > 0) { + return { path: this.activeDocumentPath, content }; + } + } + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + // Polling exhausted without resolving the document. Try the Yjs share-store + // fallback before giving up: in some reconnect scenarios no peer has + // re-broadcast a selection yet, but a previously synced Y.Text is still + // available locally and is good enough for the agent to act on. + if (!this.activeDocumentPath) { + const fallbackPath = this.tryYjsShareFallback(); + if (fallbackPath) { + this.followDocument(fallbackPath); + lastSource = 'yjs-fallback'; + const content = this.activeDocument?.toString(); + if (content && content.length > 0) { + console.error( + `[DocumentSync] waitForActiveDocument resolved via yjs-fallback after ${timeoutMs}ms ` + + `(path="${fallbackPath}")` + ); + return { path: fallbackPath, content }; + } + } + } + + // Final summary log: which sources were considered and what was selected. + const states = this.yjsAwareness.getStates() as Map; + const peerSummary = Array.from(states.values()) + .map(state => `${state.peer ?? '?'}${state.selection?.path ? `:"${state.selection.path}"` : ':'}`) + .join(', '); + console.error( + `[DocumentSync] waitForActiveDocument timed out after ${timeoutMs}ms ` + + `(path="${this.activeDocumentPath ?? 'none'}", hostId="${this.hostId ?? 'none'}", ` + + `preferredPeerId="${preferredPeerId ?? 'none'}", lastSource="${lastSource}", ` + + `awareness=[${peerSummary}])` + ); + return undefined; + } + + /** + * Checks the current awareness states for a usable selection and follows the + * referenced document. Selection is chosen with the priority: + * preferred peer → host → any peer (most recently updated wins). + * + * Returns the source category that was used (or `'none'` if no selection + * was found). This method intentionally stays silent: per-call logs were + * very noisy when invoked from `waitForActiveDocument`'s polling loop, so + * the caller is responsible for emitting any summary log. + */ + private tryFollowPeerDocument(preferredPeerId?: string): DocumentResolutionSource { + if (!this.hostId) { + return 'none'; + } + const states = this.yjsAwareness.getStates() as Map; + + if (preferredPeerId) { + for (const state of states.values()) { + if (state.peer === preferredPeerId && state.selection?.path) { + this.followDocument(state.selection.path); + return 'sender'; + } + } + } + + for (const state of states.values()) { + if (state.peer === this.hostId && state.selection?.path) { + this.followDocument(state.selection.path); + return 'host'; + } + } + + // Any peer: prefer the most recently updated awareness entry. + const meta = this.yjsAwareness.meta as Map; + let bestPath: string | undefined; + let bestUpdated = -1; + for (const [clientId, state] of states.entries()) { + if (state.peer && state.selection?.path) { + const updated = meta.get(clientId)?.lastUpdated ?? 0; + if (updated > bestUpdated) { + bestUpdated = updated; + bestPath = state.selection.path; + } + } + } + if (bestPath) { + this.followDocument(bestPath); + return 'peer'; + } + + return 'none'; + } + + /** + * Searches the local Yjs share store for the most recently edited + * `Y.Text` whose key looks like a workspace-relative path (i.e. contains + * a `/` separator). Used as the last-resort fallback in + * `waitForActiveDocument` when no peer has published a selection. + * + * Recency is approximated by the maximum CRDT clock observed across the + * Y.Text's items: clocks grow monotonically per client as edits occur, + * so a higher max clock typically corresponds to a more-recently edited + * document. + */ + private tryYjsShareFallback(): string | undefined { + let bestKey: string | undefined; + let bestClock = -1; + for (const [key, type] of this.yjs.share.entries()) { + if (!(type instanceof Y.Text)) { + continue; + } + if (!key.includes('/')) { + continue; + } + if (type.length === 0) { + continue; + } + const clock = this.maxItemClock(type); + if (clock > bestClock) { + bestClock = clock; + bestKey = key; + } + } + return bestKey; + } + + private maxItemClock(text: Y.Text): number { + let max = 0; + // Walk the linked list of items inside the Y.Text and track the + // largest (clock + length) seen. Internal Yjs structures are not + // part of the public typings, so we cast through `any`. + let item: any = (text as unknown as { _start?: unknown })._start; + while (item) { + const clock = item.id?.clock ?? 0; + const length = item.length ?? 0; + const end = clock + length; + if (end > max) { + max = end; + } + item = item.right; + } + return max; + } + + getDocumentContent(documentPath: string): string | undefined { + const document = this.activeDocumentPath === documentPath + ? this.activeDocument + : this.yjs.getText(documentPath); + return document?.toString(); + } + /** * Register a callback to be invoked when the active document's content changes * @param callback The function to call when document content changes @@ -191,6 +404,7 @@ export class DocumentSync implements IDocumentSync { if (this.onDocumentContentChangeCallback) { throw new Error('Document change callback already registered'); } + console.debug('[DEBUG] Registering document change callback'); this.onDocumentContentChangeCallback = callback; } @@ -205,6 +419,44 @@ export class DocumentSync implements IDocumentSync { this.onActiveDocumentChangeCallback = callback; } + /** + * Requests the host to open a document and waits for its content to be synced via Yjs. + * @returns The document content, or undefined if the sync times out + */ + async openAndWaitForContent(hostId: string, documentPath: string, timeoutMs = 10000): Promise { + const existing = this.getDocumentContent(documentPath); + if (existing) { + return existing; + } + + this.connection.editor.open(hostId, documentPath); + + return new Promise((resolve) => { + const ytext = this.yjs.getText(documentPath); + const timeout = setTimeout(() => { + ytext.unobserve(observer); + resolve(undefined); + }, timeoutMs); + + const observer = () => { + const content = ytext.toString(); + if (content) { + clearTimeout(timeout); + ytext.unobserve(observer); + resolve(content); + } + }; + ytext.observe(observer); + + const content = ytext.toString(); + if (content) { + clearTimeout(timeout); + ytext.unobserve(observer); + resolve(content); + } + }); + } + dispose(): void { if (this.activeDocument) { this.activeDocument.unobserve(this.handleContentChange); @@ -245,4 +497,5 @@ export class DocumentSync implements IDocumentSync { } } } + } diff --git a/packages/open-collaboration-agent/src/index.ts b/packages/open-collaboration-agent/src/index.ts index 76a89e82..115c5238 100644 --- a/packages/open-collaboration-agent/src/index.ts +++ b/packages/open-collaboration-agent/src/index.ts @@ -5,6 +5,4 @@ // ****************************************************************************** export * from './agent.js'; -export * from './prompt.js'; -export * from './agent-util.js'; export * from './document-sync.js'; diff --git a/packages/open-collaboration-agent/src/main.ts b/packages/open-collaboration-agent/src/main.ts index 0f512c30..9574e6d6 100644 --- a/packages/open-collaboration-agent/src/main.ts +++ b/packages/open-collaboration-agent/src/main.ts @@ -8,13 +8,13 @@ import { program } from 'commander'; import { startCLIAgent } from './agent.js'; import pck from '../package.json' with { type: 'json' }; -// API keys for LLM providers are loaded from .env file import 'dotenv/config'; program .version(pck.version) .option('-s, --server ', 'URL of the Open Collaboration Server to connect to', 'https://api.open-collab.tools/') - .option('-m, --model ', 'LLM model to use (e.g. claude-3-5-sonnet-latest, gpt-4o)', 'claude-3-5-sonnet-latest') + .option('--acp-agent ', 'Command to run ACP agent (default: npx @zed-industries/claude-code-acp). Allows using other ACP adapters.', 'npx @zed-industries/claude-code-acp') + .option('--config ', 'Path to oct-agent.config.json (tool whitelist, system prompt, ...). Defaults to ./oct-agent.config.json in the current working directory.') .requiredOption('-r, --room ', 'Room ID to join') .action(options => startCLIAgent(options).catch(console.error)); diff --git a/packages/open-collaboration-agent/src/prompt.ts b/packages/open-collaboration-agent/src/prompt.ts deleted file mode 100644 index 1af12915..00000000 --- a/packages/open-collaboration-agent/src/prompt.ts +++ /dev/null @@ -1,134 +0,0 @@ -// ****************************************************************************** -// Copyright 2025 TypeFox GmbH -// This program and the accompanying materials are made available under the -// terms of the MIT License, which is available in the project root. -// ****************************************************************************** - -import { type CoreMessage, generateText } from 'ai'; -import { anthropic } from '@ai-sdk/anthropic'; -import { openai } from '@ai-sdk/openai'; - -export interface PromptInput { - document: string - prompt: string - promptOffset: number - model: string -} - -export async function executePrompt(input: PromptInput): Promise { - const provider = getProviderForModel(input.model); - const languageModel = provider(input.model); - const messages: CoreMessage[] = []; - - const processedDocument = prepareDocumentForLLM(input.document, input.promptOffset); - - messages.push({ - role: 'user', - content: processedDocument - }); - messages.push({ - role: 'user', - content: `---USER PROMPT:\n${input.prompt}` - }); - - const result = await generateText({ - model: languageModel, - system: systemPrompt, - messages - }); - - return parseOutputRegions(result.text); -} - -/** - * Determines the LLM provider based on the model string. - */ -function getProviderForModel(modelId: string) { - if (modelId.startsWith('claude-')) { - return anthropic; - } - if (modelId.startsWith('gpt-') || modelId.startsWith('o')) { - return openai; - } - throw new Error(`Unknown model: ${modelId}`); -} - -const systemPrompt = ` -You are a coding agent operating on a single source code file or a portion of it. Your task is to modify the code according to a user prompt. This same prompt is also embedded in the code, typically inside a comment line starting with the user-chosen agent name, e.g. \`// @my-agent\`. The location of the prompt inside the code is important to understand the purpose of the change. - -Your response must be in **one** of the following two formats: - -1. **Full File Replacement Format** - Return the **entire updated source code**, incorporating the requested changes seamlessly. Use this format when the file is small or the changes affect many parts of the file. - -2. **Partial Change Format** - Return **only the modified code regions**, using the following structure: - - Each modified region must: - - Start with at least **one unchanged line of code before** the modified section (context). - - End with at least **one unchanged line of code after** the modified section (context). - - Clearly show the **resulting text after applying the change** (inserted, deleted, or replaced code). - - Separate multiple modified regions using a line of **10 or more equal signs**, exactly: -\`\`\` -========== -\`\`\` - - When providing multiple modified regions, ensure they are in the correct order as they appear in the code. - -Additional Rules: -- Your understanding of the task must be based only on the user's prompt and the source code provided. -- Ensure there is enough surrounding context to uniquely and unambiguously locate each change within the original code. -- Be robust to partial files: do not assume full-file context unless given. -- If the task is straightforward, you may remove the user's prompt as part of your proposed changes. -- If you'd like to provide explanations or reasoning for a more complex task, keep the user's prompt and add your own comment below it. -- Do **not** write any introductory text now any summary or concluding text. Do **not** write any placeholder text (e.g. "[remaining code unchanged]"). Your output **must** focus purely on the changes to the code. - -Your output will be automatically parsed and applied to the original code. Therefore, format compliance is critical. Do not include anything outside the valid output formats. -`; - -const CONTEXT_LIMIT = 12000; - -function prepareDocumentForLLM(document: string, promptOffset: number): string { - if (document.length <= 2 * CONTEXT_LIMIT) { - return document; - } - - let startPos = Math.max(0, promptOffset - CONTEXT_LIMIT); - while (startPos > 0 && document[startPos - 1] !== '\n') { - startPos--; - } - - let endPos = Math.min(document.length, promptOffset + CONTEXT_LIMIT); - while (endPos < document.length && document[endPos] !== '\n') { - endPos++; - } - - return document.substring(startPos, endPos); -} - -function parseOutputRegions(text: string): string[] { - // Remove any trailing line in square brackets (e.g., [...], [remaining code], etc.) - text = text.replace(/\n\[[^\]]+\]\s*$/g, ''); - - // Split by lines containing 10 or more equal signs - const separatorRegex = /^={10,}$/; - const lines = text.split('\n'); - const regions: string[] = []; - let currentRegion: string[] = []; - - for (const line of lines) { - if (separatorRegex.test(line.trim())) { - if (currentRegion.length > 0) { - regions.push(currentRegion.join('\n')); - currentRegion = []; - } - } else { - currentRegion.push(line); - } - } - - // Add the last region if it exists - if (currentRegion.length > 0) { - regions.push(currentRegion.join('\n')); - } - - return regions; -} diff --git a/packages/open-collaboration-agent/test/acp-bridge.test.ts b/packages/open-collaboration-agent/test/acp-bridge.test.ts new file mode 100644 index 00000000..bf73c1df --- /dev/null +++ b/packages/open-collaboration-agent/test/acp-bridge.test.ts @@ -0,0 +1,81 @@ +// ****************************************************************************** +// Copyright 2025 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import { describe, expect, test } from 'vitest'; +import { ACPBridge } from '../src/acp-bridge.js'; + +describe('ACPBridge', () => { + describe('getMimeType', () => { + test('should return correct MIME type for TypeScript files', () => { + const bridge = new ACPBridge('echo', undefined); + // Access private method via type assertion for testing + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('test.ts')).toBe('text/typescript'); + expect(getMimeType('test.tsx')).toBe('text/typescript'); + }); + + test('should return correct MIME type for JavaScript files', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('test.js')).toBe('text/javascript'); + expect(getMimeType('test.jsx')).toBe('text/javascript'); + }); + + test('should return correct MIME type for JSON files', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('package.json')).toBe('application/json'); + }); + + test('should return correct MIME type for Markdown files', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('README.md')).toBe('text/markdown'); + }); + + test('should return text/plain for unknown file types', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('test.unknown')).toBe('text/plain'); + expect(getMimeType('noextension')).toBe('text/plain'); + }); + + test('should handle uppercase extensions', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('test.TS')).toBe('text/typescript'); + expect(getMimeType('test.JS')).toBe('text/javascript'); + }); + + test('should return correct MIME types for various programming languages', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('script.py')).toBe('text/x-python'); + expect(getMimeType('Main.java')).toBe('text/x-java'); + expect(getMimeType('main.go')).toBe('text/x-go'); + expect(getMimeType('lib.rs')).toBe('text/x-rust'); + expect(getMimeType('script.sh')).toBe('text/x-shellscript'); + }); + + test('should return correct MIME types for markup and style files', () => { + const bridge = new ACPBridge('echo', undefined); + const getMimeType = (bridge as any).getMimeType.bind(bridge); + + expect(getMimeType('index.html')).toBe('text/html'); + expect(getMimeType('styles.css')).toBe('text/css'); + expect(getMimeType('config.xml')).toBe('text/xml'); + expect(getMimeType('config.yaml')).toBe('text/yaml'); + expect(getMimeType('config.yml')).toBe('text/yaml'); + }); + }); +}); diff --git a/packages/open-collaboration-agent/test/agent-util.test.ts b/packages/open-collaboration-agent/test/agent-util.test.ts deleted file mode 100644 index 77f4b901..00000000 --- a/packages/open-collaboration-agent/test/agent-util.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// ****************************************************************************** -// Copyright 2025 TypeFox GmbH -// This program and the accompanying materials are made available under the -// terms of the MIT License, which is available in the project root. -// ****************************************************************************** - -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; -import { applyChanges } from '../src/agent-util.js'; -import type { IDocumentSync } from '../src/document-sync.js'; - -describe('agent-util', () => { - describe('applyChanges', () => { - let mockDocumentSync: IDocumentSync; - let applyEditMock: ReturnType; - function callArgs(call: unknown[]) { - return { - path: call[0] as string, - text: call[1] as string, - offset: call[2] as number, - length: call[3] as number - }; - } - - // Reusable helper to create a real document sync that tracks content changes - const createRealDocSync = (initialContent: string) => { - let updatedContent = initialContent; - return { - sync: { - applyEdit: (path: string, text: string, offset: number, length: number) => { - // Simulate the actual edit - updatedContent = - updatedContent.substring(0, offset) + - text + - updatedContent.substring(offset + length); - - // Call the mock for verification - applyEditMock(path, text, offset, length); - } - }, - getContent: () => updatedContent - }; - }; - - beforeEach(() => { - // Create a mock for the document sync with properly typed mock function - applyEditMock = vi.fn(); - mockDocumentSync = { - applyEdit: applyEditMock - }; - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - test('should do nothing with an empty changes array', () => { - const docPath = 'test.ts'; - const docContent = 'function test() {\n return true;\n}'; - const docLines = docContent.split('\n'); - - applyChanges(docPath, docContent, docLines, [], mockDocumentSync); - - expect(applyEditMock).not.toHaveBeenCalled(); - }); - - test('should do nothing with an unchanged document', () => { - const docPath = 'test.ts'; - const docContent = 'function test() {\n return true;\n}'; - const docLines = docContent.split('\n'); - - const changes = [ - 'function test() {\n return true;\n}' - ]; - - applyChanges(docPath, docContent, docLines, changes, mockDocumentSync); - - expect(applyEditMock).not.toHaveBeenCalled(); - }); - - test('should apply simple changes correctly', () => { - const docPath = 'test.ts'; - const docContent = 'function test() {\n // Old content\n return true;\n}'; - const docLines = docContent.split('\n'); - - const changes = [ - 'function test() {\n // New content\n return true;\n}' - ]; - - applyChanges(docPath, docContent, docLines, changes, mockDocumentSync); - - expect(applyEditMock).toHaveBeenCalledTimes(1); - const call = callArgs(applyEditMock.mock.calls[0]); - expect(call.path).toBe(docPath); - expect(call.text).toBe(' // New content'); - }); - - test('should handle multiple changes correctly', () => { - const docPath = 'test.ts'; - const docContent = 'function first() {\n // First old\n}\n\nfunction second() {\n // Second old\n}'; - const docLines = docContent.split('\n'); - - const changes = [ - 'function first() {\n // First new\n}', - 'function second() {\n // Second new\n}' - ]; - - applyChanges(docPath, docContent, docLines, changes, mockDocumentSync); - - // Verify document sync was called twice with correct content - expect(applyEditMock).toHaveBeenCalledTimes(2); - const firstCall = callArgs(applyEditMock.mock.calls[0]); - expect(firstCall.text).toBe(' // First new'); - expect(firstCall.offset).toBe(19); - expect(firstCall.length).toBe(14); - const secondCall = callArgs(applyEditMock.mock.calls[1]); - expect(secondCall.text).toBe(' // Second new'); - expect(secondCall.offset).toBe(57); - expect(secondCall.length).toBe(15); - }); - - test('should handle document state updates for sequential changes', () => { - const docPath = 'test.ts'; - const docContent = 'function first() {\n return 1;\n}\n\nfunction second() {\n return 2;\n}'; - - // Create tracking doc sync - const { sync, getContent } = createRealDocSync(docContent); - - // Apply sequential changes that would affect each other - const changes = [ - 'function first() {\n return 100;\n}', - 'function second() {\n return 200;\n}' - ]; - - // Apply each change separately to simulate sequential application - applyChanges(docPath, docContent, docContent.split('\n'), [changes[0]], sync); - - // Get updated content and apply second change to it - const updatedContent = getContent(); - applyChanges(docPath, updatedContent, updatedContent.split('\n'), [changes[1]], sync); - - // Verify both changes were applied - expect(applyEditMock).toHaveBeenCalledTimes(2); - const firstCall = callArgs(applyEditMock.mock.calls[0]); - expect(firstCall.text).toBe(' return 100;'); - expect(firstCall.offset).toBe(19); - expect(firstCall.length).toBe(11); - const secondCall = callArgs(applyEditMock.mock.calls[1]); - expect(secondCall.text).toBe(' return 200;'); - expect(secondCall.offset).toBe(56); - expect(secondCall.length).toBe(11); - - const finalContent = getContent(); - expect(finalContent).toBe('function first() {\n return 100;\n}\n\nfunction second() {\n return 200;\n}'); - }); - - test('should minimize applied changes when modifying only a portion of a larger file', () => { - const docPath = 'test.ts'; - const docContent = - '// Header\n' + - 'function prelude() {}\n\n' + - 'class FirstClass {\n' + - ' method1() {\n' + - ' return "original";\n' + - ' }\n\n' + - ' method2() {\n' + - ' return true;\n' + - ' }\n' + - '}\n\n' + - 'class SecondClass {}\n'; - - const docLines = docContent.split('\n'); - - const change = [ - '// Header\n' + - 'function prelude() {}\n\n' + - 'class FirstClass {\n' + - ' method1() {\n' + - ' return "MODIFIED";\n' + - ' }\n\n' + - ' method2() {\n' + - ' return true;\n' + - ' }\n' + - '}\n\n' + - 'class SecondClass {}\n' - ]; - - applyChanges(docPath, docContent, docLines, change, mockDocumentSync); - - // Verify the edit was applied only to the changed portion - expect(applyEditMock).toHaveBeenCalledTimes(1); - const call = callArgs(applyEditMock.mock.calls[0]); - - // The replacement text should contain only the modified method - expect(call.text).toBe(' return "MODIFIED";'); - expect(call.offset).toBe(66); - expect(call.length).toBe(22); - }); - }); -}); diff --git a/packages/open-collaboration-agent/test/multi-file-new-file.test.ts b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts new file mode 100644 index 00000000..459c1902 --- /dev/null +++ b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts @@ -0,0 +1,498 @@ +// ****************************************************************************** +// Copyright 2025 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, expect, test, vi } from 'vitest'; +import { ACPBridge } from '../src/acp-bridge.js'; + +describe('multi-file and new-file regressions', () => { + test('ACPBridge write_text_file proposes changes without writing to disk when OCT document exists', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const bridge = new ACPBridge('echo', { + getDocument: () => 'original content', + getActiveDocumentPath: () => 'workspace/active.ts', + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + const relativePath = path.join('tmp', `acp-write-${Date.now()}.ts`); + const absolutePath = path.resolve(process.cwd(), relativePath); + + try { + await (bridge as any).handleFileSystemRequest({ + id: 'req-1', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: 'updated content', + }, + }); + + // Proposals are buffered during a prompt cycle and flushed at the end + // (see ACPBridge.handleResponse). Trigger the flush manually here to + // verify the buffered proposal is forwarded to the editor. + await (bridge as any).flushPendingProposals(); + + expect(fs.existsSync(absolutePath)).toBe(false); + expect(proposeChanges).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'req-1', result: null }) + ); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); + + test('ACPBridge write_text_file skips proposeChanges when content is identical', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const bridge = new ACPBridge('echo', { + getDocument: () => 'same content', + getActiveDocumentPath: () => 'workspace/active.ts', + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + await (bridge as any).handleFileSystemRequest({ + id: 'req-2', + method: 'fs/write_text_file', + params: { + path: 'some/file.ts', + content: 'same content', + }, + }); + + expect(proposeChanges).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'req-2', result: null }) + ); + }); + + test('ACPBridge write_text_file proposes new file as insert hunk when document is missing and host has no copy', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const bridge = new ACPBridge('echo', { + getDocument: () => undefined, + getActiveDocumentPath: () => 'workspace/active.ts', + openAndWaitForContent: vi.fn().mockResolvedValue(undefined), + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + const relativePath = path.join('tmp', `acp-new-${Date.now()}.ts`); + const absolutePath = path.resolve(process.cwd(), relativePath); + const newFileContent = 'export const created = true;'; + + try { + await (bridge as any).handleFileSystemRequest({ + id: 'req-3', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: newFileContent, + }, + }); + + // The bridge must no longer silently write to disk for untracked + // files. Instead, a buffered proposal with an empty-baseline insert + // hunk represents the new file, which is forwarded as a single + // proposeChanges broadcast on flush. + expect(fs.existsSync(absolutePath)).toBe(false); + + const workspaceName = 'workspace'; + const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); + const pending = (bridge as any).pendingProposals.get(octPath); + expect(pending).toBeDefined(); + expect(pending.currentContent).toBe(''); + expect(pending.newContent).toBe(newFileContent); + + await (bridge as any).flushPendingProposals(); + expect(proposeChanges).toHaveBeenCalledTimes(1); + expect(proposeChanges).toHaveBeenCalledWith( + octPath, + expect.arrayContaining([ + expect.objectContaining({ + text: newFileContent, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }), + ]) + ); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'req-3', result: null }) + ); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); + + test('ACPBridge flushPendingProposals returns count of flushed proposals', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const bridge = new ACPBridge('echo', { + getDocument: () => 'original content', + getActiveDocumentPath: () => 'workspace/active.ts', + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + // No pending proposals -> 0 + const emptyResult = await (bridge as any).flushPendingProposals(); + expect(emptyResult).toBe(0); + + // Queue two proposals for different paths and verify the count + try { + await (bridge as any).handleFileSystemRequest({ + id: 'req-a', + method: 'fs/write_text_file', + params: { + path: path.join('tmp', `acp-count-a-${Date.now()}.ts`), + content: 'updated a', + }, + }); + await (bridge as any).handleFileSystemRequest({ + id: 'req-b', + method: 'fs/write_text_file', + params: { + path: path.join('tmp', `acp-count-b-${Date.now()}.ts`), + content: 'updated b', + }, + }); + + const flushedCount = await (bridge as any).flushPendingProposals(); + expect(flushedCount).toBe(2); + expect(proposeChanges).toHaveBeenCalledTimes(2); + + // Subsequent flush returns 0 since the buffer was cleared + const afterFlush = await (bridge as any).flushPendingProposals(); + expect(afterFlush).toBe(0); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); + + test('ACPBridge read_text_file returns buffered proposal content for sequential edits to the same file', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const original = 'A\nB\nC'; + const bridge = new ACPBridge('echo', { + getDocument: () => original, + getActiveDocumentPath: () => 'workspace/active.ts', + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + const relativePath = path.join('tmp', `acp-sequential-${Date.now()}.ts`); + const absolutePath = path.resolve(process.cwd(), relativePath); + + try { + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, original, 'utf8'); + + // Edit 1: A -> A1 + const afterEdit1 = 'A1\nB\nC'; + await (bridge as any).handleFileSystemRequest({ + id: 'req-write-1', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: afterEdit1, + }, + }); + + // Simulate the agent re-reading the file between edits. The read + // must observe the buffered proposal from edit 1, otherwise edit 2 + // would clobber edit 1 in the "last write wins" buffer. + sendMessage.mockClear(); + await (bridge as any).handleFileSystemRequest({ + id: 'req-read', + method: 'fs/read_text_file', + params: { + path: relativePath, + }, + }); + + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'req-read', + result: expect.objectContaining({ content: afterEdit1 }), + }) + ); + + // Edit 2: cumulative write applying C -> C1 on top of edit 1. + const afterEdit2 = 'A1\nB\nC1'; + await (bridge as any).handleFileSystemRequest({ + id: 'req-write-2', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: afterEdit2, + }, + }); + + // The buffered proposal must reflect BOTH edits, not just the last one. + const activeDocPath = 'workspace/active.ts'; + const workspaceName = activeDocPath.split('/')[0]; + const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); + const pending = (bridge as any).pendingProposals.get(octPath); + expect(pending).toBeDefined(); + expect(pending.currentContent).toBe(original); + expect(pending.newContent).toBe(afterEdit2); + + // Flushing emits a single proposal carrying the cumulative state. + await (bridge as any).flushPendingProposals(); + expect(proposeChanges).toHaveBeenCalledTimes(1); + expect(proposeChanges).toHaveBeenCalledWith( + octPath, + expect.arrayContaining([ + expect.objectContaining({ text: afterEdit2 }), + ]) + ); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); + + test('ACPBridge write_text_file produces one proposal per file across tracked, untracked, and new files', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const trackedOriginal = 'tracked original'; + const untrackedOriginal = 'untracked original'; + + // getDocument returns content only for the tracked file. The untracked + // file lives on the host and is delivered via openAndWaitForContent. + // The new file is unknown everywhere — the bridge must still propose it + // as an insert hunk rather than touching disk. + const trackedAbs = path.resolve(process.cwd(), 'tmp', `acp-multi-tracked-${Date.now()}.ts`); + const untrackedAbs = path.resolve(process.cwd(), 'tmp', `acp-multi-untracked-${Date.now()}.ts`); + const newAbs = path.resolve(process.cwd(), 'tmp', `acp-multi-new-${Date.now()}.ts`); + + const workspaceName = 'workspace'; + const trackedOctPath = path.join(workspaceName, path.relative(process.cwd(), trackedAbs)); + const untrackedOctPath = path.join(workspaceName, path.relative(process.cwd(), untrackedAbs)); + const newOctPath = path.join(workspaceName, path.relative(process.cwd(), newAbs)); + + const openAndWaitForContent = vi.fn(async (p: string) => { + if (p === untrackedOctPath) return untrackedOriginal; + return undefined; + }); + + const bridge = new ACPBridge('echo', { + getDocument: (p: string) => (p === trackedOctPath ? trackedOriginal : undefined), + getActiveDocumentPath: () => 'workspace/active.ts', + openAndWaitForContent, + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + try { + await (bridge as any).handleFileSystemRequest({ + id: 'req-tracked', + method: 'fs/write_text_file', + params: { + path: path.relative(process.cwd(), trackedAbs), + content: 'tracked updated', + }, + }); + + await (bridge as any).handleFileSystemRequest({ + id: 'req-untracked', + method: 'fs/write_text_file', + params: { + path: path.relative(process.cwd(), untrackedAbs), + content: 'untracked updated', + }, + }); + + await (bridge as any).handleFileSystemRequest({ + id: 'req-new', + method: 'fs/write_text_file', + params: { + path: path.relative(process.cwd(), newAbs), + content: 'export const created = true;', + }, + }); + + // openAndWaitForContent should only be invoked for the two files + // that getDocument did not know about. + expect(openAndWaitForContent).toHaveBeenCalledWith(untrackedOctPath); + expect(openAndWaitForContent).toHaveBeenCalledWith(newOctPath); + expect(openAndWaitForContent).toHaveBeenCalledTimes(2); + + // No silent disk writes for any of the three files. + expect(fs.existsSync(trackedAbs)).toBe(false); + expect(fs.existsSync(untrackedAbs)).toBe(false); + expect(fs.existsSync(newAbs)).toBe(false); + + // Each file ends up with its own buffered proposal — the multi-file + // bug (only the active file produces a proposal) must not regress. + const trackedPending = (bridge as any).pendingProposals.get(trackedOctPath); + const untrackedPending = (bridge as any).pendingProposals.get(untrackedOctPath); + const newPending = (bridge as any).pendingProposals.get(newOctPath); + + expect(trackedPending).toEqual(expect.objectContaining({ + currentContent: trackedOriginal, + newContent: 'tracked updated', + })); + expect(untrackedPending).toEqual(expect.objectContaining({ + currentContent: untrackedOriginal, + newContent: 'untracked updated', + })); + expect(newPending).toEqual(expect.objectContaining({ + currentContent: '', + newContent: 'export const created = true;', + })); + + const flushed = await (bridge as any).flushPendingProposals(); + expect(flushed).toBe(3); + expect(proposeChanges).toHaveBeenCalledTimes(3); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); + + test('ACPBridge write_text_file proposes changes for untracked existing file after host opens it', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const original = 'pre-existing content'; + const openAndWaitForContent = vi.fn().mockResolvedValue(original); + + const bridge = new ACPBridge('echo', { + getDocument: () => undefined, + getActiveDocumentPath: () => 'workspace/active.ts', + openAndWaitForContent, + getConnection: () => ({ + editor: { proposeChanges }, + }), + getSessionInfo: () => ({ + roomId: 'room', + agentId: 'agent', + agentName: 'Agent', + hostId: 'host', + serverUrl: 'http://localhost', + }), + } as any); + + (bridge as any).sendMessage = sendMessage; + + const relativePath = path.join('tmp', `acp-existing-${Date.now()}.ts`); + const absolutePath = path.resolve(process.cwd(), relativePath); + const updated = 'new content from agent'; + + try { + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, original, 'utf8'); + + await (bridge as any).handleFileSystemRequest({ + id: 'req-4', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: updated, + }, + }); + + const workspaceName = 'workspace'; + const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); + + // Bridge must request the host to open the file rather than touching disk. + expect(openAndWaitForContent).toHaveBeenCalledWith(octPath); + expect(fs.readFileSync(absolutePath, 'utf8')).toBe(original); + + // The buffered proposal must be diffed against the host's real + // content, not against an empty baseline. + const pending = (bridge as any).pendingProposals.get(octPath); + expect(pending).toBeDefined(); + expect(pending.currentContent).toBe(original); + expect(pending.newContent).toBe(updated); + + await (bridge as any).flushPendingProposals(); + expect(proposeChanges).toHaveBeenCalledTimes(1); + expect(proposeChanges).toHaveBeenCalledWith( + octPath, + expect.arrayContaining([ + expect.objectContaining({ text: updated }), + ]) + ); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'req-4', result: null }) + ); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); +}); diff --git a/packages/open-collaboration-agent/vitest.config.ts b/packages/open-collaboration-agent/vitest.config.ts new file mode 100644 index 00000000..e09b3158 --- /dev/null +++ b/packages/open-collaboration-agent/vitest.config.ts @@ -0,0 +1,25 @@ +// ****************************************************************************** +// Copyright 2025 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'test/', + 'lib/', + '*.config.ts' + ] + } + } +}); diff --git a/packages/open-collaboration-monaco/src/collaboration-instance.ts b/packages/open-collaboration-monaco/src/collaboration-instance.ts index bcb14ebb..8c8161b6 100644 --- a/packages/open-collaboration-monaco/src/collaboration-instance.ts +++ b/packages/open-collaboration-monaco/src/collaboration-instance.ts @@ -17,6 +17,8 @@ import { DisposablePeer } from './collaboration-peer.js'; export type UsersChangeEvent = () => void; export type FileNameChangeEvent = (fileName: string) => void; +export type ProposedChangesEvent = (path: string, accepted: boolean) => void; +export type CloseProposalEvent = (path: string) => void; export interface Disposable { dispose(): void; @@ -43,9 +45,18 @@ export class CollaborationInstance implements Disposable { protected readonly decorations = new Map(); protected readonly usersChangedCallbacks: UsersChangeEvent[] = []; protected readonly fileNameChangeCallbacks: FileNameChangeEvent[] = []; + protected readonly proposedChangesCallbacks: ProposedChangesEvent[] = []; + protected readonly closeProposalCallbacks: CloseProposalEvent[] = []; protected currentPath?: string; protected stopPropagation = false; + + /** Path of the currently displayed proposal diff, if any. */ + private currentDiffPath?: string; + /** Cleanup callback for the currently displayed built-in diff editor, if any. */ + private diffCleanup?: () => void; + /** Re-entrancy guard so a received closeProposal does not re-broadcast. */ + private receivingCloseProposal = false; protected _following?: string; protected _fileName: string; protected previousFileName?: string; @@ -53,6 +64,9 @@ export class CollaborationInstance implements Disposable { protected connection: ProtocolBroadcastConnection; + private activeDiffEditor?: monaco.editor.IStandaloneDiffEditor; + private diffEditorContainer?: HTMLElement; + get following(): string | undefined { return this._following; } @@ -104,6 +118,14 @@ export class CollaborationInstance implements Disposable { this.fileNameChangeCallbacks.push(callback); } + onProposedChanges(callback: ProposedChangesEvent) { + this.proposedChangesCallbacks.push(callback); + } + + onCloseProposal(callback: CloseProposalEvent) { + this.closeProposalCallbacks.push(callback); + } + constructor(protected options: CollaborationInstanceOptions) { this.connection = options.connection; this.yjsAwareness = new awarenessProtocol.Awareness(this.yjs); @@ -165,6 +187,9 @@ export class CollaborationInstance implements Disposable { this.connection.peer.onInit(async (_, initData) => { await this.initialize(initData); }); + + this.connection.editor.onProposeChanges(async (_, path, changes) => this.onDidProposeChanges(path, changes)); + this.connection.editor.onCloseProposal((_, path) => this.onDidCloseProposal(path)); } private setupFileSystemHandlers(): void { @@ -215,6 +240,157 @@ export class CollaborationInstance implements Disposable { } } + private async onDidProposeChanges(path: string, changes: types.TextDiffChange[]): Promise { + const editor = this.options.editor; + if (!editor) { + return; + } + + const model = editor.getModel(); + if (!model) { + return; + } + + const originalText = model.getValue(); + + // Protocol positions are 0-based; Monaco positions are 1-based + let modifiedText = originalText; + const sorted = [...changes].sort((a, b) => { + const lineDiff = b.range.start.line - a.range.start.line; + return lineDiff !== 0 ? lineDiff : b.range.start.character - a.range.start.character; + }); + for (const change of sorted) { + const startPos = new monaco.Position(change.range.start.line + 1, change.range.start.character + 1); + const endPos = new monaco.Position(change.range.end.line + 1, change.range.end.character + 1); + const startOffset = model.getOffsetAt(startPos); + const endOffset = model.getOffsetAt(endPos); + modifiedText = modifiedText.substring(0, startOffset) + change.text + modifiedText.substring(endOffset); + } + + this.stopPropagation = true; + + if (this.options.callbacks.onProposeChanges) { + this.currentDiffPath = path; + const accept = () => { + this.stopPropagation = false; + model.setValue(modifiedText); + this.notifyProposedChanges(path, true); + this.currentDiffPath = undefined; + if (!this.receivingCloseProposal) { + this.connection.editor.closeProposal(path); + } + }; + const reject = () => { + this.stopPropagation = false; + this.notifyProposedChanges(path, false); + this.currentDiffPath = undefined; + if (!this.receivingCloseProposal) { + this.connection.editor.closeProposal(path); + } + }; + this.options.callbacks.onProposeChanges(path, originalText, modifiedText, accept, reject); + return; + } + + const editorDomNode = editor.getDomNode(); + if (!editorDomNode) { + this.stopPropagation = false; + return; + } + + const container = editorDomNode.parentElement!; + editorDomNode.style.display = 'none'; + + this.diffEditorContainer = document.createElement('div'); + this.diffEditorContainer.style.width = '100%'; + this.diffEditorContainer.style.height = '100%'; + this.diffEditorContainer.style.display = 'flex'; + this.diffEditorContainer.style.flexDirection = 'column'; + container.appendChild(this.diffEditorContainer); + + const buttonBar = document.createElement('div'); + buttonBar.style.display = 'flex'; + buttonBar.style.gap = '8px'; + buttonBar.style.padding = '8px'; + buttonBar.style.justifyContent = 'flex-end'; + buttonBar.style.backgroundColor = 'var(--vscode-editor-background, #1e1e1e)'; + buttonBar.style.borderBottom = '1px solid var(--vscode-panel-border, #444)'; + this.diffEditorContainer.appendChild(buttonBar); + + const acceptButton = document.createElement('button'); + acceptButton.textContent = 'Accept'; + acceptButton.style.padding = '4px 12px'; + acceptButton.style.cursor = 'pointer'; + acceptButton.style.backgroundColor = '#28a745'; + acceptButton.style.color = '#fff'; + acceptButton.style.border = 'none'; + acceptButton.style.borderRadius = '4px'; + buttonBar.appendChild(acceptButton); + + const rejectButton = document.createElement('button'); + rejectButton.textContent = 'Reject'; + rejectButton.style.padding = '4px 12px'; + rejectButton.style.cursor = 'pointer'; + rejectButton.style.backgroundColor = '#d73a49'; + rejectButton.style.color = '#fff'; + rejectButton.style.border = 'none'; + rejectButton.style.borderRadius = '4px'; + buttonBar.appendChild(rejectButton); + + const diffEditorWrapper = document.createElement('div'); + diffEditorWrapper.style.flex = '1'; + this.diffEditorContainer.appendChild(diffEditorWrapper); + + const language = model.getLanguageId(); + const originalModel = monaco.editor.createModel(originalText, language); + const modifiedModel = monaco.editor.createModel(modifiedText, language); + + this.activeDiffEditor = monaco.editor.createDiffEditor(diffEditorWrapper, { + readOnly: true, + originalEditable: false, + automaticLayout: true, + renderSideBySide: true + }); + this.activeDiffEditor.setModel({ + original: originalModel, + modified: modifiedModel + }); + + const cleanup = () => { + this.activeDiffEditor?.dispose(); + this.activeDiffEditor = undefined; + originalModel.dispose(); + modifiedModel.dispose(); + this.diffEditorContainer?.remove(); + this.diffEditorContainer = undefined; + editorDomNode.style.display = ''; + this.stopPropagation = false; + this.currentDiffPath = undefined; + this.diffCleanup = undefined; + editor.layout(); + }; + + this.currentDiffPath = path; + this.diffCleanup = cleanup; + + acceptButton.addEventListener('click', () => { + cleanup(); + model.setValue(modifiedText); + this.notifyProposedChanges(path, true); + if (!this.receivingCloseProposal) { + this.connection.editor.closeProposal(path); + } + }); + + rejectButton.addEventListener('click', () => { + cleanup(); + this.notifyProposedChanges(path, false); + if (!this.receivingCloseProposal) { + this.connection.editor.closeProposal(path); + } + }); + } + private notifyUsersChanged(): void { this.usersChangedCallbacks.forEach(callback => callback()); } @@ -223,6 +399,61 @@ export class CollaborationInstance implements Disposable { this.fileNameChangeCallbacks.forEach(callback => callback(fileName)); } + private notifyProposedChanges(path: string, accepted: boolean): void { + this.proposedChangesCallbacks.forEach(callback => callback(path, accepted)); + } + + private notifyCloseProposal(path: string): void { + this.closeProposalCallbacks.forEach(callback => callback(path)); + } + + /** + * Handles a `closeProposal` broadcast received from a peer. Closes the local + * diff view that matches the given path and notifies registered listeners. + * The re-entrancy guard prevents the local cleanup from re-broadcasting. + */ + private onDidCloseProposal(path: string): void { + if (this.currentDiffPath !== path) { + return; + } + this.receivingCloseProposal = true; + try { + this.diffCleanup?.(); + this.diffCleanup = undefined; + this.currentDiffPath = undefined; + this.notifyCloseProposal(path); + } finally { + this.receivingCloseProposal = false; + } + } + + /** + * Broadcasts a `closeProposal` event so peers close the diff view for the given path. + */ + closeProposal(path: string): void { + this.connection.editor.closeProposal(path); + } + + /** + * Locally cancels the currently displayed proposal for `path`: resets + * `stopPropagation` so local edits sync again, without broadcasting a + * `closeProposal` (so a peer's merge editor stays open). + */ + cancelProposal(path: string): void { + if (this.currentDiffPath !== path) { + return; + } + if (this.diffCleanup) { + // built-in DOM diff path: cleanup already resets stopPropagation + currentDiffPath + this.diffCleanup(); + this.diffCleanup = undefined; + } else { + // onProposeChanges callback path + this.stopPropagation = false; + this.currentDiffPath = undefined; + } + } + setEditor(editor: monaco.editor.IStandaloneCodeEditor): void { this.options.editor = editor; this.registerEditorEvents(); @@ -250,6 +481,12 @@ export class CollaborationInstance implements Disposable { } dispose() { + this.activeDiffEditor?.dispose(); + this.activeDiffEditor = undefined; + this.diffEditorContainer?.remove(); + this.diffEditorContainer = undefined; + this.currentDiffPath = undefined; + this.diffCleanup = undefined; this.peers.clear(); this.documentDisposables.forEach(e => e.dispose()); this.documentDisposables.clear(); diff --git a/packages/open-collaboration-monaco/src/monaco-api.ts b/packages/open-collaboration-monaco/src/monaco-api.ts index d7cf6a5b..9b32a604 100644 --- a/packages/open-collaboration-monaco/src/monaco-api.ts +++ b/packages/open-collaboration-monaco/src/monaco-api.ts @@ -5,7 +5,7 @@ // ****************************************************************************** import { ConnectionProvider, SocketIoTransportProvider } from 'open-collaboration-protocol'; -import { CollaborationInstance, UsersChangeEvent, FileNameChangeEvent } from './collaboration-instance.js'; +import { CollaborationInstance, UsersChangeEvent, FileNameChangeEvent, ProposedChangesEvent, CloseProposalEvent } from './collaboration-instance.js'; import * as types from 'open-collaboration-protocol'; import { createRoom, joinRoom, login } from './collaboration-connection.js'; import * as monaco from 'monaco-editor'; @@ -24,6 +24,12 @@ export type MonacoCollabCallbacks = { * @param info information about the changed status */ statusReporter?: (info: types.Info) => void; + /** + * Optional override for the diff display when changes are proposed. + * If provided, the default Monaco DiffEditor is not shown and the consuming + * app is responsible for presenting the diff and calling `accept()` or `reject()`. + */ + onProposeChanges?: (path: string, originalText: string, modifiedText: string, accept: () => void, reject: () => void) => void; } export type MonacoCollabOptions = { @@ -56,6 +62,10 @@ export type MonacoCollabApi = { getFileName: () => string | undefined setWorkspaceName: (workspaceName: string) => void getWorkspaceName: () => string | undefined + onProposedChanges: (callback: ProposedChangesEvent) => void + closeProposal: (path: string) => void + cancelProposal: (path: string) => void + onCloseProposal: (callback: CloseProposalEvent) => void } export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { @@ -192,6 +202,30 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { } }; + const registerProposedChangesHandler = (callback: ProposedChangesEvent) => { + if (instance) { + instance.onProposedChanges(callback); + } + }; + + const doCloseProposal = (path: string) => { + if (instance) { + instance.closeProposal(path); + } + }; + + const doCancelProposal = (path: string) => { + if (instance) { + instance.cancelProposal(path); + } + }; + + const registerCloseProposalHandler = (callback: CloseProposalEvent) => { + if (instance) { + instance.onCloseProposal(callback); + } + }; + const isLoggedIn = async () => { if (!connectionProvider) { return false; @@ -225,7 +259,11 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { setFileName: doSetFileName, getFileName: doGetFileName, getWorkspaceName: doGetWorkspaceName, - setWorkspaceName: doSetWorkspaceName + setWorkspaceName: doSetWorkspaceName, + onProposedChanges: registerProposedChangesHandler, + closeProposal: doCloseProposal, + cancelProposal: doCancelProposal, + onCloseProposal: registerCloseProposalHandler }; } diff --git a/packages/open-collaboration-protocol/src/connection.ts b/packages/open-collaboration-protocol/src/connection.ts index e15625f8..b571dbaa 100644 --- a/packages/open-collaboration-protocol/src/connection.ts +++ b/packages/open-collaboration-protocol/src/connection.ts @@ -32,6 +32,10 @@ export interface EditorHandler { open(target: MessageTarget, path: types.Path): Promise; onClose(handler: Handler<[types.Path]>): void; close(path: types.Path): Promise; + onProposeChanges(handler: Handler<[types.Path, types.TextDiffChange[]]>): void; + proposeChanges(path: types.Path, changes: types.TextDiffChange[]): Promise; + onCloseProposal(handler: Handler<[types.Path]>): void; + closeProposal(path: types.Path): Promise; } export interface FileSystemHandler { @@ -149,7 +153,11 @@ export class ProtocolBroadcastConnectionImpl extends AbstractBroadcastConnection onOpen: handler => this.onNotification(Messages.Editor.Open, handler), open: (target, path) => this.sendNotification(Messages.Editor.Open, target, path), onClose: handler => this.onBroadcast(Messages.Editor.Close, handler), - close: path => this.sendBroadcast(Messages.Editor.Close, path) + close: path => this.sendBroadcast(Messages.Editor.Close, path), + proposeChanges: (path, changes) => this.sendBroadcast(Messages.Editor.ProposeChanges, path, changes), + onProposeChanges: handler => this.onBroadcast(Messages.Editor.ProposeChanges, handler), + closeProposal: path => this.sendBroadcast(Messages.Editor.CloseProposal, path), + onCloseProposal: handler => this.onBroadcast(Messages.Editor.CloseProposal, handler) }; sync: SyncHandler = { diff --git a/packages/open-collaboration-protocol/src/messages.ts b/packages/open-collaboration-protocol/src/messages.ts index 7628859e..e5759852 100644 --- a/packages/open-collaboration-protocol/src/messages.ts +++ b/packages/open-collaboration-protocol/src/messages.ts @@ -26,6 +26,9 @@ export namespace Messages { export namespace Editor { export const Open = new NotificationType<[types.Path]>('editor/open'); export const Close = new BroadcastType<[types.Path]>('editor/close'); + /** Propose changes bypassing the Awarness tracking. Should open a diff editor */ + export const ProposeChanges = new BroadcastType<[types.Path, types.TextDiffChange[]]>('editor/createDiff'); + export const CloseProposal = new BroadcastType<[types.Path]>('editor/closeProposal'); } export namespace Sync { diff --git a/packages/open-collaboration-protocol/src/types.ts b/packages/open-collaboration-protocol/src/types.ts index 38cbc073..0ad70293 100644 --- a/packages/open-collaboration-protocol/src/types.ts +++ b/packages/open-collaboration-protocol/src/types.ts @@ -341,6 +341,11 @@ export interface RelativeTextPosition { assoc: number } +export interface TextDiffChange { + range: Range; + text: string; +} + export interface Position { line: number; character: number; diff --git a/packages/open-collaboration-vscode/package.json b/packages/open-collaboration-vscode/package.json index bb881cec..05d45402 100644 --- a/packages/open-collaboration-vscode/package.json +++ b/packages/open-collaboration-vscode/package.json @@ -238,6 +238,13 @@ "title": "%oct.signOut%", "category": "Open Collaboration Tools", "icon": "$(sign-out)" + }, + { + "command": "oct.startAgent", + "title": "%oct.startAgent%", + "category": "Open Collaboration Tools", + "icon": "$(robot)", + "enablement": "oct.connection && workbenchState != empty" } ], "colors": [ @@ -330,4 +337,4 @@ "node": "22.14.0", "npm": "10.9.2" } -} +} \ No newline at end of file diff --git a/packages/open-collaboration-vscode/package.nls.json b/packages/open-collaboration-vscode/package.nls.json index 1a7df487..f0cdd5db 100644 --- a/packages/open-collaboration-vscode/package.nls.json +++ b/packages/open-collaboration-vscode/package.nls.json @@ -21,5 +21,7 @@ "oct.joinAcceptMode.auto.description": "Automatically accept all join requests without prompting.", "oct.joinAllowlist": "List of user emails allowed to join automatically when using 'Allowlist' join accept mode. See also `#oct.joinAcceptMode#`.", "oct.files.exclude": "A list of glob patterns for files that should not be shared in a collaboration session.", - "oct.chatView": "Session Chat" + "oct.chatView": "Session Chat", + "oct.sendDiff": "Send Diff", + "oct.startAgent": "Start OCT Agent" } diff --git a/packages/open-collaboration-vscode/src/collaboration-diff-service.ts b/packages/open-collaboration-vscode/src/collaboration-diff-service.ts new file mode 100644 index 00000000..5965631b --- /dev/null +++ b/packages/open-collaboration-vscode/src/collaboration-diff-service.ts @@ -0,0 +1,81 @@ +// ****************************************************************************** +// Copyright 2026 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import { injectable, postConstruct } from 'inversify'; +import * as vscode from 'vscode'; +import { CollaborationInstance } from './collaboration-instance'; +import { TextDiffChange } from 'open-collaboration-protocol'; +import { CollaborationUri } from './utils/uri'; + +@injectable() +export class CollaborationDiffService { + + // originalDocumentUri, diffDocumentUri + private diffDocuments = new Map(); + + // diffDocumentUri, changes + private documentChanges = new Map(); + + @postConstruct() + protected init(): void { + // Listen for changes to temp diff document + vscode.workspace.onDidChangeTextDocument(async (event) => { + const tempUriString = event.document.uri.toString(); + if (this.documentChanges.has(tempUriString)) { + const changes = event.contentChanges.map(change => ({ + range: { + start: change.range.start, + end: change.range.end, + }, + text: change.text, + })); + + const existingChanges = this.documentChanges.get(tempUriString) || []; + this.documentChanges.set(tempUriString, existingChanges.concat(changes)); + } + }); + + vscode.workspace.onDidCloseTextDocument((document) => { + const tempUriString = document.uri.toString(); + if (this.documentChanges.has(tempUriString)) { + this.documentChanges.delete(tempUriString); + this.diffDocuments.delete(tempUriString); + } + }); + } + + async createTempDiffDocument(fileUri: vscode.Uri): Promise { + const originalDocument = await vscode.workspace.openTextDocument(fileUri); + + const document = await vscode.workspace.openTextDocument({ + content: originalDocument.getText(), + language: originalDocument.languageId, + encoding: originalDocument.encoding, + }); + + const tempUriString = document.uri.toString(); + this.diffDocuments.set(tempUriString, CollaborationUri.getProtocolPath(fileUri)!); + this.documentChanges.set(tempUriString, []); + + await vscode.window.showTextDocument(document, { preview: false, viewColumn: vscode.ViewColumn.Beside }); + } + + async sendDiff(diffFileUri: vscode.Uri): Promise { + const originalFilePath = this.diffDocuments.get(diffFileUri.toString()); + + if(!originalFilePath) { + vscode.window.showErrorMessage(vscode.l10n.t('No original file found for the provided diff document.')); + return; + } + + // Get the accumulated changes for this document + const changes = this.documentChanges.get(diffFileUri.toString()) || []; + + // Send the changes with the original file name + CollaborationInstance.Current?.proposeChanges(originalFilePath, changes); + } + +} diff --git a/packages/open-collaboration-vscode/src/collaboration-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index c82d45dd..2d89ef58 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -170,6 +170,13 @@ export interface PendingUser { deferred: Deferred; } +interface ProposalMergeEditor { + baseUri: vscode.Uri; + localUri: vscode.Uri; + agentUri: vscode.Uri; + outputUri: vscode.Uri; +} + export type CollaborationInstanceFactory = (options: CollaborationInstanceOptions) => CollaborationInstance; @injectable() @@ -177,6 +184,47 @@ export class CollaborationInstance implements vscode.Disposable { static Current: CollaborationInstance | undefined; + private static proposedContentProvider: vscode.TextDocumentContentProvider & { + contents: Map; + setContent(uri: vscode.Uri, text: string): void; + deleteContent(uri: vscode.Uri): void; + }; + private static proposedContentScheme = 'oct-proposed'; + private static providerRegistered = false; + + private static ensureContentProvider(): typeof CollaborationInstance.proposedContentProvider { + if (!CollaborationInstance.providerRegistered) { + const contents = new Map(); + const onDidChangeEmitter = new vscode.EventEmitter(); + CollaborationInstance.proposedContentProvider = { + contents, + onDidChange: onDidChangeEmitter.event, + provideTextDocumentContent(uri: vscode.Uri): string { + return contents.get(uri.toString()) ?? ''; + }, + setContent(uri: vscode.Uri, text: string): void { + const key = uri.toString(); + const existed = contents.has(key); + contents.set(key, text); + // Notify VSCode that cached document content for this URI is stale + // so that subsequent openTextDocument calls re-read from the provider. + if (existed) { + onDidChangeEmitter.fire(uri); + } + }, + deleteContent(uri: vscode.Uri): void { + contents.delete(uri.toString()); + } + }; + vscode.workspace.registerTextDocumentContentProvider( + CollaborationInstance.proposedContentScheme, + CollaborationInstance.proposedContentProvider + ); + CollaborationInstance.providerRegistered = true; + } + return CollaborationInstance.proposedContentProvider; + } + private yjs: Y.Doc = new Y.Doc(); private yjsAwareness = new awarenessProtocol.Awareness(this.yjs); private identity = new Deferred(); @@ -187,6 +235,9 @@ export class CollaborationInstance implements vscode.Disposable { private peers = new Map(); private pending = new Map(); private throttles = new Map void>(); + private proposalDebounces = new Map>(); + private proposalMergeEditors = new Map(); + private closingProposals = new Set(); private yjsDocuments = new Map(); private _permissions: types.Permissions = { readonly: false }; @@ -356,6 +407,10 @@ export class CollaborationInstance implements vscode.Disposable { this.yjsAwareness.setLocalStateField('peer', peer.id); this.identity.resolve(peer); this.onDidUsersChangeEmitter.fire(); + // Proactively publish the current selection so peers (e.g. the agent) + // can resolve the active document immediately on join, even when the + // user has not moved the cursor since the connection was established. + this.updateTextSelection(vscode.window.activeTextEditor); }); this.registerFileEvents(); @@ -597,6 +652,8 @@ export class CollaborationInstance implements vscode.Disposable { this.yjsDocuments.forEach(e => e.dispose()); this.yjsDocuments.clear(); this.throttles.clear(); + this.proposalMergeEditors.clear(); + this.closingProposals.clear(); this.onDidDisposeEmitter.fire(); this.toDispose.dispose(); } @@ -615,7 +672,8 @@ export class CollaborationInstance implements vscode.Disposable { this.connection.editor.onOpen(async (_, path) => { const uri = CollaborationUri.getResourceUri(path); if (uri) { - await vscode.workspace.openTextDocument(uri); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); } else { throw new Error('Could not open file'); } @@ -644,6 +702,13 @@ export class CollaborationInstance implements vscode.Disposable { this.rerenderPresence(); })); + // Switching the active editor (or opening the first one after join) does + // not always trigger selection/visible-range events, so publish the + // selection here too to keep awareness in sync for late joiners. + this.toDispose.push(vscode.window.onDidChangeActiveTextEditor(editor => { + this.updateTextSelection(editor); + })); + this.toDispose.push(vscode.workspace.onDidCloseTextDocument(document => { const uri = document.uri.toString(); this.documentDisposables.get(uri)?.dispose(); @@ -679,6 +744,165 @@ export class CollaborationInstance implements vscode.Disposable { awarenessDebounce(); } }); + + this.connection.editor.onProposeChanges((_, path, changes) => this.onDidProposeChanges(path, changes)); + this.connection.editor.onCloseProposal((_, path) => this.onDidCloseProposal(path)); + + // Broadcast a closeProposal when the user closes a tracked proposal merge tab, + // so that peers close their matching diff/merge view as well. + this.toDispose.push(vscode.window.tabGroups.onDidChangeTabs(event => { + for (const tab of event.closed) { + const path = this.findProposalPathForTab(tab); + if (path && !this.closingProposals.has(path)) { + this.cleanupProposalContent(path); + this.connection.editor.closeProposal(path); + } + } + })); + } + + proposeChanges(path: string, changes: types.TextDiffChange[]): void { + this.connection.editor.proposeChanges(path, changes); + } + + private onDidProposeChanges(path: string, changes: types.TextDiffChange[]): void { + let debouncedOpen = this.proposalDebounces.get(path); + if (!debouncedOpen) { + debouncedOpen = debounce( + (p: string, c: types.TextDiffChange[]) => { void this.openProposedChanges(p, c); }, + 200, + { leading: false, trailing: true } + ); + this.proposalDebounces.set(path, debouncedOpen); + } + debouncedOpen(path, changes); + } + + private async openProposedChanges(path: string, changes: types.TextDiffChange[]): Promise { + const originalUri = CollaborationUri.getResourceUri(path); + + if (!originalUri) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not open file for diff editor')); + return; + } + + const originalDocument = await vscode.workspace.openTextDocument(originalUri); + const originalText = originalDocument.getText(); + + let modifiedText = originalText; + const sorted = [...changes].sort((a, b) => { + const lineDiff = b.range.start.line - a.range.start.line; + return lineDiff !== 0 ? lineDiff : b.range.start.character - a.range.start.character; + }); + for (const change of sorted) { + const startOffset = originalDocument.offsetAt( + new vscode.Position(change.range.start.line, change.range.start.character) + ); + const endOffset = originalDocument.offsetAt( + new vscode.Position(change.range.end.line, change.range.end.character) + ); + modifiedText = modifiedText.substring(0, startOffset) + change.text + modifiedText.substring(endOffset); + } + + const provider = CollaborationInstance.ensureContentProvider(); + const scheme = CollaborationInstance.proposedContentScheme; + // Three distinct virtual URIs are required by the 3-way merge editor: + // re-using `originalUri` for `base`, `input1` and `output` collapses the + // view so both visible panes render the same content. + const baseUri = vscode.Uri.parse(`${scheme}:${originalUri.path}.base`); + const localUri = vscode.Uri.parse(`${scheme}:${originalUri.path}.local`); + const agentUri = vscode.Uri.parse(`${scheme}:${originalUri.path}.agent`); + + provider.setContent(baseUri, originalText); + // The host has no uncommitted local edits at review time, so input1 mirrors base. + provider.setContent(localUri, originalText); + provider.setContent(agentUri, modifiedText); + + // Ensure VSCode has freshly loaded documents for each virtual URI + // (otherwise a previously cached document could shadow setContent). + await Promise.all([ + vscode.workspace.openTextDocument(baseUri), + vscode.workspace.openTextDocument(localUri), + vscode.workspace.openTextDocument(agentUri) + ]); + + await vscode.commands.executeCommand('_open.mergeEditor', { + base: baseUri, + input1: { uri: localUri, title: 'Your Changes', description: 'Local' }, + input2: { uri: agentUri, title: 'Agent Changes', description: 'Remote' }, + output: originalUri + }); + + // Track the virtual URIs so we can later locate and close the merge tab + // for this path (either on a received closeProposal or for cleanup). + this.proposalMergeEditors.set(path, { + baseUri, + localUri, + agentUri, + outputUri: originalUri + }); + } + + private onDidCloseProposal(path: string): void { + const editor = this.proposalMergeEditors.get(path); + if (!editor) { + return; + } + const tab = this.findProposalTab(editor); + // Guard the tab-close listener so closing the view here does not rebroadcast. + this.closingProposals.add(path); + try { + if (tab) { + void vscode.window.tabGroups.close(tab); + } + } finally { + this.closingProposals.delete(path); + } + this.cleanupProposalContent(path); + } + + private cleanupProposalContent(path: string): void { + const editor = this.proposalMergeEditors.get(path); + if (!editor) { + return; + } + const provider = CollaborationInstance.ensureContentProvider(); + provider.deleteContent(editor.baseUri); + provider.deleteContent(editor.localUri); + provider.deleteContent(editor.agentUri); + this.proposalMergeEditors.delete(path); + } + + private findProposalPathForTab(tab: vscode.Tab): string | undefined { + for (const [path, editor] of this.proposalMergeEditors) { + if (this.tabMatchesProposal(tab, editor)) { + return path; + } + } + return undefined; + } + + private findProposalTab(editor: ProposalMergeEditor): vscode.Tab | undefined { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if (this.tabMatchesProposal(tab, editor)) { + return tab; + } + } + } + return undefined; + } + + private tabMatchesProposal(tab: vscode.Tab, editor: ProposalMergeEditor): boolean { + // The 3-way merge editor tab input ("TabInputTextMerge") is still a + // proposed VSCode API, so `tab.input` surfaces as `unknown`. Narrow it + // structurally by its `base`/`result` URIs instead of an instanceof check. + const input = tab.input as { base?: vscode.Uri; result?: vscode.Uri } | undefined; + if (input && input.base && input.result) { + return input.result.toString() === editor.outputUri.toString() + && input.base.toString() === editor.baseUri.toString(); + } + return false; } private createFileWatcher(): void { diff --git a/packages/open-collaboration-vscode/src/commands-list.ts b/packages/open-collaboration-vscode/src/commands-list.ts index ade934fd..07b2af75 100644 --- a/packages/open-collaboration-vscode/src/commands-list.ts +++ b/packages/open-collaboration-vscode/src/commands-list.ts @@ -14,6 +14,7 @@ export namespace OctCommands { export const CreateRoom = 'oct.createRoom'; export const CloseConnection = 'oct.closeConnection'; export const SignOut = 'oct.signOut'; + export const StartAgent = 'oct.startAgent'; export const DevFuzzing = 'oct.dev.fuzzing'; } diff --git a/packages/open-collaboration-vscode/src/commands.ts b/packages/open-collaboration-vscode/src/commands.ts index 449e8af8..3cc1af6b 100644 --- a/packages/open-collaboration-vscode/src/commands.ts +++ b/packages/open-collaboration-vscode/src/commands.ts @@ -41,6 +41,7 @@ export class Commands { private secretStorage: SecretStorage; initialize(): void { + console.log('Initializing commands'); this.context.subscriptions.push( vscode.commands.registerCommand(OctCommands.FollowPeer, (peer?: PeerWithColor) => this.followService.followPeer(peer?.id)), vscode.commands.registerCommand(OctCommands.StopFollowPeer, () => this.followService.unfollowPeer()), @@ -75,6 +76,9 @@ export class Commands { await vscode.commands.executeCommand(OctCommands.CloseConnection); await this.secretStorage.deleteUserTokens(); vscode.window.showInformationMessage(vscode.l10n.t('Signed out successfully!')); + }), + vscode.commands.registerCommand(OctCommands.StartAgent, async () => { + await this.startAgent(); }) ); if (typeof process === 'object' && process && process.env?.DEVELOPMENT === 'true') { @@ -221,4 +225,50 @@ export class Commands { instance.setPermissions({ readonly: false }); } } + + private async startAgent(): Promise { + const instance = CollaborationInstance.Current; + if (!instance) { + vscode.window.showErrorMessage(vscode.l10n.t('No active collaboration session. Please create or join a room first.')); + return; + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder || workspaceFolder.uri.scheme !== 'file') { + vscode.window.showErrorMessage(vscode.l10n.t('No local workspace folder open. Agent requires a file system workspace.')); + return; + } + + // Determine agent command based on environment + const isDevelopment = typeof process === 'object' && process && process.env?.DEVELOPMENT === 'true'; + let agentCommand: string; + + if (isDevelopment) { + // Development: Use local build (assumes mono-repo structure) + // Path is relative from extension to the agent package + const extensionPath = this.context.extensionPath; + const agentPath = `${extensionPath}/../open-collaboration-agent/bin/agent`; + agentCommand = `node "${agentPath}"`; + } else { + // Production: Use npx to run published package + agentCommand = 'npx open-collaboration-agent'; + } + + // Build full command with arguments + const fullCommand = `${agentCommand} --room ${instance.roomId} --server ${instance.serverUrl}`; + + // Create and show terminal + const terminal = vscode.window.createTerminal({ + name: 'OCT Agent', + cwd: workspaceFolder.uri.fsPath, + message: vscode.l10n.t('Starting OCT Agent...') + }); + + terminal.show(); + terminal.sendText(fullCommand); + + vscode.window.showInformationMessage( + vscode.l10n.t('OCT Agent started in terminal. Please authenticate when prompted.') + ); + } } diff --git a/test-collab-project/.gitkeep b/test-collab-project/.gitkeep new file mode 100644 index 00000000..e69de29b