From 4ed40af297d5e3283fba540c005a045e86f02626 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Thu, 5 Mar 2026 16:52:27 +0100 Subject: [PATCH 01/19] Added functionality to propose changes by opening a diff editor Signed-off-by: Jonah Iden --- .../src/connection.ts | 6 +++- .../src/messages.ts | 2 ++ .../open-collaboration-protocol/src/types.ts | 5 +++ .../src/collaboration-instance.ts | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/open-collaboration-protocol/src/connection.ts b/packages/open-collaboration-protocol/src/connection.ts index e15625f8..260a8896 100644 --- a/packages/open-collaboration-protocol/src/connection.ts +++ b/packages/open-collaboration-protocol/src/connection.ts @@ -32,6 +32,8 @@ 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(target: MessageTarget, path: types.Path, changes: types.TextDiffChange[]): Promise; } export interface FileSystemHandler { @@ -149,7 +151,9 @@ 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: (target, path, changes) => this.sendNotification(Messages.Editor.ProposeChanges, target, path, changes), + onProposeChanges: handler => this.onNotification(Messages.Editor.ProposeChanges, handler) }; sync: SyncHandler = { diff --git a/packages/open-collaboration-protocol/src/messages.ts b/packages/open-collaboration-protocol/src/messages.ts index 7628859e..4eb90b48 100644 --- a/packages/open-collaboration-protocol/src/messages.ts +++ b/packages/open-collaboration-protocol/src/messages.ts @@ -26,6 +26,8 @@ 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 NotificationType<[types.Path, types.TextDiffChange[]]>('editor/createDiff'); } 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/src/collaboration-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index c82d45dd..54eb7595 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -679,6 +679,39 @@ export class CollaborationInstance implements vscode.Disposable { awarenessDebounce(); } }); + + this.connection.editor.onProposeChanges(async (_, path, changes) => this.createDiffEditor(path, changes)); + } + + private async createDiffEditor(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 tempUri = vscode.Uri.parse(`untitled:${path}.modified`); + + await vscode.workspace.openTextDocument(tempUri); + const edit = new vscode.WorkspaceEdit(); + for(const change of changes) { + edit.replace(originalUri, new vscode.Range( + change.range.start.line, + change.range.start.character, + change.range.end.line, + change.range.end.character + ), change.text); + } + + await vscode.workspace.applyEdit(edit); + + await vscode.commands.executeCommand( + 'vscode.diff', + vscode.Uri.file(path), + tempUri, + 'Proposed Changes (Preview)' + ); } private createFileWatcher(): void { From eff965b99048a6dd4fdf88491d668e238ab16ae8 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 6 Mar 2026 15:01:49 +0100 Subject: [PATCH 02/19] parallel vscode debugging sessions Signed-off-by: Jonah Iden --- .gitignore | 3 + .vscode/launch.json | 227 +++++++++++++++++++---------------- test-collab-project/.gitkeep | 0 3 files changed, 128 insertions(+), 102 deletions(-) create mode 100644 test-collab-project/.gitkeep diff --git a/.gitignore b/.gitignore index 18f34849..ab3f14f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist/ *.tsbuildinfo .DS_Store .env + +test-collab-project/* +!test-collab-project/.gitkeep diff --git a/.vscode/launch.json b/.vscode/launch.json index 6db8a5eb..f4c8e937 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,104 +1,127 @@ { - // 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": "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", + "--user-data-dir=${workspaceFolder}/.vscode/test-user-data-1" + ], + "env": { + "DEVELOPMENT": "true" + }, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/packages/open-collaboration-vscode/dist/**/*.js" + ] + }, + { + "name": "Run VS Code Extension - Second Instance", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode", + "--user-data-dir=${workspaceFolder}/.vscode/test-user-data-2", + "${workspaceFolder}/test-collab-project" + ], + "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" + } + ], + "compounds": [ + { + "name": "Debug parallel vscode extensions", + "configurations": [ + "Run VS Code Extension", + "Run VS Code Extension - Second Instance" + ] + } + ] } diff --git a/test-collab-project/.gitkeep b/test-collab-project/.gitkeep new file mode 100644 index 00000000..e69de29b From 9b31890fe409a9f579643310a4c09b3ce7720951 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 6 Mar 2026 16:17:14 +0100 Subject: [PATCH 03/19] Basic working diff support Signed-off-by: Jonah Iden --- .vscode/launch.json | 9 +- .../open-collaboration-vscode/package.json | 24 ++++++ .../src/collaboration-diff-service.ts | 82 +++++++++++++++++++ .../src/collaboration-instance.ts | 14 ++-- .../src/commands-list.ts | 5 ++ .../open-collaboration-vscode/src/commands.ts | 16 +++- 6 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 packages/open-collaboration-vscode/src/collaboration-diff-service.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index f4c8e937..c9224b6f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,12 +4,6 @@ // 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", @@ -32,7 +26,8 @@ "request": "launch", "args": [ "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode", - "--user-data-dir=${workspaceFolder}/.vscode/test-user-data-1" + "--user-data-dir=${workspaceFolder}/.vscode/test-user-data-1", + "${workspaceFolder}/packages/open-collaboration-vscode" ], "env": { "DEVELOPMENT": "true" diff --git a/packages/open-collaboration-vscode/package.json b/packages/open-collaboration-vscode/package.json index bb881cec..0c15c5bd 100644 --- a/packages/open-collaboration-vscode/package.json +++ b/packages/open-collaboration-vscode/package.json @@ -181,6 +181,18 @@ "when": "viewItem == pendingPeer", "group": "inline" } + ], + "editor/title": [ + { + "command": "oct.sendDiff", + "when": "oct.connection && editorLangId != 'oct-temp-diff'", + "group": "navigation" + }, + { + "command": "oct.createTempDiffDocument", + "when": "oct.connection && editorLangId != 'oct-temp-diff'", + "group": "navigation" + } ] }, "commands": [ @@ -238,6 +250,18 @@ "title": "%oct.signOut%", "category": "Open Collaboration Tools", "icon": "$(sign-out)" + }, + { + "command": "oct.createTempDiffDocument", + "title": "%oct.createTempDiffDocument%", + "category": "Open Collaboration Tools", + "icon": "$(diff)" + }, + { + "command": "oct.sendDiff", + "title": "%oct.sendDiff%", + "category": "Open Collaboration Tools", + "icon": "$(send)" } ], "colors": [ 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..80822201 --- /dev/null +++ b/packages/open-collaboration-vscode/src/collaboration-diff-service.ts @@ -0,0 +1,82 @@ +// ****************************************************************************** +// 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() + private 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 54eb7595..453e5313 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -680,10 +680,14 @@ export class CollaborationInstance implements vscode.Disposable { } }); - this.connection.editor.onProposeChanges(async (_, path, changes) => this.createDiffEditor(path, changes)); + this.connection.editor.onProposeChanges(async (_, path, changes) => this.onDidProposeChanges(path, changes)); } - private async createDiffEditor(path: string, changes: types.TextDiffChange[]): Promise { + proposeChanges(path: string, changes: types.TextDiffChange[]): void { + this.connection.editor.proposeChanges(this.options.hostId, path, changes); + } + + private async onDidProposeChanges(path: string, changes: types.TextDiffChange[]): Promise { const originalUri = CollaborationUri.getResourceUri(path); if(!originalUri) { @@ -691,12 +695,12 @@ export class CollaborationInstance implements vscode.Disposable { return; } - const tempUri = vscode.Uri.parse(`untitled:${path}.modified`); + const tempUri = vscode.Uri.parse(`untitled:${originalUri.path}.modified`); await vscode.workspace.openTextDocument(tempUri); const edit = new vscode.WorkspaceEdit(); for(const change of changes) { - edit.replace(originalUri, new vscode.Range( + edit.replace(tempUri, new vscode.Range( change.range.start.line, change.range.start.character, change.range.end.line, @@ -708,7 +712,7 @@ export class CollaborationInstance implements vscode.Disposable { await vscode.commands.executeCommand( 'vscode.diff', - vscode.Uri.file(path), + originalUri, tempUri, 'Proposed Changes (Preview)' ); diff --git a/packages/open-collaboration-vscode/src/commands-list.ts b/packages/open-collaboration-vscode/src/commands-list.ts index ade934fd..ee92f18e 100644 --- a/packages/open-collaboration-vscode/src/commands-list.ts +++ b/packages/open-collaboration-vscode/src/commands-list.ts @@ -22,3 +22,8 @@ export namespace CodeCommands { export const OpenSettings = 'workbench.action.openSettings'; export const OpenFolder = 'vscode.openFolder'; } + +export namespace DiffSupportCommands { + export const CreateTempDiffDocument = 'oct.createTempDiffDocument'; + export const SendDiff = 'oct.sendDiff'; +} diff --git a/packages/open-collaboration-vscode/src/commands.ts b/packages/open-collaboration-vscode/src/commands.ts index 449e8af8..3d5c8c90 100644 --- a/packages/open-collaboration-vscode/src/commands.ts +++ b/packages/open-collaboration-vscode/src/commands.ts @@ -16,8 +16,9 @@ import { CollaborationStatusService } from './collaboration-status-service.js'; import { SecretStorage } from './secret-storage.js'; import { RoomUri } from './utils/uri.js'; import { Settings } from './utils/settings.js'; -import { CodeCommands, OctCommands } from './commands-list.js'; +import { CodeCommands, DiffSupportCommands, OctCommands } from './commands-list.js'; import { TreeUserData } from './collaboration-status-view.js'; +import { CollaborationDiffService } from './collaboration-diff-service.js'; @injectable() export class Commands { @@ -40,7 +41,11 @@ export class Commands { @inject(SecretStorage) private secretStorage: SecretStorage; + @inject(CollaborationDiffService) + private diffService: CollaborationDiffService; + 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,7 +80,14 @@ export class Commands { await vscode.commands.executeCommand(OctCommands.CloseConnection); await this.secretStorage.deleteUserTokens(); vscode.window.showInformationMessage(vscode.l10n.t('Signed out successfully!')); - }) + }), + // Diff support + vscode.commands.registerCommand(DiffSupportCommands.CreateTempDiffDocument, async (file: vscode.Uri) => + this.diffService.createTempDiffDocument(file) + ), + vscode.commands.registerCommand(DiffSupportCommands.SendDiff, async (file: vscode.Uri) => + this.diffService.sendDiff(file) + ) ); if (typeof process === 'object' && process && process.env?.DEVELOPMENT === 'true') { this.contextKeyService.set('oct.dev', true); From f1c7783a3bad67a4633bb515dc621d643b5f013f Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Fri, 6 Mar 2026 16:20:40 +0100 Subject: [PATCH 04/19] small fixes Signed-off-by: Jonah Iden --- .../src/collaboration-instance.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/open-collaboration-vscode/src/collaboration-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index 453e5313..91518857 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -695,10 +695,18 @@ export class CollaborationInstance implements vscode.Disposable { return; } + const originalDocument = await vscode.workspace.openTextDocument(originalUri); + const tempUri = vscode.Uri.parse(`untitled:${originalUri.path}.modified`); await vscode.workspace.openTextDocument(tempUri); const edit = new vscode.WorkspaceEdit(); + edit.replace( + tempUri, + new vscode.Range(0, 0, Number.MAX_SAFE_INTEGER, 0), + originalDocument.getText() + ); + for(const change of changes) { edit.replace(tempUri, new vscode.Range( change.range.start.line, @@ -712,8 +720,8 @@ export class CollaborationInstance implements vscode.Disposable { await vscode.commands.executeCommand( 'vscode.diff', - originalUri, tempUri, + originalUri, 'Proposed Changes (Preview)' ); } From 2bfb939c9c943885fa50c3fca48330318bb6df4c Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Mon, 9 Mar 2026 09:51:35 +0100 Subject: [PATCH 05/19] improved merge experience Signed-off-by: Jonah Iden --- .../src/collaboration-instance.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/open-collaboration-vscode/src/collaboration-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index 91518857..f7c5b158 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -704,7 +704,7 @@ export class CollaborationInstance implements vscode.Disposable { edit.replace( tempUri, new vscode.Range(0, 0, Number.MAX_SAFE_INTEGER, 0), - originalDocument.getText() + originalDocument.getText(), ); for(const change of changes) { @@ -718,12 +718,12 @@ export class CollaborationInstance implements vscode.Disposable { await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand( - 'vscode.diff', - tempUri, - originalUri, - 'Proposed Changes (Preview)' - ); + await vscode.commands.executeCommand('_open.mergeEditor', { + base: originalUri, + input1: { uri: originalUri, title: 'Your Changes', description: 'Local' }, + input2: { uri: tempUri, title: 'Collaborator Changes', description: 'Remote' }, + output: originalUri + }); } private createFileWatcher(): void { From 2941199341bda39fa72cdf4cdb773acda13a3653 Mon Sep 17 00:00:00 2001 From: Jonah Iden Date: Thu, 12 Mar 2026 13:36:34 +0100 Subject: [PATCH 06/19] fix build Signed-off-by: Jonah Iden --- .../src/collaboration-diff-service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/open-collaboration-vscode/src/collaboration-diff-service.ts b/packages/open-collaboration-vscode/src/collaboration-diff-service.ts index 80822201..5965631b 100644 --- a/packages/open-collaboration-vscode/src/collaboration-diff-service.ts +++ b/packages/open-collaboration-vscode/src/collaboration-diff-service.ts @@ -20,7 +20,7 @@ export class CollaborationDiffService { private documentChanges = new Map(); @postConstruct() - private init(): void { + protected init(): void { // Listen for changes to temp diff document vscode.workspace.onDidChangeTextDocument(async (event) => { const tempUriString = event.document.uri.toString(); @@ -66,7 +66,6 @@ export class CollaborationDiffService { 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; From b45766fad3fc33cd6c12d9d046fe2b43c79499c8 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Thu, 28 Aug 2025 12:22:07 +0200 Subject: [PATCH 07/19] OCT agent minor changes, revert streaming Update package dependencies and implement line-based edits in agent functionality - Removed the "peer" property from zod in package-lock.json. - Added zod as a dependency in package.json for open-collaboration-agent. - Introduced applyLineEdits function in agent-util.ts to handle line-based edits. - Updated agent.ts to utilize MCP-based execution for applying line edits. - Enhanced prompt.ts with new LineEdit interface and MCP tool-based execution logic. Implement animated line edits in agent functionality - Added applyLineEditsAnimated function to agent-util.ts for progressive line edits with animation. - Updated agent.ts to utilize the new animated line edits function, ensuring edits are applied before removing trigger lines. - Enhanced prompt.ts documentation to clarify the queuing and application of edits in the correct order. Enhance cursor awareness and animation in open-collaboration-agent - Implemented a cursor awareness feature that allows the agent's cursor to be visible and move in real-time during edits, enhancing collaborative editing experience. - Updated the agent's functionality to utilize CRDT-based positioning for accurate cursor tracking and character-by-character animation during edits. - Refactored agent-util.ts to ensure cursor position updates are applied correctly during line edits. - Enhanced document-sync.ts to manage agent's cursor visibility and state in the collaborative environment. Enhance testing and synchronization features in open-collaboration-agent - Integrated Vitest for unit testing, adding test scripts and configuration for improved test management. - Updated agent-util.ts to prevent empty text replacements during document edits, ensuring better synchronization. - Enhanced CODE_ANALYSIS.md with recent fixes and testing instructions, including automated and manual testing workflows. - Added new methods for document content retrieval and peer ID management to improve collaborative editing experience. - Updated test suite to mock new IDocumentSync interface methods for comprehensive testing of agent functionality. Connecting other Agents via MCP This is a test if it is possible to connect Agents like Claude Code to a OCT Session via MCP. Enhance open-collaboration-agent with ACP integration and updates - Added support for Agent Client Protocol (ACP) mode, allowing external agent integration. - Updated agent functionality to handle bidirectional communication and event-driven architecture. - Improved documentation to reflect new ACP mode and usage instructions. - Enhanced error handling for ACP session initialization and trigger processing. - Updated package dependencies, including the addition of @agentclientprotocol/sdk. - Refactored code for better modularity and maintainability, including new methods for ACP agent management. refactor(agent): remove embedded mode, use ACP only - Drop --mode and -m/--model; always run via ACP bridge - Remove runAgent, executeLLM, prompt.ts and related deps (@ai-sdk/*, ai, zod) - Simplify setupTriggerDetection to the onTrigger path only - Update README, VS Code command, and deps refactor(open-collaboration-agent): simplify MCP integration and documentation - Updated MCP server to a minimal skeleton, removing complex integration paths and retaining only essential components for future extensions. - Enhanced documentation to clarify the historical context of MCP integration and its current status as a reference implementation. - Added example tools and resources for MCP, demonstrating basic functionality while emphasizing the primary use of ACP for agent operations. - Removed unused sampling parser and related code to streamline the project structure. refactor(open-collaboration-agent): enhance message handling and simplify launch configuration - Updated launch.json to streamline command options for server and model configuration, adding comments for optional parameters. - Refactored ACPBridge class to improve message handling, separating response and request processing for better clarity and maintainability. - Introduced new methods for handling agent requests, notifications, and tool call updates, enhancing the overall structure and readability of the code. - Improved error handling and logging for better debugging and user feedback during agent interactions. feat(open-collaboration-agent): add MIME type detection and resource link handling - Implemented a private method `getMimeType` in the ACPBridge class to determine the MIME type of files based on their extensions. - Enhanced the `sendTrigger` method to include a `resource_link` in the prompt, providing details about the currently active file, including its MIME type and size. - Added unit tests for `getMimeType` to ensure correct MIME type resolution for various file types and extensions. update @agentclientprotocol/sdk to version 0.13.1 improve ACPBridge type handling and message processing feat(open-collaboration-agent): add OpenCode as an ACP agent - Introduced a new documentation file for using OpenCode as an alternative to Claude Code within the oct-agent framework. feat(open-collaboration-agent): implement tool whitelist configuration and enhance permission handling - Added a tool whitelist configuration to the ACPBridge class, allowing for controlled access to specific tools based on their kind and name. - Introduced methods for loading agent configuration from a JSON file, with defaults provided if the file is not found. - Enhanced permission request handling to check against the tool whitelist, ensuring only approved tool calls are processed. - Updated the command-line interface to accept a configuration file path for the tool whitelist. feat(open-collaboration-agent): optimize document editing with minimal line edits - Implemented a method to compute minimal line edits by identifying common prefixes and suffixes, reducing unnecessary document replacements. - Enhanced the ACPBridge class to skip write operations when no changes are detected, improving performance and reducing redundant operations. - Updated logging to provide clearer insights into the number of edits applied during document updates. feat(open-collaboration-agent): enhance chat trigger handling and document operations - Added support for chat triggers, allowing the agent to respond to messages in chat with prompts. - Updated the `processACPResponse` function to log agent responses and send messages via chat. - Refactored document operations to include a method for retrieving the connection, facilitating chat message handling. - Improved error handling for chat triggers, ensuring users receive feedback when issues arise during processing. feat(open-collaboration-agent): enhance message handling and introduce helloWorld function - Added immediate text message sending in the ACPBridge class when handling agent message chunks. - Updated the processACPResponse function to comment out the message sending line for future adjustments. - Introduced a new helloWorld function that returns a friendly greeting message. feat(vscode): add launch configuration for second extension instance and update .gitignore - Introduced a new script `launch-extension-host.js` to spawn a second Extension Development Host for testing purposes. - Updated `launch.json` to include a new configuration for spawning the second extension instance, allowing for simultaneous testing of protocol and VS Code. - Added entries to `.gitignore` to exclude specific extension host data directories. feat(open-collaboration-agent): refine message processing and improve chat interaction - Added a check to ensure only non-empty accumulated text is sent as a chat message in the processACPResponse function. - Updated handleAgentMessageChunk to keep the chat UI in writing mode while processing text chunks, enhancing user experience during message streaming. Enhance file synchronization and editing capabilities in ACPBridge and document operations - Updated the `fs/write_text_file` method in ACPBridge to ensure files are written to both local workspace and OCT document, improving synchronization reliability. - Added error handling for file writing operations to prevent failures when the file does not exist, creating necessary parent directories. - Refactored `processACPResponse` to apply edits directly to the requested target file, enhancing multi-file handling. - Improved document operations to treat empty document content as valid, ensuring better handling of new files and edits. - Introduced tests for multi-file scenarios and new file creation to validate recent changes and ensure robust functionality. Refactor ACPBridge file handling and synchronization logic - Updated the `fs/write_text_file` method to propose changes to OCT documents without writing to disk when the document exists, enhancing synchronization efficiency. - Implemented logic to create local files only when the OCT document is missing, preventing overwrites of existing local files. - Enhanced test coverage for file writing scenarios, ensuring correct behavior when documents are present or absent in OCT. - Improved logging for better traceability of file operations and synchronization processes. Enhance launch configuration and collaboration instance editing - Updated `launch.json` to include a new prompt for specifying the workspace folder, improving user customization. - Refactored the `onDidProposeChanges` method in `collaboration-instance.ts` to separate initial and change edits, enhancing clarity and maintainability of the code. - Improved error handling and document editing logic to ensure more reliable application of changes in the diff editor. Implement file matching and document opening enhancements in open-collaboration-agent - Added a Levenshtein distance function for fuzzy file matching, allowing users to find files even with typos. - Introduced a `findMatchingFiles` function to search for files based on user-provided paths, supporting exact and fuzzy matches. - Enhanced the `openAndWaitForContent` method in `DocumentSync` to open documents and wait for their content to sync, improving document handling. - Updated chat trigger handling to support file path responses, ensuring users can open documents directly from chat interactions. Refactor proposeChanges method and enhance content provider in collaboration instance - Updated the proposeChanges method in ACPBridge and CollaborationInstance to remove the hostId parameter, simplifying the function signature. - Changed the ProposeChanges message type from NotificationType to BroadcastType in messages.ts for improved message handling. - Introduced a new text document content provider in CollaborationInstance to manage proposed changes, enhancing the editing experience and ensuring better content management during collaboration. Add proposed changes handling in CollaborationInstance and Monaco API Enhance collaboration features and document handling - Updated launch configuration to specify the workspace folder for improved user customization. - Introduced a new `pendingProposals` map in ACPBridge to buffer document changes, consolidating edits for efficient processing. - Refactored `onDidProposeChanges` in CollaborationInstance to debounce proposal handling, improving performance during rapid edits. - Added a new method in DocumentSync to actively resolve the host's active document, addressing race conditions in document following. - Introduced a new `AcceptChanges` message type in the collaboration protocol for better handling of document changes. Enhance collaboration instance content provider with new methods - Added `setContent` and `deleteContent` methods to the proposed content provider for better document management. - Updated the `proposeChanges` method to utilize distinct URIs for 3-way merge editing, improving the merge experience. - Ensured that VSCode refreshes document content for each virtual URI to prevent caching issues. Clean up and fixes --- .claude/agents/oct-collab-agent.md | 155 +++ .claude/commands/connect-to-oct.md | 59 + .claude/commands/disconnect-from-oct.md | 18 + .claude/settings.json | 3 + .gitattributes | 3 + .gitignore | 2 + .vscode/README.md | 34 + .vscode/launch-extension-host.js | 36 + .vscode/launch.json | 94 +- .vscode/node-wrapper.sh | 19 + eslint.config.mjs | 10 + package-lock.json | 1211 +++++++++++++---- packages/OCT-Talk-Abstract.md | 23 + .../open-collaboration-agent/ACP_CONCEPT.md | 156 +++ .../open-collaboration-agent/ARCHITECTURE.md | 466 +++++++ .../DEVELOPMENT_JOURNEY.md | 381 ++++++ .../OPENCODE_AS_OCT_AGENT.md | 94 ++ packages/open-collaboration-agent/README.md | 130 ++ .../REMOTE_AGENT_CHALLENGES.md | 457 +++++++ .../open-collaboration-agent/package.json | 18 +- .../src/acp-bridge.ts | 1043 ++++++++++++++ .../src/agent-util.ts | 251 ++-- .../open-collaboration-agent/src/agent.ts | 470 ++++++- .../src/document-operations.ts | 120 ++ .../src/document-sync.ts | 318 ++++- .../open-collaboration-agent/src/index.ts | 1 - packages/open-collaboration-agent/src/main.ts | 4 +- .../open-collaboration-agent/src/prompt.ts | 134 -- .../test/acp-bridge.test.ts | 81 ++ .../test/agent-util.test.ts | 217 +-- .../test/multi-file-new-file.test.ts | 189 +++ .../open-collaboration-agent/vitest.config.ts | 25 + .../src/collaboration-instance.ts | 150 ++ .../src/monaco-api.ts | 18 +- .../.vscode/settings.json | 3 + .../src/connection.ts | 6 +- .../src/messages.ts | 3 +- .../open-collaboration-vscode/package.json | 9 +- .../package.nls.json | 4 +- .../src/collaboration-instance.ts | 136 +- .../src/commands-list.ts | 1 + .../open-collaboration-vscode/src/commands.ts | 51 +- 42 files changed, 5769 insertions(+), 834 deletions(-) create mode 100644 .claude/agents/oct-collab-agent.md create mode 100644 .claude/commands/connect-to-oct.md create mode 100644 .claude/commands/disconnect-from-oct.md create mode 100644 .claude/settings.json create mode 100644 .gitattributes create mode 100644 .vscode/README.md create mode 100644 .vscode/launch-extension-host.js create mode 100755 .vscode/node-wrapper.sh create mode 100644 packages/OCT-Talk-Abstract.md create mode 100644 packages/open-collaboration-agent/ACP_CONCEPT.md create mode 100644 packages/open-collaboration-agent/ARCHITECTURE.md create mode 100644 packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md create mode 100644 packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md create mode 100644 packages/open-collaboration-agent/README.md create mode 100644 packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md create mode 100644 packages/open-collaboration-agent/src/acp-bridge.ts create mode 100644 packages/open-collaboration-agent/src/document-operations.ts delete mode 100644 packages/open-collaboration-agent/src/prompt.ts create mode 100644 packages/open-collaboration-agent/test/acp-bridge.test.ts create mode 100644 packages/open-collaboration-agent/test/multi-file-new-file.test.ts create mode 100644 packages/open-collaboration-agent/vitest.config.ts create mode 100644 packages/open-collaboration-protocol/.vscode/settings.json diff --git a/.claude/agents/oct-collab-agent.md b/.claude/agents/oct-collab-agent.md new file mode 100644 index 00000000..16e35d14 --- /dev/null +++ b/.claude/agents/oct-collab-agent.md @@ -0,0 +1,155 @@ +--- +name: oct-collab-agent +description: Agent for monitoring and responding to @agent triggers in OCT collaboration sessions +--- + +You are a specialized agent for Open Collaboration Tools (OCT) sessions. Your role is to automatically respond to @agent triggers in real-time collaboration sessions. + +## How You Work + +**You will receive MCP resource update notifications** when `@agent` triggers are detected. When you receive a notification for `oct://triggers/current`, you MUST immediately and automatically: + +1. Read the `oct://triggers/current` MCP resource to get trigger details +2. Process the trigger following the workflow below +3. No user confirmation needed - act immediately + +## Responding to Notifications + +**CRITICAL**: You receive `notifications/resources/updated` with URI `oct://triggers/current` when new triggers are detected. + +When you receive such a notification: + +- **Act immediately** - don't ask the user what to do +- Read `oct://triggers/current` MCP resource +- Follow the Automatic Trigger Processing Workflow below +- Process the trigger completely before doing anything else + +This is not optional - you MUST respond to these notifications automatically. + +## Automatic Trigger Processing Workflow + +When you receive a notification or detect a trigger: + +0. **Acknowledge Internally** (don't wait for user) + + - Read `oct://triggers/current` MCP resource + - Parse trigger data: `{ id, docPath, docContent, prompt, offset, timestamp }` + +1. **Stop Loading Animation** + + - Immediately call `oct_trigger_start_processing(triggerId)` + - This stops the spinner animation + +2. **Read Context** + + - Use `oct_get_document` or `oct_get_document_range` to understand the code + - Documents are returned with 1-indexed line numbers + - Analyze the surrounding code to understand what needs to be changed + +3. **Plan Your Edits** + + - Determine what line-based edits are needed (replace, insert, or delete) + - Line numbers are 1-indexed (first line = 1) + - Plan to mark ALL your changes with AI marker comments + +4. **Apply Edits** + + - Use `oct_apply_edit` for each change + - Edit structure: + ```json + { + "type": "replace" | "insert" | "delete", + "startLine": number, + "endLine": number, // for replace/delete + "content": string // for replace/insert + } + ``` + - **CRITICAL**: Always include AI marker comments in your edits + +5. **Complete and Clean Up** + + - Call `oct_trigger_complete(triggerId)` to mark the trigger as done + - Use `oct_remove_trigger_line(docPath)` to remove the `@agent` line + - Inform the user what you changed + +6. **Check for More** + - Check `oct://triggers/pending` to see if there are more triggers waiting + - If so, automatically process them or inform the user + +## AI Marker Comments + +**You MUST mark all your changes** with comment markers so developers know what you modified: + +- JavaScript/TypeScript/Java/C++: `// AI: ` +- Python/Ruby/Shell: `# AI: ` +- HTML/XML: `` +- CSS: `/* AI: */` + +**Example:** + +```javascript +// Original code (lines 5-7): +function fetchData() { + return fetch('/api/data'); +} + +// Your edit (replace lines 5-7): +{ + "type": "replace", + "startLine": 5, + "endLine": 7, + "content": "// AI: Added error handling and async/await\nasync function fetchData() {\n try {\n const response = await fetch('/api/data');\n return await response.json();\n } catch (error) {\n console.error('Fetch failed:', error);\n throw error;\n }\n}" +} +``` + +## Important Rules + +1. **Line Numbers**: Always 1-indexed, not 0-indexed +2. **Original Document**: Use line numbers from the original document you read +3. **Marker Comments**: MUST be present for every change +4. **Multiple Edits**: Apply them in descending line order to avoid offset issues +5. **Cursor Updates**: The MCP server handles cursor positioning automatically +6. **Real-time Sync**: Your changes sync immediately to all collaborators + +## Tools Available + +- `oct_get_document(path)` - Get full document with line numbers +- `oct_get_document_range(path, startLine, endLine)` - Get specific lines +- `oct_apply_edit(path, edit)` - Apply a single edit +- `oct_trigger_start_processing(triggerId)` - Stop loading animation and mark as processing +- `oct_trigger_complete(triggerId)` - Mark trigger as completed +- `oct_remove_trigger_line(path)` - Remove the trigger line +- `oct_get_session_info()` - Get session metadata +- `oct_get_connection_status()` - Check if connected + +## Example Session + +``` +Developer writes: // @agent Add input validation for email + +MCP server sends notification for oct://triggers/current + +You automatically respond by: +1. Read oct://triggers/current - get trigger ID and prompt +2. oct_trigger_start_processing(triggerId) - stop loading animation +3. oct_get_document_range(path, 1, 50) to see the function +4. Identify the function that needs validation (e.g., lines 10-15) +5. oct_apply_edit to add validation logic with "// AI: Added email validation" marker +6. oct_trigger_complete(triggerId) - mark as done +7. oct_remove_trigger_line(path) to clean up the trigger +8. Report success to the developer +``` + +## Your Behavior + +- **Be automatic**: React immediately to notifications without asking the user +- **Be proactive**: When you receive a trigger notification, process it right away +- Be helpful and precise with code changes +- Always explain what you changed and why +- If unsure about the trigger request, make your best interpretation or ask for clarification AFTER attempting +- Respect the coding style of the existing code +- Keep edits minimal and focused on the request +- Test your logic mentally before applying edits +- After processing, check for more triggers and process them too + +You are a collaborative coding assistant - work seamlessly with developers in their shared editing session! diff --git a/.claude/commands/connect-to-oct.md b/.claude/commands/connect-to-oct.md new file mode 100644 index 00000000..9da52402 --- /dev/null +++ b/.claude/commands/connect-to-oct.md @@ -0,0 +1,59 @@ +--- +description: Connect Claude Code to an Open Collaboration Tools session +args: +--- + +You are being asked to connect to an Open Collaboration Tools (OCT) session. + +**First, check if room ID is provided:** + +- If `{{args}}` is empty or contains only `{{args}}` (the literal placeholder), ask the user: "Please provide the OCT room ID to connect to." +- Wait for the user to provide the room ID before proceeding +- If `{{args}}` contains a valid room ID, proceed with the connection steps below + +Follow these steps: + +1. **Connect to the OCT room** + + - Use the `oct_connect` MCP tool with `roomId` parameter set to `{{args}}` + - **CRITICAL**: The connection response will include a `loginUrl` field - this is NOT an error! + - Display the login URL prominently to the user with clear instructions: + - Tell them to open the URL in their browser + - Explain this is required for authentication + - Let them know the connection will complete once they log in + - The tool call will wait and complete automatically after the user authenticates in their browser + +2. **Verify connection** + + - Use `oct_get_connection_status` to confirm you're connected + - Display session information to the user (room ID, agent name, etc.) + +3. **Launch Background Monitoring Agent (CRITICAL)** + + - **IMPORTANT**: You MUST launch the oct-collab-agent as a background Task immediately after connection + - Use the Task tool with: + - `subagent_type: "oct-collab-agent"` + - `description: "Monitor OCT triggers"` + - `prompt: "You are now in monitoring mode for OCT collaboration session. Continuously call oct_wait_for_trigger() to wait for triggers. When a trigger arrives, process it immediately using the workflow in your agent definition, then loop back to oct_wait_for_trigger(). Keep monitoring until disconnected."` + - This agent will run in the background and automatically handle all @agent triggers + - The agent will block on `oct_wait_for_trigger()` (no tokens used while waiting) + - When a trigger arrives, it will process it and return to waiting + - **DO NOT skip this step** - without the monitoring agent, triggers won't be processed automatically + +4. **Explain to the user** + + - You are now connected as a peer in the OCT collaboration session + - You will appear as a collaborator with your agent name (e.g., "my-agent") + - A background monitoring agent is now running to handle triggers automatically + - When developers write `@your-agent-name ` in their code: + - A loading animation appears automatically + - The monitoring agent will process it in real-time + - No user action required + - **Tell the user**: "I'm connected and monitoring! When anyone writes `@agent `, I'll automatically process it. A background agent is now handling all triggers." + +Important notes: + +- Your cursor position will be visible to other collaborators +- All your edits will sync in real-time via the OCT protocol +- Multiple developers can work simultaneously with you +- Always use the appropriate comment syntax for the file type (e.g., `//` for JS/TS, `#` for Python) diff --git a/.claude/commands/disconnect-from-oct.md b/.claude/commands/disconnect-from-oct.md new file mode 100644 index 00000000..f00c5c99 --- /dev/null +++ b/.claude/commands/disconnect-from-oct.md @@ -0,0 +1,18 @@ +--- +description: Disconnect from the current OCT collaboration session +--- + +When disconnecting from an OCT session, follow these steps: + +1. **Stop Background Monitoring Agent** (if running) + - If a background oct-collab-agent Task was launched during connection, it should be stopped + - Note: The agent will automatically stop when disconnection occurs since oct_wait_for_trigger will fail + +2. **Disconnect from OCT Session** + - Use the `oct_disconnect` tool to disconnect from the OCT session + - This will cleanup all resources, stop animations, and close the connection + +3. **Confirm to User** + - Confirm successful disconnection to the user + - Inform them that the monitoring agent has been stopped (if applicable) + - Let them know they can reconnect using `/connect-to-oct ` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..da39e4ff --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} 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 ab3f14f6..9d4f74c4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist/ 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 c9224b6f..0e0ef71e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,6 +8,7 @@ "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"], @@ -20,14 +21,14 @@ "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/test-user-data-1", - "${workspaceFolder}/packages/open-collaboration-vscode" + "--user-data-dir=${workspaceFolder}/.vscode/extension-host-data-1" ], "env": { "DEVELOPMENT": "true" @@ -38,21 +39,21 @@ ] }, { - "name": "Run VS Code Extension - Second Instance", - "type": "extensionHost", + "name": "Spawn Second Extension Instance", + "type": "node", "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", + "program": "${workspaceFolder}/.vscode/launch-extension-host.js", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/packages/open-collaboration-vscode", - "--user-data-dir=${workspaceFolder}/.vscode/test-user-data-2", - "${workspaceFolder}/test-collab-project" + "packages/open-collaboration-vscode", + ".vscode/extension-host-data-2" ], "env": { - "DEVELOPMENT": "true" + "WORKSPACE_FOLDER": "${workspaceFolder}", + "VSCODE_EXEC_PATH": "${execPath}", + "OCT_SERVER_URL": "http://localhost:8100" }, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/packages/open-collaboration-vscode/dist/**/*.js" - ] + "console": "integratedTerminal" }, { "name": "Run VS Code Web Extension", @@ -69,6 +70,7 @@ "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" @@ -88,6 +90,7 @@ "name": "Vitest: Run Selected File", "type": "node", "request": "launch", + "runtimeExecutable": "${workspaceFolder}/.vscode/node-wrapper.sh", "autoAttachChildProcesses": true, "skipFiles": [ "/**", @@ -100,23 +103,68 @@ "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": "attachPort", + "id": "roomToken", "type": "promptString", - "description": "What port should the application use for debugging?", - "default": "23698" - } - ], - "compounds": [ + "description": "Enter the room token to join" + }, { - "name": "Debug parallel vscode extensions", - "configurations": [ - "Run VS Code Extension", - "Run VS Code Extension - Second Instance" - ] + "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/OCT-Talk-Abstract.md b/packages/OCT-Talk-Abstract.md new file mode 100644 index 00000000..25a63da4 --- /dev/null +++ b/packages/OCT-Talk-Abstract.md @@ -0,0 +1,23 @@ +## Collaborative Coding Across Web Applications and Theia-based IDEs + +Alternative Titles: + - Collaborative Coding Across Platforms: When AI Agents Join the Session + - Breaking Boundaries: Cross-Platform Collaboration Meets AI Agents + - Collaborative Coding Beyond IDEs: Connecting Platforms and AI Agents + - Cross-Platform Collaborative Coding: From Web Apps to AI Agents + +--- + +@Miro / @JanB + +Collaboration in software projects shouldn't be limited to developers inside an IDE. With Eclipse Theia as the foundation and Eclipse Open Collaboration Tools (OCT) providing shared editing, we can now connect Theia-based IDEs with arbitrary web applications—making live collaboration a first-class experience for everyone involved in a project. This talk will highlight how Theia enables real-time collaboration not only between developers, but also with project leads, stakeholders, and domain experts directly in their domain-specific tools. We'll also explore how to extend these collaborative sessions with AI agent integration, enabling any participant to interact with the agent directly and streamlining workflows that previously required switching between local AI tools and collaborative sessions. Key themes include: + +- Why extending live sharing beyond IDEs to custom web applications changes team dynamics and accelerates feedback +- How OCT's Monaco integration enables shared editing and cross-application synchronization +- The OCT Playground as a lightweight environment to experiment with collaborative features +- The OCT Agent: bringing AI into collaborative coding sessions as a visible participant with its own cursor, connectable to any AI API +- Extending collaboration capabilities through MCP (Model Context Protocol) for integrating additional agents +- Chat-based communication as part of the OCT Protocol for seamless interaction between all participants, including AI agents +- Forward-looking opportunities for building truly collaborative development environments on top of Theia + +Attendees will leave with a clear picture of how Theia can power collaborative development environments that bridge the gap between traditional IDE workflows and domain-specific web applications, and how AI agents can become active participants in these shared coding experiences. diff --git a/packages/open-collaboration-agent/ACP_CONCEPT.md b/packages/open-collaboration-agent/ACP_CONCEPT.md new file mode 100644 index 00000000..feaa2a0a --- /dev/null +++ b/packages/open-collaboration-agent/ACP_CONCEPT.md @@ -0,0 +1,156 @@ +# Agent Client Protocol (ACP) Concept + +**Status:** 📝 DRAFT (v4) +**Date:** 2025-11-21 + +## Objective + +The **Agent Client Protocol (ACP)** is a communication standard that allows an **AI Agent** (like Claude Code, a custom CLI agent, or an IDE plugin) to connect directly to an **Open Collaboration Tools (OCT) Session** as a first-class participant. + +## Architecture + +The `oct-agent` CLI connects exclusively via **ACP** (Agent Client Protocol): + +* **Technology:** Agent Client Protocol (ACP). +* **Behavior:** The `oct-agent` acts as a **bridge**. It connects to the OCT session and forwards all triggers/events to an external ACP agent (default: `npx @zed-industries/claude-code-acp`). Override with `--acp-agent` to use any ACP-capable agent. +* **Use Case:** Claude Code, Cursor, or custom enterprise agents; model and API keys are configured in the ACP agent, not in oct-agent. + +*(Previously, a built-in Embedded mode existed; it was removed in favor of ACP-only.)* + +## Integration with `zed-industries/claude-code-acp` + +We have discovered an existing ACP adapter for Claude Code: [`zed-industries/claude-code-acp`](https://github.com/zed-industries/claude-code-acp). This tool wraps the Claude Code SDK and exposes it as an ACP server (stdio or socket). + +This simplifies our architecture significantly! + +### The Workflow + +1. **User starts OCT Agent:** + ```bash + oct-agent --room + ``` + +2. **OCT Agent (The Bridge):** + * Connects to the OCT Session (WebSocket). + * Spawns the ACP Adapter (`npx @zed-industries/claude-code-acp`) as a child process. + * **Pipes** ACP messages between the OCT Session and the ACP Adapter. + +3. **Data Flow:** + * **OCT:** `@agent refactor this` (Trigger) + * **OCT Agent:** Wraps this in an ACP `agent/trigger` message. + * **ACP Adapter:** Receives message, calls Claude Code SDK. + * **Claude Code:** Executes logic, maybe asks for permissions. + * **ACP Adapter:** Sends `agent/response` or `agent/action`. + * **OCT Agent:** Forwards to OCT Session (applies edits via Yjs). + +### Protocol Definition (ACP) + +ACP is a set of message types exchanged over the OCT transport (WebSocket). + +#### 1. `agent/trigger` (Inbound to Agent) + +Sent when the OCT system detects an intent for the agent to act. + +```json +{ + "type": "agent/trigger", + "id": "trig-123", + "source": { + "type": "document", + "path": "src/main.ts", + "line": 42 + }, + "content": { + "prompt": "refactor this function", + "context": "..." // Optional: Immediate context if available + } +} +``` + +#### 2. `agent/action` (Outbound from Agent) + +The agent performs an action in the session. + +```json +{ + "type": "agent/action", + "triggerId": "trig-123", // Correlate with the trigger + "action": "edit", + "payload": { + "file": "src/main.ts", + "edits": [ ... ] + } +} +``` + +## VSCode Extension Integration + +The VSCode extension now provides a convenient command to start the agent directly from the IDE: + +### Starting the Agent from VSCode + +1. Create or join an OCT room in VSCode +2. Open Command Palette (Cmd+Shift+P) +3. Run: "Open Collaboration Tools: Start Agent" +4. The agent automatically starts in your workspace directory with the correct room ID and server URL + +### Implementation Details + +- **Command:** `oct.startAgent` +- **Automatically detects** development vs production environment +- **Development:** Uses local build from mono-repo (`../open-collaboration-agent/bin/agent`) +- **Production:** Uses `npx open-collaboration-agent` +- **Agent runs** in workspace directory via VSCode terminal +- **All configuration** (room ID, server URL) passed automatically + +### Workspace Requirement + +**IMPORTANT:** The agent MUST run in the same workspace directory as the OCT session because: + +- File system operations use `fs.readFileSync` for reading local files +- Agent uses `process.cwd()` as workspace context +- No remote file streaming - files are read from local filesystem +- See `REMOTE_AGENT_CHALLENGES.md` for details about deployment scenarios + +### How It Works + +```mermaid +sequenceDiagram + participant User + participant VSCode + participant Command as oct.startAgent + participant Terminal + participant Agent as oct-agent + + User->>VSCode: Open project workspace + User->>VSCode: Create/Join OCT room + User->>VSCode: Command Palette + User->>Command: "Start Agent" + Command->>Command: Check session exists + Command->>Command: Check workspace is local + Command->>Command: Detect dev/prod mode + Command->>Terminal: Create in workspace dir + Terminal->>Agent: Spawn with --room, --server + Agent->>Agent: Authenticate (browser) + Agent->>Agent: Join OCT session + Agent->>VSCode: Ready to process @agent triggers +``` + +## Implementation + +The `oct-agent` always uses the ACP bridge: + +* **Logic:** Spawns the ACP agent (default: `npx @zed-industries/claude-code-acp`) as a child process. Override with `--acp-agent`. +* **Flow:** + 1. `DocumentSync` detects trigger. + 2. `oct-agent` converts trigger to ACP JSON message (`agent/trigger`). + 3. `oct-agent` writes JSON to child process `stdin`. + 4. Child process (e.g. Zed Adapter) calls Claude Code SDK. + 5. Child process writes response JSON (`agent/action`) to `stdout`. + 6. `oct-agent` reads JSON and applies edits via `DocumentSync`. + +## Benefits + +1. **Simplicity:** One code path; no mode switch. +2. **Flexibility:** Any ACP-capable agent via `--acp-agent`. +3. **Unified CLI:** One tool (`oct-agent`) for all ACP-based integrations. diff --git a/packages/open-collaboration-agent/ARCHITECTURE.md b/packages/open-collaboration-agent/ARCHITECTURE.md new file mode 100644 index 00000000..3ee7ed5a --- /dev/null +++ b/packages/open-collaboration-agent/ARCHITECTURE.md @@ -0,0 +1,466 @@ +# Open Collaboration Agent: Architecture + +**Date:** 2025-01-19 +**Status:** Current Implementation + +## Overview + +The open-collaboration-agent enables AI agents to participate in Open Collaboration Tools (OCT) sessions as first-class peers, with real-time code editing capabilities. The agent connects exclusively via **ACP** (Agent Client Protocol) to external agents (e.g. Claude Code via `@zed-industries/claude-code-acp`). The ACP agent is configurable via `--acp-agent`. + +## Architectural Layers + +```mermaid +flowchart TB + subgraph ide [IDE Integration Layer 6] + VSCode[VSCode Extension] + Terminal[VSCode Terminal] + end + + subgraph trigger [Trigger Detection Layer 5] + TriggerDetect[Trigger Detection] + Animation[Loading Animation] + end + + subgraph mode [Agent Mode Layer 4] + ACP[ACP Bridge] + end + + subgraph ops [Document Operations Layer 3] + DocOps[DocumentSyncOperations] + LineEdit[Line-based Edits] + end + + subgraph sync [Document Sync Layer 2] + Yjs[Yjs CRDT] + Provider[OCT Yjs Provider] + end + + subgraph protocol [Protocol Connection Layer 1] + Connection[Protocol Connection] + WebSocket[WebSocket Transport] + end + + VSCode -->|oct.startAgent| Terminal + Terminal -->|spawns| TriggerDetect + TriggerDetect -->|"@agent detected"| Animation + Animation -->|onTrigger| ACP + ACP -->|tool calls| DocOps + DocOps -->|apply edits| Yjs + Yjs -->|sync| Provider + Provider -->|broadcast| Connection + Connection -->|transport| WebSocket +``` + +## Layer 1: Protocol Connection + +**Purpose:** Establish and maintain connection to OCT server + +**Components:** +- `ProtocolBroadcastConnection` from `open-collaboration-protocol` +- WebSocket transport via Socket.IO +- Authentication flow (browser-based login) +- Peer identity management + +**Key Operations:** +- Login to server +- Join room with room token +- Maintain connection with auto-reconnect +- Peer awareness (cursor tracking, active document) + +**Code Location:** `src/agent.ts` (lines 25-65) + +## Layer 2: Document Synchronization (Yjs CRDT) + +**Purpose:** Real-time collaborative document editing with conflict-free merging + +**Components:** +- `DocumentSync` class (`src/document-sync.ts`) +- Yjs `Y.Doc` and `Y.Text` for document state +- `OpenCollaborationYjsProvider` for OCT protocol integration +- Awareness protocol for cursor positions + +**Key Features:** +- Follows host's active document automatically +- Detects document changes with position tracking +- Provides callbacks for `@agent` trigger detection +- Conflict-free merge of concurrent edits (CRDT) + +**Code Location:** `src/document-sync.ts` + +## Layer 3: Document Operations Abstraction + +**Purpose:** Unified interface for document manipulation shared by all modes + +**Components:** +- `DocumentOperations` interface (`src/document-operations.ts`) +- `DocumentSyncOperations` implementation +- `LineEdit` type for structured edits + +**Operations:** +```typescript +interface DocumentOperations { + getDocument(path: string): string | undefined + applyEditsAnimated(path: string, edits: LineEdit[]): Promise + removeTriggerLine(path: string, trigger: string): void + updateCursor(path: string, offset: number): void + getSessionInfo(): SessionInfo + getActiveDocumentPath(): string | undefined +} +``` + +**Benefits:** +- Single source of truth for document operations +- Used by ACP bridge +- Animated cursor movement during edits +- Line-based editing (easier for LLMs than character offsets) + +**Code Location:** `src/document-operations.ts` + +## Layer 4: Agent Mode (ACP) + +### ACP Bridge + +**Purpose:** Bridge to external agents via Agent Client Protocol + +**Flow:** +``` +Trigger → ACPBridge.sendTrigger() → ACP Agent → session/prompt → tool calls → edits +``` + +**Components:** +- `ACPBridge` class (`src/acp-bridge.ts`) +- JSON-RPC over stdio communication +- Integration with `@zed-industries/claude-code-acp` + +**Features:** +- Spawns external ACP agent as child process +- Session management (initialize, create session) +- File system operations (fs/read_text_file, fs/write_text_file) +- Permission handling (auto-approve tool calls) +- Bidirectional communication (server → agent, agent → server) + +**Advantages:** +- Standard protocol (works with any ACP client) +- Flexible tool-based workflows +- External agent can use advanced capabilities +- Proper bidirectional communication + +**Code Location:** `src/acp-bridge.ts` + +## Layer 5: Trigger Detection & Execution + +**Purpose:** Detect `@agent` mentions and orchestrate execution + +**Components:** +- `setupTriggerDetection()` in `src/agent.ts` +- Document change handler +- Trigger line detection (newline after `@agent`) + +**Detection Logic:** +```typescript +// Detects pattern: "@agent \n" +if (change.type === 'insert' && change.text === '\n') { + const completedLine = docLines[change.position.line]; + const triggerIndex = completedLine?.indexOf('@agent'); + if (triggerIndex !== -1) { + const prompt = completedLine.substring(triggerIndex + 6).trim(); + // Execute agent with prompt + } +} +``` + +**Workflow:** +1. Monitor document changes via `DocumentSync.onDocumentChange()` +2. Detect newline insertion after `@agent` pattern +3. Extract prompt text +4. Start loading animation at trigger position +5. Invoke ACP handler (onTrigger) +6. Apply edits with animated cursor +7. Remove trigger line +8. Clear cursor position + +**Code Location:** `src/agent.ts` (lines 125-291) + +## Layer 6: IDE Integration (VSCode Extension) + +**Purpose:** Seamless agent launching from VSCode + +**Components:** +- Command: `oct.startAgent` in VSCode extension +- Terminal integration +- Automatic configuration passing + +**Workflow:** +``` +User runs command → VSCode creates terminal → Agent starts in workspace directory +``` + +**Implementation Details:** +- Detects development vs production environment +- Development: Uses local build path +- Production: Uses `npx open-collaboration-agent` +- Automatically passes room ID and server URL +- Agent runs in workspace directory (`cwd: workspaceFolder.uri.fsPath`) + +**Code Location:** `packages/open-collaboration-vscode/src/commands.ts` (lines 221-265) + +## Data Flow Diagrams + +### Trigger Processing (ACP) + +```mermaid +sequenceDiagram + participant User + participant VSCode + participant DocSync as DocumentSync + participant Bridge as ACP Bridge + participant ACP as ACP Agent + participant DocOps as DocumentOps + participant Yjs + participant OCT as OCT Session + + User->>VSCode: Write "@agent add validation\n" + VSCode->>DocSync: Document change (insert '\n') + DocSync->>Bridge: onTrigger callback + Bridge->>DocSync: Start loading animation + Bridge->>ACP: session/prompt (JSON-RPC) + ACP->>ACP: Process with Claude Code + ACP->>Bridge: tool_call (edit) + Bridge->>Bridge: Request permission (auto-approve) + Bridge->>DocOps: applyEditsAnimated() + DocOps->>Yjs: Apply edits to Y.Text + Yjs->>OCT: Broadcast changes + Bridge->>DocSync: removeTriggerLine() + Bridge->>DocSync: Clear cursor position +``` + +## Filesystem Architecture + +### Current Design: Local Filesystem Access + +``` +┌─────────────────────────────────────────┐ +│ User's Workspace │ +│ │ +│ ┌──────────────────┐ │ +│ │ Project Files │ │ +│ │ - src/ │ │ +│ │ - package.json │ │ +│ │ - ... │ │ +│ └────────┬─────────┘ │ +│ │ fs.readFileSync() │ +│ ↓ │ +│ ┌──────────────────┐ │ +│ │ oct-agent │ │ +│ │ (process.cwd()) │ │ +│ └────────┬─────────┘ │ +└───────────┼──────────────────────────────┘ + │ Yjs CRDT sync + ↓ +┌─────────────────────────────────────────┐ +│ OCT Session (Cloud/Server) │ +│ - Document state synchronized │ +│ - All participants see changes │ +│ - No file content stored on server │ +└─────────────────────────────────────────┘ +``` + +**Key Constraint:** Agent MUST run in workspace directory + +**Why:** +- ACP Bridge reads files with `fs.readFileSync(absolutePath, 'utf8')` (line 501 in `acp-bridge.ts`) +- Agent uses `process.cwd()` as workspace root +- No file streaming over network +- Files are NOT stored on OCT server - only document state (Yjs) is synchronized + +**Implications:** +- ✅ **Supported:** Agent runs on same machine as workspace +- ❌ **Not Supported:** Agent runs on remote machine without workspace access +- See `REMOTE_AGENT_CHALLENGES.md` for detailed analysis + +## Key Components Reference + +| Component | Purpose | Location | +|-----------|---------|----------| +| `main.ts` | CLI entry point, argument parsing | `src/main.ts` | +| `agent.ts` | Agent lifecycle, trigger detection | `src/agent.ts` | +| `acp-bridge.ts` | ACP protocol bridge for external agents | `src/acp-bridge.ts` | +| `document-sync.ts` | Yjs-based document synchronization | `src/document-sync.ts` | +| `document-operations.ts` | Shared document manipulation interface | `src/document-operations.ts` | +| `agent-util.ts` | Animated line-based edits | `src/agent-util.ts` | + +## Configuration + +### CLI Arguments + +```bash +oct-agent --room [options] +``` + +**Options:** +- `-s, --server ` - OCT server URL (default: https://api.open-collab.tools/) +- `--acp-agent ` - ACP agent command (default: npx @zed-industries/claude-code-acp) + +### Environment Variables + +API keys and model selection are configured in the ACP agent (e.g. Claude Code), not in oct-agent. + +## Deployment Scenarios + +### Scenario 1: Single Developer with VSCode + +``` +┌──────────────────┐ +│ VSCode IDE │ +│ │ +│ ┌────────────┐ │ +│ │ Workspace │ │ +│ └──────┬─────┘ │ +│ │ │ +│ ┌──────▼─────┐ │ +│ │ oct-agent │ │ +│ │ (terminal) │ │ +│ └──────┬─────┘ │ +└─────────┼────────┘ + │ + ↓ OCT Protocol + [OCT Server] +``` + +**How to start:** +1. Command Palette → "Open Collaboration Tools: Start Agent" +2. Agent runs in VSCode terminal with correct configuration + +### Scenario 2: Team Collaboration + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Developer │ │ Developer │ │ Agent │ +│ (VSCode) │ │ (Theia) │ │ (CLI) │ +│ [Host] │ │ [Guest] │ │ [Guest] │ +└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + └───────────────────┴───────────────────┘ + │ + ↓ OCT Protocol + [OCT Server] + │ + ┌───────────────────┴───────────────────┐ + │ │ + ┌──▼──┐ ┌──▼──┐ + │ Yjs │ Synchronized document state │ Yjs │ + └─────┘ └─────┘ +``` + +**How it works:** +- Host creates room in VSCode +- Guest joins via room ID +- Agent joins as guest peer +- All see real-time changes via Yjs CRDT + +### Scenario 3: CI/CD Integration (Future) + +``` +┌─────────────────────────────────────┐ +│ CI/CD Pipeline │ +│ │ +│ 1. Clone repository │ +│ 2. Start oct-agent in background │ +│ 3. Join test room │ +│ 4. Process automated tasks │ +│ 5. Report results │ +└─────────────────────────────────────┘ +``` + +**Not yet implemented** - requires headless authentication + +## Security Considerations + +1. **Authentication:** + - Browser-based OAuth flow + - No credentials stored in agent + - Session tokens are ephemeral + +2. **File System Access:** + - Agent has full access to workspace directory + - Security boundary at `process.cwd()` + - Path validation in ACP bridge (lines 484-496) + +3. **Code Execution:** + - LLM responses are not executed directly + - Only document edits are applied + - All changes visible to collaborators + +4. **Network:** + - End-to-end encryption via OCT protocol + - WebSocket connection over TLS + - No file content sent to server (only Yjs operations) + +## Performance Characteristics + +### ACP Mode + +- **Latency:** ~3-7 seconds per trigger (LLM inference + IPC overhead) +- **Token Usage:** Depends on the connected ACP agent +- **Memory:** ~100-200 MB (oct-agent + ACP child process) +- **Network:** Minimal from oct-agent (Yjs operations, ~1-10 KB per edit); ACP agent may call LLM APIs + +## Future Enhancements + +1. **Remote Agent Support** + - File streaming over OCT protocol + - Virtual file system abstraction + - See `REMOTE_AGENT_CHALLENGES.md` for analysis + +2. **Multi-File Operations** + - Agent can edit multiple files in one trigger + - File creation/deletion support + - Project-wide refactoring + +3. **Persistent Agent Sessions** + - Long-running agent that doesn't exit + - Maintains conversation context + - Background monitoring + +## Troubleshooting + +### Agent doesn't detect triggers + +**Check:** +- Agent is connected (look for "✅ Joined the room") +- You're writing in the active document +- Pattern is exactly `@agent \n` +- Agent name matches (check with `oct_get_session_info()`) + +### Agent can't read files + +**Check:** +- Agent is running in workspace directory +- File paths are relative to workspace +- Path is within workspace (security check) + +### Edits don't appear + +**Check:** +- Other participants have file open +- Network connection is stable +- Yjs provider is connected (check logs) + +## Additional Documentation + +- **README.md** - Getting started guide +- **ACP_CONCEPT.md** - Agent Client Protocol design and integration +- **REMOTE_AGENT_CHALLENGES.md** - Remote deployment challenges +- **DEVELOPMENT_JOURNEY.md** - Historical context: how the architecture evolved from MCP attempts to ACP + +## Conclusion + +The open-collaboration-agent architecture is designed for: + +- **Real-time collaboration** via Yjs CRDT +- **ACP-only design** – connect any ACP-capable agent via `--acp-agent` +- **IDE integration** for seamless developer experience +- **Local-first design** with workspace filesystem access +- **Extensibility** through shared document operations abstraction + +The architecture prioritizes **simplicity** and **efficiency** while maintaining **flexibility** for future enhancements. diff --git a/packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md b/packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md new file mode 100644 index 00000000..38627104 --- /dev/null +++ b/packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md @@ -0,0 +1,381 @@ +# OCT Agent: Development Journey + +**Status:** 📚 Historical Documentation +**Last Updated:** 2026-01-19 + +## Purpose of This Document + +This document chronicles the development journey of the Open Collaboration Tools (OCT) Agent, documenting the architectural decisions, challenges encountered, and solutions implemented. It serves as a guide to understanding **why** the current architecture exists and **how** we arrived at the final design. + +## What is the OCT Agent? + +The OCT Agent is an AI-powered participant in collaborative coding sessions that can: +- Join OCT sessions as a peer +- Respond to `@agent` triggers in shared documents +- Make real-time code edits visible to all participants +- Connect to external agents exclusively via **ACP** (Agent Client Protocol) + +## Development Timeline + +```mermaid +gantt + title OCT Agent Development Timeline + dateFormat YYYY-MM-DD + section Phase 1 MCP Integration + MCP Notification Problem discovered :milestone, 2025-10-20, 0d + Agent Autonomy Problem identified :milestone, 2025-10-21, 0d + section Phase 2 ACP Solution + ACP Concept developed :milestone, 2025-11-21, 0d + section Phase 3 Final Implementation + Architecture finalized :milestone, 2026-01-19, 0d + Deployment analysis completed :milestone, 2026-01-19, 0d + section Phase 4 Simplification + Embedded mode removed, ACP only :milestone, 2026-01-20, 0d +``` + +## Phase 1: MCP Integration Attempts (October 2025) + +### The Initial Vision + +The first approach attempted to integrate the agent with Claude Code using the **Model Context Protocol (MCP)**. The idea was elegant: + +1. OCT Agent runs as an MCP server +2. Claude Code connects as MCP client +3. When `@agent` triggers are detected, send MCP notifications +4. Claude Code automatically invokes agent to process triggers + +### Problem 1: MCP Notification Limitation + +**Discovered:** October 20, 2025 + +**What we learned:** +- MCP notifications are **passive information updates** +- Claude Code receives notifications but doesn't automatically invoke agents +- MCP is designed for **client → server** (pull), not **server → client** (push) + +**The architectural mismatch:** + +```mermaid +sequenceDiagram + participant Dev as Developer + participant OCT as OCT MCP Server + participant Claude as Claude Code + participant Agent as Agent + + Dev->>OCT: Writes @agent trigger + OCT->>OCT: Detects trigger + OCT->>Claude: MCP Notification + Claude->>Claude: Receives notification + Note over Claude,Agent: ❌ No automatic agent invocation + Note over Agent: Agent never gets called +``` + +**Solutions explored:** +- **Solution A**: Blocking wait tool (`oct_wait_for_trigger`) +- **Solution B**: MCP sampling (server-initiated AI inference) +- **Solution C**: Hybrid auto-detecting approach + +**Result:** Solution C was implemented but revealed a deeper problem... + +### Problem 2: Agent Autonomy Problem + +**Discovered:** October 21, 2025 + +**The core issue:** +- Task agents in Claude Code are designed for **one-off tasks** +- After processing first trigger, agent **terminates automatically** +- No mechanism for persistent background agents +- Subsequent triggers have no agent to process them + +**The lifecycle problem:** + +```mermaid +flowchart TB + subgraph first [First Trigger - Works] + T1[Trigger arrives]-->Launch[Launch background agent] + Launch-->Wait[Agent calls oct_wait_for_trigger] + Wait-->Process[Agent processes trigger] + Process-->Exit[Agent Task completes and EXITS] + end + + subgraph second [Second Trigger - Fails] + T2[Trigger arrives]-->Queue[Queued in pendingTriggers] + Queue-->NoAgent[❌ No agent running to dequeue] + end + + Exit-->T2 + + style NoAgent fill:#f99 + style Exit fill:#f99 +``` + +**Why this was a fundamental problem:** +- Not a bug in our implementation +- Architectural limitation in Claude Code's Task agent system +- MCP protocol doesn't provide agent lifecycle management +- Workarounds were complex and fragile + +**Key insight:** MCP is the wrong protocol for our use case. + +### Why MCP Wasn't Ideal + +| Aspect | What MCP Provides | What OCT Agent Needs | +|--------|-------------------|---------------------| +| **Communication** | Unidirectional (Client → Server) | Bidirectional (Both ways) | +| **Triggering** | Client decides when to call tools | External events trigger agent | +| **Lifecycle** | Client-managed | Session-managed | +| **Use Case** | Tools/Resources for agents | Agent collaboration | + +## Phase 2: ACP Solution (November 2025) + +### The Breakthrough: Agent Client Protocol + +**Developed:** November 21, 2025 +**Documented in:** [ACP_CONCEPT.md](ACP_CONCEPT.md) + +**Discovery:** The `@zed-industries/claude-code-acp` package provides proper bidirectional communication with Claude Code through the Agent Client Protocol. + +### Why ACP Solved the Problems + +**ACP provides:** +- ✅ **Bidirectional communication**: Server can send requests to agent +- ✅ **Event-driven architecture**: External events naturally trigger agent actions +- ✅ **Session-based**: Proper lifecycle management +- ✅ **Direct stdio communication**: Lower latency, simpler flow +- ✅ **Structured tool calls**: Agent gets context and responds with edits + +**The ACP flow:** + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Bridge as ACP Bridge + participant ACP as Claude Code ACP + participant Yjs as Document Sync + + Dev->>Bridge: Writes @agent trigger + Bridge->>Bridge: Detects trigger + Bridge->>ACP: session/prompt (JSON-RPC) + ACP->>ACP: Process with Claude Code + ACP->>Bridge: tool_call (edit) + Bridge->>Bridge: Auto-approve permission + Bridge->>Yjs: Apply edits + Yjs->>Dev: Changes visible in real-time +``` + +### The ACP-Only Architecture + +The solution was to integrate external agents via **ACP (Agent Client Protocol)**. Initially, the OCT Agent supported two modes (Embedded: direct LLM, and ACP Bridge). In a later simplification, **Embedded was removed**; the agent now runs only via the ACP Bridge. + +**ACP Bridge (current):** +- Integration with external agents (Claude Code, etc.) via `--acp-agent` +- Bidirectional communication via ACP +- Advanced capabilities and flexible tool-based workflows +- Any ACP-capable agent can be connected + +## Phase 3: Final Architecture (January 2026) + +### Current Implementation + +**Finalized:** January 19, 2026 +**Documented in:** [ARCHITECTURE.md](ARCHITECTURE.md) + +The final architecture consists of 6 layers: + +```mermaid +flowchart TB + subgraph Layer6 [Layer 6 IDE Integration] + VSCode[VSCode Extension] + end + + subgraph Layer5 [Layer 5 Trigger Detection] + TriggerDetect[Trigger Detection] + end + + subgraph Layer4 [Layer 4 Agent Mode] + ACP[ACP Bridge] + end + + subgraph Layer3 [Layer 3 Document Operations] + DocOps[DocumentSyncOperations] + end + + subgraph Layer2 [Layer 2 Document Sync] + Yjs[Yjs CRDT] + end + + subgraph Layer1 [Layer 1 Protocol Connection] + Connection[OCT Connection] + end + + VSCode-->TriggerDetect + TriggerDetect-->ACP + ACP-->DocOps + DocOps-->Yjs + Yjs-->Connection +``` + +**Unified interface:** The ACP bridge and MCP server share the same `DocumentOperations` abstraction. + +### Deployment Considerations + +**Analyzed in:** [REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md) + +**Key constraint:** Agent must run in workspace directory +- Uses `fs.readFileSync()` for local file access +- No remote file streaming +- `process.cwd()` is workspace root + +**Supported scenarios:** +- ✅ Host starts agent (same machine as workspace) +- ⚠️ Participant starts agent (requires manual workspace sync) +- ❌ Remote agent server (no workspace access) + +**Design philosophy:** Local-first architecture for simplicity, performance, and security. + +### Phase 4: Simplification – ACP Only (January 2026) + +**Milestone:** January 20, 2026 + +Embedded mode (direct LLM via `executeLLM`/`prompt.ts`) was removed. The agent now runs exclusively via the ACP bridge. Benefits: + +- **Single code path:** No `--mode` switch; fewer branches and dependencies +- **Thinner agent:** Removed `@ai-sdk/*`, `ai`, `zod`; no `prompt.ts` +- **Same flexibility:** Any ACP-capable agent can be connected with `--acp-agent` + +## Documentation Roadmap + +### For Understanding the Journey + +Read in this order to understand the development process: + +1. **[DEVELOPMENT_JOURNEY.md](DEVELOPMENT_JOURNEY.md)** (this file) - Overview of the journey +2. **[ACP_CONCEPT.md](ACP_CONCEPT.md)** - The solution +3. **[ARCHITECTURE.md](ARCHITECTURE.md)** - Final implementation + +### For Using the Agent + +Read in this order if you just want to use it: + +1. **[README.md](README.md)** - Getting started guide +2. **[ARCHITECTURE.md](ARCHITECTURE.md)** - How it works +3. **[REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md)** - Deployment scenarios + +### For Specific Topics + +- **Integration patterns**: [ACP_CONCEPT.md](ACP_CONCEPT.md) +- **Deployment scenarios**: [REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md) +- **Architecture layers**: [ARCHITECTURE.md](ARCHITECTURE.md) + +## Architectural Learnings + +### 1. Protocol Selection Matters + +**Lesson:** Choose protocols based on your communication pattern, not popularity. + +- MCP is excellent for client-driven tool access +- ACP is better for event-driven agent collaboration +- The right protocol eliminates workarounds + +### 2. Bidirectional Communication is Hard + +**Lesson:** Server-initiated actions require proper protocol support. + +What we learned: +- Notifications are not the same as requests +- Polling and blocking are workarounds, not solutions +- Agent lifecycle management needs to be built into the protocol + +### 3. Simplicity Through Abstraction + +**Lesson:** Shared abstractions enable flexibility. + +The `DocumentOperations` interface: +- Is shared by the ACP bridge and MCP server +- Makes testing easier (mock the interface) +- Enables future additions without breaking existing code + +### 4. Document the Journey, Not Just the Destination + +**Lesson:** Historical context helps future developers understand "why." + +Benefits: +- New team members understand design decisions +- Avoids repeating past mistakes +- Provides justification for current architecture +- Helps evaluate when to reconsider decisions + +### 5. Local-First is a Feature, Not a Limitation + +**Lesson:** Constraints drive good design. + +The local workspace requirement: +- Eliminates file streaming complexity +- Improves performance (no network latency) +- Enhances security (no workspace upload) +- Simplifies implementation + +Trade-off: Remote deployment requires different approach, but that's okay. + +## Key Milestones + +| Date | Milestone | Significance | +|------|-----------|--------------| +| 2025-10-20 | MCP Notification Problem discovered | First attempt at integration, learned MCP limitations | +| 2025-10-21 | Agent Autonomy Problem identified | Understood fundamental architectural mismatch | +| 2025-11-21 | ACP Concept developed | Found the right protocol for the job | +| 2026-01-19 | Architecture finalized | Dual-mode system with shared abstractions | +| 2026-01-20 | Embedded mode removed, ACP only | Simplified to single code path; any ACP-capable agent connectable via `--acp-agent` | + +## Evolution Summary + +```mermaid +flowchart LR + subgraph attempt1 [Attempt 1 MCP] + MCP[MCP Integration]-->Notif[Notifications dont trigger agents] + Notif-->Auto[Autonomy problem] + end + + subgraph solution [Solution ACP] + ACP[Agent Client Protocol]-->Bidir[Bidirectional communication] + Bidir-->Session[Session management] + end + + subgraph final [Final Design] + ACPOnly[ACP-Only]-->ACP2[ACP Bridge] + ACP2-->Shared[Shared DocumentOperations] + end + + Auto-->ACP + Session-->ACPOnly + + style Notif fill:#f99 + style Auto fill:#f99 + style Bidir fill:#9f9 + style Session fill:#9f9 + style Shared fill:#9f9 +``` + +## Conclusion + +The OCT Agent development journey demonstrates that: + +1. **First solutions aren't always the best solutions** - MCP seemed ideal but had fundamental limitations +2. **Understanding protocols deeply matters** - Knowing the difference between MCP and ACP was crucial +3. **Simplicity through single code path** - ACP-only avoids mode switches; `--acp-agent` keeps flexibility +4. **Documentation is a gift to future developers** - This journey guide helps others understand the "why" + +The current architecture is not just the result of implementation, but the result of learning, iterating, and finding the right tools for the job. + +## Related Documentation + +### Current Implementation +- [ARCHITECTURE.md](ARCHITECTURE.md) - Complete architecture overview +- [ACP_CONCEPT.md](ACP_CONCEPT.md) - ACP integration design +- [README.md](README.md) - User guide and getting started + +### Deployment +- [REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md) - Deployment scenarios and constraints + +### Alternative ACP agents +- [OPENCODE_AS_OCT_AGENT.md](OPENCODE_AS_OCT_AGENT.md) - Using OpenCode as the ACP agent diff --git a/packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md b/packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md new file mode 100644 index 00000000..6972aa2d --- /dev/null +++ b/packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md @@ -0,0 +1,94 @@ +# OpenCode as OCT Agent (ACP Bridge) + +OpenCode can be used instead of Claude Code as the ACP agent. When you start the **oct-agent** with OpenCode as the ACP agent, the ACP bridge spawns the OpenCode process on startup and forwards all `@agent` triggers to it. + +## Overview + +- **ACP Bridge:** The oct-agent spawns an ACP-capable process (e.g. `opencode acp`) and communicates via JSON-RPC over stdio. +- **OpenCode ACP:** The command `opencode acp` starts OpenCode as an ACP subprocess; it uses the same protocol as Claude Code via `@zed-industries/claude-code-acp`. +- **Flow:** OCT session → oct-agent → ACP bridge spawns `opencode acp` → triggers and edits work as usual. + +## Prerequisites + +1. **OpenCode installed** (e.g. `curl -fsSL https://opencode.ai/install | bash` or `npm install -g opencode-ai`). +2. **Anthropic (Claude) configured** in OpenCode (e.g. `/connect` in the OpenCode TUI or API key in `~/.config/opencode/opencode.json`). +3. **Model (optional):** Claude Sonnet 4.5 via `"model": "anthropic/claude-sonnet-4-5"` in the OpenCode config. + +## 1. Install OpenCode + +```bash +# Option A: Install script +curl -fsSL https://opencode.ai/install | bash + +# Option B: npm +npm install -g opencode-ai + +# Option C: Homebrew (macOS) +brew install anomalyco/tap/opencode +``` + +Verify: `opencode --version` + +## 2. Configure OpenCode (Claude Sonnet 4.5) + +- **API key:** Run `opencode` in the TUI, then `/connect` → Anthropic → enter API key (or “Create an API Key” / Claude Pro/Max). +- **Model:** In `~/.config/opencode/opencode.json` or per-project in `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-5" +} +``` + +See [OpenCode Docs – Config](https://opencode.ai/docs/config) and [Providers (Anthropic)](https://opencode.ai/docs/providers#anthropic). + +## 3. Start oct-agent with OpenCode + +The oct-agent spawns the ACP agent via the **`--acp-agent`** option. The default is `npx @zed-industries/claude-code-acp`. To use OpenCode: + +```bash +cd /path/to/your/workspace +node /path/to/oct/packages/open-collaboration-agent/bin/agent -r --acp-agent "opencode acp" +``` + +**From a built project:** + +```bash +cd /path/to/open-collaboration-tools +npm run build --workspace=open-collaboration-agent +node packages/open-collaboration-agent/bin/agent.js -r --acp-agent "opencode acp" +``` + +**Via the VSCode extension:** +If the extension starts the agent, the command line used must override the ACP agent. Once the extension supports an option for the ACP agent command, set it to `opencode acp`. Otherwise start the agent manually as above. + +## 4. Flow (ACP Bridge) + +1. You start the oct-agent with `--acp-agent "opencode acp"`. +2. The ACP bridge spawns the command `opencode acp` as a child process (stdio). +3. The bridge performs ACP initialization and session creation. +4. When an `@agent` trigger is detected in the document, the bridge sends an ACP trigger to OpenCode. +5. OpenCode runs in the local workspace (process.cwd() = workspace root). +6. Tool calls (e.g. read/write file) go over ACP; the bridge applies write operations via OCT DocumentSync. +7. All participants in the OCT session see the changes in real time. + +## 5. Usage in the OCT Room + +- **Trigger:** A line starting with `@agent` (or your chosen agent name) followed by your prompt, then Enter. +- **Example:** `// @agent Refactor this function to use async/await` +- Behaviour is the same as with Claude Code: same triggers, same sync logic; only the LLM and tools are provided by OpenCode. + +## 6. Notes + +- **Workspace:** The oct-agent must run in the **workspace directory** (as described in the README). OpenCode inherits the same `cwd()` for file access. +- **Slash commands:** Via ACP, OpenCode does not support all slash commands (e.g. `/undo`, `/redo`); see [OpenCode ACP Support](https://opencode.ai/docs/acp/). +- **Troubleshooting:** If the agent does not start, check in the console that `opencode acp` is on your PATH and that the OpenCode config and API key are correct. + +## References + +- [OpenCode – Intro & Install](https://opencode.ai/docs/) +- [OpenCode – Config & Models](https://opencode.ai/docs/config) +- [OpenCode – ACP Support](https://opencode.ai/docs/acp/) +- [OpenCode – Providers (Anthropic)](https://opencode.ai/docs/providers#anthropic) +- **OCT Agent:** `README.md`, `ACP_CONCEPT.md`, `ARCHITECTURE.md` diff --git a/packages/open-collaboration-agent/README.md b/packages/open-collaboration-agent/README.md new file mode 100644 index 00000000..6d47f1bf --- /dev/null +++ b/packages/open-collaboration-agent/README.md @@ -0,0 +1,130 @@ +# Open Collaboration Agent + +An AI agent for Open Collaboration Tools (OCT) sessions that runs in your local workspace and synchronizes changes with the collaborative session. + +## 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. + +### Example + +```bash +cd ~/my-project +node ~/oct-tools/packages/open-collaboration-agent/bin/agent -r my-room-id +``` + +## 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 synchronized to the OCT session (visible to all participants) +- Your cursor position is visible to other session participants +- Changes made by the agent appear in real-time to all collaborators + +## 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's cursor is visible to all participants + - Changes are synchronized in real-time + - Other participants can see the agent's edits as they happen + +## How It Works + +``` +Local Workspace → oct-agent (process.cwd()) + ↓ + ACP Agent (e.g. Claude Code) + ↓ + Local File Operations + ↓ + OCT Session Sync + ↓ + All Participants See 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/REMOTE_AGENT_CHALLENGES.md b/packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md new file mode 100644 index 00000000..709e55d4 --- /dev/null +++ b/packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md @@ -0,0 +1,457 @@ +# Remote Agent Deployment: Challenges & Limitations + +**Date:** 2025-01-19 +**Status:** Analysis & Design Document + +## Problem Statement + +The current open-collaboration-agent architecture **requires the agent to run on the same machine as the workspace** it's editing. This document explains why this limitation exists, when it matters, and explores potential future solutions. + +## Current Architecture Constraints + +### Filesystem Access Pattern + +The agent directly reads files from the local filesystem: + +**In ACP Bridge** (`src/acp-bridge.ts:501`): +```typescript +// Read file from local filesystem +let content = fs.readFileSync(absolutePath, 'utf8'); +``` + +The ACP agent receives document context via the bridge; file reads for multi-file or workspace access use the local filesystem. + +### Why This Matters + +``` +┌──────────────────────────────────────┐ +│ Machine A: Developer │ +│ │ +│ ┌────────────┐ │ +│ │ Workspace │ ← Files stored here │ +│ │ /project/ │ │ +│ └────────────┘ │ +│ │ +│ ┌────────────┐ │ +│ │ VSCode │ │ +│ │ + OCT │ │ +│ └────────────┘ │ +└──────────┬───────────────────────────┘ + │ OCT Protocol (WebSocket) + │ Only Yjs operations + │ NO file content + ↓ +┌──────────────────────────────────────┐ +│ OCT Server (Cloud) │ +│ - Routes messages │ +│ - No file storage │ +│ - Only Yjs sync state │ +└──────────┬───────────────────────────┘ + │ OCT Protocol + ↓ +┌──────────────────────────────────────┐ +│ Machine B: Remote Agent │ +│ │ +│ ┌────────────┐ │ +│ │ oct-agent │ │ +│ └────────────┘ │ +│ ↓ fs.readFileSync()? │ +│ ┌────────────┐ │ +│ │ Workspace │ ❌ NOT HERE! │ +│ │ /???/ │ │ +│ └────────────┘ │ +└──────────────────────────────────────┘ +``` + +**The Problem:** +- Agent on Machine B tries to read files from local filesystem +- Files only exist on Machine A +- OCT protocol doesn't transfer file contents - only document edits (Yjs operations) +- Agent has no way to access workspace files + +### What Gets Synchronized vs What Doesn't + +**✅ Synchronized via OCT (Yjs CRDT):** +- Currently open document content +- Document edits in real-time +- Cursor positions +- Active document path + +**❌ NOT Synchronized:** +- File system structure (directory tree) +- Closed files / files not currently open +- File metadata (permissions, timestamps) +- Binary files +- Project configuration files (unless opened) + +## Deployment Scenarios + +### Scenario 1: Host Starts Agent (✅ Supported) + +``` +┌─────────────────────────────────────┐ +│ Developer's Machine │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ VSCode │ │oct-agent │ │ +│ │ (Host) │ │ (Guest) │ │ +│ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ +│ ┌────▼─────────────────▼───┐ │ +│ │ Workspace │ │ +│ │ /home/dev/project/ │ │ +│ │ - src/ │ │ +│ │ - package.json │ │ +│ └───────────────────────────┘ │ +└─────────────────────────────────────┘ + │ + ↓ OCT Protocol + [OCT Server] +``` + +**How it works:** +1. Developer creates OCT room in VSCode +2. Developer starts agent in same workspace directory +3. Agent reads files directly from disk +4. Agent joins room as guest peer +5. Both VSCode and agent see all changes via Yjs + +**Why it works:** +- Both VSCode and agent run on same machine +- Both have access to same filesystem +- Agent uses `process.cwd()` = `/home/dev/project/` + +**Use Cases:** +- Single developer with AI assistant +- Local testing and development +- Personal productivity enhancement + +### Scenario 2: Participant Starts Agent (⚠️ Limited Support) + +``` +┌────────────────────┐ ┌────────────────────┐ +│ Machine A │ │ Machine B │ +│ (Host) │ │ (Guest) │ +│ │ │ │ +│ ┌────────────┐ │ │ ┌────────────┐ │ +│ │ VSCode │ │ │ │ oct-agent │ │ +│ │ /workspace/│ │ │ │ /workspace/│ │ +│ │ - src/ │ │ │ │ - src/ │ │ +│ └─────┬──────┘ │ │ └─────┬──────┘ │ +└───────┼────────────┘ └───────┼────────────┘ + │ │ + └───────────────┬───────────────┘ + ↓ + [OCT Server] +``` + +**Requirements:** +- Guest must have **identical copy** of workspace +- Files must be at same relative paths +- Guest must keep workspace in sync manually + +**Challenges:** +- ❌ No automatic workspace synchronization +- ❌ File changes outside OCT session not reflected +- ❌ Different file versions cause confusion +- ⚠️ Works only if guest manually syncs (git pull, etc.) + +**Use Cases:** +- Team member joins to help with AI suggestions +- Multiple developers with git-synced workspace +- Requires manual coordination + +### Scenario 3: Remote Agent Server (❌ Not Supported) + +``` +┌────────────────────┐ ┌────────────────────┐ +│ Developer │ │ Cloud Agent │ +│ Machine │ │ Server │ +│ │ │ │ +│ ┌────────────┐ │ │ ┌────────────┐ │ +│ │ VSCode │ │ │ │ oct-agent │ │ +│ │ │ │ │ │ (pool) │ │ +│ │ Workspace: │ │ │ │ │ │ +│ │ /project/ │ │ │ │ Workspace: │ │ +│ │ │ │ │ │ ??? │ │ +│ └─────┬──────┘ │ │ └─────┬──────┘ │ +└───────┼────────────┘ └───────┼────────────┘ + │ │ + └───────────────┬───────────────┘ + ↓ + [OCT Server] +``` + +**Why it doesn't work:** +- Cloud agent has no access to developer's local files +- OCT doesn't transfer complete workspace +- No file streaming mechanism in current protocol + +**Why you might want this:** +- Centralized agent serving multiple developers +- Powerful cloud hardware for agent +- No local agent installation required +- Consistent agent behavior across team + +**Why it's not implemented:** +- Major architectural change needed +- File streaming adds complexity and latency +- Security concerns (uploading workspace to cloud) +- Current design prioritizes simplicity + +## Technical Deep Dive + +### Code References + +**1. ACP Bridge File Reading** (`src/acp-bridge.ts:498-534`) + +```typescript +if (message.method === 'fs/read_text_file') { + // Read file from local filesystem + try { + let content = fs.readFileSync(absolutePath, 'utf8'); + + // Security check: ensure path is within workspace + const workspaceRoot = path.normalize(process.cwd()); + if (!absolutePath.startsWith(workspaceRoot)) { + // Reject access outside workspace + return error; + } + + return { content }; + } catch (error) { + return { error: 'File not found' }; + } +} +``` + +**Problem:** `fs.readFileSync` requires file to exist on local disk. + +**2. Workspace Context** (`src/document-operations.ts:82-85`) + +```typescript +export class DocumentSyncOperations implements DocumentOperations { + constructor( + private readonly documentSync: DocumentSync, + private readonly sessionInfo: SessionInfo // Contains workspace info + ) {} +} +``` + +**Session Info:** +```typescript +interface SessionInfo { + roomId: string; + agentId: string; + agentName: string; + hostId: string; + serverUrl: string; + // Note: NO workspacePath - each participant has their own +} +``` + +**3. VSCode Extension Integration** (`packages/open-collaboration-vscode/src/commands.ts:228-232`) + +```typescript +const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; +if (!workspaceFolder || workspaceFolder.uri.scheme !== 'file') { + vscode.window.showErrorMessage('No local workspace folder open'); + return; +} +``` + +**Requirement:** VSCode command explicitly checks for local file system workspace. + +### What Would Be Needed for Remote Support + +#### Option 1: File Streaming via OCT Protocol + +**Add to Protocol:** +```typescript +// New message types +Messages.FileSystem.RequestFile +Messages.FileSystem.FileContent +Messages.FileSystem.ListDirectory +``` + +**Flow:** +``` +Remote Agent → RequestFile("src/utils.ts") + ↓ (via OCT Server) + Host VSCode → Reads file → FileContent(content) + ↓ (via OCT Server) +Remote Agent → Receives content → Uses in LLM context +``` + +**Challenges:** +- Latency (network round-trip for every file) +- Security (host must approve file access) +- Large files (binary files, node_modules, etc.) +- File watching (how to detect changes?) +- Complexity (new protocol messages, caching, etc.) + +#### Option 2: Workspace Synchronization + +**Approach:** Automatically sync workspace to remote agent + +**Technologies:** +- rsync, git, or custom sync protocol +- Watch for file changes and sync +- Bidirectional sync (agent edits → host) + +**Challenges:** +- Large workspace (gigabytes of node_modules, etc.) +- Continuous syncing overhead +- Sync conflicts +- Security (uploading entire workspace) +- Setup complexity + +#### Option 3: Virtual File System (VFS) + +**Approach:** Abstract filesystem access + +**Implementation:** +```typescript +interface VirtualFileSystem { + readFile(path: string): Promise + writeFile(path: string, content: string): Promise + listDirectory(path: string): Promise +} + +// Implementations: +class LocalFileSystem implements VirtualFileSystem { /* Uses fs */ } +class RemoteFileSystem implements VirtualFileSystem { /* Uses OCT protocol */ } +``` + +**Benefits:** +- Clean abstraction +- Can swap implementations +- Testable + +**Challenges:** +- Must update all filesystem access points +- Async complications (currently sync) +- Performance overhead +- Cache management + +## Current Workarounds + +### Workaround 1: Manual Workspace Sync + +**For Scenario 2 (Participant starts agent):** + +1. Guest clones repository +2. Guest keeps workspace in sync via git +3. Guest starts agent in local workspace copy +4. Works as long as files stay in sync + +**Limitations:** +- Manual synchronization required +- Unsaved changes not synced +- Potential version mismatches + +### Workaround 2: Host-Only Agent + +**Best practice for current architecture:** + +- Only the host starts the agent +- Host has full workspace access +- Agent works perfectly +- Other participants just use regular editor + +**This is the recommended approach for now.** + +### Workaround 3: Share Workspace via Network FS + +**Using NFS, SSHFS, or similar:** + +``` +┌──────────────┐ ┌──────────────┐ +│ Machine A │ │ Machine B │ +│ (Host) │ │ (Agent) │ +│ │ │ │ +│ /workspace/ │◄──NFS──┤ /mnt/workspace/ +│ │ │ │ +└──────────────┘ └──────────────┘ +``` + +**Agent on Machine B:** +```bash +cd /mnt/workspace # Mounted from Machine A +oct-agent --room ... +``` + +**Challenges:** +- Network latency +- Requires infrastructure setup +- Potential permission issues +- Not suitable for cloud deployment + +## Comparison: Local vs Remote + +| Aspect | Local Agent | Remote Agent | +|--------|-------------|--------------| +| **Filesystem Access** | ✅ Direct | ❌ Requires streaming | +| **Latency** | ✅ Minimal | ❌ Network overhead | +| **Setup Complexity** | ✅ Simple | ❌ Complex | +| **Security** | ✅ No data upload | ⚠️ Workspace exposure | +| **Multi-user** | ❌ One per machine | ✅ Shared agent pool | +| **Cost** | ✅ Local compute | 💰 Cloud infrastructure | +| **Consistency** | ⚠️ Per-developer | ✅ Same for all | + +## Recommendations + +### For Current Implementation + +**✅ DO:** +- Run agent on same machine as workspace +- Use VSCode Extension command to start agent +- Ensure `process.cwd()` is workspace root + +**❌ DON'T:** +- Try to run agent remotely without workspace access +- Expect automatic file synchronization +- Share agent across machines without coordination + +### For Future Enhancement + +**If remote support is needed:** + +1. **Start with VFS abstraction** + - Cleanest architectural approach + - Allows testing different implementations + - Gradual migration + +2. **Implement file streaming protocol** + - Add OCT protocol messages for file operations + - Start with simple read-only access + - Add caching to reduce latency + +3. **Consider security implications** + - File access permissions + - Privacy (what files can agent see?) + - Audit logging + +4. **Optimize for common cases** + - Cache frequently accessed files + - Batch file requests + - Use workspace introspection to minimize requests + +## Conclusion + +The current local-agent architecture is a **deliberate design choice** that prioritizes: + +- ✅ **Simplicity** - Direct filesystem access, no complex protocols +- ✅ **Performance** - No network latency for file operations +- ✅ **Security** - No workspace data leaves developer's machine +- ✅ **Reliability** - Fewer failure modes, simpler debugging + +**Remote agent support** would require significant architectural changes and is **not planned for the initial release**. + +**Recommendation:** Use the host-starts-agent pattern (Scenario 1) for best experience with current implementation. + +## Related Documentation + +- **ARCHITECTURE.md** - Complete architecture overview +- **ACP_CONCEPT.md** - ACP protocol and external agent integration +- **README.md** - Getting started and workspace requirements 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..ae03cf83 --- /dev/null +++ b/packages/open-collaboration-agent/src/acp-bridge.ts @@ -0,0 +1,1043 @@ +// ****************************************************************************** +// 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[]; +} + +/** + * Agent configuration loaded from oct-agent.config.json + */ +export interface AgentConfig { + toolWhitelist: ToolWhitelistConfig; +} + +/** + * Default configuration used when no config file is found + */ +const DEFAULT_AGENT_CONFIG: AgentConfig = { + toolWhitelist: { + allowedKinds: ['read', 'edit'], + allowedToolNames: ['mcp__acp__Read', 'mcp__acp__Edit', 'mcp__acp__Write'] + } +}; + +/** + * 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}`); + return { + toolWhitelist: { + allowedKinds: parsed.toolWhitelist?.allowedKinds ?? DEFAULT_AGENT_CONFIG.toolWhitelist.allowedKinds, + allowedToolNames: parsed.toolWhitelist?.allowedToolNames ?? DEFAULT_AGENT_CONFIG.toolWhitelist.allowedToolNames + } + }; + } 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(() => { + 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, + }); + } else if (result.stopReason) { + pending.resolve({ + type: 'agent/response', + content: '', + stopReason: result.stopReason, + }); + } else { + pending.resolve(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. + */ + private async flushPendingProposals(): Promise { + if (this.pendingProposals.size === 0) { + return; + } + + const connection = this.documentOps?.getConnection(); + if (!connection) { + console.error('[ACP] Cannot flush proposals: no connection available'); + this.pendingProposals.clear(); + return; + } + + console.info(`[ACP] Flushing ${this.pendingProposals.size} 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(); + } + + 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)); + + // Try OCT document first, fallback to local filesystem + let content = this.documentOps?.getDocument(octPath); + if (content === undefined) { + content = fs.readFileSync(absolutePath, 'utf8'); + console.error(`[ACP] Read file from filesystem (not in OCT): ${absolutePath}`); + } 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'})`); + + const currentContent = this.documentOps.getDocument(octPath); + console.info(`[ACP] Current content from OCT: ${currentContent !== undefined ? 'found' : 'not found'}`); + + if (currentContent === undefined) { + // Document not tracked by OCT — write to local filesystem as fallback + // so the file exists for subsequent operations. + if (!fs.existsSync(absolutePath)) { + try { + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, newContent, 'utf8'); + console.info(`[ACP] Document not in OCT, created local file: ${octPath}`); + } catch (error: any) { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: `Failed to write local file: ${error.message || 'Unknown error'}`, + }, + }); + return; + } + } else { + console.info(`[ACP] Document not in OCT, local file already exists: ${octPath}`); + } + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + result: null, + }); + return; + } + + 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. + const existing = this.pendingProposals.get(octPath); + this.pendingProposals.set(octPath, { + octPath, + currentContent: existing?.currentContent ?? 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'); + } + + /** + * 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, + }, + ]; + + // 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 index 3d2ba696..7981e858 100644 --- a/packages/open-collaboration-agent/src/agent-util.ts +++ b/packages/open-collaboration-agent/src/agent-util.ts @@ -4,160 +4,149 @@ // 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'; +import { LineEdit } from './document-operations.js'; /** - * Applies the text region changes returned by the LLM to the document. + * Returns a typing delay in milliseconds based on the character type + * to simulate more realistic human typing patterns */ -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'); - } - } +function getTypingDelay(char: string): number { + // No delay for most characters to keep it responsive + if (char === ' ') return 50; // Slight pause after spaces + if (char === '\n') return 100; // Longer pause after newlines + if (char === '.' || char === ',' || char === ';') return 80; // Pause after punctuation + if (char === '{' || char === '}' || char === '(' || char === ')') return 30; // Small pause for structural characters + + // Random variation for other characters (20-60ms) + return Math.random() * 40 + 20; } /** - * Displays a loading animation at the specified position in the document. - * @returns A promise that resolves when the animation is complete (aborted) + * Calculates the character offset in the document for a given line. */ -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(); - }); +function calculateOffset(text: string, line: number): number { + const lines = text.split('\n'); + let offset = 0; - // Start the animation - updateChar(); + for (let i = 0; i < line && i < lines.length; i++) { + offset += lines[i].length + 1; // +1 for the newline character + } - return deferred.promise; + return offset; } /** - * Locates where in the document the change should be applied by finding the best - * matching context and identifying the section to be replaced. + * Applies line-based edits with natural, progressive animation. + * Makes the agent feel like a real colleague typing code changes. */ -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; +export async function applyLineEditsAnimated( + docPath: string, + docContent: string, + edits: LineEdit[], + documentSync: IDocumentSync +): Promise { + if (edits.length === 0) { + return; + } + + // Sort edits by line number (descending) to avoid offset shifts when applying multiple edits + const sortedEdits = [...edits].sort((a, b) => b.startLine - a.startLine); + + for (let i = 0; i < sortedEdits.length; i++) { + const edit = sortedEdits[i]; + + // Add a small pause between different edit operations + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, 250)); } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false; + + // Get fresh content from Yjs document (source of truth) + const currentContent = documentSync.getDocumentContent(docPath) || ''; + + if (edit.type === 'replace' && edit.endLine !== undefined && edit.content !== undefined) { + // Replace only the differing middle segment: + // - apply deletions immediately + // - animate only newly inserted characters + const startOffset = calculateOffset(currentContent, edit.startLine - 1); + const endOffset = calculateOffset(currentContent, edit.endLine); + const oldSegment = currentContent.substring(startOffset, endOffset); + const newSegment = edit.content; + + console.log(`Replacing lines ${edit.startLine}-${edit.endLine} (offset ${startOffset}, length ${oldSegment.length})`); + + // Find common prefix + let prefixLength = 0; + const maxPrefix = Math.min(oldSegment.length, newSegment.length); + while (prefixLength < maxPrefix && oldSegment[prefixLength] === newSegment[prefixLength]) { + prefixLength++; } - } - 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 common suffix (without overlapping prefix) + let suffixLength = 0; + while ( + suffixLength < (oldSegment.length - prefixLength) && + suffixLength < (newSegment.length - prefixLength) && + oldSegment[oldSegment.length - 1 - suffixLength] === newSegment[newSegment.length - 1 - suffixLength] + ) { + suffixLength++; } - } - } - // 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 oldDiffLength = oldSegment.length - prefixLength - suffixLength; + const insertText = newSegment.slice(prefixLength, newSegment.length - suffixLength); + const diffOffset = startOffset + prefixLength; + + // Apply deletions/replacements immediately + if (oldDiffLength > 0) { + documentSync.applyEdit(docPath, '', diffOffset, oldDiffLength); + documentSync.updateCursorPosition(docPath, diffOffset); } - } - } - const replacementText = changeLines.slice(changeSliceStart, changeSliceEnd).join('\n'); + // Animate only newly inserted text + let insertOffset = diffOffset; + for (const char of insertText) { + documentSync.applyEdit(docPath, char, insertOffset, 0); + insertOffset++; + documentSync.updateCursorPosition(docPath, insertOffset); + + const delay = getTypingDelay(char); + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + } - return { - startLine: docReplaceStartLine, - endLine: docReplaceEndLine, - replacementText: replacementText - }; -} + } else if (edit.type === 'insert' && edit.content !== undefined) { + // Insert: type content character by character + const insertOffset = edit.startLine === 1 ? 0 : calculateOffset(currentContent, edit.startLine - 1); + const contentToInsert = edit.startLine === 1 ? edit.content + '\n' : edit.content + '\n'; -/** - * 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; + console.log(`Inserting at line ${edit.startLine} (offset ${insertOffset})`); - for (let i = 0; i < line; i++) { - offset += lines[i].length + 1; // +1 for the newline character - } + let currentOffset = insertOffset; + for (const char of contentToInsert) { + documentSync.applyEdit(docPath, char, currentOffset, 0); + currentOffset++; + documentSync.updateCursorPosition(docPath, currentOffset); - return offset; + const delay = getTypingDelay(char); + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + } else if (edit.type === 'delete' && edit.endLine !== undefined) { + // Delete: remove content character by character (backwards) + const startOffset = calculateOffset(currentContent, edit.startLine - 1); + const endOffset = calculateOffset(currentContent, edit.endLine); + const length = endOffset - startOffset; + + console.log(`Deleting lines ${edit.startLine}-${edit.endLine} (offset ${startOffset}, length ${length})`); + + for (let deleteLen = length; deleteLen > 0; deleteLen--) { + documentSync.applyEdit(docPath, '', startOffset, 1); + documentSync.updateCursorPosition(docPath, startOffset); + await new Promise(resolve => setTimeout(resolve, 5)); // Fast deletion + } + } + } } diff --git a/packages/open-collaboration-agent/src/agent.ts b/packages/open-collaboration-agent/src/agent.ts index 9110c893..e8d6018c 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,29 +110,131 @@ export async function startCLIAgent(options: AgentOptions): Promise { }); console.log(`✅ Received peer info: ${identity.name} (${identity.id})`); - runAgent(documentSync, identity, options); + // Set the agent's peer ID in the awareness state so its cursor is visible + documentSync.setAgentPeerId(identity.id); + + // 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) + animationAbort: AbortController + source: 'document' | 'chat' + sendChatResponse?: (message: string) => Promise // Only present for chat triggers + }) => Promise } -export function runAgent(documentSync: DocumentSync, identity: Peer, options: AgentOptions): void { +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); +} + +/** + * 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 + 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(); @@ -128,49 +242,39 @@ export function runAgent(documentSync: DocumentSync, identity: Peer, options: Ag } 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}"`); + console.error(`Received prompt: "${prompt}"`); // Create an AbortController for the loading animation state.animationAbort = new AbortController(); 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, + animationAbort: state.animationAbort, + 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(); @@ -185,5 +289,295 @@ export function runAgent(documentSync: DocumentSync, identity: Peer, options: Ag } } } + }; + + 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) => { + state.animationAbort = new AbortController(); + try { + state.executing = true; + await onTrigger({ + docPath, + docContent, + prompt, + animationAbort: state.animationAbort, + source: 'chat', + sendChatResponse: (msg: string) => connection.chat.sendMessage(msg), + }); + } catch (error) { + state.animationAbort?.abort(); + 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; + state.animationAbort = undefined; + } + }; + + const isFileAtPath = (filePath: string): boolean => { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch { + return 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 () => { + if (state.animationAbort) { + state.animationAbort.abort(); + } + 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, animationAbort, 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 acpTrigger = { + 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(acpTrigger); + } catch (error: any) { + // Handle sessionId missing error specifically + if (error.message?.includes('sessionId is required')) { + // Abort the animation + animationAbort.abort(); + + const errorMessage = 'Error: ACP session not initialized. Please ensure the ACP agent started successfully.'; + + if (source === 'chat' && sendChatResponse) { + // Send error via chat + await sendChatResponse(errorMessage); + } else if (change) { + // Insert error message into document after the trigger line + await documentOps.applyEditsAnimated(docPath, [{ + type: 'insert', + startLine: triggerLine + 1, + content: `// ${errorMessage}`, + }]); + + // Remove the trigger line + const trigger = `@${identity.name}`; + documentOps.removeTriggerLine(docPath, trigger); + + // Clear cursor + documentOps.updateCursor(docPath, 0); + } + + // Re-throw to be caught by outer catch block for logging + throw error; + } + // Re-throw other errors + throw error; + } + + // Abort the animation (the setupTriggerDetection will handle awaiting it) + animationAbort.abort(); + + // 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 } shape on top of + // the typed AgentResponse, so we read the fields defensively. + const wrappedResponse = response as { type?: string; content?: unknown }; + 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 and clear cursor + if (source === 'document' && change) { + // Remove the trigger line LAST (after all edits are applied) + const trigger = `@${identity.name}`; + documentOps.removeTriggerLine(docPath, trigger); + + // Clear the agent's cursor position after all work is done + documentOps.updateCursor(docPath, 0); + } + + // For chat triggers, send a response + if (source === 'chat' && sendChatResponse) { + await sendChatResponse('Done! I have applied the changes to the active document.'); + } + }, + }); + + 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..90fc1ec4 --- /dev/null +++ b/packages/open-collaboration-agent/src/document-operations.ts @@ -0,0 +1,120 @@ +// ****************************************************************************** +// 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'; + +/** + * Represents a line-based edit operation + */ +export interface LineEdit { + type: 'replace' | 'insert' | 'delete'; + startLine: number; + endLine?: number; + content?: string; +} + +/** + * 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; + + /** + * Apply multiple line-based edits with animation + */ + applyEditsAnimated(path: string, edits: LineEdit[]): Promise; + + /** + * Remove a line containing the trigger pattern + */ + removeTriggerLine(path: string, trigger: string): void; + + /** + * Update the cursor position for awareness + */ + updateCursor(path: string, offset: number): void; + + /** + * Get session information + */ + getSessionInfo(): SessionInfo; + + /** + * Get the currently active document path + */ + getActiveDocumentPath(): string | undefined; +} + +/** + * 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); + } + + async applyEditsAnimated(path: string, edits: LineEdit[]): Promise { + const { applyLineEditsAnimated } = await import('./agent-util.js'); + const content = this.documentSync.getDocumentContent(path); + if (content === undefined) { + throw new Error(`Document not found: ${path}`); + } + await applyLineEditsAnimated(path, content, edits, this.documentSync); + } + + 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); + } + } + + updateCursor(path: string, offset: number): void { + this.documentSync.updateCursorPosition(path, offset); + } + + getSessionInfo(): SessionInfo { + return this.sessionInfo; + } + + getActiveDocumentPath(): string | undefined { + return this.documentSync.getActiveDocumentPath(); + } +} diff --git a/packages/open-collaboration-agent/src/document-sync.ts b/packages/open-collaboration-agent/src/document-sync.ts index a1534b5f..b04a2fd6 100644 --- a/packages/open-collaboration-agent/src/document-sync.ts +++ b/packages/open-collaboration-agent/src/document-sync.ts @@ -4,7 +4,7 @@ // terms of the MIT License, which is available in the project root. // ****************************************************************************** -import type { ClientAwareness, ProtocolBroadcastConnection } from 'open-collaboration-protocol'; +import type { ClientAwareness, ClientTextSelection, ProtocolBroadcastConnection } from 'open-collaboration-protocol'; import { OpenCollaborationYjsProvider, LOCAL_ORIGIN } from 'open-collaboration-yjs'; import * as Y from 'yjs'; import * as awarenessProtocol from 'y-protocols/awareness'; @@ -31,8 +31,22 @@ 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; + updateCursorPosition(documentPath: string, offset: number): void; + getDocumentContent(documentPath: string): string | undefined; } export class DocumentSync implements IDocumentSync { @@ -45,6 +59,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 +69,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 +88,58 @@ 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; + } + + /** + * Sets the agent's peer ID in the awareness state + * This makes the agent's cursor visible to other collaborators + */ + setAgentPeerId(peerId: string): void { + this.yjsAwareness.setLocalStateField('peer', peerId); + } + + /** + * 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 +226,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 +413,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 +428,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 +506,29 @@ export class DocumentSync implements IDocumentSync { } } } + + /** + * Updates the agent's cursor position in the awareness state + * @param documentPath The path of the document + * @param offset The character offset of the cursor + */ + updateCursorPosition(documentPath: string, offset: number): void { + const ytext = this.yjs.getText(documentPath); + + // Create a CRDT-based relative position for the cursor + const relativePosition = Y.createRelativePositionFromTypeIndex(ytext, offset); + + // Create a selection range (cursor is a zero-width selection) + const textSelection: ClientTextSelection = { + path: documentPath, + textSelections: [{ + start: relativePosition, + end: relativePosition, + direction: 1 // LeftToRight + }] + }; + + // Update the awareness state with the new cursor position + this.yjsAwareness.setLocalStateField('selection', textSelection); + } } diff --git a/packages/open-collaboration-agent/src/index.ts b/packages/open-collaboration-agent/src/index.ts index 76a89e82..f1a38832 100644 --- a/packages/open-collaboration-agent/src/index.ts +++ b/packages/open-collaboration-agent/src/index.ts @@ -5,6 +5,5 @@ // ****************************************************************************** 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..39cd2eb1 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 for tool whitelist configuration') .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 index 77f4b901..a7faf7b4 100644 --- a/packages/open-collaboration-agent/test/agent-util.test.ts +++ b/packages/open-collaboration-agent/test/agent-util.test.ts @@ -4,196 +4,43 @@ // 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 { describe, expect, test, vi } from 'vitest'; +import { applyLineEditsAnimated } 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', () => { + describe('applyLineEditsAnimated', () => { + test('should apply replace deletions instantly and animate only inserted diff', async () => { 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]); + let content = 'const value = old;\n'; + const applyEditMock = vi.fn((_: string, text: string, offset: number, length: number) => { + content = content.substring(0, offset) + text + content.substring(offset + length); + }); + const updateCursorPositionMock = vi.fn(); + + const documentSync: IDocumentSync = { + applyEdit: applyEditMock, + updateCursorPosition: updateCursorPositionMock, + getDocumentContent: () => content + }; - // 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); + await applyLineEditsAnimated(docPath, content, [{ + type: 'replace', + startLine: 1, + endLine: 1, + content: 'const value = new;\n' + }], documentSync); + + expect(content).toBe('const value = new;\n'); + expect(applyEditMock).toHaveBeenCalledTimes(4); + + const [deleteCall, ...insertCalls] = applyEditMock.mock.calls; + expect(deleteCall).toEqual([docPath, '', 14, 3]); + expect(insertCalls).toEqual([ + [docPath, 'n', 14, 0], + [docPath, 'e', 15, 0], + [docPath, 'w', 16, 0] + ]); }); }); }); 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..ec1da07c --- /dev/null +++ b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts @@ -0,0 +1,189 @@ +// ****************************************************************************** +// 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 writes locally when OCT document is missing and file does not exist', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const bridge = new ACPBridge('echo', { + getDocument: () => undefined, + 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-new-${Date.now()}.ts`); + const absolutePath = path.resolve(process.cwd(), relativePath); + + try { + await (bridge as any).handleFileSystemRequest({ + id: 'req-3', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: 'export const created = true;', + }, + }); + + expect(fs.existsSync(absolutePath)).toBe(true); + expect(fs.readFileSync(absolutePath, 'utf8')).toBe('export const created = true;'); + expect(proposeChanges).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'req-3', result: null }) + ); + } finally { + fs.rmSync(path.resolve(process.cwd(), 'tmp'), { recursive: true, force: true }); + } + }); + + test('ACPBridge write_text_file does not overwrite when OCT document is missing but file exists locally', async () => { + const proposeChanges = vi.fn().mockResolvedValue(undefined); + const sendMessage = vi.fn(); + + const bridge = new ACPBridge('echo', { + getDocument: () => undefined, + 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-existing-${Date.now()}.ts`); + const absolutePath = path.resolve(process.cwd(), relativePath); + + try { + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, 'pre-existing content', 'utf8'); + + await (bridge as any).handleFileSystemRequest({ + id: 'req-4', + method: 'fs/write_text_file', + params: { + path: relativePath, + content: 'new content from agent', + }, + }); + + expect(fs.readFileSync(absolutePath, 'utf8')).toBe('pre-existing content'); + expect(proposeChanges).not.toHaveBeenCalled(); + 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..86ac4503 100644 --- a/packages/open-collaboration-monaco/src/collaboration-instance.ts +++ b/packages/open-collaboration-monaco/src/collaboration-instance.ts @@ -17,6 +17,7 @@ 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 interface Disposable { dispose(): void; @@ -43,6 +44,7 @@ export class CollaborationInstance implements Disposable { protected readonly decorations = new Map(); protected readonly usersChangedCallbacks: UsersChangeEvent[] = []; protected readonly fileNameChangeCallbacks: FileNameChangeEvent[] = []; + protected readonly proposedChangesCallbacks: ProposedChangesEvent[] = []; protected currentPath?: string; protected stopPropagation = false; @@ -53,6 +55,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 +109,10 @@ export class CollaborationInstance implements Disposable { this.fileNameChangeCallbacks.push(callback); } + onProposedChanges(callback: ProposedChangesEvent) { + this.proposedChangesCallbacks.push(callback); + } + constructor(protected options: CollaborationInstanceOptions) { this.connection = options.connection; this.yjsAwareness = new awarenessProtocol.Awareness(this.yjs); @@ -165,6 +174,8 @@ 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)); } private setupFileSystemHandlers(): void { @@ -215,6 +226,137 @@ 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) { + const accept = () => { + this.stopPropagation = false; + model.setValue(modifiedText); + this.notifyProposedChanges(path, true); + }; + const reject = () => { + this.stopPropagation = false; + this.notifyProposedChanges(path, false); + }; + 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; + editor.layout(); + }; + + acceptButton.addEventListener('click', () => { + cleanup(); + model.setValue(modifiedText); + this.notifyProposedChanges(path, true); + }); + + rejectButton.addEventListener('click', () => { + cleanup(); + this.notifyProposedChanges(path, false); + }); + } + private notifyUsersChanged(): void { this.usersChangedCallbacks.forEach(callback => callback()); } @@ -223,6 +365,10 @@ 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)); + } + setEditor(editor: monaco.editor.IStandaloneCodeEditor): void { this.options.editor = editor; this.registerEditorEvents(); @@ -250,6 +396,10 @@ export class CollaborationInstance implements Disposable { } dispose() { + this.activeDiffEditor?.dispose(); + this.activeDiffEditor = undefined; + this.diffEditorContainer?.remove(); + this.diffEditorContainer = 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..01aaee22 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 } 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,7 @@ export type MonacoCollabApi = { getFileName: () => string | undefined setWorkspaceName: (workspaceName: string) => void getWorkspaceName: () => string | undefined + onProposedChanges: (callback: ProposedChangesEvent) => void } export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { @@ -192,6 +199,12 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { } }; + const registerProposedChangesHandler = (callback: ProposedChangesEvent) => { + if (instance) { + instance.onProposedChanges(callback); + } + }; + const isLoggedIn = async () => { if (!connectionProvider) { return false; @@ -225,7 +238,8 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { setFileName: doSetFileName, getFileName: doGetFileName, getWorkspaceName: doGetWorkspaceName, - setWorkspaceName: doSetWorkspaceName + setWorkspaceName: doSetWorkspaceName, + onProposedChanges: registerProposedChangesHandler }; } diff --git a/packages/open-collaboration-protocol/.vscode/settings.json b/packages/open-collaboration-protocol/.vscode/settings.json new file mode 100644 index 00000000..29550f08 --- /dev/null +++ b/packages/open-collaboration-protocol/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oct.serverUrl": "http://localhost:8100" +} diff --git a/packages/open-collaboration-protocol/src/connection.ts b/packages/open-collaboration-protocol/src/connection.ts index 260a8896..f75a928b 100644 --- a/packages/open-collaboration-protocol/src/connection.ts +++ b/packages/open-collaboration-protocol/src/connection.ts @@ -33,7 +33,7 @@ export interface EditorHandler { onClose(handler: Handler<[types.Path]>): void; close(path: types.Path): Promise; onProposeChanges(handler: Handler<[types.Path, types.TextDiffChange[]]>): void; - proposeChanges(target: MessageTarget, path: types.Path, changes: types.TextDiffChange[]): Promise; + proposeChanges(path: types.Path, changes: types.TextDiffChange[]): Promise; } export interface FileSystemHandler { @@ -152,8 +152,8 @@ export class ProtocolBroadcastConnectionImpl extends AbstractBroadcastConnection 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), - proposeChanges: (target, path, changes) => this.sendNotification(Messages.Editor.ProposeChanges, target, path, changes), - onProposeChanges: handler => this.onNotification(Messages.Editor.ProposeChanges, handler) + proposeChanges: (path, changes) => this.sendBroadcast(Messages.Editor.ProposeChanges, path, changes), + onProposeChanges: handler => this.onBroadcast(Messages.Editor.ProposeChanges, handler) }; sync: SyncHandler = { diff --git a/packages/open-collaboration-protocol/src/messages.ts b/packages/open-collaboration-protocol/src/messages.ts index 4eb90b48..e97b80d1 100644 --- a/packages/open-collaboration-protocol/src/messages.ts +++ b/packages/open-collaboration-protocol/src/messages.ts @@ -27,7 +27,8 @@ export namespace Messages { 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 NotificationType<[types.Path, types.TextDiffChange[]]>('editor/createDiff'); + export const ProposeChanges = new BroadcastType<[types.Path, types.TextDiffChange[]]>('editor/createDiff'); + export const AcceptChanges = new BroadcastType<[types.Path]>('editor/acceptDiff'); } export namespace Sync { diff --git a/packages/open-collaboration-vscode/package.json b/packages/open-collaboration-vscode/package.json index 0c15c5bd..9940a8a3 100644 --- a/packages/open-collaboration-vscode/package.json +++ b/packages/open-collaboration-vscode/package.json @@ -262,6 +262,13 @@ "title": "%oct.sendDiff%", "category": "Open Collaboration Tools", "icon": "$(send)" + }, + { + "command": "oct.startAgent", + "title": "%oct.startAgent%", + "category": "Open Collaboration Tools", + "icon": "$(robot)", + "enablement": "oct.connection && workbenchState != empty" } ], "colors": [ @@ -354,4 +361,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-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index f7c5b158..ddf34fd3 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -177,6 +177,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 +228,7 @@ export class CollaborationInstance implements vscode.Disposable { private peers = new Map(); private pending = new Map(); private throttles = new Map void>(); + private proposalDebounces = new Map>(); private yjsDocuments = new Map(); private _permissions: types.Permissions = { readonly: false }; @@ -356,6 +398,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(); @@ -615,7 +661,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 +691,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(); @@ -680,48 +734,78 @@ export class CollaborationInstance implements vscode.Disposable { } }); - this.connection.editor.onProposeChanges(async (_, path, changes) => this.onDidProposeChanges(path, changes)); + this.connection.editor.onProposeChanges((_, path, changes) => this.onDidProposeChanges(path, changes)); } proposeChanges(path: string, changes: types.TextDiffChange[]): void { - this.connection.editor.proposeChanges(this.options.hostId, path, changes); + this.connection.editor.proposeChanges(path, changes); } - private async onDidProposeChanges(path: string, changes: types.TextDiffChange[]): Promise { + 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) { + 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(); - const tempUri = vscode.Uri.parse(`untitled:${originalUri.path}.modified`); - - await vscode.workspace.openTextDocument(tempUri); - const edit = new vscode.WorkspaceEdit(); - edit.replace( - tempUri, - new vscode.Range(0, 0, Number.MAX_SAFE_INTEGER, 0), - originalDocument.getText(), - ); - - for(const change of changes) { - edit.replace(tempUri, new vscode.Range( - change.range.start.line, - change.range.start.character, - change.range.end.line, - change.range.end.character - ), change.text); + 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); } - await vscode.workspace.applyEdit(edit); + 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: originalUri, - input1: { uri: originalUri, title: 'Your Changes', description: 'Local' }, - input2: { uri: tempUri, title: 'Collaborator Changes', description: 'Remote' }, + base: baseUri, + input1: { uri: localUri, title: 'Your Changes', description: 'Local' }, + input2: { uri: agentUri, title: 'Agent Changes', description: 'Remote' }, output: originalUri }); } diff --git a/packages/open-collaboration-vscode/src/commands-list.ts b/packages/open-collaboration-vscode/src/commands-list.ts index ee92f18e..e7f9213e 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 3d5c8c90..732d819f 100644 --- a/packages/open-collaboration-vscode/src/commands.ts +++ b/packages/open-collaboration-vscode/src/commands.ts @@ -87,7 +87,10 @@ export class Commands { ), vscode.commands.registerCommand(DiffSupportCommands.SendDiff, async (file: vscode.Uri) => this.diffService.sendDiff(file) - ) + ), + vscode.commands.registerCommand(OctCommands.StartAgent, async () => { + await this.startAgent(); + }) ); if (typeof process === 'object' && process && process.env?.DEVELOPMENT === 'true') { this.contextKeyService.set('oct.dev', true); @@ -233,4 +236,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.') + ); + } } From 2ed5f64ea66a5aba30cc9f5cec59e94e9c884bdb Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Wed, 27 May 2026 14:52:20 +0200 Subject: [PATCH 08/19] Remove files related to the OCT agent, including local user settings, superfluous documentation and agent definitions. --- .claude/agents/oct-collab-agent.md | 155 ------ .claude/commands/connect-to-oct.md | 59 --- .claude/commands/disconnect-from-oct.md | 18 - .claude/settings.json | 3 - packages/OCT-Talk-Abstract.md | 23 - .../open-collaboration-agent/ACP_CONCEPT.md | 156 ------ .../open-collaboration-agent/ARCHITECTURE.md | 466 ------------------ .../DEVELOPMENT_JOURNEY.md | 381 -------------- .../OPENCODE_AS_OCT_AGENT.md | 94 ---- .../REMOTE_AGENT_CHALLENGES.md | 457 ----------------- .../.vscode/settings.json | 3 - 11 files changed, 1815 deletions(-) delete mode 100644 .claude/agents/oct-collab-agent.md delete mode 100644 .claude/commands/connect-to-oct.md delete mode 100644 .claude/commands/disconnect-from-oct.md delete mode 100644 .claude/settings.json delete mode 100644 packages/OCT-Talk-Abstract.md delete mode 100644 packages/open-collaboration-agent/ACP_CONCEPT.md delete mode 100644 packages/open-collaboration-agent/ARCHITECTURE.md delete mode 100644 packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md delete mode 100644 packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md delete mode 100644 packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md delete mode 100644 packages/open-collaboration-protocol/.vscode/settings.json diff --git a/.claude/agents/oct-collab-agent.md b/.claude/agents/oct-collab-agent.md deleted file mode 100644 index 16e35d14..00000000 --- a/.claude/agents/oct-collab-agent.md +++ /dev/null @@ -1,155 +0,0 @@ ---- -name: oct-collab-agent -description: Agent for monitoring and responding to @agent triggers in OCT collaboration sessions ---- - -You are a specialized agent for Open Collaboration Tools (OCT) sessions. Your role is to automatically respond to @agent triggers in real-time collaboration sessions. - -## How You Work - -**You will receive MCP resource update notifications** when `@agent` triggers are detected. When you receive a notification for `oct://triggers/current`, you MUST immediately and automatically: - -1. Read the `oct://triggers/current` MCP resource to get trigger details -2. Process the trigger following the workflow below -3. No user confirmation needed - act immediately - -## Responding to Notifications - -**CRITICAL**: You receive `notifications/resources/updated` with URI `oct://triggers/current` when new triggers are detected. - -When you receive such a notification: - -- **Act immediately** - don't ask the user what to do -- Read `oct://triggers/current` MCP resource -- Follow the Automatic Trigger Processing Workflow below -- Process the trigger completely before doing anything else - -This is not optional - you MUST respond to these notifications automatically. - -## Automatic Trigger Processing Workflow - -When you receive a notification or detect a trigger: - -0. **Acknowledge Internally** (don't wait for user) - - - Read `oct://triggers/current` MCP resource - - Parse trigger data: `{ id, docPath, docContent, prompt, offset, timestamp }` - -1. **Stop Loading Animation** - - - Immediately call `oct_trigger_start_processing(triggerId)` - - This stops the spinner animation - -2. **Read Context** - - - Use `oct_get_document` or `oct_get_document_range` to understand the code - - Documents are returned with 1-indexed line numbers - - Analyze the surrounding code to understand what needs to be changed - -3. **Plan Your Edits** - - - Determine what line-based edits are needed (replace, insert, or delete) - - Line numbers are 1-indexed (first line = 1) - - Plan to mark ALL your changes with AI marker comments - -4. **Apply Edits** - - - Use `oct_apply_edit` for each change - - Edit structure: - ```json - { - "type": "replace" | "insert" | "delete", - "startLine": number, - "endLine": number, // for replace/delete - "content": string // for replace/insert - } - ``` - - **CRITICAL**: Always include AI marker comments in your edits - -5. **Complete and Clean Up** - - - Call `oct_trigger_complete(triggerId)` to mark the trigger as done - - Use `oct_remove_trigger_line(docPath)` to remove the `@agent` line - - Inform the user what you changed - -6. **Check for More** - - Check `oct://triggers/pending` to see if there are more triggers waiting - - If so, automatically process them or inform the user - -## AI Marker Comments - -**You MUST mark all your changes** with comment markers so developers know what you modified: - -- JavaScript/TypeScript/Java/C++: `// AI: ` -- Python/Ruby/Shell: `# AI: ` -- HTML/XML: `` -- CSS: `/* AI: */` - -**Example:** - -```javascript -// Original code (lines 5-7): -function fetchData() { - return fetch('/api/data'); -} - -// Your edit (replace lines 5-7): -{ - "type": "replace", - "startLine": 5, - "endLine": 7, - "content": "// AI: Added error handling and async/await\nasync function fetchData() {\n try {\n const response = await fetch('/api/data');\n return await response.json();\n } catch (error) {\n console.error('Fetch failed:', error);\n throw error;\n }\n}" -} -``` - -## Important Rules - -1. **Line Numbers**: Always 1-indexed, not 0-indexed -2. **Original Document**: Use line numbers from the original document you read -3. **Marker Comments**: MUST be present for every change -4. **Multiple Edits**: Apply them in descending line order to avoid offset issues -5. **Cursor Updates**: The MCP server handles cursor positioning automatically -6. **Real-time Sync**: Your changes sync immediately to all collaborators - -## Tools Available - -- `oct_get_document(path)` - Get full document with line numbers -- `oct_get_document_range(path, startLine, endLine)` - Get specific lines -- `oct_apply_edit(path, edit)` - Apply a single edit -- `oct_trigger_start_processing(triggerId)` - Stop loading animation and mark as processing -- `oct_trigger_complete(triggerId)` - Mark trigger as completed -- `oct_remove_trigger_line(path)` - Remove the trigger line -- `oct_get_session_info()` - Get session metadata -- `oct_get_connection_status()` - Check if connected - -## Example Session - -``` -Developer writes: // @agent Add input validation for email - -MCP server sends notification for oct://triggers/current - -You automatically respond by: -1. Read oct://triggers/current - get trigger ID and prompt -2. oct_trigger_start_processing(triggerId) - stop loading animation -3. oct_get_document_range(path, 1, 50) to see the function -4. Identify the function that needs validation (e.g., lines 10-15) -5. oct_apply_edit to add validation logic with "// AI: Added email validation" marker -6. oct_trigger_complete(triggerId) - mark as done -7. oct_remove_trigger_line(path) to clean up the trigger -8. Report success to the developer -``` - -## Your Behavior - -- **Be automatic**: React immediately to notifications without asking the user -- **Be proactive**: When you receive a trigger notification, process it right away -- Be helpful and precise with code changes -- Always explain what you changed and why -- If unsure about the trigger request, make your best interpretation or ask for clarification AFTER attempting -- Respect the coding style of the existing code -- Keep edits minimal and focused on the request -- Test your logic mentally before applying edits -- After processing, check for more triggers and process them too - -You are a collaborative coding assistant - work seamlessly with developers in their shared editing session! diff --git a/.claude/commands/connect-to-oct.md b/.claude/commands/connect-to-oct.md deleted file mode 100644 index 9da52402..00000000 --- a/.claude/commands/connect-to-oct.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -description: Connect Claude Code to an Open Collaboration Tools session -args: ---- - -You are being asked to connect to an Open Collaboration Tools (OCT) session. - -**First, check if room ID is provided:** - -- If `{{args}}` is empty or contains only `{{args}}` (the literal placeholder), ask the user: "Please provide the OCT room ID to connect to." -- Wait for the user to provide the room ID before proceeding -- If `{{args}}` contains a valid room ID, proceed with the connection steps below - -Follow these steps: - -1. **Connect to the OCT room** - - - Use the `oct_connect` MCP tool with `roomId` parameter set to `{{args}}` - - **CRITICAL**: The connection response will include a `loginUrl` field - this is NOT an error! - - Display the login URL prominently to the user with clear instructions: - - Tell them to open the URL in their browser - - Explain this is required for authentication - - Let them know the connection will complete once they log in - - The tool call will wait and complete automatically after the user authenticates in their browser - -2. **Verify connection** - - - Use `oct_get_connection_status` to confirm you're connected - - Display session information to the user (room ID, agent name, etc.) - -3. **Launch Background Monitoring Agent (CRITICAL)** - - - **IMPORTANT**: You MUST launch the oct-collab-agent as a background Task immediately after connection - - Use the Task tool with: - - `subagent_type: "oct-collab-agent"` - - `description: "Monitor OCT triggers"` - - `prompt: "You are now in monitoring mode for OCT collaboration session. Continuously call oct_wait_for_trigger() to wait for triggers. When a trigger arrives, process it immediately using the workflow in your agent definition, then loop back to oct_wait_for_trigger(). Keep monitoring until disconnected."` - - This agent will run in the background and automatically handle all @agent triggers - - The agent will block on `oct_wait_for_trigger()` (no tokens used while waiting) - - When a trigger arrives, it will process it and return to waiting - - **DO NOT skip this step** - without the monitoring agent, triggers won't be processed automatically - -4. **Explain to the user** - - - You are now connected as a peer in the OCT collaboration session - - You will appear as a collaborator with your agent name (e.g., "my-agent") - - A background monitoring agent is now running to handle triggers automatically - - When developers write `@your-agent-name ` in their code: - - A loading animation appears automatically - - The monitoring agent will process it in real-time - - No user action required - - **Tell the user**: "I'm connected and monitoring! When anyone writes `@agent `, I'll automatically process it. A background agent is now handling all triggers." - -Important notes: - -- Your cursor position will be visible to other collaborators -- All your edits will sync in real-time via the OCT protocol -- Multiple developers can work simultaneously with you -- Always use the appropriate comment syntax for the file type (e.g., `//` for JS/TS, `#` for Python) diff --git a/.claude/commands/disconnect-from-oct.md b/.claude/commands/disconnect-from-oct.md deleted file mode 100644 index f00c5c99..00000000 --- a/.claude/commands/disconnect-from-oct.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Disconnect from the current OCT collaboration session ---- - -When disconnecting from an OCT session, follow these steps: - -1. **Stop Background Monitoring Agent** (if running) - - If a background oct-collab-agent Task was launched during connection, it should be stopped - - Note: The agent will automatically stop when disconnection occurs since oct_wait_for_trigger will fail - -2. **Disconnect from OCT Session** - - Use the `oct_disconnect` tool to disconnect from the OCT session - - This will cleanup all resources, stop animations, and close the connection - -3. **Confirm to User** - - Confirm successful disconnection to the user - - Inform them that the monitoring agent has been stopped (if applicable) - - Let them know they can reconnect using `/connect-to-oct ` diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index da39e4ff..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "mcpServers": {} -} diff --git a/packages/OCT-Talk-Abstract.md b/packages/OCT-Talk-Abstract.md deleted file mode 100644 index 25a63da4..00000000 --- a/packages/OCT-Talk-Abstract.md +++ /dev/null @@ -1,23 +0,0 @@ -## Collaborative Coding Across Web Applications and Theia-based IDEs - -Alternative Titles: - - Collaborative Coding Across Platforms: When AI Agents Join the Session - - Breaking Boundaries: Cross-Platform Collaboration Meets AI Agents - - Collaborative Coding Beyond IDEs: Connecting Platforms and AI Agents - - Cross-Platform Collaborative Coding: From Web Apps to AI Agents - ---- - -@Miro / @JanB - -Collaboration in software projects shouldn't be limited to developers inside an IDE. With Eclipse Theia as the foundation and Eclipse Open Collaboration Tools (OCT) providing shared editing, we can now connect Theia-based IDEs with arbitrary web applications—making live collaboration a first-class experience for everyone involved in a project. This talk will highlight how Theia enables real-time collaboration not only between developers, but also with project leads, stakeholders, and domain experts directly in their domain-specific tools. We'll also explore how to extend these collaborative sessions with AI agent integration, enabling any participant to interact with the agent directly and streamlining workflows that previously required switching between local AI tools and collaborative sessions. Key themes include: - -- Why extending live sharing beyond IDEs to custom web applications changes team dynamics and accelerates feedback -- How OCT's Monaco integration enables shared editing and cross-application synchronization -- The OCT Playground as a lightweight environment to experiment with collaborative features -- The OCT Agent: bringing AI into collaborative coding sessions as a visible participant with its own cursor, connectable to any AI API -- Extending collaboration capabilities through MCP (Model Context Protocol) for integrating additional agents -- Chat-based communication as part of the OCT Protocol for seamless interaction between all participants, including AI agents -- Forward-looking opportunities for building truly collaborative development environments on top of Theia - -Attendees will leave with a clear picture of how Theia can power collaborative development environments that bridge the gap between traditional IDE workflows and domain-specific web applications, and how AI agents can become active participants in these shared coding experiences. diff --git a/packages/open-collaboration-agent/ACP_CONCEPT.md b/packages/open-collaboration-agent/ACP_CONCEPT.md deleted file mode 100644 index feaa2a0a..00000000 --- a/packages/open-collaboration-agent/ACP_CONCEPT.md +++ /dev/null @@ -1,156 +0,0 @@ -# Agent Client Protocol (ACP) Concept - -**Status:** 📝 DRAFT (v4) -**Date:** 2025-11-21 - -## Objective - -The **Agent Client Protocol (ACP)** is a communication standard that allows an **AI Agent** (like Claude Code, a custom CLI agent, or an IDE plugin) to connect directly to an **Open Collaboration Tools (OCT) Session** as a first-class participant. - -## Architecture - -The `oct-agent` CLI connects exclusively via **ACP** (Agent Client Protocol): - -* **Technology:** Agent Client Protocol (ACP). -* **Behavior:** The `oct-agent` acts as a **bridge**. It connects to the OCT session and forwards all triggers/events to an external ACP agent (default: `npx @zed-industries/claude-code-acp`). Override with `--acp-agent` to use any ACP-capable agent. -* **Use Case:** Claude Code, Cursor, or custom enterprise agents; model and API keys are configured in the ACP agent, not in oct-agent. - -*(Previously, a built-in Embedded mode existed; it was removed in favor of ACP-only.)* - -## Integration with `zed-industries/claude-code-acp` - -We have discovered an existing ACP adapter for Claude Code: [`zed-industries/claude-code-acp`](https://github.com/zed-industries/claude-code-acp). This tool wraps the Claude Code SDK and exposes it as an ACP server (stdio or socket). - -This simplifies our architecture significantly! - -### The Workflow - -1. **User starts OCT Agent:** - ```bash - oct-agent --room - ``` - -2. **OCT Agent (The Bridge):** - * Connects to the OCT Session (WebSocket). - * Spawns the ACP Adapter (`npx @zed-industries/claude-code-acp`) as a child process. - * **Pipes** ACP messages between the OCT Session and the ACP Adapter. - -3. **Data Flow:** - * **OCT:** `@agent refactor this` (Trigger) - * **OCT Agent:** Wraps this in an ACP `agent/trigger` message. - * **ACP Adapter:** Receives message, calls Claude Code SDK. - * **Claude Code:** Executes logic, maybe asks for permissions. - * **ACP Adapter:** Sends `agent/response` or `agent/action`. - * **OCT Agent:** Forwards to OCT Session (applies edits via Yjs). - -### Protocol Definition (ACP) - -ACP is a set of message types exchanged over the OCT transport (WebSocket). - -#### 1. `agent/trigger` (Inbound to Agent) - -Sent when the OCT system detects an intent for the agent to act. - -```json -{ - "type": "agent/trigger", - "id": "trig-123", - "source": { - "type": "document", - "path": "src/main.ts", - "line": 42 - }, - "content": { - "prompt": "refactor this function", - "context": "..." // Optional: Immediate context if available - } -} -``` - -#### 2. `agent/action` (Outbound from Agent) - -The agent performs an action in the session. - -```json -{ - "type": "agent/action", - "triggerId": "trig-123", // Correlate with the trigger - "action": "edit", - "payload": { - "file": "src/main.ts", - "edits": [ ... ] - } -} -``` - -## VSCode Extension Integration - -The VSCode extension now provides a convenient command to start the agent directly from the IDE: - -### Starting the Agent from VSCode - -1. Create or join an OCT room in VSCode -2. Open Command Palette (Cmd+Shift+P) -3. Run: "Open Collaboration Tools: Start Agent" -4. The agent automatically starts in your workspace directory with the correct room ID and server URL - -### Implementation Details - -- **Command:** `oct.startAgent` -- **Automatically detects** development vs production environment -- **Development:** Uses local build from mono-repo (`../open-collaboration-agent/bin/agent`) -- **Production:** Uses `npx open-collaboration-agent` -- **Agent runs** in workspace directory via VSCode terminal -- **All configuration** (room ID, server URL) passed automatically - -### Workspace Requirement - -**IMPORTANT:** The agent MUST run in the same workspace directory as the OCT session because: - -- File system operations use `fs.readFileSync` for reading local files -- Agent uses `process.cwd()` as workspace context -- No remote file streaming - files are read from local filesystem -- See `REMOTE_AGENT_CHALLENGES.md` for details about deployment scenarios - -### How It Works - -```mermaid -sequenceDiagram - participant User - participant VSCode - participant Command as oct.startAgent - participant Terminal - participant Agent as oct-agent - - User->>VSCode: Open project workspace - User->>VSCode: Create/Join OCT room - User->>VSCode: Command Palette - User->>Command: "Start Agent" - Command->>Command: Check session exists - Command->>Command: Check workspace is local - Command->>Command: Detect dev/prod mode - Command->>Terminal: Create in workspace dir - Terminal->>Agent: Spawn with --room, --server - Agent->>Agent: Authenticate (browser) - Agent->>Agent: Join OCT session - Agent->>VSCode: Ready to process @agent triggers -``` - -## Implementation - -The `oct-agent` always uses the ACP bridge: - -* **Logic:** Spawns the ACP agent (default: `npx @zed-industries/claude-code-acp`) as a child process. Override with `--acp-agent`. -* **Flow:** - 1. `DocumentSync` detects trigger. - 2. `oct-agent` converts trigger to ACP JSON message (`agent/trigger`). - 3. `oct-agent` writes JSON to child process `stdin`. - 4. Child process (e.g. Zed Adapter) calls Claude Code SDK. - 5. Child process writes response JSON (`agent/action`) to `stdout`. - 6. `oct-agent` reads JSON and applies edits via `DocumentSync`. - -## Benefits - -1. **Simplicity:** One code path; no mode switch. -2. **Flexibility:** Any ACP-capable agent via `--acp-agent`. -3. **Unified CLI:** One tool (`oct-agent`) for all ACP-based integrations. diff --git a/packages/open-collaboration-agent/ARCHITECTURE.md b/packages/open-collaboration-agent/ARCHITECTURE.md deleted file mode 100644 index 3ee7ed5a..00000000 --- a/packages/open-collaboration-agent/ARCHITECTURE.md +++ /dev/null @@ -1,466 +0,0 @@ -# Open Collaboration Agent: Architecture - -**Date:** 2025-01-19 -**Status:** Current Implementation - -## Overview - -The open-collaboration-agent enables AI agents to participate in Open Collaboration Tools (OCT) sessions as first-class peers, with real-time code editing capabilities. The agent connects exclusively via **ACP** (Agent Client Protocol) to external agents (e.g. Claude Code via `@zed-industries/claude-code-acp`). The ACP agent is configurable via `--acp-agent`. - -## Architectural Layers - -```mermaid -flowchart TB - subgraph ide [IDE Integration Layer 6] - VSCode[VSCode Extension] - Terminal[VSCode Terminal] - end - - subgraph trigger [Trigger Detection Layer 5] - TriggerDetect[Trigger Detection] - Animation[Loading Animation] - end - - subgraph mode [Agent Mode Layer 4] - ACP[ACP Bridge] - end - - subgraph ops [Document Operations Layer 3] - DocOps[DocumentSyncOperations] - LineEdit[Line-based Edits] - end - - subgraph sync [Document Sync Layer 2] - Yjs[Yjs CRDT] - Provider[OCT Yjs Provider] - end - - subgraph protocol [Protocol Connection Layer 1] - Connection[Protocol Connection] - WebSocket[WebSocket Transport] - end - - VSCode -->|oct.startAgent| Terminal - Terminal -->|spawns| TriggerDetect - TriggerDetect -->|"@agent detected"| Animation - Animation -->|onTrigger| ACP - ACP -->|tool calls| DocOps - DocOps -->|apply edits| Yjs - Yjs -->|sync| Provider - Provider -->|broadcast| Connection - Connection -->|transport| WebSocket -``` - -## Layer 1: Protocol Connection - -**Purpose:** Establish and maintain connection to OCT server - -**Components:** -- `ProtocolBroadcastConnection` from `open-collaboration-protocol` -- WebSocket transport via Socket.IO -- Authentication flow (browser-based login) -- Peer identity management - -**Key Operations:** -- Login to server -- Join room with room token -- Maintain connection with auto-reconnect -- Peer awareness (cursor tracking, active document) - -**Code Location:** `src/agent.ts` (lines 25-65) - -## Layer 2: Document Synchronization (Yjs CRDT) - -**Purpose:** Real-time collaborative document editing with conflict-free merging - -**Components:** -- `DocumentSync` class (`src/document-sync.ts`) -- Yjs `Y.Doc` and `Y.Text` for document state -- `OpenCollaborationYjsProvider` for OCT protocol integration -- Awareness protocol for cursor positions - -**Key Features:** -- Follows host's active document automatically -- Detects document changes with position tracking -- Provides callbacks for `@agent` trigger detection -- Conflict-free merge of concurrent edits (CRDT) - -**Code Location:** `src/document-sync.ts` - -## Layer 3: Document Operations Abstraction - -**Purpose:** Unified interface for document manipulation shared by all modes - -**Components:** -- `DocumentOperations` interface (`src/document-operations.ts`) -- `DocumentSyncOperations` implementation -- `LineEdit` type for structured edits - -**Operations:** -```typescript -interface DocumentOperations { - getDocument(path: string): string | undefined - applyEditsAnimated(path: string, edits: LineEdit[]): Promise - removeTriggerLine(path: string, trigger: string): void - updateCursor(path: string, offset: number): void - getSessionInfo(): SessionInfo - getActiveDocumentPath(): string | undefined -} -``` - -**Benefits:** -- Single source of truth for document operations -- Used by ACP bridge -- Animated cursor movement during edits -- Line-based editing (easier for LLMs than character offsets) - -**Code Location:** `src/document-operations.ts` - -## Layer 4: Agent Mode (ACP) - -### ACP Bridge - -**Purpose:** Bridge to external agents via Agent Client Protocol - -**Flow:** -``` -Trigger → ACPBridge.sendTrigger() → ACP Agent → session/prompt → tool calls → edits -``` - -**Components:** -- `ACPBridge` class (`src/acp-bridge.ts`) -- JSON-RPC over stdio communication -- Integration with `@zed-industries/claude-code-acp` - -**Features:** -- Spawns external ACP agent as child process -- Session management (initialize, create session) -- File system operations (fs/read_text_file, fs/write_text_file) -- Permission handling (auto-approve tool calls) -- Bidirectional communication (server → agent, agent → server) - -**Advantages:** -- Standard protocol (works with any ACP client) -- Flexible tool-based workflows -- External agent can use advanced capabilities -- Proper bidirectional communication - -**Code Location:** `src/acp-bridge.ts` - -## Layer 5: Trigger Detection & Execution - -**Purpose:** Detect `@agent` mentions and orchestrate execution - -**Components:** -- `setupTriggerDetection()` in `src/agent.ts` -- Document change handler -- Trigger line detection (newline after `@agent`) - -**Detection Logic:** -```typescript -// Detects pattern: "@agent \n" -if (change.type === 'insert' && change.text === '\n') { - const completedLine = docLines[change.position.line]; - const triggerIndex = completedLine?.indexOf('@agent'); - if (triggerIndex !== -1) { - const prompt = completedLine.substring(triggerIndex + 6).trim(); - // Execute agent with prompt - } -} -``` - -**Workflow:** -1. Monitor document changes via `DocumentSync.onDocumentChange()` -2. Detect newline insertion after `@agent` pattern -3. Extract prompt text -4. Start loading animation at trigger position -5. Invoke ACP handler (onTrigger) -6. Apply edits with animated cursor -7. Remove trigger line -8. Clear cursor position - -**Code Location:** `src/agent.ts` (lines 125-291) - -## Layer 6: IDE Integration (VSCode Extension) - -**Purpose:** Seamless agent launching from VSCode - -**Components:** -- Command: `oct.startAgent` in VSCode extension -- Terminal integration -- Automatic configuration passing - -**Workflow:** -``` -User runs command → VSCode creates terminal → Agent starts in workspace directory -``` - -**Implementation Details:** -- Detects development vs production environment -- Development: Uses local build path -- Production: Uses `npx open-collaboration-agent` -- Automatically passes room ID and server URL -- Agent runs in workspace directory (`cwd: workspaceFolder.uri.fsPath`) - -**Code Location:** `packages/open-collaboration-vscode/src/commands.ts` (lines 221-265) - -## Data Flow Diagrams - -### Trigger Processing (ACP) - -```mermaid -sequenceDiagram - participant User - participant VSCode - participant DocSync as DocumentSync - participant Bridge as ACP Bridge - participant ACP as ACP Agent - participant DocOps as DocumentOps - participant Yjs - participant OCT as OCT Session - - User->>VSCode: Write "@agent add validation\n" - VSCode->>DocSync: Document change (insert '\n') - DocSync->>Bridge: onTrigger callback - Bridge->>DocSync: Start loading animation - Bridge->>ACP: session/prompt (JSON-RPC) - ACP->>ACP: Process with Claude Code - ACP->>Bridge: tool_call (edit) - Bridge->>Bridge: Request permission (auto-approve) - Bridge->>DocOps: applyEditsAnimated() - DocOps->>Yjs: Apply edits to Y.Text - Yjs->>OCT: Broadcast changes - Bridge->>DocSync: removeTriggerLine() - Bridge->>DocSync: Clear cursor position -``` - -## Filesystem Architecture - -### Current Design: Local Filesystem Access - -``` -┌─────────────────────────────────────────┐ -│ User's Workspace │ -│ │ -│ ┌──────────────────┐ │ -│ │ Project Files │ │ -│ │ - src/ │ │ -│ │ - package.json │ │ -│ │ - ... │ │ -│ └────────┬─────────┘ │ -│ │ fs.readFileSync() │ -│ ↓ │ -│ ┌──────────────────┐ │ -│ │ oct-agent │ │ -│ │ (process.cwd()) │ │ -│ └────────┬─────────┘ │ -└───────────┼──────────────────────────────┘ - │ Yjs CRDT sync - ↓ -┌─────────────────────────────────────────┐ -│ OCT Session (Cloud/Server) │ -│ - Document state synchronized │ -│ - All participants see changes │ -│ - No file content stored on server │ -└─────────────────────────────────────────┘ -``` - -**Key Constraint:** Agent MUST run in workspace directory - -**Why:** -- ACP Bridge reads files with `fs.readFileSync(absolutePath, 'utf8')` (line 501 in `acp-bridge.ts`) -- Agent uses `process.cwd()` as workspace root -- No file streaming over network -- Files are NOT stored on OCT server - only document state (Yjs) is synchronized - -**Implications:** -- ✅ **Supported:** Agent runs on same machine as workspace -- ❌ **Not Supported:** Agent runs on remote machine without workspace access -- See `REMOTE_AGENT_CHALLENGES.md` for detailed analysis - -## Key Components Reference - -| Component | Purpose | Location | -|-----------|---------|----------| -| `main.ts` | CLI entry point, argument parsing | `src/main.ts` | -| `agent.ts` | Agent lifecycle, trigger detection | `src/agent.ts` | -| `acp-bridge.ts` | ACP protocol bridge for external agents | `src/acp-bridge.ts` | -| `document-sync.ts` | Yjs-based document synchronization | `src/document-sync.ts` | -| `document-operations.ts` | Shared document manipulation interface | `src/document-operations.ts` | -| `agent-util.ts` | Animated line-based edits | `src/agent-util.ts` | - -## Configuration - -### CLI Arguments - -```bash -oct-agent --room [options] -``` - -**Options:** -- `-s, --server ` - OCT server URL (default: https://api.open-collab.tools/) -- `--acp-agent ` - ACP agent command (default: npx @zed-industries/claude-code-acp) - -### Environment Variables - -API keys and model selection are configured in the ACP agent (e.g. Claude Code), not in oct-agent. - -## Deployment Scenarios - -### Scenario 1: Single Developer with VSCode - -``` -┌──────────────────┐ -│ VSCode IDE │ -│ │ -│ ┌────────────┐ │ -│ │ Workspace │ │ -│ └──────┬─────┘ │ -│ │ │ -│ ┌──────▼─────┐ │ -│ │ oct-agent │ │ -│ │ (terminal) │ │ -│ └──────┬─────┘ │ -└─────────┼────────┘ - │ - ↓ OCT Protocol - [OCT Server] -``` - -**How to start:** -1. Command Palette → "Open Collaboration Tools: Start Agent" -2. Agent runs in VSCode terminal with correct configuration - -### Scenario 2: Team Collaboration - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Developer │ │ Developer │ │ Agent │ -│ (VSCode) │ │ (Theia) │ │ (CLI) │ -│ [Host] │ │ [Guest] │ │ [Guest] │ -└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ - │ │ │ - └───────────────────┴───────────────────┘ - │ - ↓ OCT Protocol - [OCT Server] - │ - ┌───────────────────┴───────────────────┐ - │ │ - ┌──▼──┐ ┌──▼──┐ - │ Yjs │ Synchronized document state │ Yjs │ - └─────┘ └─────┘ -``` - -**How it works:** -- Host creates room in VSCode -- Guest joins via room ID -- Agent joins as guest peer -- All see real-time changes via Yjs CRDT - -### Scenario 3: CI/CD Integration (Future) - -``` -┌─────────────────────────────────────┐ -│ CI/CD Pipeline │ -│ │ -│ 1. Clone repository │ -│ 2. Start oct-agent in background │ -│ 3. Join test room │ -│ 4. Process automated tasks │ -│ 5. Report results │ -└─────────────────────────────────────┘ -``` - -**Not yet implemented** - requires headless authentication - -## Security Considerations - -1. **Authentication:** - - Browser-based OAuth flow - - No credentials stored in agent - - Session tokens are ephemeral - -2. **File System Access:** - - Agent has full access to workspace directory - - Security boundary at `process.cwd()` - - Path validation in ACP bridge (lines 484-496) - -3. **Code Execution:** - - LLM responses are not executed directly - - Only document edits are applied - - All changes visible to collaborators - -4. **Network:** - - End-to-end encryption via OCT protocol - - WebSocket connection over TLS - - No file content sent to server (only Yjs operations) - -## Performance Characteristics - -### ACP Mode - -- **Latency:** ~3-7 seconds per trigger (LLM inference + IPC overhead) -- **Token Usage:** Depends on the connected ACP agent -- **Memory:** ~100-200 MB (oct-agent + ACP child process) -- **Network:** Minimal from oct-agent (Yjs operations, ~1-10 KB per edit); ACP agent may call LLM APIs - -## Future Enhancements - -1. **Remote Agent Support** - - File streaming over OCT protocol - - Virtual file system abstraction - - See `REMOTE_AGENT_CHALLENGES.md` for analysis - -2. **Multi-File Operations** - - Agent can edit multiple files in one trigger - - File creation/deletion support - - Project-wide refactoring - -3. **Persistent Agent Sessions** - - Long-running agent that doesn't exit - - Maintains conversation context - - Background monitoring - -## Troubleshooting - -### Agent doesn't detect triggers - -**Check:** -- Agent is connected (look for "✅ Joined the room") -- You're writing in the active document -- Pattern is exactly `@agent \n` -- Agent name matches (check with `oct_get_session_info()`) - -### Agent can't read files - -**Check:** -- Agent is running in workspace directory -- File paths are relative to workspace -- Path is within workspace (security check) - -### Edits don't appear - -**Check:** -- Other participants have file open -- Network connection is stable -- Yjs provider is connected (check logs) - -## Additional Documentation - -- **README.md** - Getting started guide -- **ACP_CONCEPT.md** - Agent Client Protocol design and integration -- **REMOTE_AGENT_CHALLENGES.md** - Remote deployment challenges -- **DEVELOPMENT_JOURNEY.md** - Historical context: how the architecture evolved from MCP attempts to ACP - -## Conclusion - -The open-collaboration-agent architecture is designed for: - -- **Real-time collaboration** via Yjs CRDT -- **ACP-only design** – connect any ACP-capable agent via `--acp-agent` -- **IDE integration** for seamless developer experience -- **Local-first design** with workspace filesystem access -- **Extensibility** through shared document operations abstraction - -The architecture prioritizes **simplicity** and **efficiency** while maintaining **flexibility** for future enhancements. diff --git a/packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md b/packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md deleted file mode 100644 index 38627104..00000000 --- a/packages/open-collaboration-agent/DEVELOPMENT_JOURNEY.md +++ /dev/null @@ -1,381 +0,0 @@ -# OCT Agent: Development Journey - -**Status:** 📚 Historical Documentation -**Last Updated:** 2026-01-19 - -## Purpose of This Document - -This document chronicles the development journey of the Open Collaboration Tools (OCT) Agent, documenting the architectural decisions, challenges encountered, and solutions implemented. It serves as a guide to understanding **why** the current architecture exists and **how** we arrived at the final design. - -## What is the OCT Agent? - -The OCT Agent is an AI-powered participant in collaborative coding sessions that can: -- Join OCT sessions as a peer -- Respond to `@agent` triggers in shared documents -- Make real-time code edits visible to all participants -- Connect to external agents exclusively via **ACP** (Agent Client Protocol) - -## Development Timeline - -```mermaid -gantt - title OCT Agent Development Timeline - dateFormat YYYY-MM-DD - section Phase 1 MCP Integration - MCP Notification Problem discovered :milestone, 2025-10-20, 0d - Agent Autonomy Problem identified :milestone, 2025-10-21, 0d - section Phase 2 ACP Solution - ACP Concept developed :milestone, 2025-11-21, 0d - section Phase 3 Final Implementation - Architecture finalized :milestone, 2026-01-19, 0d - Deployment analysis completed :milestone, 2026-01-19, 0d - section Phase 4 Simplification - Embedded mode removed, ACP only :milestone, 2026-01-20, 0d -``` - -## Phase 1: MCP Integration Attempts (October 2025) - -### The Initial Vision - -The first approach attempted to integrate the agent with Claude Code using the **Model Context Protocol (MCP)**. The idea was elegant: - -1. OCT Agent runs as an MCP server -2. Claude Code connects as MCP client -3. When `@agent` triggers are detected, send MCP notifications -4. Claude Code automatically invokes agent to process triggers - -### Problem 1: MCP Notification Limitation - -**Discovered:** October 20, 2025 - -**What we learned:** -- MCP notifications are **passive information updates** -- Claude Code receives notifications but doesn't automatically invoke agents -- MCP is designed for **client → server** (pull), not **server → client** (push) - -**The architectural mismatch:** - -```mermaid -sequenceDiagram - participant Dev as Developer - participant OCT as OCT MCP Server - participant Claude as Claude Code - participant Agent as Agent - - Dev->>OCT: Writes @agent trigger - OCT->>OCT: Detects trigger - OCT->>Claude: MCP Notification - Claude->>Claude: Receives notification - Note over Claude,Agent: ❌ No automatic agent invocation - Note over Agent: Agent never gets called -``` - -**Solutions explored:** -- **Solution A**: Blocking wait tool (`oct_wait_for_trigger`) -- **Solution B**: MCP sampling (server-initiated AI inference) -- **Solution C**: Hybrid auto-detecting approach - -**Result:** Solution C was implemented but revealed a deeper problem... - -### Problem 2: Agent Autonomy Problem - -**Discovered:** October 21, 2025 - -**The core issue:** -- Task agents in Claude Code are designed for **one-off tasks** -- After processing first trigger, agent **terminates automatically** -- No mechanism for persistent background agents -- Subsequent triggers have no agent to process them - -**The lifecycle problem:** - -```mermaid -flowchart TB - subgraph first [First Trigger - Works] - T1[Trigger arrives]-->Launch[Launch background agent] - Launch-->Wait[Agent calls oct_wait_for_trigger] - Wait-->Process[Agent processes trigger] - Process-->Exit[Agent Task completes and EXITS] - end - - subgraph second [Second Trigger - Fails] - T2[Trigger arrives]-->Queue[Queued in pendingTriggers] - Queue-->NoAgent[❌ No agent running to dequeue] - end - - Exit-->T2 - - style NoAgent fill:#f99 - style Exit fill:#f99 -``` - -**Why this was a fundamental problem:** -- Not a bug in our implementation -- Architectural limitation in Claude Code's Task agent system -- MCP protocol doesn't provide agent lifecycle management -- Workarounds were complex and fragile - -**Key insight:** MCP is the wrong protocol for our use case. - -### Why MCP Wasn't Ideal - -| Aspect | What MCP Provides | What OCT Agent Needs | -|--------|-------------------|---------------------| -| **Communication** | Unidirectional (Client → Server) | Bidirectional (Both ways) | -| **Triggering** | Client decides when to call tools | External events trigger agent | -| **Lifecycle** | Client-managed | Session-managed | -| **Use Case** | Tools/Resources for agents | Agent collaboration | - -## Phase 2: ACP Solution (November 2025) - -### The Breakthrough: Agent Client Protocol - -**Developed:** November 21, 2025 -**Documented in:** [ACP_CONCEPT.md](ACP_CONCEPT.md) - -**Discovery:** The `@zed-industries/claude-code-acp` package provides proper bidirectional communication with Claude Code through the Agent Client Protocol. - -### Why ACP Solved the Problems - -**ACP provides:** -- ✅ **Bidirectional communication**: Server can send requests to agent -- ✅ **Event-driven architecture**: External events naturally trigger agent actions -- ✅ **Session-based**: Proper lifecycle management -- ✅ **Direct stdio communication**: Lower latency, simpler flow -- ✅ **Structured tool calls**: Agent gets context and responds with edits - -**The ACP flow:** - -```mermaid -sequenceDiagram - participant Dev as Developer - participant Bridge as ACP Bridge - participant ACP as Claude Code ACP - participant Yjs as Document Sync - - Dev->>Bridge: Writes @agent trigger - Bridge->>Bridge: Detects trigger - Bridge->>ACP: session/prompt (JSON-RPC) - ACP->>ACP: Process with Claude Code - ACP->>Bridge: tool_call (edit) - Bridge->>Bridge: Auto-approve permission - Bridge->>Yjs: Apply edits - Yjs->>Dev: Changes visible in real-time -``` - -### The ACP-Only Architecture - -The solution was to integrate external agents via **ACP (Agent Client Protocol)**. Initially, the OCT Agent supported two modes (Embedded: direct LLM, and ACP Bridge). In a later simplification, **Embedded was removed**; the agent now runs only via the ACP Bridge. - -**ACP Bridge (current):** -- Integration with external agents (Claude Code, etc.) via `--acp-agent` -- Bidirectional communication via ACP -- Advanced capabilities and flexible tool-based workflows -- Any ACP-capable agent can be connected - -## Phase 3: Final Architecture (January 2026) - -### Current Implementation - -**Finalized:** January 19, 2026 -**Documented in:** [ARCHITECTURE.md](ARCHITECTURE.md) - -The final architecture consists of 6 layers: - -```mermaid -flowchart TB - subgraph Layer6 [Layer 6 IDE Integration] - VSCode[VSCode Extension] - end - - subgraph Layer5 [Layer 5 Trigger Detection] - TriggerDetect[Trigger Detection] - end - - subgraph Layer4 [Layer 4 Agent Mode] - ACP[ACP Bridge] - end - - subgraph Layer3 [Layer 3 Document Operations] - DocOps[DocumentSyncOperations] - end - - subgraph Layer2 [Layer 2 Document Sync] - Yjs[Yjs CRDT] - end - - subgraph Layer1 [Layer 1 Protocol Connection] - Connection[OCT Connection] - end - - VSCode-->TriggerDetect - TriggerDetect-->ACP - ACP-->DocOps - DocOps-->Yjs - Yjs-->Connection -``` - -**Unified interface:** The ACP bridge and MCP server share the same `DocumentOperations` abstraction. - -### Deployment Considerations - -**Analyzed in:** [REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md) - -**Key constraint:** Agent must run in workspace directory -- Uses `fs.readFileSync()` for local file access -- No remote file streaming -- `process.cwd()` is workspace root - -**Supported scenarios:** -- ✅ Host starts agent (same machine as workspace) -- ⚠️ Participant starts agent (requires manual workspace sync) -- ❌ Remote agent server (no workspace access) - -**Design philosophy:** Local-first architecture for simplicity, performance, and security. - -### Phase 4: Simplification – ACP Only (January 2026) - -**Milestone:** January 20, 2026 - -Embedded mode (direct LLM via `executeLLM`/`prompt.ts`) was removed. The agent now runs exclusively via the ACP bridge. Benefits: - -- **Single code path:** No `--mode` switch; fewer branches and dependencies -- **Thinner agent:** Removed `@ai-sdk/*`, `ai`, `zod`; no `prompt.ts` -- **Same flexibility:** Any ACP-capable agent can be connected with `--acp-agent` - -## Documentation Roadmap - -### For Understanding the Journey - -Read in this order to understand the development process: - -1. **[DEVELOPMENT_JOURNEY.md](DEVELOPMENT_JOURNEY.md)** (this file) - Overview of the journey -2. **[ACP_CONCEPT.md](ACP_CONCEPT.md)** - The solution -3. **[ARCHITECTURE.md](ARCHITECTURE.md)** - Final implementation - -### For Using the Agent - -Read in this order if you just want to use it: - -1. **[README.md](README.md)** - Getting started guide -2. **[ARCHITECTURE.md](ARCHITECTURE.md)** - How it works -3. **[REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md)** - Deployment scenarios - -### For Specific Topics - -- **Integration patterns**: [ACP_CONCEPT.md](ACP_CONCEPT.md) -- **Deployment scenarios**: [REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md) -- **Architecture layers**: [ARCHITECTURE.md](ARCHITECTURE.md) - -## Architectural Learnings - -### 1. Protocol Selection Matters - -**Lesson:** Choose protocols based on your communication pattern, not popularity. - -- MCP is excellent for client-driven tool access -- ACP is better for event-driven agent collaboration -- The right protocol eliminates workarounds - -### 2. Bidirectional Communication is Hard - -**Lesson:** Server-initiated actions require proper protocol support. - -What we learned: -- Notifications are not the same as requests -- Polling and blocking are workarounds, not solutions -- Agent lifecycle management needs to be built into the protocol - -### 3. Simplicity Through Abstraction - -**Lesson:** Shared abstractions enable flexibility. - -The `DocumentOperations` interface: -- Is shared by the ACP bridge and MCP server -- Makes testing easier (mock the interface) -- Enables future additions without breaking existing code - -### 4. Document the Journey, Not Just the Destination - -**Lesson:** Historical context helps future developers understand "why." - -Benefits: -- New team members understand design decisions -- Avoids repeating past mistakes -- Provides justification for current architecture -- Helps evaluate when to reconsider decisions - -### 5. Local-First is a Feature, Not a Limitation - -**Lesson:** Constraints drive good design. - -The local workspace requirement: -- Eliminates file streaming complexity -- Improves performance (no network latency) -- Enhances security (no workspace upload) -- Simplifies implementation - -Trade-off: Remote deployment requires different approach, but that's okay. - -## Key Milestones - -| Date | Milestone | Significance | -|------|-----------|--------------| -| 2025-10-20 | MCP Notification Problem discovered | First attempt at integration, learned MCP limitations | -| 2025-10-21 | Agent Autonomy Problem identified | Understood fundamental architectural mismatch | -| 2025-11-21 | ACP Concept developed | Found the right protocol for the job | -| 2026-01-19 | Architecture finalized | Dual-mode system with shared abstractions | -| 2026-01-20 | Embedded mode removed, ACP only | Simplified to single code path; any ACP-capable agent connectable via `--acp-agent` | - -## Evolution Summary - -```mermaid -flowchart LR - subgraph attempt1 [Attempt 1 MCP] - MCP[MCP Integration]-->Notif[Notifications dont trigger agents] - Notif-->Auto[Autonomy problem] - end - - subgraph solution [Solution ACP] - ACP[Agent Client Protocol]-->Bidir[Bidirectional communication] - Bidir-->Session[Session management] - end - - subgraph final [Final Design] - ACPOnly[ACP-Only]-->ACP2[ACP Bridge] - ACP2-->Shared[Shared DocumentOperations] - end - - Auto-->ACP - Session-->ACPOnly - - style Notif fill:#f99 - style Auto fill:#f99 - style Bidir fill:#9f9 - style Session fill:#9f9 - style Shared fill:#9f9 -``` - -## Conclusion - -The OCT Agent development journey demonstrates that: - -1. **First solutions aren't always the best solutions** - MCP seemed ideal but had fundamental limitations -2. **Understanding protocols deeply matters** - Knowing the difference between MCP and ACP was crucial -3. **Simplicity through single code path** - ACP-only avoids mode switches; `--acp-agent` keeps flexibility -4. **Documentation is a gift to future developers** - This journey guide helps others understand the "why" - -The current architecture is not just the result of implementation, but the result of learning, iterating, and finding the right tools for the job. - -## Related Documentation - -### Current Implementation -- [ARCHITECTURE.md](ARCHITECTURE.md) - Complete architecture overview -- [ACP_CONCEPT.md](ACP_CONCEPT.md) - ACP integration design -- [README.md](README.md) - User guide and getting started - -### Deployment -- [REMOTE_AGENT_CHALLENGES.md](REMOTE_AGENT_CHALLENGES.md) - Deployment scenarios and constraints - -### Alternative ACP agents -- [OPENCODE_AS_OCT_AGENT.md](OPENCODE_AS_OCT_AGENT.md) - Using OpenCode as the ACP agent diff --git a/packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md b/packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md deleted file mode 100644 index 6972aa2d..00000000 --- a/packages/open-collaboration-agent/OPENCODE_AS_OCT_AGENT.md +++ /dev/null @@ -1,94 +0,0 @@ -# OpenCode as OCT Agent (ACP Bridge) - -OpenCode can be used instead of Claude Code as the ACP agent. When you start the **oct-agent** with OpenCode as the ACP agent, the ACP bridge spawns the OpenCode process on startup and forwards all `@agent` triggers to it. - -## Overview - -- **ACP Bridge:** The oct-agent spawns an ACP-capable process (e.g. `opencode acp`) and communicates via JSON-RPC over stdio. -- **OpenCode ACP:** The command `opencode acp` starts OpenCode as an ACP subprocess; it uses the same protocol as Claude Code via `@zed-industries/claude-code-acp`. -- **Flow:** OCT session → oct-agent → ACP bridge spawns `opencode acp` → triggers and edits work as usual. - -## Prerequisites - -1. **OpenCode installed** (e.g. `curl -fsSL https://opencode.ai/install | bash` or `npm install -g opencode-ai`). -2. **Anthropic (Claude) configured** in OpenCode (e.g. `/connect` in the OpenCode TUI or API key in `~/.config/opencode/opencode.json`). -3. **Model (optional):** Claude Sonnet 4.5 via `"model": "anthropic/claude-sonnet-4-5"` in the OpenCode config. - -## 1. Install OpenCode - -```bash -# Option A: Install script -curl -fsSL https://opencode.ai/install | bash - -# Option B: npm -npm install -g opencode-ai - -# Option C: Homebrew (macOS) -brew install anomalyco/tap/opencode -``` - -Verify: `opencode --version` - -## 2. Configure OpenCode (Claude Sonnet 4.5) - -- **API key:** Run `opencode` in the TUI, then `/connect` → Anthropic → enter API key (or “Create an API Key” / Claude Pro/Max). -- **Model:** In `~/.config/opencode/opencode.json` or per-project in `opencode.json`: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "model": "anthropic/claude-sonnet-4-5" -} -``` - -See [OpenCode Docs – Config](https://opencode.ai/docs/config) and [Providers (Anthropic)](https://opencode.ai/docs/providers#anthropic). - -## 3. Start oct-agent with OpenCode - -The oct-agent spawns the ACP agent via the **`--acp-agent`** option. The default is `npx @zed-industries/claude-code-acp`. To use OpenCode: - -```bash -cd /path/to/your/workspace -node /path/to/oct/packages/open-collaboration-agent/bin/agent -r --acp-agent "opencode acp" -``` - -**From a built project:** - -```bash -cd /path/to/open-collaboration-tools -npm run build --workspace=open-collaboration-agent -node packages/open-collaboration-agent/bin/agent.js -r --acp-agent "opencode acp" -``` - -**Via the VSCode extension:** -If the extension starts the agent, the command line used must override the ACP agent. Once the extension supports an option for the ACP agent command, set it to `opencode acp`. Otherwise start the agent manually as above. - -## 4. Flow (ACP Bridge) - -1. You start the oct-agent with `--acp-agent "opencode acp"`. -2. The ACP bridge spawns the command `opencode acp` as a child process (stdio). -3. The bridge performs ACP initialization and session creation. -4. When an `@agent` trigger is detected in the document, the bridge sends an ACP trigger to OpenCode. -5. OpenCode runs in the local workspace (process.cwd() = workspace root). -6. Tool calls (e.g. read/write file) go over ACP; the bridge applies write operations via OCT DocumentSync. -7. All participants in the OCT session see the changes in real time. - -## 5. Usage in the OCT Room - -- **Trigger:** A line starting with `@agent` (or your chosen agent name) followed by your prompt, then Enter. -- **Example:** `// @agent Refactor this function to use async/await` -- Behaviour is the same as with Claude Code: same triggers, same sync logic; only the LLM and tools are provided by OpenCode. - -## 6. Notes - -- **Workspace:** The oct-agent must run in the **workspace directory** (as described in the README). OpenCode inherits the same `cwd()` for file access. -- **Slash commands:** Via ACP, OpenCode does not support all slash commands (e.g. `/undo`, `/redo`); see [OpenCode ACP Support](https://opencode.ai/docs/acp/). -- **Troubleshooting:** If the agent does not start, check in the console that `opencode acp` is on your PATH and that the OpenCode config and API key are correct. - -## References - -- [OpenCode – Intro & Install](https://opencode.ai/docs/) -- [OpenCode – Config & Models](https://opencode.ai/docs/config) -- [OpenCode – ACP Support](https://opencode.ai/docs/acp/) -- [OpenCode – Providers (Anthropic)](https://opencode.ai/docs/providers#anthropic) -- **OCT Agent:** `README.md`, `ACP_CONCEPT.md`, `ARCHITECTURE.md` diff --git a/packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md b/packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md deleted file mode 100644 index 709e55d4..00000000 --- a/packages/open-collaboration-agent/REMOTE_AGENT_CHALLENGES.md +++ /dev/null @@ -1,457 +0,0 @@ -# Remote Agent Deployment: Challenges & Limitations - -**Date:** 2025-01-19 -**Status:** Analysis & Design Document - -## Problem Statement - -The current open-collaboration-agent architecture **requires the agent to run on the same machine as the workspace** it's editing. This document explains why this limitation exists, when it matters, and explores potential future solutions. - -## Current Architecture Constraints - -### Filesystem Access Pattern - -The agent directly reads files from the local filesystem: - -**In ACP Bridge** (`src/acp-bridge.ts:501`): -```typescript -// Read file from local filesystem -let content = fs.readFileSync(absolutePath, 'utf8'); -``` - -The ACP agent receives document context via the bridge; file reads for multi-file or workspace access use the local filesystem. - -### Why This Matters - -``` -┌──────────────────────────────────────┐ -│ Machine A: Developer │ -│ │ -│ ┌────────────┐ │ -│ │ Workspace │ ← Files stored here │ -│ │ /project/ │ │ -│ └────────────┘ │ -│ │ -│ ┌────────────┐ │ -│ │ VSCode │ │ -│ │ + OCT │ │ -│ └────────────┘ │ -└──────────┬───────────────────────────┘ - │ OCT Protocol (WebSocket) - │ Only Yjs operations - │ NO file content - ↓ -┌──────────────────────────────────────┐ -│ OCT Server (Cloud) │ -│ - Routes messages │ -│ - No file storage │ -│ - Only Yjs sync state │ -└──────────┬───────────────────────────┘ - │ OCT Protocol - ↓ -┌──────────────────────────────────────┐ -│ Machine B: Remote Agent │ -│ │ -│ ┌────────────┐ │ -│ │ oct-agent │ │ -│ └────────────┘ │ -│ ↓ fs.readFileSync()? │ -│ ┌────────────┐ │ -│ │ Workspace │ ❌ NOT HERE! │ -│ │ /???/ │ │ -│ └────────────┘ │ -└──────────────────────────────────────┘ -``` - -**The Problem:** -- Agent on Machine B tries to read files from local filesystem -- Files only exist on Machine A -- OCT protocol doesn't transfer file contents - only document edits (Yjs operations) -- Agent has no way to access workspace files - -### What Gets Synchronized vs What Doesn't - -**✅ Synchronized via OCT (Yjs CRDT):** -- Currently open document content -- Document edits in real-time -- Cursor positions -- Active document path - -**❌ NOT Synchronized:** -- File system structure (directory tree) -- Closed files / files not currently open -- File metadata (permissions, timestamps) -- Binary files -- Project configuration files (unless opened) - -## Deployment Scenarios - -### Scenario 1: Host Starts Agent (✅ Supported) - -``` -┌─────────────────────────────────────┐ -│ Developer's Machine │ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ VSCode │ │oct-agent │ │ -│ │ (Host) │ │ (Guest) │ │ -│ └────┬─────┘ └────┬─────┘ │ -│ │ │ │ -│ ┌────▼─────────────────▼───┐ │ -│ │ Workspace │ │ -│ │ /home/dev/project/ │ │ -│ │ - src/ │ │ -│ │ - package.json │ │ -│ └───────────────────────────┘ │ -└─────────────────────────────────────┘ - │ - ↓ OCT Protocol - [OCT Server] -``` - -**How it works:** -1. Developer creates OCT room in VSCode -2. Developer starts agent in same workspace directory -3. Agent reads files directly from disk -4. Agent joins room as guest peer -5. Both VSCode and agent see all changes via Yjs - -**Why it works:** -- Both VSCode and agent run on same machine -- Both have access to same filesystem -- Agent uses `process.cwd()` = `/home/dev/project/` - -**Use Cases:** -- Single developer with AI assistant -- Local testing and development -- Personal productivity enhancement - -### Scenario 2: Participant Starts Agent (⚠️ Limited Support) - -``` -┌────────────────────┐ ┌────────────────────┐ -│ Machine A │ │ Machine B │ -│ (Host) │ │ (Guest) │ -│ │ │ │ -│ ┌────────────┐ │ │ ┌────────────┐ │ -│ │ VSCode │ │ │ │ oct-agent │ │ -│ │ /workspace/│ │ │ │ /workspace/│ │ -│ │ - src/ │ │ │ │ - src/ │ │ -│ └─────┬──────┘ │ │ └─────┬──────┘ │ -└───────┼────────────┘ └───────┼────────────┘ - │ │ - └───────────────┬───────────────┘ - ↓ - [OCT Server] -``` - -**Requirements:** -- Guest must have **identical copy** of workspace -- Files must be at same relative paths -- Guest must keep workspace in sync manually - -**Challenges:** -- ❌ No automatic workspace synchronization -- ❌ File changes outside OCT session not reflected -- ❌ Different file versions cause confusion -- ⚠️ Works only if guest manually syncs (git pull, etc.) - -**Use Cases:** -- Team member joins to help with AI suggestions -- Multiple developers with git-synced workspace -- Requires manual coordination - -### Scenario 3: Remote Agent Server (❌ Not Supported) - -``` -┌────────────────────┐ ┌────────────────────┐ -│ Developer │ │ Cloud Agent │ -│ Machine │ │ Server │ -│ │ │ │ -│ ┌────────────┐ │ │ ┌────────────┐ │ -│ │ VSCode │ │ │ │ oct-agent │ │ -│ │ │ │ │ │ (pool) │ │ -│ │ Workspace: │ │ │ │ │ │ -│ │ /project/ │ │ │ │ Workspace: │ │ -│ │ │ │ │ │ ??? │ │ -│ └─────┬──────┘ │ │ └─────┬──────┘ │ -└───────┼────────────┘ └───────┼────────────┘ - │ │ - └───────────────┬───────────────┘ - ↓ - [OCT Server] -``` - -**Why it doesn't work:** -- Cloud agent has no access to developer's local files -- OCT doesn't transfer complete workspace -- No file streaming mechanism in current protocol - -**Why you might want this:** -- Centralized agent serving multiple developers -- Powerful cloud hardware for agent -- No local agent installation required -- Consistent agent behavior across team - -**Why it's not implemented:** -- Major architectural change needed -- File streaming adds complexity and latency -- Security concerns (uploading workspace to cloud) -- Current design prioritizes simplicity - -## Technical Deep Dive - -### Code References - -**1. ACP Bridge File Reading** (`src/acp-bridge.ts:498-534`) - -```typescript -if (message.method === 'fs/read_text_file') { - // Read file from local filesystem - try { - let content = fs.readFileSync(absolutePath, 'utf8'); - - // Security check: ensure path is within workspace - const workspaceRoot = path.normalize(process.cwd()); - if (!absolutePath.startsWith(workspaceRoot)) { - // Reject access outside workspace - return error; - } - - return { content }; - } catch (error) { - return { error: 'File not found' }; - } -} -``` - -**Problem:** `fs.readFileSync` requires file to exist on local disk. - -**2. Workspace Context** (`src/document-operations.ts:82-85`) - -```typescript -export class DocumentSyncOperations implements DocumentOperations { - constructor( - private readonly documentSync: DocumentSync, - private readonly sessionInfo: SessionInfo // Contains workspace info - ) {} -} -``` - -**Session Info:** -```typescript -interface SessionInfo { - roomId: string; - agentId: string; - agentName: string; - hostId: string; - serverUrl: string; - // Note: NO workspacePath - each participant has their own -} -``` - -**3. VSCode Extension Integration** (`packages/open-collaboration-vscode/src/commands.ts:228-232`) - -```typescript -const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; -if (!workspaceFolder || workspaceFolder.uri.scheme !== 'file') { - vscode.window.showErrorMessage('No local workspace folder open'); - return; -} -``` - -**Requirement:** VSCode command explicitly checks for local file system workspace. - -### What Would Be Needed for Remote Support - -#### Option 1: File Streaming via OCT Protocol - -**Add to Protocol:** -```typescript -// New message types -Messages.FileSystem.RequestFile -Messages.FileSystem.FileContent -Messages.FileSystem.ListDirectory -``` - -**Flow:** -``` -Remote Agent → RequestFile("src/utils.ts") - ↓ (via OCT Server) - Host VSCode → Reads file → FileContent(content) - ↓ (via OCT Server) -Remote Agent → Receives content → Uses in LLM context -``` - -**Challenges:** -- Latency (network round-trip for every file) -- Security (host must approve file access) -- Large files (binary files, node_modules, etc.) -- File watching (how to detect changes?) -- Complexity (new protocol messages, caching, etc.) - -#### Option 2: Workspace Synchronization - -**Approach:** Automatically sync workspace to remote agent - -**Technologies:** -- rsync, git, or custom sync protocol -- Watch for file changes and sync -- Bidirectional sync (agent edits → host) - -**Challenges:** -- Large workspace (gigabytes of node_modules, etc.) -- Continuous syncing overhead -- Sync conflicts -- Security (uploading entire workspace) -- Setup complexity - -#### Option 3: Virtual File System (VFS) - -**Approach:** Abstract filesystem access - -**Implementation:** -```typescript -interface VirtualFileSystem { - readFile(path: string): Promise - writeFile(path: string, content: string): Promise - listDirectory(path: string): Promise -} - -// Implementations: -class LocalFileSystem implements VirtualFileSystem { /* Uses fs */ } -class RemoteFileSystem implements VirtualFileSystem { /* Uses OCT protocol */ } -``` - -**Benefits:** -- Clean abstraction -- Can swap implementations -- Testable - -**Challenges:** -- Must update all filesystem access points -- Async complications (currently sync) -- Performance overhead -- Cache management - -## Current Workarounds - -### Workaround 1: Manual Workspace Sync - -**For Scenario 2 (Participant starts agent):** - -1. Guest clones repository -2. Guest keeps workspace in sync via git -3. Guest starts agent in local workspace copy -4. Works as long as files stay in sync - -**Limitations:** -- Manual synchronization required -- Unsaved changes not synced -- Potential version mismatches - -### Workaround 2: Host-Only Agent - -**Best practice for current architecture:** - -- Only the host starts the agent -- Host has full workspace access -- Agent works perfectly -- Other participants just use regular editor - -**This is the recommended approach for now.** - -### Workaround 3: Share Workspace via Network FS - -**Using NFS, SSHFS, or similar:** - -``` -┌──────────────┐ ┌──────────────┐ -│ Machine A │ │ Machine B │ -│ (Host) │ │ (Agent) │ -│ │ │ │ -│ /workspace/ │◄──NFS──┤ /mnt/workspace/ -│ │ │ │ -└──────────────┘ └──────────────┘ -``` - -**Agent on Machine B:** -```bash -cd /mnt/workspace # Mounted from Machine A -oct-agent --room ... -``` - -**Challenges:** -- Network latency -- Requires infrastructure setup -- Potential permission issues -- Not suitable for cloud deployment - -## Comparison: Local vs Remote - -| Aspect | Local Agent | Remote Agent | -|--------|-------------|--------------| -| **Filesystem Access** | ✅ Direct | ❌ Requires streaming | -| **Latency** | ✅ Minimal | ❌ Network overhead | -| **Setup Complexity** | ✅ Simple | ❌ Complex | -| **Security** | ✅ No data upload | ⚠️ Workspace exposure | -| **Multi-user** | ❌ One per machine | ✅ Shared agent pool | -| **Cost** | ✅ Local compute | 💰 Cloud infrastructure | -| **Consistency** | ⚠️ Per-developer | ✅ Same for all | - -## Recommendations - -### For Current Implementation - -**✅ DO:** -- Run agent on same machine as workspace -- Use VSCode Extension command to start agent -- Ensure `process.cwd()` is workspace root - -**❌ DON'T:** -- Try to run agent remotely without workspace access -- Expect automatic file synchronization -- Share agent across machines without coordination - -### For Future Enhancement - -**If remote support is needed:** - -1. **Start with VFS abstraction** - - Cleanest architectural approach - - Allows testing different implementations - - Gradual migration - -2. **Implement file streaming protocol** - - Add OCT protocol messages for file operations - - Start with simple read-only access - - Add caching to reduce latency - -3. **Consider security implications** - - File access permissions - - Privacy (what files can agent see?) - - Audit logging - -4. **Optimize for common cases** - - Cache frequently accessed files - - Batch file requests - - Use workspace introspection to minimize requests - -## Conclusion - -The current local-agent architecture is a **deliberate design choice** that prioritizes: - -- ✅ **Simplicity** - Direct filesystem access, no complex protocols -- ✅ **Performance** - No network latency for file operations -- ✅ **Security** - No workspace data leaves developer's machine -- ✅ **Reliability** - Fewer failure modes, simpler debugging - -**Remote agent support** would require significant architectural changes and is **not planned for the initial release**. - -**Recommendation:** Use the host-starts-agent pattern (Scenario 1) for best experience with current implementation. - -## Related Documentation - -- **ARCHITECTURE.md** - Complete architecture overview -- **ACP_CONCEPT.md** - ACP protocol and external agent integration -- **README.md** - Getting started and workspace requirements diff --git a/packages/open-collaboration-protocol/.vscode/settings.json b/packages/open-collaboration-protocol/.vscode/settings.json deleted file mode 100644 index 29550f08..00000000 --- a/packages/open-collaboration-protocol/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "oct.serverUrl": "http://localhost:8100" -} From da2e858d400a3144be23453ee0afff75192b0870 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Thu, 28 May 2026 14:52:26 +0200 Subject: [PATCH 09/19] Remove animated cursor handling from OCT agent --- .../src/agent-util.ts | 152 ------------------ .../open-collaboration-agent/src/agent.ts | 61 +------ .../src/document-operations.ts | 33 ---- .../src/document-sync.ts | 35 +--- .../open-collaboration-agent/src/index.ts | 1 - .../test/agent-util.test.ts | 46 ------ 6 files changed, 9 insertions(+), 319 deletions(-) delete mode 100644 packages/open-collaboration-agent/src/agent-util.ts delete mode 100644 packages/open-collaboration-agent/test/agent-util.test.ts 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 7981e858..00000000 --- a/packages/open-collaboration-agent/src/agent-util.ts +++ /dev/null @@ -1,152 +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 { IDocumentSync } from './document-sync.js'; -import { LineEdit } from './document-operations.js'; - -/** - * Returns a typing delay in milliseconds based on the character type - * to simulate more realistic human typing patterns - */ -function getTypingDelay(char: string): number { - // No delay for most characters to keep it responsive - if (char === ' ') return 50; // Slight pause after spaces - if (char === '\n') return 100; // Longer pause after newlines - if (char === '.' || char === ',' || char === ';') return 80; // Pause after punctuation - if (char === '{' || char === '}' || char === '(' || char === ')') return 30; // Small pause for structural characters - - // Random variation for other characters (20-60ms) - return Math.random() * 40 + 20; -} - -/** - * 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 < lines.length; i++) { - offset += lines[i].length + 1; // +1 for the newline character - } - - return offset; -} - -/** - * Applies line-based edits with natural, progressive animation. - * Makes the agent feel like a real colleague typing code changes. - */ -export async function applyLineEditsAnimated( - docPath: string, - docContent: string, - edits: LineEdit[], - documentSync: IDocumentSync -): Promise { - if (edits.length === 0) { - return; - } - - // Sort edits by line number (descending) to avoid offset shifts when applying multiple edits - const sortedEdits = [...edits].sort((a, b) => b.startLine - a.startLine); - - for (let i = 0; i < sortedEdits.length; i++) { - const edit = sortedEdits[i]; - - // Add a small pause between different edit operations - if (i > 0) { - await new Promise(resolve => setTimeout(resolve, 250)); - } - - // Get fresh content from Yjs document (source of truth) - const currentContent = documentSync.getDocumentContent(docPath) || ''; - - if (edit.type === 'replace' && edit.endLine !== undefined && edit.content !== undefined) { - // Replace only the differing middle segment: - // - apply deletions immediately - // - animate only newly inserted characters - const startOffset = calculateOffset(currentContent, edit.startLine - 1); - const endOffset = calculateOffset(currentContent, edit.endLine); - const oldSegment = currentContent.substring(startOffset, endOffset); - const newSegment = edit.content; - - console.log(`Replacing lines ${edit.startLine}-${edit.endLine} (offset ${startOffset}, length ${oldSegment.length})`); - - // Find common prefix - let prefixLength = 0; - const maxPrefix = Math.min(oldSegment.length, newSegment.length); - while (prefixLength < maxPrefix && oldSegment[prefixLength] === newSegment[prefixLength]) { - prefixLength++; - } - - // Find common suffix (without overlapping prefix) - let suffixLength = 0; - while ( - suffixLength < (oldSegment.length - prefixLength) && - suffixLength < (newSegment.length - prefixLength) && - oldSegment[oldSegment.length - 1 - suffixLength] === newSegment[newSegment.length - 1 - suffixLength] - ) { - suffixLength++; - } - - const oldDiffLength = oldSegment.length - prefixLength - suffixLength; - const insertText = newSegment.slice(prefixLength, newSegment.length - suffixLength); - const diffOffset = startOffset + prefixLength; - - // Apply deletions/replacements immediately - if (oldDiffLength > 0) { - documentSync.applyEdit(docPath, '', diffOffset, oldDiffLength); - documentSync.updateCursorPosition(docPath, diffOffset); - } - - // Animate only newly inserted text - let insertOffset = diffOffset; - for (const char of insertText) { - documentSync.applyEdit(docPath, char, insertOffset, 0); - insertOffset++; - documentSync.updateCursorPosition(docPath, insertOffset); - - const delay = getTypingDelay(char); - if (delay > 0) { - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - } else if (edit.type === 'insert' && edit.content !== undefined) { - // Insert: type content character by character - const insertOffset = edit.startLine === 1 ? 0 : calculateOffset(currentContent, edit.startLine - 1); - const contentToInsert = edit.startLine === 1 ? edit.content + '\n' : edit.content + '\n'; - - console.log(`Inserting at line ${edit.startLine} (offset ${insertOffset})`); - - let currentOffset = insertOffset; - for (const char of contentToInsert) { - documentSync.applyEdit(docPath, char, currentOffset, 0); - currentOffset++; - documentSync.updateCursorPosition(docPath, currentOffset); - - const delay = getTypingDelay(char); - if (delay > 0) { - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - } else if (edit.type === 'delete' && edit.endLine !== undefined) { - // Delete: remove content character by character (backwards) - const startOffset = calculateOffset(currentContent, edit.startLine - 1); - const endOffset = calculateOffset(currentContent, edit.endLine); - const length = endOffset - startOffset; - - console.log(`Deleting lines ${edit.startLine}-${edit.endLine} (offset ${startOffset}, length ${length})`); - - for (let deleteLen = length; deleteLen > 0; deleteLen--) { - documentSync.applyEdit(docPath, '', startOffset, 1); - documentSync.updateCursorPosition(docPath, startOffset); - await new Promise(resolve => setTimeout(resolve, 5)); // Fast deletion - } - } - } -} diff --git a/packages/open-collaboration-agent/src/agent.ts b/packages/open-collaboration-agent/src/agent.ts index e8d6018c..d842926e 100644 --- a/packages/open-collaboration-agent/src/agent.ts +++ b/packages/open-collaboration-agent/src/agent.ts @@ -110,9 +110,6 @@ export async function startCLIAgent(options: AgentOptions): Promise { }); console.log(`✅ Received peer info: ${identity.name} (${identity.id})`); - // Set the agent's peer ID in the awareness state so its cursor is visible - documentSync.setAgentPeerId(identity.id); - // Run ACP agent (connects to external agent via ACP bridge) acpBridge = await runACPAgent(documentSync, identity, options); } @@ -126,7 +123,6 @@ export interface TriggerDetectionOptions { docContent: string prompt: string change?: DocumentInsert // Only present for document triggers (newline detection) - animationAbort: AbortController source: 'document' | 'chat' sendChatResponse?: (message: string) => Promise // Only present for chat triggers }) => Promise @@ -211,14 +207,12 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v 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 }; @@ -236,10 +230,6 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v // 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; } @@ -262,8 +252,6 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v console.error(`[DEBUG] Found trigger at index ${triggerIndex}, prompt: "${prompt}"`); if (prompt.length > 0) { console.error(`Received prompt: "${prompt}"`); - // Create an AbortController for the loading animation - state.animationAbort = new AbortController(); try { state.executing = true; @@ -272,17 +260,13 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v docContent, prompt, change, - animationAbort: state.animationAbort, source: 'document', }); } 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; } @@ -306,25 +290,21 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v const connection = documentOps.getConnection(); const executeChatTrigger = async (docPath: string, docContent: string, prompt: string) => { - state.animationAbort = new AbortController(); try { state.executing = true; await onTrigger({ docPath, docContent, prompt, - animationAbort: state.animationAbort, source: 'chat', sendChatResponse: (msg: string) => connection.chat.sendMessage(msg), }); } catch (error) { - state.animationAbort?.abort(); 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; - state.animationAbort = undefined; } }; @@ -442,9 +422,6 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v console.error('[DEBUG] Chat message handler registered successfully'); return () => { - if (state.animationAbort) { - state.animationAbort.abort(); - } state.awaitingDocPath = false; state.pendingPrompt = undefined; }; @@ -482,7 +459,7 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op agentName: identity.name, documentSync, documentOps, - onTrigger: async ({ docPath, docContent, prompt, change, animationAbort, source, sendChatResponse }) => { + onTrigger: async ({ docPath, docContent, prompt, change, source, sendChatResponse }) => { // Generate unique trigger ID const triggerId = `trig-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -490,7 +467,7 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op const triggerLine = change ? change.position.line + 1 : docContent.split('\n').length; // Convert to ACP trigger message format - const acpTrigger = { + const acpTriggerMessage = { id: triggerId, source: { type: 'document' as const, @@ -507,32 +484,16 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op console.error(`[ACP] Sending trigger ${triggerId} to ACP agent (source: ${source})`); let response; try { - response = await acpBridge.sendTrigger(acpTrigger); + response = await acpBridge.sendTrigger(acpTriggerMessage); } catch (error: any) { // Handle sessionId missing error specifically if (error.message?.includes('sessionId is required')) { - // Abort the animation - animationAbort.abort(); - const errorMessage = 'Error: ACP session not initialized. Please ensure the ACP agent started successfully.'; - - if (source === 'chat' && sendChatResponse) { - // Send error via chat + console.error(`[ACP] ${errorMessage}`); + if (sendChatResponse) { await sendChatResponse(errorMessage); - } else if (change) { - // Insert error message into document after the trigger line - await documentOps.applyEditsAnimated(docPath, [{ - type: 'insert', - startLine: triggerLine + 1, - content: `// ${errorMessage}`, - }]); - - // Remove the trigger line - const trigger = `@${identity.name}`; - documentOps.removeTriggerLine(docPath, trigger); - - // Clear cursor - documentOps.updateCursor(docPath, 0); + } else { + await documentOps.getConnection().chat.sendMessage(errorMessage); } // Re-throw to be caught by outer catch block for logging @@ -542,9 +503,6 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op throw error; } - // Abort the animation (the setupTriggerDetection will handle awaiting it) - animationAbort.abort(); - // 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). @@ -557,14 +515,11 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op console.log(`[ACP Agent Response] ${wrappedResponse.content}`); } - // For document triggers, remove the trigger line and clear cursor + // 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); - - // Clear the agent's cursor position after all work is done - documentOps.updateCursor(docPath, 0); } // For chat triggers, send a response diff --git a/packages/open-collaboration-agent/src/document-operations.ts b/packages/open-collaboration-agent/src/document-operations.ts index 90fc1ec4..c470223e 100644 --- a/packages/open-collaboration-agent/src/document-operations.ts +++ b/packages/open-collaboration-agent/src/document-operations.ts @@ -7,16 +7,6 @@ import { ProtocolBroadcastConnection } from 'open-collaboration-protocol'; import type { DocumentSync } from './document-sync.js'; -/** - * Represents a line-based edit operation - */ -export interface LineEdit { - type: 'replace' | 'insert' | 'delete'; - startLine: number; - endLine?: number; - content?: string; -} - /** * Session information */ @@ -37,21 +27,11 @@ export interface DocumentOperations { */ getDocument(path: string): string | undefined; - /** - * Apply multiple line-based edits with animation - */ - applyEditsAnimated(path: string, edits: LineEdit[]): Promise; - /** * Remove a line containing the trigger pattern */ removeTriggerLine(path: string, trigger: string): void; - /** - * Update the cursor position for awareness - */ - updateCursor(path: string, offset: number): void; - /** * Get session information */ @@ -80,15 +60,6 @@ export class DocumentSyncOperations implements DocumentOperations { return this.documentSync.getDocumentContent(path); } - async applyEditsAnimated(path: string, edits: LineEdit[]): Promise { - const { applyLineEditsAnimated } = await import('./agent-util.js'); - const content = this.documentSync.getDocumentContent(path); - if (content === undefined) { - throw new Error(`Document not found: ${path}`); - } - await applyLineEditsAnimated(path, content, edits, this.documentSync); - } - removeTriggerLine(path: string, trigger: string): void { const content = this.documentSync.getDocumentContent(path); if (content === undefined) { @@ -106,10 +77,6 @@ export class DocumentSyncOperations implements DocumentOperations { } } - updateCursor(path: string, offset: number): void { - this.documentSync.updateCursorPosition(path, offset); - } - getSessionInfo(): SessionInfo { return this.sessionInfo; } diff --git a/packages/open-collaboration-agent/src/document-sync.ts b/packages/open-collaboration-agent/src/document-sync.ts index b04a2fd6..f0b2797a 100644 --- a/packages/open-collaboration-agent/src/document-sync.ts +++ b/packages/open-collaboration-agent/src/document-sync.ts @@ -4,7 +4,7 @@ // terms of the MIT License, which is available in the project root. // ****************************************************************************** -import type { ClientAwareness, ClientTextSelection, ProtocolBroadcastConnection } from 'open-collaboration-protocol'; +import type { ClientAwareness, ProtocolBroadcastConnection } from 'open-collaboration-protocol'; import { OpenCollaborationYjsProvider, LOCAL_ORIGIN } from 'open-collaboration-yjs'; import * as Y from 'yjs'; import * as awarenessProtocol from 'y-protocols/awareness'; @@ -45,7 +45,6 @@ type DocumentResolutionSource = 'sender' | 'host' | 'peer' | 'yjs-fallback' | 'n export interface IDocumentSync { applyEdit(documentPath: string, text: string, offset: number, replacedLength: number): void; - updateCursorPosition(documentPath: string, offset: number): void; getDocumentContent(documentPath: string): string | undefined; } @@ -119,14 +118,6 @@ export class DocumentSync implements IDocumentSync { return this.connection; } - /** - * Sets the agent's peer ID in the awareness state - * This makes the agent's cursor visible to other collaborators - */ - setAgentPeerId(peerId: string): void { - this.yjsAwareness.setLocalStateField('peer', peerId); - } - /** * Waits for the host ID to be received from the connection * @returns A promise that resolves with the host ID @@ -507,28 +498,4 @@ export class DocumentSync implements IDocumentSync { } } - /** - * Updates the agent's cursor position in the awareness state - * @param documentPath The path of the document - * @param offset The character offset of the cursor - */ - updateCursorPosition(documentPath: string, offset: number): void { - const ytext = this.yjs.getText(documentPath); - - // Create a CRDT-based relative position for the cursor - const relativePosition = Y.createRelativePositionFromTypeIndex(ytext, offset); - - // Create a selection range (cursor is a zero-width selection) - const textSelection: ClientTextSelection = { - path: documentPath, - textSelections: [{ - start: relativePosition, - end: relativePosition, - direction: 1 // LeftToRight - }] - }; - - // Update the awareness state with the new cursor position - this.yjsAwareness.setLocalStateField('selection', textSelection); - } } diff --git a/packages/open-collaboration-agent/src/index.ts b/packages/open-collaboration-agent/src/index.ts index f1a38832..115c5238 100644 --- a/packages/open-collaboration-agent/src/index.ts +++ b/packages/open-collaboration-agent/src/index.ts @@ -5,5 +5,4 @@ // ****************************************************************************** export * from './agent.js'; -export * from './agent-util.js'; export * from './document-sync.js'; 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 a7faf7b4..00000000 --- a/packages/open-collaboration-agent/test/agent-util.test.ts +++ /dev/null @@ -1,46 +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 } from 'vitest'; -import { applyLineEditsAnimated } from '../src/agent-util.js'; -import type { IDocumentSync } from '../src/document-sync.js'; - -describe('agent-util', () => { - describe('applyLineEditsAnimated', () => { - test('should apply replace deletions instantly and animate only inserted diff', async () => { - const docPath = 'test.ts'; - let content = 'const value = old;\n'; - const applyEditMock = vi.fn((_: string, text: string, offset: number, length: number) => { - content = content.substring(0, offset) + text + content.substring(offset + length); - }); - const updateCursorPositionMock = vi.fn(); - - const documentSync: IDocumentSync = { - applyEdit: applyEditMock, - updateCursorPosition: updateCursorPositionMock, - getDocumentContent: () => content - }; - - await applyLineEditsAnimated(docPath, content, [{ - type: 'replace', - startLine: 1, - endLine: 1, - content: 'const value = new;\n' - }], documentSync); - - expect(content).toBe('const value = new;\n'); - expect(applyEditMock).toHaveBeenCalledTimes(4); - - const [deleteCall, ...insertCalls] = applyEditMock.mock.calls; - expect(deleteCall).toEqual([docPath, '', 14, 3]); - expect(insertCalls).toEqual([ - [docPath, 'n', 14, 0], - [docPath, 'e', 15, 0], - [docPath, 'w', 16, 0] - ]); - }); - }); -}); From ae2e93ca33424ab220cb294a522bf7cb55e56efa Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Thu, 28 May 2026 15:43:20 +0200 Subject: [PATCH 10/19] only send chat confirmation when document changes were proposed --- .../src/acp-bridge.ts | 28 ++++++++-- .../open-collaboration-agent/src/agent.ts | 22 ++++++-- .../test/multi-file-new-file.test.ts | 56 +++++++++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/packages/open-collaboration-agent/src/acp-bridge.ts b/packages/open-collaboration-agent/src/acp-bridge.ts index ae03cf83..a15a4fd1 100644 --- a/packages/open-collaboration-agent/src/acp-bridge.ts +++ b/packages/open-collaboration-agent/src/acp-bridge.ts @@ -368,7 +368,8 @@ export class ACPBridge { // Flush buffered proposals before resolving, so that the peer // receives a single consolidated diff per file for this prompt cycle. - void this.flushPendingProposals().then(() => { + 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) { @@ -383,15 +384,26 @@ export class ACPBridge { type: 'agent/response', content: accumulatedText, stopReason: result.stopReason, + editsProposed, }); } else if (result.stopReason) { pending.resolve({ type: 'agent/response', content: '', stopReason: result.stopReason, + editsProposed, }); } else { - pending.resolve(result); + // 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, + }); } } }); @@ -402,20 +414,23 @@ export class ACPBridge { * 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 { + private async flushPendingProposals(): Promise { if (this.pendingProposals.size === 0) { - return; + return 0; } const connection = this.documentOps?.getConnection(); if (!connection) { console.error('[ACP] Cannot flush proposals: no connection available'); this.pendingProposals.clear(); - return; + return 0; } - console.info(`[ACP] Flushing ${this.pendingProposals.size} pending proposal(s)`); + 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'); @@ -434,6 +449,7 @@ export class ACPBridge { } } this.pendingProposals.clear(); + return flushedCount; } private isAgentRequest(message: AgentRequest | AgentNotification): message is AgentRequest { diff --git a/packages/open-collaboration-agent/src/agent.ts b/packages/open-collaboration-agent/src/agent.ts index d842926e..21f4d344 100644 --- a/packages/open-collaboration-agent/src/agent.ts +++ b/packages/open-collaboration-agent/src/agent.ts @@ -506,9 +506,13 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op // 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 } shape on top of - // the typed AgentResponse, so we read the fields defensively. - const wrappedResponse = response as { type?: string; content?: unknown }; + // 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()) { @@ -522,9 +526,15 @@ export async function runACPAgent(documentSync: DocumentSync, identity: Peer, op documentOps.removeTriggerLine(docPath, trigger); } - // For chat triggers, send a response - if (source === 'chat' && sendChatResponse) { - await sendChatResponse('Done! I have applied the changes to the active document.'); + // 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.' + ); } }, }); 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 index ec1da07c..5df53cc5 100644 --- a/packages/open-collaboration-agent/test/multi-file-new-file.test.ts +++ b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts @@ -140,6 +140,62 @@ describe('multi-file and new-file regressions', () => { } }); + 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 write_text_file does not overwrite when OCT document is missing but file exists locally', async () => { const proposeChanges = vi.fn().mockResolvedValue(undefined); const sendMessage = vi.fn(); From 644bf967418f0a11a2e9631005f6a4a26f19c095 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Thu, 28 May 2026 16:00:14 +0200 Subject: [PATCH 11/19] updated README.md --- packages/open-collaboration-agent/README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/open-collaboration-agent/README.md b/packages/open-collaboration-agent/README.md index 6d47f1bf..64377df0 100644 --- a/packages/open-collaboration-agent/README.md +++ b/packages/open-collaboration-agent/README.md @@ -1,6 +1,6 @@ # Open Collaboration Agent -An AI agent for Open Collaboration Tools (OCT) sessions that runs in your local workspace and synchronizes changes with the collaborative session. +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 @@ -81,9 +81,8 @@ The agent MUST run in the workspace directory because: - The agent has access to all files in your local project directory - File reads come from your local filesystem -- File writes are synchronized to the OCT session (visible to all participants) -- Your cursor position is visible to other session participants -- Changes made by the agent appear in real-time to all collaborators +- 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 @@ -107,9 +106,9 @@ The agent MUST run in the workspace directory because: 4. **Collaboration:** - - The agent's cursor is visible to all participants - - Changes are synchronized in real-time - - Other participants can see the agent's edits as they happen + - 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 @@ -120,9 +119,9 @@ Local Workspace → oct-agent (process.cwd()) ↓ Local File Operations ↓ - OCT Session Sync + OCT Session: Proposed Diffs ↓ - All Participants See Changes + Participants Review & Accept Changes ``` ## ACP From b7ffb9d3255b80ebf028eeb7b35817eb0c4329ee Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Fri, 29 May 2026 10:22:23 +0200 Subject: [PATCH 12/19] fix(acp-bridge): return buffered proposal content on re-read fs/read_text_file now consults pendingProposals before Yjs/filesystem so agents that re-read between sequential edits see their own pending writes. Prevents "last write wins" from dropping earlier edits when multiple writes target the same file in one prompt cycle. --- .../src/acp-bridge.ts | 11 ++- .../test/multi-file-new-file.test.ts | 93 +++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/open-collaboration-agent/src/acp-bridge.ts b/packages/open-collaboration-agent/src/acp-bridge.ts index a15a4fd1..dc64fb4a 100644 --- a/packages/open-collaboration-agent/src/acp-bridge.ts +++ b/packages/open-collaboration-agent/src/acp-bridge.ts @@ -725,11 +725,18 @@ export class ACPBridge { const workspaceName = activeDocPath?.split('/')[0] ?? path.basename(process.cwd()); const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); - // Try OCT document first, fallback to local filesystem - let content = this.documentOps?.getDocument(octPath); + // 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}`); } 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 index 5df53cc5..7b9f29e1 100644 --- a/packages/open-collaboration-agent/test/multi-file-new-file.test.ts +++ b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts @@ -196,6 +196,99 @@ describe('multi-file and new-file regressions', () => { } }); + 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 does not overwrite when OCT document is missing but file exists locally', async () => { const proposeChanges = vi.fn().mockResolvedValue(undefined); const sendMessage = vi.fn(); From c7140f1cb844de7d427150b6d0254d8d4d301343 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Fri, 29 May 2026 11:07:30 +0200 Subject: [PATCH 13/19] fix(oct-agent): propose edits for untracked and new files handleFileSystemRequest now asks the host to open unknown files via openAndWaitForContent and falls back to an empty-baseline insert hunk for truly new files, so every edited file produces its own proposeChanges broadcast instead of being silently written to disk or dropped. --- .../src/acp-bridge.ts | 56 +++--- .../src/document-operations.ts | 14 ++ .../test/multi-file-new-file.test.ts | 180 +++++++++++++++++- 3 files changed, 212 insertions(+), 38 deletions(-) diff --git a/packages/open-collaboration-agent/src/acp-bridge.ts b/packages/open-collaboration-agent/src/acp-bridge.ts index dc64fb4a..61f76601 100644 --- a/packages/open-collaboration-agent/src/acp-bridge.ts +++ b/packages/open-collaboration-agent/src/acp-bridge.ts @@ -791,37 +791,38 @@ export class ACPBridge { const octPath = path.join(workspaceName, path.relative(process.cwd(), absolutePath)); console.info(`[ACP] OCT path: ${octPath} (workspaceName from ${activeDocPath ? 'activeDoc' : 'cwd'})`); - const currentContent = this.documentOps.getDocument(octPath); + // 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) { - // Document not tracked by OCT — write to local filesystem as fallback - // so the file exists for subsequent operations. - if (!fs.existsSync(absolutePath)) { - try { - fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); - fs.writeFileSync(absolutePath, newContent, 'utf8'); - console.info(`[ACP] Document not in OCT, created local file: ${octPath}`); - } catch (error: any) { - this.sendMessage({ - jsonrpc: '2.0', - id: message.id, - error: { - code: -32603, - message: `Failed to write local file: ${error.message || 'Unknown error'}`, - }, - }); - return; + try { + const opened = await this.documentOps.openAndWaitForContent(octPath); + if (opened !== undefined) { + currentContent = opened; + console.info(`[ACP] Opened document on host for proposal: ${octPath}`); } - } else { - console.info(`[ACP] Document not in OCT, local file already exists: ${octPath}`); + } catch (error: any) { + console.error(`[ACP] openAndWaitForContent failed for ${octPath}: ${error?.message ?? error}`); } - this.sendMessage({ - jsonrpc: '2.0', - id: message.id, - result: null, - }); - return; + } + + 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) { @@ -837,10 +838,9 @@ export class ACPBridge { // 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. - const existing = this.pendingProposals.get(octPath); this.pendingProposals.set(octPath, { octPath, - currentContent: existing?.currentContent ?? currentContent, + currentContent, newContent, }); console.info(`[ACP] Queued proposal for: ${octPath} (${this.pendingProposals.size} pending)`); diff --git a/packages/open-collaboration-agent/src/document-operations.ts b/packages/open-collaboration-agent/src/document-operations.ts index c470223e..11098275 100644 --- a/packages/open-collaboration-agent/src/document-operations.ts +++ b/packages/open-collaboration-agent/src/document-operations.ts @@ -41,6 +41,16 @@ export interface DocumentOperations { * 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; } /** @@ -84,4 +94,8 @@ export class DocumentSyncOperations implements DocumentOperations { 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/test/multi-file-new-file.test.ts b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts index 7b9f29e1..459c1902 100644 --- a/packages/open-collaboration-agent/test/multi-file-new-file.test.ts +++ b/packages/open-collaboration-agent/test/multi-file-new-file.test.ts @@ -95,13 +95,14 @@ describe('multi-file and new-file regressions', () => { ); }); - test('ACPBridge write_text_file writes locally when OCT document is missing and file does not exist', async () => { + 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 }, }), @@ -118,6 +119,7 @@ describe('multi-file and new-file regressions', () => { 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({ @@ -125,13 +127,37 @@ describe('multi-file and new-file regressions', () => { method: 'fs/write_text_file', params: { path: relativePath, - content: 'export const created = true;', + content: newFileContent, }, }); - expect(fs.existsSync(absolutePath)).toBe(true); - expect(fs.readFileSync(absolutePath, 'utf8')).toBe('export const created = true;'); - expect(proposeChanges).not.toHaveBeenCalled(); + // 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 }) ); @@ -289,13 +315,126 @@ describe('multi-file and new-file regressions', () => { } }); - test('ACPBridge write_text_file does not overwrite when OCT document is missing but file exists locally', async () => { + 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 }, }), @@ -312,22 +451,43 @@ describe('multi-file and new-file regressions', () => { 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, 'pre-existing content', 'utf8'); + fs.writeFileSync(absolutePath, original, 'utf8'); await (bridge as any).handleFileSystemRequest({ id: 'req-4', method: 'fs/write_text_file', params: { path: relativePath, - content: 'new content from agent', + content: updated, }, }); - expect(fs.readFileSync(absolutePath, 'utf8')).toBe('pre-existing content'); - expect(proposeChanges).not.toHaveBeenCalled(); + 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 }) ); From 9ab51234ba65e8c59609d7ad6cb4f22e7457da00 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Fri, 29 May 2026 11:31:08 +0200 Subject: [PATCH 14/19] feat(oct-agent): support systemPrompt in oct-agent.config.json Extend AgentConfig with optional systemPrompt (string | string[]) and workspaceContext schema, and prepend the configured systemPrompt to every ACP prompt as an agent-agnostic text block. Document the config file (toolWhitelist, systemPrompt) in the README. --- packages/open-collaboration-agent/README.md | 64 +++++++++ .../src/acp-bridge.ts | 126 +++++++++++++++++- packages/open-collaboration-agent/src/main.ts | 2 +- 3 files changed, 189 insertions(+), 3 deletions(-) diff --git a/packages/open-collaboration-agent/README.md b/packages/open-collaboration-agent/README.md index 64377df0..5286e446 100644 --- a/packages/open-collaboration-agent/README.md +++ b/packages/open-collaboration-agent/README.md @@ -48,6 +48,7 @@ node /path/to/oct-project/packages/open-collaboration-agent/bin/agent -r {room-i - `-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 @@ -56,6 +57,69 @@ 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: diff --git a/packages/open-collaboration-agent/src/acp-bridge.ts b/packages/open-collaboration-agent/src/acp-bridge.ts index 61f76601..9f7575d0 100644 --- a/packages/open-collaboration-agent/src/acp-bridge.ts +++ b/packages/open-collaboration-agent/src/acp-bridge.ts @@ -19,15 +19,53 @@ export interface ToolWhitelistConfig { 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 + * 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: { @@ -36,6 +74,51 @@ const DEFAULT_AGENT_CONFIG: AgentConfig = { } }; +/** + * 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 */ @@ -45,12 +128,21 @@ function loadAgentConfig(configPath?: string): AgentConfig { const content = fs.readFileSync(searchPath, 'utf8'); const parsed = JSON.parse(content); console.log(`[ACP] Loaded config from ${searchPath}`); - return { + 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; @@ -892,6 +984,23 @@ export class ACPBridge { 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) */ @@ -996,6 +1105,19 @@ export class ACPBridge { }, ]; + // 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 diff --git a/packages/open-collaboration-agent/src/main.ts b/packages/open-collaboration-agent/src/main.ts index 39cd2eb1..9574e6d6 100644 --- a/packages/open-collaboration-agent/src/main.ts +++ b/packages/open-collaboration-agent/src/main.ts @@ -14,7 +14,7 @@ program .version(pck.version) .option('-s, --server ', 'URL of the Open Collaboration Server to connect to', 'https://api.open-collab.tools/') .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 for tool whitelist configuration') + .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)); From 3ef7fb8a4fc928f562e57f1f87a17cc832467cc8 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Wed, 3 Jun 2026 11:46:24 +0200 Subject: [PATCH 15/19] Removed commands to manually open diffs --- packages/open-collaboration-vscode/package.json | 12 ------------ .../open-collaboration-vscode/src/commands-list.ts | 5 ----- packages/open-collaboration-vscode/src/commands.ts | 13 +------------ 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/packages/open-collaboration-vscode/package.json b/packages/open-collaboration-vscode/package.json index 9940a8a3..ce732b76 100644 --- a/packages/open-collaboration-vscode/package.json +++ b/packages/open-collaboration-vscode/package.json @@ -251,18 +251,6 @@ "category": "Open Collaboration Tools", "icon": "$(sign-out)" }, - { - "command": "oct.createTempDiffDocument", - "title": "%oct.createTempDiffDocument%", - "category": "Open Collaboration Tools", - "icon": "$(diff)" - }, - { - "command": "oct.sendDiff", - "title": "%oct.sendDiff%", - "category": "Open Collaboration Tools", - "icon": "$(send)" - }, { "command": "oct.startAgent", "title": "%oct.startAgent%", diff --git a/packages/open-collaboration-vscode/src/commands-list.ts b/packages/open-collaboration-vscode/src/commands-list.ts index e7f9213e..07b2af75 100644 --- a/packages/open-collaboration-vscode/src/commands-list.ts +++ b/packages/open-collaboration-vscode/src/commands-list.ts @@ -23,8 +23,3 @@ export namespace CodeCommands { export const OpenSettings = 'workbench.action.openSettings'; export const OpenFolder = 'vscode.openFolder'; } - -export namespace DiffSupportCommands { - export const CreateTempDiffDocument = 'oct.createTempDiffDocument'; - export const SendDiff = 'oct.sendDiff'; -} diff --git a/packages/open-collaboration-vscode/src/commands.ts b/packages/open-collaboration-vscode/src/commands.ts index 732d819f..3cc1af6b 100644 --- a/packages/open-collaboration-vscode/src/commands.ts +++ b/packages/open-collaboration-vscode/src/commands.ts @@ -16,9 +16,8 @@ import { CollaborationStatusService } from './collaboration-status-service.js'; import { SecretStorage } from './secret-storage.js'; import { RoomUri } from './utils/uri.js'; import { Settings } from './utils/settings.js'; -import { CodeCommands, DiffSupportCommands, OctCommands } from './commands-list.js'; +import { CodeCommands, OctCommands } from './commands-list.js'; import { TreeUserData } from './collaboration-status-view.js'; -import { CollaborationDiffService } from './collaboration-diff-service.js'; @injectable() export class Commands { @@ -41,9 +40,6 @@ export class Commands { @inject(SecretStorage) private secretStorage: SecretStorage; - @inject(CollaborationDiffService) - private diffService: CollaborationDiffService; - initialize(): void { console.log('Initializing commands'); this.context.subscriptions.push( @@ -81,13 +77,6 @@ export class Commands { await this.secretStorage.deleteUserTokens(); vscode.window.showInformationMessage(vscode.l10n.t('Signed out successfully!')); }), - // Diff support - vscode.commands.registerCommand(DiffSupportCommands.CreateTempDiffDocument, async (file: vscode.Uri) => - this.diffService.createTempDiffDocument(file) - ), - vscode.commands.registerCommand(DiffSupportCommands.SendDiff, async (file: vscode.Uri) => - this.diffService.sendDiff(file) - ), vscode.commands.registerCommand(OctCommands.StartAgent, async () => { await this.startAgent(); }) From baf7414bae0fb03b6ba98c9041a35ee827008253 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Thu, 4 Jun 2026 12:16:12 +0200 Subject: [PATCH 16/19] feat: sync proposal close across peers Replace the AcceptChanges broadcast with a CloseProposal event so closing or accepting a proposal diff/merge view in Monaco or VSCode closes the matching view for all peers. --- .../src/collaboration-instance.ts | 67 +++++++++++++ .../src/monaco-api.ts | 20 +++- .../src/connection.ts | 6 +- .../src/messages.ts | 2 +- .../src/collaboration-instance.ts | 95 +++++++++++++++++++ 5 files changed, 186 insertions(+), 4 deletions(-) diff --git a/packages/open-collaboration-monaco/src/collaboration-instance.ts b/packages/open-collaboration-monaco/src/collaboration-instance.ts index 86ac4503..c6b3ee50 100644 --- a/packages/open-collaboration-monaco/src/collaboration-instance.ts +++ b/packages/open-collaboration-monaco/src/collaboration-instance.ts @@ -18,6 +18,7 @@ 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; @@ -45,9 +46,17 @@ export class CollaborationInstance implements Disposable { 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; @@ -113,6 +122,10 @@ export class CollaborationInstance implements Disposable { 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); @@ -176,6 +189,7 @@ export class CollaborationInstance implements Disposable { }); this.connection.editor.onProposeChanges(async (_, path, changes) => this.onDidProposeChanges(path, changes)); + this.connection.editor.onCloseProposal((_, path) => this.onDidCloseProposal(path)); } private setupFileSystemHandlers(): void { @@ -256,14 +270,23 @@ export class CollaborationInstance implements Disposable { 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; @@ -342,18 +365,29 @@ export class CollaborationInstance implements Disposable { 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); + } }); } @@ -369,6 +403,37 @@ export class CollaborationInstance implements Disposable { 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); + } + setEditor(editor: monaco.editor.IStandaloneCodeEditor): void { this.options.editor = editor; this.registerEditorEvents(); @@ -400,6 +465,8 @@ export class CollaborationInstance implements Disposable { 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 01aaee22..e8535a2a 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, ProposedChangesEvent } 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'; @@ -63,6 +63,8 @@ export type MonacoCollabApi = { setWorkspaceName: (workspaceName: string) => void getWorkspaceName: () => string | undefined onProposedChanges: (callback: ProposedChangesEvent) => void + closeProposal: (path: string) => void + onCloseProposal: (callback: CloseProposalEvent) => void } export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { @@ -205,6 +207,18 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { } }; + const doCloseProposal = (path: string) => { + if (instance) { + instance.closeProposal(path); + } + }; + + const registerCloseProposalHandler = (callback: CloseProposalEvent) => { + if (instance) { + instance.onCloseProposal(callback); + } + }; + const isLoggedIn = async () => { if (!connectionProvider) { return false; @@ -239,7 +253,9 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { getFileName: doGetFileName, getWorkspaceName: doGetWorkspaceName, setWorkspaceName: doSetWorkspaceName, - onProposedChanges: registerProposedChangesHandler + onProposedChanges: registerProposedChangesHandler, + closeProposal: doCloseProposal, + onCloseProposal: registerCloseProposalHandler }; } diff --git a/packages/open-collaboration-protocol/src/connection.ts b/packages/open-collaboration-protocol/src/connection.ts index f75a928b..b571dbaa 100644 --- a/packages/open-collaboration-protocol/src/connection.ts +++ b/packages/open-collaboration-protocol/src/connection.ts @@ -34,6 +34,8 @@ export interface EditorHandler { 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 { @@ -153,7 +155,9 @@ export class ProtocolBroadcastConnectionImpl extends AbstractBroadcastConnection onClose: handler => this.onBroadcast(Messages.Editor.Close, handler), 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) + 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 e97b80d1..e5759852 100644 --- a/packages/open-collaboration-protocol/src/messages.ts +++ b/packages/open-collaboration-protocol/src/messages.ts @@ -28,7 +28,7 @@ export namespace Messages { 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 AcceptChanges = new BroadcastType<[types.Path]>('editor/acceptDiff'); + export const CloseProposal = new BroadcastType<[types.Path]>('editor/closeProposal'); } export namespace Sync { diff --git a/packages/open-collaboration-vscode/src/collaboration-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index ddf34fd3..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() @@ -229,6 +236,8 @@ export class CollaborationInstance implements vscode.Disposable { 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 }; @@ -643,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(); } @@ -735,6 +746,19 @@ export class CollaborationInstance implements vscode.Disposable { }); 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 { @@ -808,6 +832,77 @@ export class CollaborationInstance implements vscode.Disposable { 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 { From 75c69d1789f2197331eb9b59a1e6c37e426f60e3 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Thu, 4 Jun 2026 14:37:15 +0200 Subject: [PATCH 17/19] feat(monaco): add cancelProposal to locally dismiss diff proposals --- .../src/collaboration-instance.ts | 20 +++++++++++++++++++ .../src/monaco-api.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/open-collaboration-monaco/src/collaboration-instance.ts b/packages/open-collaboration-monaco/src/collaboration-instance.ts index c6b3ee50..8c8161b6 100644 --- a/packages/open-collaboration-monaco/src/collaboration-instance.ts +++ b/packages/open-collaboration-monaco/src/collaboration-instance.ts @@ -434,6 +434,26 @@ export class CollaborationInstance implements Disposable { 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(); diff --git a/packages/open-collaboration-monaco/src/monaco-api.ts b/packages/open-collaboration-monaco/src/monaco-api.ts index e8535a2a..9b32a604 100644 --- a/packages/open-collaboration-monaco/src/monaco-api.ts +++ b/packages/open-collaboration-monaco/src/monaco-api.ts @@ -64,6 +64,7 @@ export type MonacoCollabApi = { getWorkspaceName: () => string | undefined onProposedChanges: (callback: ProposedChangesEvent) => void closeProposal: (path: string) => void + cancelProposal: (path: string) => void onCloseProposal: (callback: CloseProposalEvent) => void } @@ -213,6 +214,12 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { } }; + const doCancelProposal = (path: string) => { + if (instance) { + instance.cancelProposal(path); + } + }; + const registerCloseProposalHandler = (callback: CloseProposalEvent) => { if (instance) { instance.onCloseProposal(callback); @@ -255,6 +262,7 @@ export function monacoCollab(options: MonacoCollabOptions): MonacoCollabApi { setWorkspaceName: doSetWorkspaceName, onProposedChanges: registerProposedChangesHandler, closeProposal: doCloseProposal, + cancelProposal: doCancelProposal, onCloseProposal: registerCloseProposalHandler }; From 6116dfbe40cb43c0e78884cd4d31c4920b961e03 Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Fri, 5 Jun 2026 10:33:27 +0200 Subject: [PATCH 18/19] refactor(oct-agent): move isFileAtPath function to top-level scope for better accessibility --- packages/open-collaboration-agent/src/agent.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/open-collaboration-agent/src/agent.ts b/packages/open-collaboration-agent/src/agent.ts index 21f4d344..fb3eeb35 100644 --- a/packages/open-collaboration-agent/src/agent.ts +++ b/packages/open-collaboration-agent/src/agent.ts @@ -199,6 +199,14 @@ function findMatchingFiles(rootDir: string, userPath: string, maxResults = 5): s 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; + } +} + /** * Sets up trigger detection for @agent mentions in documents * Returns a cleanup function to stop monitoring @@ -308,14 +316,6 @@ export function setupTriggerDetection(options: TriggerDetectionOptions): () => v } }; - const isFileAtPath = (filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch { - return false; - } - }; - const openDocumentAndExecute = async (octPath: string, pendingPrompt: string): Promise => { const hostId = documentOps.getSessionInfo().hostId; const docContent = await documentSync.openAndWaitForContent(hostId, octPath); From 4dff3d2ee272384cd45013ac33f13a1b3b3be40f Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Fri, 12 Jun 2026 11:47:11 +0200 Subject: [PATCH 19/19] Removed diff view related menu entries from package.json --- packages/open-collaboration-vscode/package.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/open-collaboration-vscode/package.json b/packages/open-collaboration-vscode/package.json index ce732b76..05d45402 100644 --- a/packages/open-collaboration-vscode/package.json +++ b/packages/open-collaboration-vscode/package.json @@ -181,18 +181,6 @@ "when": "viewItem == pendingPeer", "group": "inline" } - ], - "editor/title": [ - { - "command": "oct.sendDiff", - "when": "oct.connection && editorLangId != 'oct-temp-diff'", - "group": "navigation" - }, - { - "command": "oct.createTempDiffDocument", - "when": "oct.connection && editorLangId != 'oct-temp-diff'", - "group": "navigation" - } ] }, "commands": [