soulclaw-vscode 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscodeignore +8 -0
- package/.vsixignore +2 -0
- package/LICENSE +190 -0
- package/README.md +167 -0
- package/docs/TESTCASE.md +217 -0
- package/esbuild.mjs +59 -0
- package/media/chat.css +316 -0
- package/media/clawsouls_vscode_logo.png +0 -0
- package/media/clawsouls_vscode_logo_small.png +0 -0
- package/media/icon.png +0 -0
- package/media/soul-icon.svg +18 -0
- package/media/swarm-icon.png +0 -0
- package/package.json +344 -0
- package/resources/icon.png +0 -0
- package/resources/soul-icon.svg +6 -0
- package/resources/soul-spec-schema.json +55 -0
- package/src/commands/setup.ts +866 -0
- package/src/context/workspaceTracker.ts +229 -0
- package/src/extension.ts +198 -0
- package/src/gateway/connection.ts +291 -0
- package/src/gateway/launcher.ts +458 -0
- package/src/ui/chatHistoryPanel.ts +100 -0
- package/src/ui/chatPanel.ts +502 -0
- package/src/ui/checkpointPanel.ts +218 -0
- package/src/ui/soulExplorer.ts +438 -0
- package/src/ui/statusBar.ts +206 -0
- package/src/ui/swarmPanel.ts +338 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
|
|
6
|
+
export interface WorkspaceContext {
|
|
7
|
+
workspacePath?: string;
|
|
8
|
+
currentFile?: string;
|
|
9
|
+
openFiles: string[];
|
|
10
|
+
gitBranch?: string;
|
|
11
|
+
projectType?: string;
|
|
12
|
+
soulConfig?: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class WorkspaceTracker {
|
|
16
|
+
private context: WorkspaceContext = {
|
|
17
|
+
openFiles: []
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
constructor(private extensionContext: vscode.ExtensionContext) {
|
|
21
|
+
this.setupEventListeners();
|
|
22
|
+
this.updateContext();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private setupEventListeners(): void {
|
|
26
|
+
// Listen for active editor changes
|
|
27
|
+
vscode.window.onDidChangeActiveTextEditor(this.onActiveEditorChanged.bind(this));
|
|
28
|
+
|
|
29
|
+
// Listen for file saves
|
|
30
|
+
vscode.workspace.onDidSaveTextDocument(this.onDocumentSaved.bind(this));
|
|
31
|
+
|
|
32
|
+
// Listen for workspace changes
|
|
33
|
+
vscode.workspace.onDidChangeWorkspaceFolders(this.updateContext.bind(this));
|
|
34
|
+
|
|
35
|
+
// Listen for configuration changes
|
|
36
|
+
vscode.workspace.onDidChangeConfiguration(this.onConfigurationChanged.bind(this));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private onActiveEditorChanged(editor?: vscode.TextEditor): void {
|
|
40
|
+
if (editor) {
|
|
41
|
+
this.context.currentFile = editor.document.fileName;
|
|
42
|
+
}
|
|
43
|
+
this.updateOpenFiles();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private onDocumentSaved(document: vscode.TextDocument): void {
|
|
47
|
+
// If a soul file was saved, reload soul config
|
|
48
|
+
const fileName = path.basename(document.fileName).toLowerCase();
|
|
49
|
+
if (fileName === 'soul.json') {
|
|
50
|
+
this.loadSoulConfig();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private onConfigurationChanged(event: vscode.ConfigurationChangeEvent): void {
|
|
55
|
+
if (event.affectsConfiguration('clawsouls')) {
|
|
56
|
+
this.updateContext();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private updateContext(): void {
|
|
61
|
+
this.updateWorkspacePath();
|
|
62
|
+
this.updateOpenFiles();
|
|
63
|
+
this.updateProjectType();
|
|
64
|
+
this.loadSoulConfig();
|
|
65
|
+
this.updateGitBranch();
|
|
66
|
+
this.syncProjectToToolsMd();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private updateWorkspacePath(): void {
|
|
70
|
+
const workspaces = vscode.workspace.workspaceFolders;
|
|
71
|
+
if (workspaces && workspaces.length > 0) {
|
|
72
|
+
this.context.workspacePath = workspaces[0].uri.fsPath;
|
|
73
|
+
} else {
|
|
74
|
+
this.context.workspacePath = undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private updateOpenFiles(): void {
|
|
79
|
+
this.context.openFiles = vscode.window.tabGroups.all
|
|
80
|
+
.flatMap(group => group.tabs)
|
|
81
|
+
.map(tab => {
|
|
82
|
+
if (tab.input instanceof vscode.TabInputText) {
|
|
83
|
+
return tab.input.uri.fsPath;
|
|
84
|
+
}
|
|
85
|
+
return '';
|
|
86
|
+
})
|
|
87
|
+
.filter(path => path !== '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private updateProjectType(): void {
|
|
91
|
+
if (!this.context.workspacePath) {
|
|
92
|
+
this.context.projectType = undefined;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rootPath = this.context.workspacePath;
|
|
97
|
+
|
|
98
|
+
// Check for common project types
|
|
99
|
+
if (this.fileExists(path.join(rootPath, 'package.json'))) {
|
|
100
|
+
this.context.projectType = 'node';
|
|
101
|
+
} else if (this.fileExists(path.join(rootPath, 'requirements.txt')) ||
|
|
102
|
+
this.fileExists(path.join(rootPath, 'pyproject.toml'))) {
|
|
103
|
+
this.context.projectType = 'python';
|
|
104
|
+
} else if (this.fileExists(path.join(rootPath, 'Cargo.toml'))) {
|
|
105
|
+
this.context.projectType = 'rust';
|
|
106
|
+
} else if (this.fileExists(path.join(rootPath, 'go.mod'))) {
|
|
107
|
+
this.context.projectType = 'go';
|
|
108
|
+
} else if (this.fileExists(path.join(rootPath, 'pom.xml')) ||
|
|
109
|
+
this.fileExists(path.join(rootPath, 'build.gradle'))) {
|
|
110
|
+
this.context.projectType = 'java';
|
|
111
|
+
} else {
|
|
112
|
+
this.context.projectType = 'unknown';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async loadSoulConfig(): Promise<void> {
|
|
117
|
+
if (!this.context.workspacePath) {
|
|
118
|
+
this.context.soulConfig = undefined;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const soulJsonPath = path.join(this.context.workspacePath, 'soul.json');
|
|
124
|
+
const document = await vscode.workspace.openTextDocument(soulJsonPath);
|
|
125
|
+
this.context.soulConfig = JSON.parse(document.getText());
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.context.soulConfig = undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private updateGitBranch(): void {
|
|
132
|
+
// For MVP, we'll implement a simple git branch detection
|
|
133
|
+
// This could be expanded to use the built-in Git extension API
|
|
134
|
+
if (!this.context.workspacePath) {
|
|
135
|
+
this.context.gitBranch = undefined;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const gitHeadPath = path.join(this.context.workspacePath, '.git', 'HEAD');
|
|
141
|
+
if (this.fileExists(gitHeadPath)) {
|
|
142
|
+
// This is a simplified implementation
|
|
143
|
+
// A full implementation would read the HEAD file and resolve refs
|
|
144
|
+
this.context.gitBranch = 'main'; // Default
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
this.context.gitBranch = undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private fileExists(filePath: string): boolean {
|
|
152
|
+
try {
|
|
153
|
+
const stat = vscode.workspace.fs.stat(vscode.Uri.file(filePath));
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Write current project info to ~/.openclaw/workspace/TOOLS.md
|
|
162
|
+
* so the LLM knows the active project path.
|
|
163
|
+
*/
|
|
164
|
+
private syncProjectToToolsMd(): void {
|
|
165
|
+
if (!this.context.workspacePath) return;
|
|
166
|
+
|
|
167
|
+
const toolsMdPath = path.join(os.homedir(), '.openclaw', 'workspace', 'TOOLS.md');
|
|
168
|
+
const sectionHeader = '## Current Project';
|
|
169
|
+
const newSection = [
|
|
170
|
+
sectionHeader,
|
|
171
|
+
`- **Path**: \`${this.context.workspacePath}\``,
|
|
172
|
+
`- **Name**: ${path.basename(this.context.workspacePath)}`,
|
|
173
|
+
this.context.projectType ? `- **Type**: ${this.context.projectType}` : '',
|
|
174
|
+
this.context.gitBranch ? `- **Branch**: ${this.context.gitBranch}` : '',
|
|
175
|
+
`- **Updated**: ${new Date().toISOString()}`,
|
|
176
|
+
].filter(Boolean).join('\n');
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
let content = '';
|
|
180
|
+
if (fs.existsSync(toolsMdPath)) {
|
|
181
|
+
content = fs.readFileSync(toolsMdPath, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Replace existing section or append
|
|
185
|
+
const sectionRegex = /## Current Project[\s\S]*?(?=\n## |\n---|\n$|$)/;
|
|
186
|
+
if (sectionRegex.test(content)) {
|
|
187
|
+
content = content.replace(sectionRegex, newSection);
|
|
188
|
+
} else {
|
|
189
|
+
content = content.trimEnd() + '\n\n' + newSection + '\n';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fs.writeFileSync(toolsMdPath, content, 'utf8');
|
|
193
|
+
} catch {
|
|
194
|
+
// Non-fatal — TOOLS.md may not exist yet
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
public getContext(): WorkspaceContext {
|
|
199
|
+
return { ...this.context }; // Return a copy to prevent external modification
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public getCurrentFile(): string | undefined {
|
|
203
|
+
return this.context.currentFile;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public getWorkspacePath(): string | undefined {
|
|
207
|
+
return this.context.workspacePath;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public getSoulConfig(): any {
|
|
211
|
+
return this.context.soulConfig;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public getProjectType(): string | undefined {
|
|
215
|
+
return this.context.projectType;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public getRelativePath(absolutePath: string): string {
|
|
219
|
+
if (!this.context.workspacePath) {
|
|
220
|
+
return absolutePath;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
return path.relative(this.context.workspacePath, absolutePath);
|
|
225
|
+
} catch {
|
|
226
|
+
return absolutePath;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { GatewayConnection } from './gateway/connection';
|
|
3
|
+
import { ChatPanel } from './ui/chatPanel';
|
|
4
|
+
import { SoulExplorerProvider } from './ui/soulExplorer';
|
|
5
|
+
import { StatusBarManager } from './ui/statusBar';
|
|
6
|
+
import { WorkspaceTracker } from './context/workspaceTracker';
|
|
7
|
+
import { CheckpointProvider } from './ui/checkpointPanel';
|
|
8
|
+
import { SwarmProvider } from './ui/swarmPanel';
|
|
9
|
+
import { ChatHistoryProvider } from './ui/chatHistoryPanel';
|
|
10
|
+
import { setupWizard } from './commands/setup';
|
|
11
|
+
import { GatewayLauncher } from './gateway/launcher';
|
|
12
|
+
|
|
13
|
+
export let gatewayConnection: GatewayConnection;
|
|
14
|
+
export let gatewayLauncher: GatewayLauncher;
|
|
15
|
+
export let chatPanel: ChatPanel;
|
|
16
|
+
export let workspaceTracker: WorkspaceTracker;
|
|
17
|
+
export let outputChannel: vscode.OutputChannel;
|
|
18
|
+
|
|
19
|
+
export async function activate(context: vscode.ExtensionContext) {
|
|
20
|
+
_context = context;
|
|
21
|
+
// Create output channel (shows in OUTPUT panel Tasks dropdown)
|
|
22
|
+
outputChannel = vscode.window.createOutputChannel('SoulClaw');
|
|
23
|
+
context.subscriptions.push(outputChannel);
|
|
24
|
+
outputChannel.appendLine('SoulClaw v0.1.0 activated');
|
|
25
|
+
console.log('SoulClaw activated');
|
|
26
|
+
|
|
27
|
+
// Register ALL commands first — before anything that might throw
|
|
28
|
+
context.subscriptions.push(
|
|
29
|
+
vscode.commands.registerCommand('clawsouls.setup', async () => {
|
|
30
|
+
// Stop everything cleanly
|
|
31
|
+
if (gatewayConnection?.currentState === 'connected' || gatewayConnection?.currentState === 'connecting') {
|
|
32
|
+
gatewayConnection.disconnect();
|
|
33
|
+
}
|
|
34
|
+
if (gatewayLauncher?.isRunning()) {
|
|
35
|
+
gatewayLauncher.stop();
|
|
36
|
+
}
|
|
37
|
+
outputChannel.appendLine('Gateway stopped for setup');
|
|
38
|
+
// Wait for port to free
|
|
39
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
40
|
+
|
|
41
|
+
const result = await setupWizard();
|
|
42
|
+
outputChannel.appendLine(`Setup ${result.completed ? 'completed' : 'cancelled'} — starting Gateway...`);
|
|
43
|
+
await restartGateway();
|
|
44
|
+
}),
|
|
45
|
+
vscode.commands.registerCommand('clawsouls.openChat', () => chatPanel?.show()),
|
|
46
|
+
vscode.commands.registerCommand('clawsouls.clearChat', () => chatPanel?.clearChat()),
|
|
47
|
+
vscode.commands.registerCommand('clawsouls.switchHistory', () => chatPanel?.switchHistory()),
|
|
48
|
+
vscode.commands.registerCommand('clawsouls.restartGateway', () => gatewayConnection?.restart()),
|
|
49
|
+
vscode.commands.registerCommand('clawsouls.connect', async () => {
|
|
50
|
+
if (gatewayLauncher?.gatewayToken) {
|
|
51
|
+
gatewayConnection?.setToken(gatewayLauncher.gatewayToken);
|
|
52
|
+
}
|
|
53
|
+
outputChannel.appendLine('Manual connect triggered');
|
|
54
|
+
gatewayConnection?.disconnect();
|
|
55
|
+
await gatewayConnection?.connect();
|
|
56
|
+
}),
|
|
57
|
+
// clawsouls.refresh is registered by SoulExplorerProvider
|
|
58
|
+
vscode.commands.registerCommand('clawsouls.runScan', async () => {
|
|
59
|
+
const ws = vscode.workspace.workspaceFolders;
|
|
60
|
+
if (!ws) { vscode.window.showWarningMessage('No workspace open'); return; }
|
|
61
|
+
const dir = ws[0].uri.fsPath;
|
|
62
|
+
const terminal = vscode.window.createTerminal({ name: 'SoulScan', cwd: dir });
|
|
63
|
+
terminal.show();
|
|
64
|
+
terminal.sendText('npx clawsouls scan');
|
|
65
|
+
}),
|
|
66
|
+
vscode.commands.registerCommand('clawsouls.editSoul', async () => {
|
|
67
|
+
const ws = vscode.workspace.workspaceFolders;
|
|
68
|
+
if (!ws) return;
|
|
69
|
+
const fs = require('fs');
|
|
70
|
+
const pathMod = require('path');
|
|
71
|
+
const soulPath = pathMod.join(ws[0].uri.fsPath, 'soul.json');
|
|
72
|
+
if (!fs.existsSync(soulPath)) {
|
|
73
|
+
const create = await vscode.window.showInformationMessage('No soul.json found. Create one?', 'Create', 'Cancel');
|
|
74
|
+
if (create !== 'Create') return;
|
|
75
|
+
fs.writeFileSync(soulPath, JSON.stringify({
|
|
76
|
+
specVersion: "0.5",
|
|
77
|
+
name: "my-soul",
|
|
78
|
+
displayName: "My Soul",
|
|
79
|
+
version: "0.1.0",
|
|
80
|
+
description: "",
|
|
81
|
+
persona: { identity: "", style: "" }
|
|
82
|
+
}, null, 2));
|
|
83
|
+
}
|
|
84
|
+
const doc = await vscode.workspace.openTextDocument(soulPath);
|
|
85
|
+
await vscode.window.showTextDocument(doc);
|
|
86
|
+
}),
|
|
87
|
+
// clawsouls.initSwarm, joinAgent, pushChanges, pullLatest, mergeBranches → SwarmProvider
|
|
88
|
+
// clawsouls.createCheckpoint → CheckpointProvider
|
|
89
|
+
);
|
|
90
|
+
outputChannel.appendLine('Commands registered');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Initialize workspace tracker
|
|
94
|
+
workspaceTracker = new WorkspaceTracker(context);
|
|
95
|
+
|
|
96
|
+
// Initialize Gateway connection
|
|
97
|
+
gatewayConnection = new GatewayConnection(context);
|
|
98
|
+
|
|
99
|
+
// Initialize chat panel
|
|
100
|
+
chatPanel = new ChatPanel(context, gatewayConnection);
|
|
101
|
+
|
|
102
|
+
// Initialize status bar
|
|
103
|
+
const statusBar = new StatusBarManager(context, gatewayConnection);
|
|
104
|
+
|
|
105
|
+
// Initialize Soul Explorer
|
|
106
|
+
// Initialize Chat History panel
|
|
107
|
+
const chatHistoryProvider = new ChatHistoryProvider(context);
|
|
108
|
+
vscode.window.createTreeView('clawsouls.chatHistory', {
|
|
109
|
+
treeDataProvider: chatHistoryProvider
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const soulExplorerProvider = new SoulExplorerProvider(context);
|
|
113
|
+
vscode.window.createTreeView('clawsouls.soulExplorer', {
|
|
114
|
+
treeDataProvider: soulExplorerProvider
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Initialize Swarm panel
|
|
118
|
+
const swarmProvider = new SwarmProvider(context);
|
|
119
|
+
vscode.window.createTreeView('clawsouls.swarm', {
|
|
120
|
+
treeDataProvider: swarmProvider
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Initialize Checkpoint panel
|
|
124
|
+
const checkpointProvider = new CheckpointProvider(context);
|
|
125
|
+
vscode.window.createTreeView('clawsouls.checkpoints', {
|
|
126
|
+
treeDataProvider: checkpointProvider
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// First run: show setup wizard, wait for completion, then start gateway
|
|
130
|
+
const hasSetup = context.globalState.get('hasSetup', false);
|
|
131
|
+
if (!hasSetup) {
|
|
132
|
+
outputChannel.appendLine('First run — opening setup wizard...');
|
|
133
|
+
const result = await setupWizard();
|
|
134
|
+
context.globalState.update('hasSetup', true);
|
|
135
|
+
outputChannel.appendLine(`Setup ${result.completed ? 'completed' : 'skipped'} — starting Gateway...`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Launch gateway and connect
|
|
139
|
+
await restartGateway();
|
|
140
|
+
|
|
141
|
+
outputChannel.appendLine('Fully initialized');
|
|
142
|
+
} catch (err) {
|
|
143
|
+
outputChannel.appendLine(`Activation error: ${err}`);
|
|
144
|
+
console.error('SoulClaw activation error:', err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let _context: vscode.ExtensionContext;
|
|
149
|
+
|
|
150
|
+
async function restartGateway(): Promise<void> {
|
|
151
|
+
const config = vscode.workspace.getConfiguration('clawsouls');
|
|
152
|
+
if (!config.get('autoConnect', true)) return;
|
|
153
|
+
|
|
154
|
+
if (!gatewayLauncher) {
|
|
155
|
+
gatewayLauncher = new GatewayLauncher(_context);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
outputChannel.appendLine('Ensuring Gateway is running...');
|
|
159
|
+
await gatewayLauncher.ensureRunning();
|
|
160
|
+
|
|
161
|
+
if (gatewayLauncher.gatewayToken) {
|
|
162
|
+
gatewayConnection.setToken(gatewayLauncher.gatewayToken);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
outputChannel.appendLine('Connecting to Gateway...');
|
|
166
|
+
let connected = false;
|
|
167
|
+
for (let i = 0; i < 6; i++) {
|
|
168
|
+
try {
|
|
169
|
+
await gatewayConnection.connect();
|
|
170
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
171
|
+
if (gatewayConnection.currentState === 'connected') {
|
|
172
|
+
connected = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
gatewayConnection.disconnect();
|
|
176
|
+
} catch {}
|
|
177
|
+
outputChannel.appendLine(`Connection attempt ${i + 1} failed, retrying in 5s...`);
|
|
178
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
179
|
+
}
|
|
180
|
+
if (!connected) {
|
|
181
|
+
outputChannel.appendLine('Could not connect to Gateway after retries');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function deactivate() {
|
|
186
|
+
console.log('SoulClaw deactivated');
|
|
187
|
+
|
|
188
|
+
if (gatewayLauncher) {
|
|
189
|
+
gatewayLauncher.stop();
|
|
190
|
+
}
|
|
191
|
+
if (gatewayConnection) {
|
|
192
|
+
gatewayConnection.disconnect();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (chatPanel) {
|
|
196
|
+
chatPanel.dispose();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
4
|
+
const WebSocket = require('ws');
|
|
5
|
+
|
|
6
|
+
function log(msg: string) {
|
|
7
|
+
try {
|
|
8
|
+
const { outputChannel } = require('../extension');
|
|
9
|
+
outputChannel?.appendLine(msg);
|
|
10
|
+
} catch {}
|
|
11
|
+
console.log(msg);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
|
|
15
|
+
|
|
16
|
+
export interface GatewayMessage {
|
|
17
|
+
type: string;
|
|
18
|
+
data?: any;
|
|
19
|
+
event?: string;
|
|
20
|
+
payload?: any;
|
|
21
|
+
id?: string;
|
|
22
|
+
ok?: boolean;
|
|
23
|
+
error?: any;
|
|
24
|
+
method?: string;
|
|
25
|
+
params?: any;
|
|
26
|
+
seq?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class GatewayConnection {
|
|
30
|
+
private ws: any = null;
|
|
31
|
+
private state: ConnectionState = 'idle';
|
|
32
|
+
private readonly onStateChangedEmitter = new vscode.EventEmitter<ConnectionState>();
|
|
33
|
+
private readonly onMessageEmitter = new vscode.EventEmitter<GatewayMessage>();
|
|
34
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
35
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
36
|
+
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
37
|
+
|
|
38
|
+
public readonly onStateChanged = this.onStateChangedEmitter.event;
|
|
39
|
+
public readonly onMessage = this.onMessageEmitter.event;
|
|
40
|
+
|
|
41
|
+
constructor(private context: vscode.ExtensionContext) {
|
|
42
|
+
this.context.subscriptions.push(
|
|
43
|
+
this.onStateChangedEmitter,
|
|
44
|
+
this.onMessageEmitter
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public get currentState(): ConnectionState {
|
|
49
|
+
return this.state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private token: string = '';
|
|
53
|
+
|
|
54
|
+
public setToken(token: string): void {
|
|
55
|
+
this.token = token;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public async connect(): Promise<void> {
|
|
59
|
+
if (this.state === 'connecting' || this.state === 'connected') {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.setState('connecting');
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const baseUrl = vscode.workspace.getConfiguration('clawsouls').get('gatewayUrl', 'ws://127.0.0.1:18789');
|
|
67
|
+
|
|
68
|
+
log(`WS connecting to: ${baseUrl} (token: ${this.token ? 'yes' : 'no'})`);
|
|
69
|
+
|
|
70
|
+
this.ws = new WebSocket(baseUrl);
|
|
71
|
+
|
|
72
|
+
this.ws.on('open', () => {
|
|
73
|
+
log('WebSocket open — waiting for connect.challenge...');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.ws.on('message', (data: Buffer) => {
|
|
77
|
+
try {
|
|
78
|
+
const msg = JSON.parse(data.toString());
|
|
79
|
+
this.handleFrame(msg);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
log(`Failed to parse Gateway message: ${error}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.ws.on('close', (code: number, reason: Buffer) => {
|
|
86
|
+
log(`WebSocket closed: code=${code} reason=${reason?.toString()}`);
|
|
87
|
+
this.setState('disconnected');
|
|
88
|
+
this.stopPing();
|
|
89
|
+
this.rejectAll(new Error(`WebSocket closed: ${code}`));
|
|
90
|
+
this.scheduleReconnect();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
this.ws.on('error', (error: Error) => {
|
|
94
|
+
log(`WebSocket error: ${error.message}`);
|
|
95
|
+
this.setState('error');
|
|
96
|
+
this.stopPing();
|
|
97
|
+
this.rejectAll(error);
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
} catch (error: any) {
|
|
102
|
+
log(`Failed to connect to Gateway: ${error?.message || error}`);
|
|
103
|
+
this.setState('error');
|
|
104
|
+
this.scheduleReconnect();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private handleFrame(msg: any): void {
|
|
109
|
+
if (msg.type === 'event') {
|
|
110
|
+
if (msg.event === 'connect.challenge') {
|
|
111
|
+
const nonce = msg.payload?.nonce;
|
|
112
|
+
log(`Got connect.challenge (nonce: ${nonce ? 'yes' : 'no'})`);
|
|
113
|
+
this.sendConnectRequest(nonce);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Log all events for debugging
|
|
117
|
+
log(`Event: ${msg.event} state=${msg.payload?.state || '-'}`);
|
|
118
|
+
// Forward other events
|
|
119
|
+
this.onMessageEmitter.fire(msg);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (msg.type === 'res') {
|
|
124
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
125
|
+
if (pending) {
|
|
126
|
+
this.pendingRequests.delete(msg.id);
|
|
127
|
+
if (msg.ok) {
|
|
128
|
+
pending.resolve(msg.payload);
|
|
129
|
+
} else {
|
|
130
|
+
pending.reject(new Error(msg.error?.message || 'Request failed'));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Forward anything else
|
|
137
|
+
this.onMessageEmitter.fire(msg);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private _sessionKey: string = 'main';
|
|
141
|
+
public get sessionKey(): string { return this._sessionKey; }
|
|
142
|
+
|
|
143
|
+
private async sendConnectRequest(nonce?: string): Promise<void> {
|
|
144
|
+
const params: any = {
|
|
145
|
+
minProtocol: 3,
|
|
146
|
+
maxProtocol: 3,
|
|
147
|
+
client: {
|
|
148
|
+
id: 'gateway-client',
|
|
149
|
+
displayName: 'SoulClaw (VSCode)',
|
|
150
|
+
version: '0.1.0',
|
|
151
|
+
platform: process.platform,
|
|
152
|
+
mode: 'ui'
|
|
153
|
+
},
|
|
154
|
+
caps: [],
|
|
155
|
+
auth: this.token ? { token: this.token } : undefined,
|
|
156
|
+
role: 'operator',
|
|
157
|
+
scopes: ['operator.admin']
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const hello = await this.request('connect', params);
|
|
162
|
+
// Extract session key from hello snapshot
|
|
163
|
+
const defaults = hello?.snapshot?.sessionDefaults;
|
|
164
|
+
if (defaults?.mainSessionKey) {
|
|
165
|
+
this._sessionKey = defaults.mainSessionKey;
|
|
166
|
+
}
|
|
167
|
+
log(`Connected! sessionKey=${this._sessionKey}`);
|
|
168
|
+
this.setState('connected');
|
|
169
|
+
this.startPing();
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
log(`Connect request failed: ${err.message}`);
|
|
172
|
+
this.ws?.close(1008, 'connect failed');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private request(method: string, params?: any): Promise<any> {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
if (!this.ws || this.ws.readyState !== 1 /* OPEN */) {
|
|
179
|
+
reject(new Error('WebSocket not open'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const id = crypto.randomUUID();
|
|
183
|
+
const frame = {
|
|
184
|
+
type: 'req',
|
|
185
|
+
id,
|
|
186
|
+
method,
|
|
187
|
+
params
|
|
188
|
+
};
|
|
189
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
190
|
+
this.ws.send(JSON.stringify(frame));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private rejectAll(err: Error): void {
|
|
195
|
+
for (const [, p] of this.pendingRequests) {
|
|
196
|
+
p.reject(err);
|
|
197
|
+
}
|
|
198
|
+
this.pendingRequests.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
public disconnect(): void {
|
|
202
|
+
this.clearReconnectTimer();
|
|
203
|
+
this.stopPing();
|
|
204
|
+
this.rejectAll(new Error('Disconnected'));
|
|
205
|
+
|
|
206
|
+
if (this.ws) {
|
|
207
|
+
this.ws.close();
|
|
208
|
+
this.ws = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.setState('idle');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public async restart(): Promise<void> {
|
|
215
|
+
vscode.window.showInformationMessage('Restarting Gateway...');
|
|
216
|
+
this.disconnect();
|
|
217
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
218
|
+
await this.connect();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public sendMessage(message: GatewayMessage): void {
|
|
222
|
+
if (this.ws && this.state === 'connected') {
|
|
223
|
+
this.ws.send(JSON.stringify(message));
|
|
224
|
+
} else {
|
|
225
|
+
log('Cannot send message: Gateway not connected');
|
|
226
|
+
vscode.window.showWarningMessage('Gateway not connected. Trying to reconnect...');
|
|
227
|
+
this.connect();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Generic RPC request */
|
|
232
|
+
public async requestRPC(method: string, params?: any): Promise<any> {
|
|
233
|
+
return this.request(method, params);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Send a chat message to the gateway */
|
|
237
|
+
public async sendChat(text: string, sessionKey?: string): Promise<any> {
|
|
238
|
+
const key = sessionKey || this._sessionKey;
|
|
239
|
+
const idempotencyKey = crypto.randomUUID();
|
|
240
|
+
log(`chat.send → sessionKey=${key}, msg=${text.slice(0, 50)}`);
|
|
241
|
+
return this.request('chat.send', {
|
|
242
|
+
sessionKey: key,
|
|
243
|
+
message: text,
|
|
244
|
+
idempotencyKey,
|
|
245
|
+
deliver: false
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private setState(newState: ConnectionState): void {
|
|
250
|
+
if (this.state !== newState) {
|
|
251
|
+
this.state = newState;
|
|
252
|
+
this.onStateChangedEmitter.fire(newState);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private scheduleReconnect(): void {
|
|
257
|
+
this.clearReconnectTimer();
|
|
258
|
+
|
|
259
|
+
const config = vscode.workspace.getConfiguration('clawsouls');
|
|
260
|
+
if (config.get('autoConnect', true)) {
|
|
261
|
+
this.reconnectTimer = setTimeout(() => {
|
|
262
|
+
if (this.state !== 'connected') {
|
|
263
|
+
log('Attempting to reconnect to Gateway...');
|
|
264
|
+
this.connect();
|
|
265
|
+
}
|
|
266
|
+
}, 5000);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private clearReconnectTimer(): void {
|
|
271
|
+
if (this.reconnectTimer) {
|
|
272
|
+
clearTimeout(this.reconnectTimer);
|
|
273
|
+
this.reconnectTimer = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private startPing(): void {
|
|
278
|
+
this.pingTimer = setInterval(() => {
|
|
279
|
+
if (this.ws && this.state === 'connected') {
|
|
280
|
+
this.ws.ping();
|
|
281
|
+
}
|
|
282
|
+
}, 30000);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private stopPing(): void {
|
|
286
|
+
if (this.pingTimer) {
|
|
287
|
+
clearInterval(this.pingTimer);
|
|
288
|
+
this.pingTimer = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|