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.
@@ -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
+ }
@@ -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
+ }