@hexidecibel/companion 0.0.1
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/dist/__tests__/task-parser.test.d.ts +2 -0
- package/dist/__tests__/task-parser.test.d.ts.map +1 -0
- package/dist/__tests__/task-parser.test.js +79 -0
- package/dist/__tests__/task-parser.test.js.map +1 -0
- package/dist/anthropic-usage.d.ts +5 -0
- package/dist/anthropic-usage.d.ts.map +1 -0
- package/dist/anthropic-usage.js +112 -0
- package/dist/anthropic-usage.js.map +1 -0
- package/dist/cert-generator.d.ts +15 -0
- package/dist/cert-generator.d.ts.map +1 -0
- package/dist/cert-generator.js +298 -0
- package/dist/cert-generator.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +122 -0
- package/dist/config.js.map +1 -0
- package/dist/encryption.d.ts +28 -0
- package/dist/encryption.d.ts.map +1 -0
- package/dist/encryption.js +95 -0
- package/dist/encryption.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +211 -0
- package/dist/index.js.map +1 -0
- package/dist/input-injector.d.ts +21 -0
- package/dist/input-injector.d.ts.map +1 -0
- package/dist/input-injector.js +126 -0
- package/dist/input-injector.js.map +1 -0
- package/dist/mdns.d.ts +11 -0
- package/dist/mdns.d.ts.map +1 -0
- package/dist/mdns.js +93 -0
- package/dist/mdns.js.map +1 -0
- package/dist/parser.d.ts +43 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +800 -0
- package/dist/parser.js.map +1 -0
- package/dist/push.d.ts +38 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +359 -0
- package/dist/push.js.map +1 -0
- package/dist/qr-server.d.ts +13 -0
- package/dist/qr-server.d.ts.map +1 -0
- package/dist/qr-server.js +421 -0
- package/dist/qr-server.js.map +1 -0
- package/dist/scaffold/generator.d.ts +11 -0
- package/dist/scaffold/generator.d.ts.map +1 -0
- package/dist/scaffold/generator.js +206 -0
- package/dist/scaffold/generator.js.map +1 -0
- package/dist/scaffold/templates/index.d.ts +5 -0
- package/dist/scaffold/templates/index.d.ts.map +1 -0
- package/dist/scaffold/templates/index.js +22 -0
- package/dist/scaffold/templates/index.js.map +1 -0
- package/dist/scaffold/templates/node-express.d.ts +3 -0
- package/dist/scaffold/templates/node-express.d.ts.map +1 -0
- package/dist/scaffold/templates/node-express.js +218 -0
- package/dist/scaffold/templates/node-express.js.map +1 -0
- package/dist/scaffold/templates/python-fastapi.d.ts +3 -0
- package/dist/scaffold/templates/python-fastapi.d.ts.map +1 -0
- package/dist/scaffold/templates/python-fastapi.js +302 -0
- package/dist/scaffold/templates/python-fastapi.js.map +1 -0
- package/dist/scaffold/templates/react-mui-website.d.ts +3 -0
- package/dist/scaffold/templates/react-mui-website.d.ts.map +1 -0
- package/dist/scaffold/templates/react-mui-website.js +405 -0
- package/dist/scaffold/templates/react-mui-website.js.map +1 -0
- package/dist/scaffold/templates/react-typescript.d.ts +3 -0
- package/dist/scaffold/templates/react-typescript.d.ts.map +1 -0
- package/dist/scaffold/templates/react-typescript.js +275 -0
- package/dist/scaffold/templates/react-typescript.js.map +1 -0
- package/dist/scaffold/types.d.ts +55 -0
- package/dist/scaffold/types.d.ts.map +1 -0
- package/dist/scaffold/types.js +3 -0
- package/dist/scaffold/types.js.map +1 -0
- package/dist/subagent-watcher.d.ts +24 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +307 -0
- package/dist/subagent-watcher.js.map +1 -0
- package/dist/tls.d.ts +10 -0
- package/dist/tls.d.ts.map +1 -0
- package/dist/tls.js +77 -0
- package/dist/tls.js.map +1 -0
- package/dist/tmux-manager.d.ts +71 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +243 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/tool-config.d.ts +33 -0
- package/dist/tool-config.d.ts.map +1 -0
- package/dist/tool-config.js +211 -0
- package/dist/tool-config.js.map +1 -0
- package/dist/types.d.ts +218 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/watcher.d.ts +63 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +596 -0
- package/dist/watcher.js.map +1 -0
- package/dist/watcher.test.d.ts +2 -0
- package/dist/watcher.test.d.ts.map +1 -0
- package/dist/watcher.test.js +110 -0
- package/dist/watcher.test.js.map +1 -0
- package/dist/websocket.d.ts +62 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +1695 -0
- package/dist/websocket.js.map +1 -0
- package/package.json +71 -0
- package/scripts/build.sh +23 -0
- package/scripts/install-remote.sh +18 -0
- package/scripts/install.sh +558 -0
- package/scripts/uninstall.sh +113 -0
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.WebSocketHandler = void 0;
|
|
37
|
+
const ws_1 = require("ws");
|
|
38
|
+
const uuid_1 = require("uuid");
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const crypto = __importStar(require("crypto"));
|
|
43
|
+
const tmux_manager_1 = require("./tmux-manager");
|
|
44
|
+
const parser_1 = require("./parser");
|
|
45
|
+
const config_1 = require("./config");
|
|
46
|
+
const anthropic_usage_1 = require("./anthropic-usage");
|
|
47
|
+
const tool_config_1 = require("./tool-config");
|
|
48
|
+
const templates_1 = require("./scaffold/templates");
|
|
49
|
+
const generator_1 = require("./scaffold/generator");
|
|
50
|
+
// File for persisting tmux session configs
|
|
51
|
+
const TMUX_CONFIGS_FILE = path.join(os.homedir(), '.companion', 'tmux-sessions.json');
|
|
52
|
+
class WebSocketHandler {
|
|
53
|
+
wss;
|
|
54
|
+
clients = new Map();
|
|
55
|
+
token;
|
|
56
|
+
watcher;
|
|
57
|
+
subAgentWatcher;
|
|
58
|
+
injector;
|
|
59
|
+
push;
|
|
60
|
+
tmux;
|
|
61
|
+
tmuxSessionConfigs = new Map();
|
|
62
|
+
config;
|
|
63
|
+
clientErrors = [];
|
|
64
|
+
MAX_CLIENT_ERRORS = 50;
|
|
65
|
+
scrollLogs = [];
|
|
66
|
+
MAX_SCROLL_LOGS = 200;
|
|
67
|
+
autoApproveEnabled = false;
|
|
68
|
+
constructor(server, config, watcher, injector, push, tmux, subAgentWatcher) {
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.token = config.token;
|
|
71
|
+
this.watcher = watcher;
|
|
72
|
+
this.subAgentWatcher = subAgentWatcher || null;
|
|
73
|
+
this.injector = injector;
|
|
74
|
+
this.push = push;
|
|
75
|
+
this.tmux = tmux || new tmux_manager_1.TmuxManager('companion');
|
|
76
|
+
this.wss = new ws_1.WebSocketServer({ server });
|
|
77
|
+
this.wss.on('connection', (ws, req) => this.handleConnection(ws, req));
|
|
78
|
+
// Forward watcher events to subscribed clients
|
|
79
|
+
this.watcher.on('conversation-update', (data) => {
|
|
80
|
+
this.broadcast('conversation_update', data);
|
|
81
|
+
});
|
|
82
|
+
this.watcher.on('status-change', (data) => {
|
|
83
|
+
this.broadcast('status_change', data);
|
|
84
|
+
// Schedule push notification if waiting for input (include session info)
|
|
85
|
+
if (data.isWaitingForInput && data.lastMessage) {
|
|
86
|
+
this.push.scheduleWaitingNotification(data.lastMessage.content, data.sessionId || undefined, this.injector.getActiveSession() || undefined);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.push.cancelPendingNotification();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// Notify about activity in other (non-active) sessions
|
|
93
|
+
this.watcher.on('other-session-activity', (data) => {
|
|
94
|
+
this.broadcast('other_session_activity', data);
|
|
95
|
+
});
|
|
96
|
+
// Notify about conversation compaction (for archiving)
|
|
97
|
+
this.watcher.on('compaction', (data) => {
|
|
98
|
+
this.broadcast('compaction', data);
|
|
99
|
+
});
|
|
100
|
+
// Load saved tmux session configs
|
|
101
|
+
this.loadTmuxSessionConfigs();
|
|
102
|
+
console.log('WebSocket: Server initialized');
|
|
103
|
+
}
|
|
104
|
+
loadTmuxSessionConfigs() {
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(TMUX_CONFIGS_FILE)) {
|
|
107
|
+
const content = fs.readFileSync(TMUX_CONFIGS_FILE, 'utf-8');
|
|
108
|
+
const configs = JSON.parse(content);
|
|
109
|
+
for (const config of configs) {
|
|
110
|
+
this.tmuxSessionConfigs.set(config.name, config);
|
|
111
|
+
}
|
|
112
|
+
console.log(`WebSocket: Loaded ${configs.length} saved tmux session configs`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
console.error('Failed to load tmux session configs:', err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
saveTmuxSessionConfigs() {
|
|
120
|
+
try {
|
|
121
|
+
const dir = path.dirname(TMUX_CONFIGS_FILE);
|
|
122
|
+
if (!fs.existsSync(dir)) {
|
|
123
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
const configs = Array.from(this.tmuxSessionConfigs.values());
|
|
126
|
+
fs.writeFileSync(TMUX_CONFIGS_FILE, JSON.stringify(configs, null, 2));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
console.error('Failed to save tmux session configs:', err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
storeTmuxSessionConfig(name, workingDir, startCli = true) {
|
|
133
|
+
this.tmuxSessionConfigs.set(name, {
|
|
134
|
+
name,
|
|
135
|
+
workingDir,
|
|
136
|
+
startCli,
|
|
137
|
+
lastUsed: Date.now(),
|
|
138
|
+
});
|
|
139
|
+
this.saveTmuxSessionConfigs();
|
|
140
|
+
console.log(`WebSocket: Stored tmux session config for "${name}" (${workingDir})`);
|
|
141
|
+
}
|
|
142
|
+
handleConnection(ws, req) {
|
|
143
|
+
const clientId = (0, uuid_1.v4)();
|
|
144
|
+
const client = {
|
|
145
|
+
id: clientId,
|
|
146
|
+
ws,
|
|
147
|
+
authenticated: false,
|
|
148
|
+
subscribed: false,
|
|
149
|
+
};
|
|
150
|
+
this.clients.set(clientId, client);
|
|
151
|
+
console.log(`WebSocket: Client connected (${clientId})`);
|
|
152
|
+
ws.on('message', (data) => {
|
|
153
|
+
try {
|
|
154
|
+
const message = JSON.parse(data.toString());
|
|
155
|
+
this.handleMessage(client, message);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.sendError(ws, 'Invalid JSON message');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
ws.on('close', () => {
|
|
162
|
+
this.clients.delete(clientId);
|
|
163
|
+
console.log(`WebSocket: Client disconnected (${clientId})`);
|
|
164
|
+
});
|
|
165
|
+
ws.on('error', (err) => {
|
|
166
|
+
console.error(`WebSocket: Client error (${clientId}):`, err);
|
|
167
|
+
this.clients.delete(clientId);
|
|
168
|
+
});
|
|
169
|
+
// Send connection acknowledgment
|
|
170
|
+
this.send(ws, {
|
|
171
|
+
type: 'connected',
|
|
172
|
+
success: true,
|
|
173
|
+
payload: { clientId },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
handleMessage(client, message) {
|
|
177
|
+
const { type, token, payload, requestId } = message;
|
|
178
|
+
const reqStart = Date.now();
|
|
179
|
+
if (type !== 'ping') {
|
|
180
|
+
console.log(`WebSocket: >> recv ${type} (${requestId || 'no-id'}) from ${client.id}`);
|
|
181
|
+
}
|
|
182
|
+
// Authenticate first
|
|
183
|
+
if (type === 'authenticate') {
|
|
184
|
+
if (token === this.token) {
|
|
185
|
+
client.authenticated = true;
|
|
186
|
+
client.deviceId = payload?.deviceId;
|
|
187
|
+
this.send(client.ws, {
|
|
188
|
+
type: 'authenticated',
|
|
189
|
+
success: true,
|
|
190
|
+
requestId,
|
|
191
|
+
});
|
|
192
|
+
console.log(`WebSocket: Client authenticated (${client.id})`);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
this.send(client.ws, {
|
|
196
|
+
type: 'authenticated',
|
|
197
|
+
success: false,
|
|
198
|
+
error: 'Invalid token',
|
|
199
|
+
requestId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// All other messages require authentication
|
|
205
|
+
if (!client.authenticated) {
|
|
206
|
+
this.send(client.ws, {
|
|
207
|
+
type: 'error',
|
|
208
|
+
success: false,
|
|
209
|
+
error: 'Not authenticated',
|
|
210
|
+
requestId,
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Handle authenticated messages
|
|
215
|
+
switch (type) {
|
|
216
|
+
case 'subscribe':
|
|
217
|
+
const subscribePayload = payload;
|
|
218
|
+
client.subscribed = true;
|
|
219
|
+
// Track which session the client is subscribed to
|
|
220
|
+
if (subscribePayload?.sessionId) {
|
|
221
|
+
client.subscribedSessionId = subscribePayload.sessionId;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
// Default to current active session
|
|
225
|
+
client.subscribedSessionId = this.watcher.getActiveSessionId() || undefined;
|
|
226
|
+
}
|
|
227
|
+
console.log(`WebSocket: Client subscribed (${client.id}) to session ${client.subscribedSessionId}`);
|
|
228
|
+
this.send(client.ws, {
|
|
229
|
+
type: 'subscribed',
|
|
230
|
+
success: true,
|
|
231
|
+
sessionId: client.subscribedSessionId,
|
|
232
|
+
requestId,
|
|
233
|
+
});
|
|
234
|
+
break;
|
|
235
|
+
case 'unsubscribe':
|
|
236
|
+
client.subscribed = false;
|
|
237
|
+
this.send(client.ws, {
|
|
238
|
+
type: 'unsubscribed',
|
|
239
|
+
success: true,
|
|
240
|
+
requestId,
|
|
241
|
+
});
|
|
242
|
+
break;
|
|
243
|
+
case 'get_highlights': {
|
|
244
|
+
const hlParams = payload;
|
|
245
|
+
const t0 = Date.now();
|
|
246
|
+
const messages = this.watcher.getMessages();
|
|
247
|
+
const t1 = Date.now();
|
|
248
|
+
const allHighlights = (0, parser_1.extractHighlights)(messages);
|
|
249
|
+
const t2 = Date.now();
|
|
250
|
+
const hlSessionId = this.watcher.getActiveSessionId();
|
|
251
|
+
const total = allHighlights.length;
|
|
252
|
+
// Paginate: return most recent `limit` items, with optional offset from end
|
|
253
|
+
let resultHighlights = allHighlights;
|
|
254
|
+
let hasMore = false;
|
|
255
|
+
if (hlParams?.limit && hlParams.limit > 0) {
|
|
256
|
+
const offset = hlParams.offset || 0;
|
|
257
|
+
const startIdx = Math.max(0, total - offset - hlParams.limit);
|
|
258
|
+
const endIdx = total - offset;
|
|
259
|
+
resultHighlights = allHighlights.slice(startIdx, endIdx);
|
|
260
|
+
hasMore = startIdx > 0;
|
|
261
|
+
}
|
|
262
|
+
console.log(`WebSocket: get_highlights - getMessages: ${t1 - t0}ms, extractHighlights: ${t2 - t1}ms, ${messages.length} msgs, returning ${resultHighlights.length}/${total}`);
|
|
263
|
+
this.send(client.ws, {
|
|
264
|
+
type: 'highlights',
|
|
265
|
+
success: true,
|
|
266
|
+
payload: { highlights: resultHighlights, total, hasMore },
|
|
267
|
+
sessionId: hlSessionId,
|
|
268
|
+
requestId,
|
|
269
|
+
});
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'get_full': {
|
|
273
|
+
const t0 = Date.now();
|
|
274
|
+
const fullMessages = this.watcher.getMessages();
|
|
275
|
+
const t1 = Date.now();
|
|
276
|
+
const fullSessionId = this.watcher.getActiveSessionId();
|
|
277
|
+
console.log(`WebSocket: get_full - getMessages: ${t1 - t0}ms, ${fullMessages.length} msgs`);
|
|
278
|
+
this.send(client.ws, {
|
|
279
|
+
type: 'full',
|
|
280
|
+
success: true,
|
|
281
|
+
payload: { messages: fullMessages },
|
|
282
|
+
sessionId: fullSessionId,
|
|
283
|
+
requestId,
|
|
284
|
+
});
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case 'get_status': {
|
|
288
|
+
const t0 = Date.now();
|
|
289
|
+
const status = this.watcher.getStatus();
|
|
290
|
+
const t1 = Date.now();
|
|
291
|
+
const statusSessionId = this.watcher.getActiveSessionId();
|
|
292
|
+
console.log(`WebSocket: get_status - ${t1 - t0}ms`);
|
|
293
|
+
this.send(client.ws, {
|
|
294
|
+
type: 'status',
|
|
295
|
+
success: true,
|
|
296
|
+
payload: status,
|
|
297
|
+
sessionId: statusSessionId,
|
|
298
|
+
requestId,
|
|
299
|
+
});
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case 'get_server_summary':
|
|
303
|
+
// Get tmux sessions to filter - only show conversations with active tmux sessions
|
|
304
|
+
this.tmux.listSessions().then(async (tmuxSessions) => {
|
|
305
|
+
const summary = await this.watcher.getServerSummary(tmuxSessions);
|
|
306
|
+
this.send(client.ws, {
|
|
307
|
+
type: 'server_summary',
|
|
308
|
+
success: true,
|
|
309
|
+
payload: summary,
|
|
310
|
+
requestId,
|
|
311
|
+
});
|
|
312
|
+
}).catch((err) => {
|
|
313
|
+
console.error('Failed to get server summary:', err);
|
|
314
|
+
this.send(client.ws, {
|
|
315
|
+
type: 'server_summary',
|
|
316
|
+
success: false,
|
|
317
|
+
error: 'Failed to get server summary',
|
|
318
|
+
requestId,
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
break;
|
|
322
|
+
case 'get_sessions':
|
|
323
|
+
const sessions = this.watcher.getSessions();
|
|
324
|
+
const activeSessionId = this.watcher.getActiveSessionId();
|
|
325
|
+
this.send(client.ws, {
|
|
326
|
+
type: 'sessions',
|
|
327
|
+
success: true,
|
|
328
|
+
payload: { sessions, activeSessionId },
|
|
329
|
+
requestId,
|
|
330
|
+
});
|
|
331
|
+
break;
|
|
332
|
+
case 'get_tasks':
|
|
333
|
+
// Get tasks for a specific session
|
|
334
|
+
const tasksPayload = payload;
|
|
335
|
+
const tasksSessionId = tasksPayload?.sessionId || this.watcher.getActiveSessionId();
|
|
336
|
+
if (tasksSessionId) {
|
|
337
|
+
const sessionSessions = this.watcher.getSessions();
|
|
338
|
+
const session = sessionSessions.find(s => s.id === tasksSessionId);
|
|
339
|
+
if (session?.conversationPath) {
|
|
340
|
+
try {
|
|
341
|
+
const fs = require('fs');
|
|
342
|
+
const content = fs.readFileSync(session.conversationPath, 'utf-8');
|
|
343
|
+
const tasks = (0, parser_1.extractTasks)(content);
|
|
344
|
+
this.send(client.ws, {
|
|
345
|
+
type: 'tasks',
|
|
346
|
+
success: true,
|
|
347
|
+
payload: { tasks, sessionId: tasksSessionId },
|
|
348
|
+
requestId,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
this.send(client.ws, {
|
|
353
|
+
type: 'tasks',
|
|
354
|
+
success: false,
|
|
355
|
+
error: 'Failed to read session file',
|
|
356
|
+
requestId,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
this.send(client.ws, {
|
|
362
|
+
type: 'tasks',
|
|
363
|
+
success: true,
|
|
364
|
+
payload: { tasks: [], sessionId: tasksSessionId },
|
|
365
|
+
requestId,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
this.send(client.ws, {
|
|
371
|
+
type: 'tasks',
|
|
372
|
+
success: false,
|
|
373
|
+
error: 'No session specified',
|
|
374
|
+
requestId,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
case 'switch_session':
|
|
379
|
+
// Handle switch_session asynchronously but await completion
|
|
380
|
+
this.handleSwitchSession(client, payload, requestId);
|
|
381
|
+
break;
|
|
382
|
+
case 'send_input':
|
|
383
|
+
this.handleSendInput(client, payload, requestId);
|
|
384
|
+
break;
|
|
385
|
+
case 'send_image':
|
|
386
|
+
this.handleSendImage(client, payload, requestId);
|
|
387
|
+
break;
|
|
388
|
+
case 'upload_image':
|
|
389
|
+
// Just upload and save, don't send yet
|
|
390
|
+
this.handleUploadImage(client, payload, requestId);
|
|
391
|
+
break;
|
|
392
|
+
case 'send_with_images':
|
|
393
|
+
// Send message with image paths combined
|
|
394
|
+
this.handleSendWithImages(client, payload, requestId);
|
|
395
|
+
break;
|
|
396
|
+
case 'register_push':
|
|
397
|
+
const pushPayload = payload;
|
|
398
|
+
if (pushPayload?.fcmToken && pushPayload?.deviceId) {
|
|
399
|
+
const isExpoToken = pushPayload.fcmToken.startsWith('ExponentPushToken');
|
|
400
|
+
console.log(`Push registration: device=${pushPayload.deviceId}, type=${isExpoToken ? 'expo' : 'fcm'}, token=${pushPayload.fcmToken.substring(0, 30)}...`);
|
|
401
|
+
// Link deviceId to this client for instant notify
|
|
402
|
+
client.deviceId = pushPayload.deviceId;
|
|
403
|
+
this.push.registerDevice(pushPayload.deviceId, pushPayload.fcmToken);
|
|
404
|
+
this.send(client.ws, {
|
|
405
|
+
type: 'push_registered',
|
|
406
|
+
success: true,
|
|
407
|
+
requestId,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
this.send(client.ws, {
|
|
412
|
+
type: 'push_registered',
|
|
413
|
+
success: false,
|
|
414
|
+
error: 'Missing fcmToken or deviceId',
|
|
415
|
+
requestId,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
case 'unregister_push':
|
|
420
|
+
const unregPayload = payload;
|
|
421
|
+
if (unregPayload?.deviceId) {
|
|
422
|
+
this.push.unregisterDevice(unregPayload.deviceId);
|
|
423
|
+
this.send(client.ws, {
|
|
424
|
+
type: 'push_unregistered',
|
|
425
|
+
success: true,
|
|
426
|
+
requestId,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
case 'set_instant_notify':
|
|
431
|
+
const instantPayload = payload;
|
|
432
|
+
if (client.deviceId) {
|
|
433
|
+
this.push.setInstantNotify(client.deviceId, instantPayload?.enabled ?? false);
|
|
434
|
+
this.send(client.ws, {
|
|
435
|
+
type: 'instant_notify_set',
|
|
436
|
+
success: true,
|
|
437
|
+
payload: { enabled: instantPayload?.enabled ?? false },
|
|
438
|
+
requestId,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
this.send(client.ws, {
|
|
443
|
+
type: 'instant_notify_set',
|
|
444
|
+
success: false,
|
|
445
|
+
error: 'Device not registered for push',
|
|
446
|
+
requestId,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
case 'set_auto_approve': {
|
|
451
|
+
const autoApprovePayload = payload;
|
|
452
|
+
this.autoApproveEnabled = autoApprovePayload?.enabled ?? false;
|
|
453
|
+
console.log(`Auto-approve ${this.autoApproveEnabled ? 'enabled' : 'disabled'} by client`);
|
|
454
|
+
this.send(client.ws, {
|
|
455
|
+
type: 'auto_approve_set',
|
|
456
|
+
success: true,
|
|
457
|
+
payload: { enabled: this.autoApproveEnabled },
|
|
458
|
+
requestId,
|
|
459
|
+
});
|
|
460
|
+
// When toggled ON, immediately check for pending tools that should be auto-approved
|
|
461
|
+
if (this.autoApproveEnabled) {
|
|
462
|
+
this.watcher.checkAndEmitPendingApproval();
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case 'set_notification_prefs':
|
|
467
|
+
const notifPrefs = payload;
|
|
468
|
+
if (client.deviceId) {
|
|
469
|
+
this.push.setNotificationPrefs(client.deviceId, {
|
|
470
|
+
quietHoursEnabled: notifPrefs?.quietHoursEnabled ?? false,
|
|
471
|
+
quietHoursStart: notifPrefs?.quietHoursStart ?? '22:00',
|
|
472
|
+
quietHoursEnd: notifPrefs?.quietHoursEnd ?? '08:00',
|
|
473
|
+
throttleMinutes: notifPrefs?.throttleMinutes ?? 0,
|
|
474
|
+
});
|
|
475
|
+
this.send(client.ws, {
|
|
476
|
+
type: 'notification_prefs_set',
|
|
477
|
+
success: true,
|
|
478
|
+
requestId,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
this.send(client.ws, {
|
|
483
|
+
type: 'notification_prefs_set',
|
|
484
|
+
success: false,
|
|
485
|
+
error: 'Device not registered for push',
|
|
486
|
+
requestId,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
490
|
+
case 'ping':
|
|
491
|
+
if (client.deviceId) {
|
|
492
|
+
this.push.updateDeviceLastSeen(client.deviceId);
|
|
493
|
+
}
|
|
494
|
+
this.send(client.ws, {
|
|
495
|
+
type: 'pong',
|
|
496
|
+
success: true,
|
|
497
|
+
requestId,
|
|
498
|
+
});
|
|
499
|
+
break;
|
|
500
|
+
case 'rotate_token':
|
|
501
|
+
this.handleRotateToken(client, requestId);
|
|
502
|
+
break;
|
|
503
|
+
// Tmux session management
|
|
504
|
+
case 'list_tmux_sessions':
|
|
505
|
+
console.log('WebSocket: Received list_tmux_sessions request');
|
|
506
|
+
this.handleListTmuxSessions(client, requestId);
|
|
507
|
+
break;
|
|
508
|
+
case 'get_terminal_output': {
|
|
509
|
+
const termPayload = payload;
|
|
510
|
+
if (termPayload?.sessionName) {
|
|
511
|
+
this.tmux.capturePane(termPayload.sessionName, termPayload.lines || 100)
|
|
512
|
+
.then((output) => {
|
|
513
|
+
this.send(client.ws, {
|
|
514
|
+
type: 'terminal_output',
|
|
515
|
+
success: true,
|
|
516
|
+
payload: { output, sessionName: termPayload.sessionName },
|
|
517
|
+
requestId,
|
|
518
|
+
});
|
|
519
|
+
})
|
|
520
|
+
.catch(() => {
|
|
521
|
+
this.send(client.ws, {
|
|
522
|
+
type: 'terminal_output',
|
|
523
|
+
success: false,
|
|
524
|
+
error: 'Failed to capture terminal output',
|
|
525
|
+
requestId,
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
this.send(client.ws, {
|
|
531
|
+
type: 'terminal_output',
|
|
532
|
+
success: false,
|
|
533
|
+
error: 'Missing sessionName',
|
|
534
|
+
requestId,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case 'get_tool_config':
|
|
540
|
+
this.send(client.ws, {
|
|
541
|
+
type: 'tool_config',
|
|
542
|
+
success: true,
|
|
543
|
+
payload: { tools: tool_config_1.DEFAULT_TOOL_CONFIG },
|
|
544
|
+
requestId,
|
|
545
|
+
});
|
|
546
|
+
break;
|
|
547
|
+
case 'create_tmux_session':
|
|
548
|
+
this.handleCreateTmuxSession(client, payload, requestId);
|
|
549
|
+
break;
|
|
550
|
+
case 'kill_tmux_session':
|
|
551
|
+
this.handleKillTmuxSession(client, payload, requestId);
|
|
552
|
+
break;
|
|
553
|
+
case 'switch_tmux_session':
|
|
554
|
+
this.handleSwitchTmuxSession(client, payload, requestId);
|
|
555
|
+
break;
|
|
556
|
+
case 'recreate_tmux_session':
|
|
557
|
+
this.handleRecreateTmuxSession(client, payload, requestId);
|
|
558
|
+
break;
|
|
559
|
+
case 'browse_directories':
|
|
560
|
+
this.handleBrowseDirectories(client, payload, requestId);
|
|
561
|
+
break;
|
|
562
|
+
case 'read_file':
|
|
563
|
+
this.handleReadFile(client, payload, requestId);
|
|
564
|
+
break;
|
|
565
|
+
case 'download_file':
|
|
566
|
+
this.handleDownloadFile(client, payload, requestId);
|
|
567
|
+
break;
|
|
568
|
+
case 'get_usage':
|
|
569
|
+
this.handleGetUsage(client, requestId);
|
|
570
|
+
break;
|
|
571
|
+
case 'get_api_usage':
|
|
572
|
+
this.handleGetApiUsage(client, payload, requestId);
|
|
573
|
+
break;
|
|
574
|
+
case 'get_agent_tree':
|
|
575
|
+
this.handleGetAgentTree(client, payload, requestId);
|
|
576
|
+
break;
|
|
577
|
+
case 'get_agent_detail':
|
|
578
|
+
this.handleGetAgentDetail(client, payload, requestId);
|
|
579
|
+
break;
|
|
580
|
+
case 'client_error':
|
|
581
|
+
this.handleClientError(client, payload, requestId);
|
|
582
|
+
break;
|
|
583
|
+
case 'get_client_errors':
|
|
584
|
+
this.handleGetClientErrors(client, requestId);
|
|
585
|
+
break;
|
|
586
|
+
case 'scroll_log':
|
|
587
|
+
this.handleScrollLog(payload);
|
|
588
|
+
// No response needed - fire and forget
|
|
589
|
+
break;
|
|
590
|
+
case 'get_scroll_logs':
|
|
591
|
+
this.handleGetScrollLogs(client, requestId);
|
|
592
|
+
break;
|
|
593
|
+
case 'clear_scroll_logs':
|
|
594
|
+
this.scrollLogs = [];
|
|
595
|
+
this.send(client.ws, { type: 'scroll_logs_cleared', success: true, requestId });
|
|
596
|
+
break;
|
|
597
|
+
// Scaffold endpoints
|
|
598
|
+
case 'get_scaffold_templates':
|
|
599
|
+
this.send(client.ws, {
|
|
600
|
+
type: 'scaffold_templates',
|
|
601
|
+
success: true,
|
|
602
|
+
payload: {
|
|
603
|
+
templates: templates_1.templates.map(t => ({
|
|
604
|
+
id: t.id,
|
|
605
|
+
name: t.name,
|
|
606
|
+
description: t.description,
|
|
607
|
+
type: t.type,
|
|
608
|
+
icon: t.icon,
|
|
609
|
+
tags: t.tags,
|
|
610
|
+
})),
|
|
611
|
+
},
|
|
612
|
+
requestId,
|
|
613
|
+
});
|
|
614
|
+
break;
|
|
615
|
+
case 'scaffold_preview':
|
|
616
|
+
(async () => {
|
|
617
|
+
const previewConfig = payload;
|
|
618
|
+
const previewResult = await (0, generator_1.previewScaffold)(previewConfig);
|
|
619
|
+
this.send(client.ws, {
|
|
620
|
+
type: 'scaffold_preview',
|
|
621
|
+
success: !('error' in previewResult),
|
|
622
|
+
payload: previewResult,
|
|
623
|
+
requestId,
|
|
624
|
+
});
|
|
625
|
+
})();
|
|
626
|
+
break;
|
|
627
|
+
case 'scaffold_create':
|
|
628
|
+
(async () => {
|
|
629
|
+
try {
|
|
630
|
+
const createConfig = payload;
|
|
631
|
+
console.log('Scaffold: Creating project', createConfig.name, 'at', createConfig.location);
|
|
632
|
+
const createResult = await (0, generator_1.scaffoldProject)(createConfig, (progress) => {
|
|
633
|
+
console.log('Scaffold progress:', progress.step, progress.detail || '');
|
|
634
|
+
// Send progress updates
|
|
635
|
+
this.send(client.ws, {
|
|
636
|
+
type: 'scaffold_progress',
|
|
637
|
+
success: true,
|
|
638
|
+
payload: progress,
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
console.log('Scaffold result:', createResult.success ? 'success' : createResult.error);
|
|
642
|
+
this.send(client.ws, {
|
|
643
|
+
type: 'scaffold_result',
|
|
644
|
+
success: createResult.success,
|
|
645
|
+
payload: createResult,
|
|
646
|
+
requestId,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
console.error('Scaffold error:', err);
|
|
651
|
+
this.send(client.ws, {
|
|
652
|
+
type: 'scaffold_result',
|
|
653
|
+
success: false,
|
|
654
|
+
error: err instanceof Error ? err.message : String(err),
|
|
655
|
+
requestId,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
})();
|
|
659
|
+
break;
|
|
660
|
+
default:
|
|
661
|
+
this.send(client.ws, {
|
|
662
|
+
type: 'error',
|
|
663
|
+
success: false,
|
|
664
|
+
error: `Unknown message type: ${type}`,
|
|
665
|
+
requestId,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async handleSendInput(client, payload, requestId) {
|
|
670
|
+
if (!payload?.input) {
|
|
671
|
+
this.send(client.ws, {
|
|
672
|
+
type: 'input_sent',
|
|
673
|
+
success: false,
|
|
674
|
+
error: 'Missing input',
|
|
675
|
+
requestId,
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// Cancel any pending push notification since user is responding
|
|
680
|
+
this.push.cancelPendingNotification();
|
|
681
|
+
// Check if the target session exists before trying to send
|
|
682
|
+
const activeSession = this.injector.getActiveSession();
|
|
683
|
+
const sessionExists = await this.injector.checkSessionExists(activeSession);
|
|
684
|
+
if (!sessionExists) {
|
|
685
|
+
// Check if we have a stored config for this session
|
|
686
|
+
const savedConfig = this.tmuxSessionConfigs.get(activeSession);
|
|
687
|
+
this.send(client.ws, {
|
|
688
|
+
type: 'input_sent',
|
|
689
|
+
success: false,
|
|
690
|
+
error: 'tmux_session_not_found',
|
|
691
|
+
payload: {
|
|
692
|
+
sessionName: activeSession,
|
|
693
|
+
canRecreate: !!savedConfig,
|
|
694
|
+
savedConfig: savedConfig ? {
|
|
695
|
+
name: savedConfig.name,
|
|
696
|
+
workingDir: savedConfig.workingDir,
|
|
697
|
+
} : undefined,
|
|
698
|
+
},
|
|
699
|
+
requestId,
|
|
700
|
+
});
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const success = await this.injector.sendInput(payload.input);
|
|
704
|
+
this.send(client.ws, {
|
|
705
|
+
type: 'input_sent',
|
|
706
|
+
success,
|
|
707
|
+
error: success ? undefined : 'Failed to send input to session',
|
|
708
|
+
requestId,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
async handleSendImage(client, payload, requestId) {
|
|
712
|
+
if (!payload?.base64) {
|
|
713
|
+
this.send(client.ws, {
|
|
714
|
+
type: 'image_sent',
|
|
715
|
+
success: false,
|
|
716
|
+
error: 'Missing image data',
|
|
717
|
+
requestId,
|
|
718
|
+
});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
// Determine file extension from mime type
|
|
723
|
+
const ext = payload.mimeType === 'image/png' ? 'png' : 'jpg';
|
|
724
|
+
const filename = `companion-${Date.now()}.${ext}`;
|
|
725
|
+
const filepath = path.join(os.tmpdir(), filename);
|
|
726
|
+
// Save image to temp file
|
|
727
|
+
const buffer = Buffer.from(payload.base64, 'base64');
|
|
728
|
+
fs.writeFileSync(filepath, buffer);
|
|
729
|
+
console.log(`Image saved to: ${filepath}`);
|
|
730
|
+
// Cancel any pending push notification
|
|
731
|
+
this.push.cancelPendingNotification();
|
|
732
|
+
// Send the file path to the coding session
|
|
733
|
+
const success = await this.injector.sendInput(`Please look at this image: ${filepath}`);
|
|
734
|
+
this.send(client.ws, {
|
|
735
|
+
type: 'image_sent',
|
|
736
|
+
success,
|
|
737
|
+
payload: { filepath },
|
|
738
|
+
error: success ? undefined : 'Failed to send image path to session',
|
|
739
|
+
requestId,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
console.error('Error saving image:', err);
|
|
744
|
+
this.send(client.ws, {
|
|
745
|
+
type: 'image_sent',
|
|
746
|
+
success: false,
|
|
747
|
+
error: 'Failed to save image',
|
|
748
|
+
requestId,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async handleUploadImage(client, payload, requestId) {
|
|
753
|
+
if (!payload?.base64) {
|
|
754
|
+
this.send(client.ws, {
|
|
755
|
+
type: 'image_uploaded',
|
|
756
|
+
success: false,
|
|
757
|
+
error: 'Missing image data',
|
|
758
|
+
requestId,
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const ext = payload.mimeType === 'image/png' ? 'png' : 'jpg';
|
|
764
|
+
const filename = `companion-${Date.now()}.${ext}`;
|
|
765
|
+
const filepath = path.join(os.tmpdir(), filename);
|
|
766
|
+
const buffer = Buffer.from(payload.base64, 'base64');
|
|
767
|
+
fs.writeFileSync(filepath, buffer);
|
|
768
|
+
console.log(`Image uploaded to: ${filepath}`);
|
|
769
|
+
this.send(client.ws, {
|
|
770
|
+
type: 'image_uploaded',
|
|
771
|
+
success: true,
|
|
772
|
+
payload: { filepath },
|
|
773
|
+
requestId,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
console.error('Error uploading image:', err);
|
|
778
|
+
this.send(client.ws, {
|
|
779
|
+
type: 'image_uploaded',
|
|
780
|
+
success: false,
|
|
781
|
+
error: 'Failed to save image',
|
|
782
|
+
requestId,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async handleSendWithImages(client, payload, requestId) {
|
|
787
|
+
if (!payload) {
|
|
788
|
+
this.send(client.ws, {
|
|
789
|
+
type: 'message_sent',
|
|
790
|
+
success: false,
|
|
791
|
+
error: 'Missing payload',
|
|
792
|
+
requestId,
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
// Cancel any pending push notification
|
|
797
|
+
this.push.cancelPendingNotification();
|
|
798
|
+
// Build combined message: image paths + user message
|
|
799
|
+
const parts = [];
|
|
800
|
+
if (payload.imagePaths && payload.imagePaths.length > 0) {
|
|
801
|
+
for (const imgPath of payload.imagePaths) {
|
|
802
|
+
parts.push(`[image: ${imgPath}]`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (payload.message && payload.message.trim()) {
|
|
806
|
+
parts.push(payload.message.trim());
|
|
807
|
+
}
|
|
808
|
+
const combinedMessage = parts.join(' ');
|
|
809
|
+
if (!combinedMessage) {
|
|
810
|
+
this.send(client.ws, {
|
|
811
|
+
type: 'message_sent',
|
|
812
|
+
success: false,
|
|
813
|
+
error: 'No content to send',
|
|
814
|
+
requestId,
|
|
815
|
+
});
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const success = await this.injector.sendInput(combinedMessage);
|
|
819
|
+
this.send(client.ws, {
|
|
820
|
+
type: 'message_sent',
|
|
821
|
+
success,
|
|
822
|
+
error: success ? undefined : 'Failed to send message',
|
|
823
|
+
requestId,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Handle session switch synchronously - waits for tmux switch to complete
|
|
828
|
+
* before returning success. This prevents race conditions.
|
|
829
|
+
*/
|
|
830
|
+
async handleSwitchSession(client, payload, requestId) {
|
|
831
|
+
if (!payload?.sessionId) {
|
|
832
|
+
this.send(client.ws, {
|
|
833
|
+
type: 'session_switched',
|
|
834
|
+
success: false,
|
|
835
|
+
error: 'Missing sessionId',
|
|
836
|
+
requestId,
|
|
837
|
+
});
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const { sessionId, epoch } = payload;
|
|
841
|
+
console.log(`WebSocket: Switching to session ${sessionId} (epoch: ${epoch})`);
|
|
842
|
+
// 1. Switch the watcher's active session
|
|
843
|
+
const switched = this.watcher.setActiveSession(sessionId);
|
|
844
|
+
if (!switched) {
|
|
845
|
+
this.send(client.ws, {
|
|
846
|
+
type: 'session_switched',
|
|
847
|
+
success: false,
|
|
848
|
+
error: 'Session not found',
|
|
849
|
+
sessionId,
|
|
850
|
+
requestId,
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
// 2. Update client's subscription to this session
|
|
855
|
+
client.subscribedSessionId = sessionId;
|
|
856
|
+
// 3. Find and switch to corresponding tmux session
|
|
857
|
+
let tmuxSessionName;
|
|
858
|
+
try {
|
|
859
|
+
const convSession = this.watcher.getSessions().find(s => s.id === sessionId);
|
|
860
|
+
if (convSession?.projectPath) {
|
|
861
|
+
const tmuxSessions = await this.tmux.listSessions();
|
|
862
|
+
const matchingTmux = tmuxSessions.find(ts => ts.workingDir === convSession.projectPath);
|
|
863
|
+
if (matchingTmux) {
|
|
864
|
+
this.injector.setActiveSession(matchingTmux.name);
|
|
865
|
+
tmuxSessionName = matchingTmux.name;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
console.error('Failed to switch tmux session:', err);
|
|
871
|
+
// Continue anyway - watcher switch succeeded
|
|
872
|
+
}
|
|
873
|
+
// 4. Return success with session context
|
|
874
|
+
this.send(client.ws, {
|
|
875
|
+
type: 'session_switched',
|
|
876
|
+
success: true,
|
|
877
|
+
payload: {
|
|
878
|
+
sessionId,
|
|
879
|
+
tmuxSession: tmuxSessionName,
|
|
880
|
+
epoch, // Echo back epoch for client validation
|
|
881
|
+
},
|
|
882
|
+
sessionId, // Include at top level for validation
|
|
883
|
+
requestId,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
handleRotateToken(client, requestId) {
|
|
887
|
+
try {
|
|
888
|
+
// Generate new token
|
|
889
|
+
const newToken = crypto.randomBytes(32).toString('hex');
|
|
890
|
+
// Update config file
|
|
891
|
+
const config = (0, config_1.loadConfig)();
|
|
892
|
+
config.token = newToken;
|
|
893
|
+
(0, config_1.saveConfig)(config);
|
|
894
|
+
// Update in-memory token
|
|
895
|
+
this.token = newToken;
|
|
896
|
+
// Notify the requesting client of the new token
|
|
897
|
+
this.send(client.ws, {
|
|
898
|
+
type: 'token_rotated',
|
|
899
|
+
success: true,
|
|
900
|
+
payload: { newToken },
|
|
901
|
+
requestId,
|
|
902
|
+
});
|
|
903
|
+
console.log('WebSocket: Token rotated successfully');
|
|
904
|
+
// Disconnect all other clients (they need to re-authenticate)
|
|
905
|
+
for (const [id, c] of this.clients) {
|
|
906
|
+
if (id !== client.id && c.authenticated) {
|
|
907
|
+
this.send(c.ws, {
|
|
908
|
+
type: 'token_invalidated',
|
|
909
|
+
success: true,
|
|
910
|
+
payload: { reason: 'Token has been rotated' },
|
|
911
|
+
});
|
|
912
|
+
c.authenticated = false;
|
|
913
|
+
c.subscribed = false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
console.error('Failed to rotate token:', err);
|
|
919
|
+
this.send(client.ws, {
|
|
920
|
+
type: 'token_rotated',
|
|
921
|
+
success: false,
|
|
922
|
+
error: 'Failed to rotate token',
|
|
923
|
+
requestId,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// Tmux session management handlers
|
|
928
|
+
async handleListTmuxSessions(client, requestId) {
|
|
929
|
+
try {
|
|
930
|
+
const sessions = await this.tmux.listSessions();
|
|
931
|
+
const activeSession = this.injector.getActiveSession();
|
|
932
|
+
this.send(client.ws, {
|
|
933
|
+
type: 'tmux_sessions',
|
|
934
|
+
success: true,
|
|
935
|
+
payload: {
|
|
936
|
+
sessions,
|
|
937
|
+
activeSession,
|
|
938
|
+
homeDir: this.tmux.getHomeDir(),
|
|
939
|
+
},
|
|
940
|
+
requestId,
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
this.send(client.ws, {
|
|
945
|
+
type: 'tmux_sessions',
|
|
946
|
+
success: false,
|
|
947
|
+
error: 'Failed to list sessions',
|
|
948
|
+
requestId,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
async handleCreateTmuxSession(client, payload, requestId) {
|
|
953
|
+
if (!payload?.workingDir) {
|
|
954
|
+
this.send(client.ws, {
|
|
955
|
+
type: 'tmux_session_created',
|
|
956
|
+
success: false,
|
|
957
|
+
error: 'Missing workingDir',
|
|
958
|
+
requestId,
|
|
959
|
+
});
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// Validate directory exists
|
|
963
|
+
if (!fs.existsSync(payload.workingDir)) {
|
|
964
|
+
this.send(client.ws, {
|
|
965
|
+
type: 'tmux_session_created',
|
|
966
|
+
success: false,
|
|
967
|
+
error: `Directory does not exist: ${payload.workingDir}`,
|
|
968
|
+
requestId,
|
|
969
|
+
});
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const sessionName = payload.name || this.tmux.generateSessionName(payload.workingDir);
|
|
973
|
+
const startCli = payload.startCli !== false; // Default true
|
|
974
|
+
console.log(`WebSocket: Creating tmux session "${sessionName}" in ${payload.workingDir}`);
|
|
975
|
+
const result = await this.tmux.createSession(sessionName, payload.workingDir, startCli);
|
|
976
|
+
if (result.success) {
|
|
977
|
+
// Store the session config for potential recreation later
|
|
978
|
+
this.storeTmuxSessionConfig(sessionName, payload.workingDir, startCli);
|
|
979
|
+
// Switch input target to the new session
|
|
980
|
+
this.injector.setActiveSession(sessionName);
|
|
981
|
+
// Clear the watcher's active session - no conversation exists yet
|
|
982
|
+
// This prevents returning old session data until the new conversation is created
|
|
983
|
+
this.watcher.clearActiveSession();
|
|
984
|
+
console.log(`WebSocket: Cleared active session after creating tmux session "${sessionName}"`);
|
|
985
|
+
// Immediately refresh tmux paths so the watcher recognizes the new session's
|
|
986
|
+
// conversation files as soon as they appear (otherwise waits up to 5s)
|
|
987
|
+
await this.watcher.refreshTmuxPaths();
|
|
988
|
+
this.send(client.ws, {
|
|
989
|
+
type: 'tmux_session_created',
|
|
990
|
+
success: true,
|
|
991
|
+
payload: {
|
|
992
|
+
sessionName,
|
|
993
|
+
workingDir: payload.workingDir,
|
|
994
|
+
},
|
|
995
|
+
requestId,
|
|
996
|
+
});
|
|
997
|
+
// Broadcast to all clients that sessions changed
|
|
998
|
+
this.broadcast('tmux_sessions_changed', { action: 'created', sessionName });
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
this.send(client.ws, {
|
|
1002
|
+
type: 'tmux_session_created',
|
|
1003
|
+
success: false,
|
|
1004
|
+
error: result.error,
|
|
1005
|
+
requestId,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async handleKillTmuxSession(client, payload, requestId) {
|
|
1010
|
+
if (!payload?.sessionName) {
|
|
1011
|
+
this.send(client.ws, {
|
|
1012
|
+
type: 'tmux_session_killed',
|
|
1013
|
+
success: false,
|
|
1014
|
+
error: 'Missing sessionName',
|
|
1015
|
+
requestId,
|
|
1016
|
+
});
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
console.log(`WebSocket: Killing tmux session "${payload.sessionName}"`);
|
|
1020
|
+
const result = await this.tmux.killSession(payload.sessionName);
|
|
1021
|
+
if (result.success) {
|
|
1022
|
+
// If we killed the active session, switch to another
|
|
1023
|
+
if (this.injector.getActiveSession() === payload.sessionName) {
|
|
1024
|
+
const remaining = await this.tmux.listSessions();
|
|
1025
|
+
if (remaining.length > 0) {
|
|
1026
|
+
this.injector.setActiveSession(remaining[0].name);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
this.send(client.ws, {
|
|
1030
|
+
type: 'tmux_session_killed',
|
|
1031
|
+
success: true,
|
|
1032
|
+
payload: { sessionName: payload.sessionName },
|
|
1033
|
+
requestId,
|
|
1034
|
+
});
|
|
1035
|
+
// Broadcast to all clients
|
|
1036
|
+
this.broadcast('tmux_sessions_changed', { action: 'killed', sessionName: payload.sessionName });
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
this.send(client.ws, {
|
|
1040
|
+
type: 'tmux_session_killed',
|
|
1041
|
+
success: false,
|
|
1042
|
+
error: result.error,
|
|
1043
|
+
requestId,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
async handleSwitchTmuxSession(client, payload, requestId) {
|
|
1048
|
+
if (!payload?.sessionName) {
|
|
1049
|
+
this.send(client.ws, {
|
|
1050
|
+
type: 'tmux_session_switched',
|
|
1051
|
+
success: false,
|
|
1052
|
+
error: 'Missing sessionName',
|
|
1053
|
+
requestId,
|
|
1054
|
+
});
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
// Verify session exists
|
|
1058
|
+
const exists = await this.tmux.sessionExists(payload.sessionName);
|
|
1059
|
+
if (!exists) {
|
|
1060
|
+
this.send(client.ws, {
|
|
1061
|
+
type: 'tmux_session_switched',
|
|
1062
|
+
success: false,
|
|
1063
|
+
error: `Session "${payload.sessionName}" does not exist`,
|
|
1064
|
+
requestId,
|
|
1065
|
+
});
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
// Switch input target to this tmux session
|
|
1069
|
+
this.injector.setActiveSession(payload.sessionName);
|
|
1070
|
+
console.log(`WebSocket: Switched to tmux session "${payload.sessionName}"`);
|
|
1071
|
+
// Tag the session as managed by Companion (adopt if not already tagged)
|
|
1072
|
+
await this.tmux.tagSession(payload.sessionName);
|
|
1073
|
+
// Refresh tmux paths so watcher picks up the newly tagged session
|
|
1074
|
+
await this.watcher.refreshTmuxPaths();
|
|
1075
|
+
// Try to find and switch to the corresponding conversation session
|
|
1076
|
+
// Get the tmux session's working directory
|
|
1077
|
+
const sessions = await this.tmux.listSessions();
|
|
1078
|
+
const tmuxSession = sessions.find(s => s.name === payload.sessionName);
|
|
1079
|
+
let conversationSessionId;
|
|
1080
|
+
if (tmuxSession?.workingDir) {
|
|
1081
|
+
// Store the session config for potential recreation later
|
|
1082
|
+
this.storeTmuxSessionConfig(payload.sessionName, tmuxSession.workingDir, true);
|
|
1083
|
+
// Encode the working directory the same way the CLI does: /a/b/c -> -a-b-c
|
|
1084
|
+
const encodedPath = tmuxSession.workingDir.replace(/\//g, '-');
|
|
1085
|
+
// Find conversation session whose ID matches or starts with this encoded path
|
|
1086
|
+
const convSessions = this.watcher.getSessions();
|
|
1087
|
+
const matchingConv = convSessions.find(cs => cs.id === encodedPath);
|
|
1088
|
+
if (matchingConv) {
|
|
1089
|
+
this.watcher.setActiveSession(matchingConv.id);
|
|
1090
|
+
conversationSessionId = matchingConv.id;
|
|
1091
|
+
console.log(`WebSocket: Switched conversation to "${matchingConv.id}" for project ${tmuxSession.workingDir}`);
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
// No conversation yet for this project - clear active session so old data stops flowing
|
|
1095
|
+
this.watcher.clearActiveSession();
|
|
1096
|
+
console.log(`WebSocket: No conversation found for ${encodedPath}, cleared active session. Available: ${convSessions.map(c => c.id).join(', ')}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
// No working directory - clear active session
|
|
1101
|
+
this.watcher.clearActiveSession();
|
|
1102
|
+
console.log(`WebSocket: No working directory for tmux session, cleared active session`);
|
|
1103
|
+
}
|
|
1104
|
+
this.send(client.ws, {
|
|
1105
|
+
type: 'tmux_session_switched',
|
|
1106
|
+
success: true,
|
|
1107
|
+
payload: {
|
|
1108
|
+
sessionName: payload.sessionName,
|
|
1109
|
+
conversationSessionId,
|
|
1110
|
+
},
|
|
1111
|
+
requestId,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
async handleRecreateTmuxSession(client, payload, requestId) {
|
|
1115
|
+
// Use provided session name or the currently active one
|
|
1116
|
+
const sessionName = payload?.sessionName || this.injector.getActiveSession();
|
|
1117
|
+
const savedConfig = this.tmuxSessionConfigs.get(sessionName);
|
|
1118
|
+
if (!savedConfig) {
|
|
1119
|
+
this.send(client.ws, {
|
|
1120
|
+
type: 'tmux_session_recreated',
|
|
1121
|
+
success: false,
|
|
1122
|
+
error: `No saved configuration for session "${sessionName}"`,
|
|
1123
|
+
requestId,
|
|
1124
|
+
});
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// Check if directory still exists
|
|
1128
|
+
if (!fs.existsSync(savedConfig.workingDir)) {
|
|
1129
|
+
this.send(client.ws, {
|
|
1130
|
+
type: 'tmux_session_recreated',
|
|
1131
|
+
success: false,
|
|
1132
|
+
error: `Working directory no longer exists: ${savedConfig.workingDir}`,
|
|
1133
|
+
requestId,
|
|
1134
|
+
});
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
// Check if session already exists (maybe it was recreated manually)
|
|
1138
|
+
const exists = await this.tmux.sessionExists(sessionName);
|
|
1139
|
+
if (exists) {
|
|
1140
|
+
this.send(client.ws, {
|
|
1141
|
+
type: 'tmux_session_recreated',
|
|
1142
|
+
success: true,
|
|
1143
|
+
payload: {
|
|
1144
|
+
sessionName,
|
|
1145
|
+
workingDir: savedConfig.workingDir,
|
|
1146
|
+
alreadyExisted: true,
|
|
1147
|
+
},
|
|
1148
|
+
requestId,
|
|
1149
|
+
});
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
console.log(`WebSocket: Recreating tmux session "${sessionName}" in ${savedConfig.workingDir}`);
|
|
1153
|
+
const result = await this.tmux.createSession(savedConfig.name, savedConfig.workingDir, savedConfig.startCli);
|
|
1154
|
+
if (result.success) {
|
|
1155
|
+
// Update the last used timestamp
|
|
1156
|
+
this.storeTmuxSessionConfig(savedConfig.name, savedConfig.workingDir, savedConfig.startCli);
|
|
1157
|
+
// Ensure we're targeting this session
|
|
1158
|
+
this.injector.setActiveSession(sessionName);
|
|
1159
|
+
this.send(client.ws, {
|
|
1160
|
+
type: 'tmux_session_recreated',
|
|
1161
|
+
success: true,
|
|
1162
|
+
payload: {
|
|
1163
|
+
sessionName,
|
|
1164
|
+
workingDir: savedConfig.workingDir,
|
|
1165
|
+
},
|
|
1166
|
+
requestId,
|
|
1167
|
+
});
|
|
1168
|
+
// Broadcast to all clients
|
|
1169
|
+
this.broadcast('tmux_sessions_changed', { action: 'recreated', sessionName });
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
this.send(client.ws, {
|
|
1173
|
+
type: 'tmux_session_recreated',
|
|
1174
|
+
success: false,
|
|
1175
|
+
error: result.error,
|
|
1176
|
+
requestId,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
async handleBrowseDirectories(client, payload, requestId) {
|
|
1181
|
+
const basePath = payload?.path || this.tmux.getHomeDir();
|
|
1182
|
+
try {
|
|
1183
|
+
// Get directory contents
|
|
1184
|
+
const entries = [];
|
|
1185
|
+
// Add parent directory option if not at root
|
|
1186
|
+
if (basePath !== '/') {
|
|
1187
|
+
entries.push({
|
|
1188
|
+
name: '..',
|
|
1189
|
+
path: path.dirname(basePath),
|
|
1190
|
+
isDirectory: true,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
const items = fs.readdirSync(basePath, { withFileTypes: true });
|
|
1194
|
+
for (const item of items) {
|
|
1195
|
+
// Skip hidden files and common non-project directories
|
|
1196
|
+
if (item.name.startsWith('.') && item.name !== '..')
|
|
1197
|
+
continue;
|
|
1198
|
+
if (['node_modules', '__pycache__', 'venv', '.git'].includes(item.name))
|
|
1199
|
+
continue;
|
|
1200
|
+
if (item.isDirectory()) {
|
|
1201
|
+
entries.push({
|
|
1202
|
+
name: item.name,
|
|
1203
|
+
path: path.join(basePath, item.name),
|
|
1204
|
+
isDirectory: true,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
// Sort: directories first, then alphabetically
|
|
1209
|
+
entries.sort((a, b) => {
|
|
1210
|
+
if (a.name === '..')
|
|
1211
|
+
return -1;
|
|
1212
|
+
if (b.name === '..')
|
|
1213
|
+
return 1;
|
|
1214
|
+
return a.name.localeCompare(b.name);
|
|
1215
|
+
});
|
|
1216
|
+
this.send(client.ws, {
|
|
1217
|
+
type: 'directory_listing',
|
|
1218
|
+
success: true,
|
|
1219
|
+
payload: {
|
|
1220
|
+
currentPath: basePath,
|
|
1221
|
+
entries: entries.slice(0, 100), // Limit to 100 entries
|
|
1222
|
+
},
|
|
1223
|
+
requestId,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
this.send(client.ws, {
|
|
1228
|
+
type: 'directory_listing',
|
|
1229
|
+
success: false,
|
|
1230
|
+
error: `Cannot read directory: ${basePath}`,
|
|
1231
|
+
requestId,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
async handleReadFile(client, payload, requestId) {
|
|
1236
|
+
const filePath = payload?.path;
|
|
1237
|
+
if (!filePath) {
|
|
1238
|
+
this.send(client.ws, {
|
|
1239
|
+
type: 'file_content',
|
|
1240
|
+
success: false,
|
|
1241
|
+
error: 'No file path provided',
|
|
1242
|
+
requestId,
|
|
1243
|
+
});
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
try {
|
|
1247
|
+
const homeDir = this.tmux.getHomeDir();
|
|
1248
|
+
let resolvedPath;
|
|
1249
|
+
// Handle different path formats
|
|
1250
|
+
if (filePath.startsWith('~/')) {
|
|
1251
|
+
// Expand ~ to home directory
|
|
1252
|
+
resolvedPath = path.join(homeDir, filePath.slice(2));
|
|
1253
|
+
}
|
|
1254
|
+
else if (filePath.startsWith('/')) {
|
|
1255
|
+
// Absolute path
|
|
1256
|
+
resolvedPath = filePath;
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
// Relative path - resolve against active tmux session's working directory
|
|
1260
|
+
// (more reliable than decoded project path which can mangle hyphenated names)
|
|
1261
|
+
const sessions = await this.tmux.listSessions();
|
|
1262
|
+
const activeSessionId = this.watcher.getActiveSessionId();
|
|
1263
|
+
// Try to find matching tmux session by encoded path
|
|
1264
|
+
let workingDir = homeDir;
|
|
1265
|
+
if (activeSessionId) {
|
|
1266
|
+
// The session ID is the encoded path like -Users-foo-project
|
|
1267
|
+
// Match it against tmux session working directories
|
|
1268
|
+
const matchingSession = sessions.find(s => {
|
|
1269
|
+
if (!s.workingDir)
|
|
1270
|
+
return false;
|
|
1271
|
+
const encoded = s.workingDir.replace(/\//g, '-');
|
|
1272
|
+
return encoded === activeSessionId || s.name === activeSessionId;
|
|
1273
|
+
});
|
|
1274
|
+
if (matchingSession?.workingDir) {
|
|
1275
|
+
workingDir = matchingSession.workingDir;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
resolvedPath = path.resolve(workingDir, filePath);
|
|
1279
|
+
}
|
|
1280
|
+
// Normalize the path
|
|
1281
|
+
resolvedPath = path.normalize(resolvedPath);
|
|
1282
|
+
// Security: only allow reading files in certain directories
|
|
1283
|
+
const allowedPaths = [
|
|
1284
|
+
homeDir,
|
|
1285
|
+
'/tmp',
|
|
1286
|
+
'/var/tmp',
|
|
1287
|
+
];
|
|
1288
|
+
const isAllowed = allowedPaths.some(allowed => resolvedPath.startsWith(allowed));
|
|
1289
|
+
if (!isAllowed) {
|
|
1290
|
+
this.send(client.ws, {
|
|
1291
|
+
type: 'file_content',
|
|
1292
|
+
success: false,
|
|
1293
|
+
error: `Access denied: file outside allowed directories (resolved: ${resolvedPath})`,
|
|
1294
|
+
requestId,
|
|
1295
|
+
});
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
// Check file exists and is readable
|
|
1299
|
+
const stats = fs.statSync(resolvedPath);
|
|
1300
|
+
if (stats.isDirectory()) {
|
|
1301
|
+
this.send(client.ws, {
|
|
1302
|
+
type: 'file_content',
|
|
1303
|
+
success: false,
|
|
1304
|
+
error: 'Path is a directory, not a file',
|
|
1305
|
+
requestId,
|
|
1306
|
+
});
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
// Limit file size to 1MB
|
|
1310
|
+
if (stats.size > 1024 * 1024) {
|
|
1311
|
+
this.send(client.ws, {
|
|
1312
|
+
type: 'file_content',
|
|
1313
|
+
success: false,
|
|
1314
|
+
error: 'File too large (max 1MB)',
|
|
1315
|
+
requestId,
|
|
1316
|
+
});
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
1320
|
+
this.send(client.ws, {
|
|
1321
|
+
type: 'file_content',
|
|
1322
|
+
success: true,
|
|
1323
|
+
payload: { content, path: resolvedPath },
|
|
1324
|
+
requestId,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
catch (err) {
|
|
1328
|
+
this.send(client.ws, {
|
|
1329
|
+
type: 'file_content',
|
|
1330
|
+
success: false,
|
|
1331
|
+
error: `Cannot read file: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
1332
|
+
requestId,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async handleDownloadFile(client, payload, requestId) {
|
|
1337
|
+
const filePath = payload?.path;
|
|
1338
|
+
if (!filePath) {
|
|
1339
|
+
this.send(client.ws, {
|
|
1340
|
+
type: 'file_download',
|
|
1341
|
+
success: false,
|
|
1342
|
+
error: 'No file path provided',
|
|
1343
|
+
requestId,
|
|
1344
|
+
});
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
const homeDir = this.tmux.getHomeDir();
|
|
1349
|
+
let resolvedPath;
|
|
1350
|
+
// Handle different path formats
|
|
1351
|
+
if (filePath.startsWith('~/')) {
|
|
1352
|
+
resolvedPath = path.join(homeDir, filePath.slice(2));
|
|
1353
|
+
}
|
|
1354
|
+
else if (filePath.startsWith('/')) {
|
|
1355
|
+
resolvedPath = filePath;
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
// Relative path - resolve against home
|
|
1359
|
+
resolvedPath = path.resolve(homeDir, filePath);
|
|
1360
|
+
}
|
|
1361
|
+
resolvedPath = path.normalize(resolvedPath);
|
|
1362
|
+
// Security: only allow downloading files in certain directories
|
|
1363
|
+
const allowedPaths = [homeDir, '/tmp', '/var/tmp'];
|
|
1364
|
+
const isAllowed = allowedPaths.some(allowed => resolvedPath.startsWith(allowed));
|
|
1365
|
+
if (!isAllowed) {
|
|
1366
|
+
this.send(client.ws, {
|
|
1367
|
+
type: 'file_download',
|
|
1368
|
+
success: false,
|
|
1369
|
+
error: `Access denied: file outside allowed directories`,
|
|
1370
|
+
requestId,
|
|
1371
|
+
});
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
// Only allow specific file types for download
|
|
1375
|
+
const allowedExtensions = ['.apk', '.ipa', '.zip', '.tar.gz', '.tgz'];
|
|
1376
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
1377
|
+
const isApkOrZip = allowedExtensions.some(e => resolvedPath.toLowerCase().endsWith(e));
|
|
1378
|
+
if (!isApkOrZip) {
|
|
1379
|
+
this.send(client.ws, {
|
|
1380
|
+
type: 'file_download',
|
|
1381
|
+
success: false,
|
|
1382
|
+
error: `File type not allowed for download. Allowed: ${allowedExtensions.join(', ')}`,
|
|
1383
|
+
requestId,
|
|
1384
|
+
});
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const stats = fs.statSync(resolvedPath);
|
|
1388
|
+
if (stats.isDirectory()) {
|
|
1389
|
+
this.send(client.ws, {
|
|
1390
|
+
type: 'file_download',
|
|
1391
|
+
success: false,
|
|
1392
|
+
error: 'Path is a directory, not a file',
|
|
1393
|
+
requestId,
|
|
1394
|
+
});
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
// Limit to 150MB for APKs
|
|
1398
|
+
const maxSize = 150 * 1024 * 1024;
|
|
1399
|
+
if (stats.size > maxSize) {
|
|
1400
|
+
this.send(client.ws, {
|
|
1401
|
+
type: 'file_download',
|
|
1402
|
+
success: false,
|
|
1403
|
+
error: `File too large (max 150MB, file is ${Math.round(stats.size / 1024 / 1024)}MB)`,
|
|
1404
|
+
requestId,
|
|
1405
|
+
});
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
// Read file as binary and encode as base64
|
|
1409
|
+
const content = fs.readFileSync(resolvedPath);
|
|
1410
|
+
const base64 = content.toString('base64');
|
|
1411
|
+
const fileName = path.basename(resolvedPath);
|
|
1412
|
+
console.log(`WebSocket: Sending file download: ${fileName} (${Math.round(stats.size / 1024)}KB)`);
|
|
1413
|
+
this.send(client.ws, {
|
|
1414
|
+
type: 'file_download',
|
|
1415
|
+
success: true,
|
|
1416
|
+
payload: {
|
|
1417
|
+
fileName,
|
|
1418
|
+
size: stats.size,
|
|
1419
|
+
mimeType: ext === '.apk' ? 'application/vnd.android.package-archive' : 'application/octet-stream',
|
|
1420
|
+
data: base64,
|
|
1421
|
+
},
|
|
1422
|
+
requestId,
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
catch (err) {
|
|
1426
|
+
this.send(client.ws, {
|
|
1427
|
+
type: 'file_download',
|
|
1428
|
+
success: false,
|
|
1429
|
+
error: `Cannot download file: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
1430
|
+
requestId,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
async handleGetApiUsage(client, payload, requestId) {
|
|
1435
|
+
const adminApiKey = this.config.anthropicAdminApiKey;
|
|
1436
|
+
if (!adminApiKey) {
|
|
1437
|
+
this.send(client.ws, {
|
|
1438
|
+
type: 'api_usage',
|
|
1439
|
+
success: false,
|
|
1440
|
+
error: 'No Anthropic Admin API key configured. Add "anthropicAdminApiKey" to your config.json (key starts with sk-ant-admin-...)',
|
|
1441
|
+
requestId,
|
|
1442
|
+
});
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
const period = payload?.period || 'today';
|
|
1447
|
+
let stats;
|
|
1448
|
+
if (period === 'today') {
|
|
1449
|
+
stats = await (0, anthropic_usage_1.fetchTodayUsage)(adminApiKey);
|
|
1450
|
+
}
|
|
1451
|
+
else if (period === 'month') {
|
|
1452
|
+
stats = await (0, anthropic_usage_1.fetchMonthUsage)(adminApiKey);
|
|
1453
|
+
}
|
|
1454
|
+
else if (period === 'custom' && payload?.startDate && payload?.endDate) {
|
|
1455
|
+
stats = await (0, anthropic_usage_1.fetchAnthropicUsage)(adminApiKey, new Date(payload.startDate), new Date(payload.endDate));
|
|
1456
|
+
}
|
|
1457
|
+
else {
|
|
1458
|
+
stats = await (0, anthropic_usage_1.fetchTodayUsage)(adminApiKey);
|
|
1459
|
+
}
|
|
1460
|
+
this.send(client.ws, {
|
|
1461
|
+
type: 'api_usage',
|
|
1462
|
+
success: true,
|
|
1463
|
+
payload: stats,
|
|
1464
|
+
requestId,
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
catch (err) {
|
|
1468
|
+
console.error('Failed to get API usage:', err);
|
|
1469
|
+
this.send(client.ws, {
|
|
1470
|
+
type: 'api_usage',
|
|
1471
|
+
success: false,
|
|
1472
|
+
error: `Failed to fetch API usage: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
1473
|
+
requestId,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
handleGetAgentTree(client, payload, requestId) {
|
|
1478
|
+
if (!this.subAgentWatcher) {
|
|
1479
|
+
this.send(client.ws, {
|
|
1480
|
+
type: 'agent_tree',
|
|
1481
|
+
success: false,
|
|
1482
|
+
error: 'Sub-agent watcher not initialized',
|
|
1483
|
+
requestId,
|
|
1484
|
+
});
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
try {
|
|
1488
|
+
const tree = this.subAgentWatcher.getAgentTree(payload?.sessionId);
|
|
1489
|
+
this.send(client.ws, {
|
|
1490
|
+
type: 'agent_tree',
|
|
1491
|
+
success: true,
|
|
1492
|
+
payload: tree,
|
|
1493
|
+
requestId,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
console.error('Failed to get agent tree:', err);
|
|
1498
|
+
this.send(client.ws, {
|
|
1499
|
+
type: 'agent_tree',
|
|
1500
|
+
success: false,
|
|
1501
|
+
error: 'Failed to get agent tree',
|
|
1502
|
+
requestId,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
handleGetAgentDetail(client, payload, requestId) {
|
|
1507
|
+
if (!this.subAgentWatcher) {
|
|
1508
|
+
this.send(client.ws, {
|
|
1509
|
+
type: 'agent_detail',
|
|
1510
|
+
success: false,
|
|
1511
|
+
error: 'Sub-agent watcher not initialized',
|
|
1512
|
+
requestId,
|
|
1513
|
+
});
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (!payload?.agentId) {
|
|
1517
|
+
this.send(client.ws, {
|
|
1518
|
+
type: 'agent_detail',
|
|
1519
|
+
success: false,
|
|
1520
|
+
error: 'Missing agentId',
|
|
1521
|
+
requestId,
|
|
1522
|
+
});
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
try {
|
|
1526
|
+
const detail = this.subAgentWatcher.getAgentDetail(payload.agentId);
|
|
1527
|
+
if (!detail) {
|
|
1528
|
+
this.send(client.ws, {
|
|
1529
|
+
type: 'agent_detail',
|
|
1530
|
+
success: false,
|
|
1531
|
+
error: 'Agent not found',
|
|
1532
|
+
requestId,
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
this.send(client.ws, {
|
|
1537
|
+
type: 'agent_detail',
|
|
1538
|
+
success: true,
|
|
1539
|
+
payload: detail,
|
|
1540
|
+
requestId,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
console.error('Failed to get agent detail:', err);
|
|
1545
|
+
this.send(client.ws, {
|
|
1546
|
+
type: 'agent_detail',
|
|
1547
|
+
success: false,
|
|
1548
|
+
error: 'Failed to get agent detail',
|
|
1549
|
+
requestId,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
handleClientError(client, payload, requestId) {
|
|
1554
|
+
// Log to console (goes to journalctl)
|
|
1555
|
+
console.error('Client error:', payload.message);
|
|
1556
|
+
if (payload.stack) {
|
|
1557
|
+
console.error('Stack:', payload.stack);
|
|
1558
|
+
}
|
|
1559
|
+
// Store in memory for later retrieval
|
|
1560
|
+
const error = {
|
|
1561
|
+
message: payload.message,
|
|
1562
|
+
stack: payload.stack,
|
|
1563
|
+
componentStack: payload.componentStack,
|
|
1564
|
+
timestamp: payload.timestamp || Date.now(),
|
|
1565
|
+
deviceId: client.deviceId,
|
|
1566
|
+
};
|
|
1567
|
+
this.clientErrors.unshift(error);
|
|
1568
|
+
if (this.clientErrors.length > this.MAX_CLIENT_ERRORS) {
|
|
1569
|
+
this.clientErrors = this.clientErrors.slice(0, this.MAX_CLIENT_ERRORS);
|
|
1570
|
+
}
|
|
1571
|
+
this.send(client.ws, {
|
|
1572
|
+
type: 'client_error',
|
|
1573
|
+
success: true,
|
|
1574
|
+
requestId,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
handleGetClientErrors(client, requestId) {
|
|
1578
|
+
this.send(client.ws, {
|
|
1579
|
+
type: 'client_errors',
|
|
1580
|
+
success: true,
|
|
1581
|
+
payload: {
|
|
1582
|
+
errors: this.clientErrors,
|
|
1583
|
+
count: this.clientErrors.length,
|
|
1584
|
+
},
|
|
1585
|
+
requestId,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
handleScrollLog(payload) {
|
|
1589
|
+
this.scrollLogs.push(payload);
|
|
1590
|
+
if (this.scrollLogs.length > this.MAX_SCROLL_LOGS) {
|
|
1591
|
+
this.scrollLogs = this.scrollLogs.slice(-this.MAX_SCROLL_LOGS);
|
|
1592
|
+
}
|
|
1593
|
+
// Also log to console for real-time viewing via journalctl
|
|
1594
|
+
console.log(`[SCROLL] ${payload.event}:`, JSON.stringify(payload));
|
|
1595
|
+
}
|
|
1596
|
+
handleGetScrollLogs(client, requestId) {
|
|
1597
|
+
this.send(client.ws, {
|
|
1598
|
+
type: 'scroll_logs',
|
|
1599
|
+
success: true,
|
|
1600
|
+
payload: {
|
|
1601
|
+
logs: this.scrollLogs,
|
|
1602
|
+
count: this.scrollLogs.length,
|
|
1603
|
+
},
|
|
1604
|
+
requestId,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
handleGetUsage(client, requestId) {
|
|
1608
|
+
try {
|
|
1609
|
+
const sessions = this.watcher.getSessions();
|
|
1610
|
+
const sessionUsages = [];
|
|
1611
|
+
let totalInputTokens = 0;
|
|
1612
|
+
let totalOutputTokens = 0;
|
|
1613
|
+
let totalCacheCreationTokens = 0;
|
|
1614
|
+
let totalCacheReadTokens = 0;
|
|
1615
|
+
for (const session of sessions) {
|
|
1616
|
+
if (session.conversationPath) {
|
|
1617
|
+
const usage = (0, parser_1.extractUsageFromFile)(session.conversationPath, session.name);
|
|
1618
|
+
sessionUsages.push(usage);
|
|
1619
|
+
totalInputTokens += usage.totalInputTokens;
|
|
1620
|
+
totalOutputTokens += usage.totalOutputTokens;
|
|
1621
|
+
totalCacheCreationTokens += usage.totalCacheCreationTokens;
|
|
1622
|
+
totalCacheReadTokens += usage.totalCacheReadTokens;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
this.send(client.ws, {
|
|
1626
|
+
type: 'usage',
|
|
1627
|
+
success: true,
|
|
1628
|
+
payload: {
|
|
1629
|
+
sessions: sessionUsages,
|
|
1630
|
+
totalInputTokens,
|
|
1631
|
+
totalOutputTokens,
|
|
1632
|
+
totalCacheCreationTokens,
|
|
1633
|
+
totalCacheReadTokens,
|
|
1634
|
+
periodStart: Date.now() - 24 * 60 * 60 * 1000, // Last 24h
|
|
1635
|
+
periodEnd: Date.now(),
|
|
1636
|
+
},
|
|
1637
|
+
requestId,
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
catch (err) {
|
|
1641
|
+
console.error('Failed to get usage:', err);
|
|
1642
|
+
this.send(client.ws, {
|
|
1643
|
+
type: 'usage',
|
|
1644
|
+
success: false,
|
|
1645
|
+
error: 'Failed to get usage statistics',
|
|
1646
|
+
requestId,
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
send(ws, response) {
|
|
1651
|
+
if (ws.readyState === ws_1.WebSocket.OPEN) {
|
|
1652
|
+
const data = JSON.stringify(response);
|
|
1653
|
+
if (response.type !== 'pong' && response.requestId) {
|
|
1654
|
+
console.log(`WebSocket: << send ${response.type} (${response.requestId}) ${data.length} bytes`);
|
|
1655
|
+
}
|
|
1656
|
+
ws.send(data);
|
|
1657
|
+
}
|
|
1658
|
+
else {
|
|
1659
|
+
console.log(`WebSocket: !! send FAILED - ws not open (state: ${ws.readyState}) for ${response.type}`);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
sendError(ws, error) {
|
|
1663
|
+
this.send(ws, {
|
|
1664
|
+
type: 'error',
|
|
1665
|
+
success: false,
|
|
1666
|
+
error,
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
broadcast(type, payload, sessionId) {
|
|
1670
|
+
// Get the session ID to include in the message
|
|
1671
|
+
const activeSessionId = sessionId || this.watcher.getActiveSessionId();
|
|
1672
|
+
const message = JSON.stringify({
|
|
1673
|
+
type,
|
|
1674
|
+
success: true,
|
|
1675
|
+
payload,
|
|
1676
|
+
sessionId: activeSessionId, // Always include session context
|
|
1677
|
+
});
|
|
1678
|
+
for (const client of this.clients.values()) {
|
|
1679
|
+
if (client.authenticated && client.subscribed && client.ws.readyState === ws_1.WebSocket.OPEN) {
|
|
1680
|
+
// Only send to clients subscribed to this session (or all if no session filter)
|
|
1681
|
+
if (!client.subscribedSessionId || client.subscribedSessionId === activeSessionId) {
|
|
1682
|
+
client.ws.send(message);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
getConnectedClientCount() {
|
|
1688
|
+
return this.clients.size;
|
|
1689
|
+
}
|
|
1690
|
+
getAuthenticatedClientCount() {
|
|
1691
|
+
return Array.from(this.clients.values()).filter((c) => c.authenticated).length;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
exports.WebSocketHandler = WebSocketHandler;
|
|
1695
|
+
//# sourceMappingURL=websocket.js.map
|