@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.
Files changed (109) hide show
  1. package/dist/__tests__/task-parser.test.d.ts +2 -0
  2. package/dist/__tests__/task-parser.test.d.ts.map +1 -0
  3. package/dist/__tests__/task-parser.test.js +79 -0
  4. package/dist/__tests__/task-parser.test.js.map +1 -0
  5. package/dist/anthropic-usage.d.ts +5 -0
  6. package/dist/anthropic-usage.d.ts.map +1 -0
  7. package/dist/anthropic-usage.js +112 -0
  8. package/dist/anthropic-usage.js.map +1 -0
  9. package/dist/cert-generator.d.ts +15 -0
  10. package/dist/cert-generator.d.ts.map +1 -0
  11. package/dist/cert-generator.js +298 -0
  12. package/dist/cert-generator.js.map +1 -0
  13. package/dist/config.d.ts +4 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +122 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/encryption.d.ts +28 -0
  18. package/dist/encryption.d.ts.map +1 -0
  19. package/dist/encryption.js +95 -0
  20. package/dist/encryption.js.map +1 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +211 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/input-injector.d.ts +21 -0
  26. package/dist/input-injector.d.ts.map +1 -0
  27. package/dist/input-injector.js +126 -0
  28. package/dist/input-injector.js.map +1 -0
  29. package/dist/mdns.d.ts +11 -0
  30. package/dist/mdns.d.ts.map +1 -0
  31. package/dist/mdns.js +93 -0
  32. package/dist/mdns.js.map +1 -0
  33. package/dist/parser.d.ts +43 -0
  34. package/dist/parser.d.ts.map +1 -0
  35. package/dist/parser.js +800 -0
  36. package/dist/parser.js.map +1 -0
  37. package/dist/push.d.ts +38 -0
  38. package/dist/push.d.ts.map +1 -0
  39. package/dist/push.js +359 -0
  40. package/dist/push.js.map +1 -0
  41. package/dist/qr-server.d.ts +13 -0
  42. package/dist/qr-server.d.ts.map +1 -0
  43. package/dist/qr-server.js +421 -0
  44. package/dist/qr-server.js.map +1 -0
  45. package/dist/scaffold/generator.d.ts +11 -0
  46. package/dist/scaffold/generator.d.ts.map +1 -0
  47. package/dist/scaffold/generator.js +206 -0
  48. package/dist/scaffold/generator.js.map +1 -0
  49. package/dist/scaffold/templates/index.d.ts +5 -0
  50. package/dist/scaffold/templates/index.d.ts.map +1 -0
  51. package/dist/scaffold/templates/index.js +22 -0
  52. package/dist/scaffold/templates/index.js.map +1 -0
  53. package/dist/scaffold/templates/node-express.d.ts +3 -0
  54. package/dist/scaffold/templates/node-express.d.ts.map +1 -0
  55. package/dist/scaffold/templates/node-express.js +218 -0
  56. package/dist/scaffold/templates/node-express.js.map +1 -0
  57. package/dist/scaffold/templates/python-fastapi.d.ts +3 -0
  58. package/dist/scaffold/templates/python-fastapi.d.ts.map +1 -0
  59. package/dist/scaffold/templates/python-fastapi.js +302 -0
  60. package/dist/scaffold/templates/python-fastapi.js.map +1 -0
  61. package/dist/scaffold/templates/react-mui-website.d.ts +3 -0
  62. package/dist/scaffold/templates/react-mui-website.d.ts.map +1 -0
  63. package/dist/scaffold/templates/react-mui-website.js +405 -0
  64. package/dist/scaffold/templates/react-mui-website.js.map +1 -0
  65. package/dist/scaffold/templates/react-typescript.d.ts +3 -0
  66. package/dist/scaffold/templates/react-typescript.d.ts.map +1 -0
  67. package/dist/scaffold/templates/react-typescript.js +275 -0
  68. package/dist/scaffold/templates/react-typescript.js.map +1 -0
  69. package/dist/scaffold/types.d.ts +55 -0
  70. package/dist/scaffold/types.d.ts.map +1 -0
  71. package/dist/scaffold/types.js +3 -0
  72. package/dist/scaffold/types.js.map +1 -0
  73. package/dist/subagent-watcher.d.ts +24 -0
  74. package/dist/subagent-watcher.d.ts.map +1 -0
  75. package/dist/subagent-watcher.js +307 -0
  76. package/dist/subagent-watcher.js.map +1 -0
  77. package/dist/tls.d.ts +10 -0
  78. package/dist/tls.d.ts.map +1 -0
  79. package/dist/tls.js +77 -0
  80. package/dist/tls.js.map +1 -0
  81. package/dist/tmux-manager.d.ts +71 -0
  82. package/dist/tmux-manager.d.ts.map +1 -0
  83. package/dist/tmux-manager.js +243 -0
  84. package/dist/tmux-manager.js.map +1 -0
  85. package/dist/tool-config.d.ts +33 -0
  86. package/dist/tool-config.d.ts.map +1 -0
  87. package/dist/tool-config.js +211 -0
  88. package/dist/tool-config.js.map +1 -0
  89. package/dist/types.d.ts +218 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +3 -0
  92. package/dist/types.js.map +1 -0
  93. package/dist/watcher.d.ts +63 -0
  94. package/dist/watcher.d.ts.map +1 -0
  95. package/dist/watcher.js +596 -0
  96. package/dist/watcher.js.map +1 -0
  97. package/dist/watcher.test.d.ts +2 -0
  98. package/dist/watcher.test.d.ts.map +1 -0
  99. package/dist/watcher.test.js +110 -0
  100. package/dist/watcher.test.js.map +1 -0
  101. package/dist/websocket.d.ts +62 -0
  102. package/dist/websocket.d.ts.map +1 -0
  103. package/dist/websocket.js +1695 -0
  104. package/dist/websocket.js.map +1 -0
  105. package/package.json +71 -0
  106. package/scripts/build.sh +23 -0
  107. package/scripts/install-remote.sh +18 -0
  108. package/scripts/install.sh +558 -0
  109. 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