@hexidecibel/companion 0.0.1 → 0.1.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.js +29 -29
- package/dist/__tests__/task-parser.test.js.map +1 -1
- package/dist/anthropic-usage.d.ts.map +1 -1
- package/dist/anthropic-usage.js +1 -1
- package/dist/anthropic-usage.js.map +1 -1
- package/dist/cert-generator.d.ts.map +1 -1
- package/dist/cert-generator.js +4 -21
- package/dist/cert-generator.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +413 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -7
- package/dist/config.js.map +1 -1
- package/dist/escalation.d.ts +51 -0
- package/dist/escalation.d.ts.map +1 -0
- package/dist/escalation.js +198 -0
- package/dist/escalation.js.map +1 -0
- package/dist/index.js +67 -30
- package/dist/index.js.map +1 -1
- package/dist/input-injector.d.ts.map +1 -1
- package/dist/input-injector.js +9 -5
- package/dist/input-injector.js.map +1 -1
- package/dist/notification-store.d.ts +35 -0
- package/dist/notification-store.d.ts.map +1 -0
- package/dist/notification-store.js +272 -0
- package/dist/notification-store.js.map +1 -0
- package/dist/parser.d.ts +15 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +106 -61
- package/dist/parser.js.map +1 -1
- package/dist/push.d.ts +18 -26
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +90 -184
- package/dist/push.js.map +1 -1
- package/dist/qr-server.d.ts.map +1 -1
- package/dist/qr-server.js +159 -139
- package/dist/qr-server.js.map +1 -1
- package/dist/rules-engine.d.ts +20 -0
- package/dist/rules-engine.d.ts.map +1 -0
- package/dist/rules-engine.js +71 -0
- package/dist/rules-engine.js.map +1 -0
- package/dist/scaffold/claude-commands.d.ts +18 -0
- package/dist/scaffold/claude-commands.d.ts.map +1 -0
- package/dist/scaffold/claude-commands.js +352 -0
- package/dist/scaffold/claude-commands.js.map +1 -0
- package/dist/scaffold/generator.d.ts.map +1 -1
- package/dist/scaffold/generator.js +26 -1
- package/dist/scaffold/generator.js.map +1 -1
- package/dist/scaffold/scorer.d.ts +19 -0
- package/dist/scaffold/scorer.d.ts.map +1 -0
- package/dist/scaffold/scorer.js +92 -0
- package/dist/scaffold/scorer.js.map +1 -0
- package/dist/scaffold/templates/go-cli.d.ts +3 -0
- package/dist/scaffold/templates/go-cli.d.ts.map +1 -0
- package/dist/scaffold/templates/go-cli.js +249 -0
- package/dist/scaffold/templates/go-cli.js.map +1 -0
- package/dist/scaffold/templates/index.d.ts.map +1 -1
- package/dist/scaffold/templates/index.js +8 -2
- package/dist/scaffold/templates/index.js.map +1 -1
- package/dist/scaffold/templates/nextjs.d.ts +3 -0
- package/dist/scaffold/templates/nextjs.d.ts.map +1 -0
- package/dist/scaffold/templates/nextjs.js +336 -0
- package/dist/scaffold/templates/nextjs.js.map +1 -0
- package/dist/scaffold/templates/node-express.d.ts.map +1 -1
- package/dist/scaffold/templates/node-express.js +170 -157
- package/dist/scaffold/templates/node-express.js.map +1 -1
- package/dist/scaffold/templates/python-fastapi.d.ts.map +1 -1
- package/dist/scaffold/templates/python-fastapi.js +234 -221
- package/dist/scaffold/templates/python-fastapi.js.map +1 -1
- package/dist/scaffold/templates/react-mui-website.d.ts.map +1 -1
- package/dist/scaffold/templates/react-mui-website.js +337 -324
- package/dist/scaffold/templates/react-mui-website.js.map +1 -1
- package/dist/scaffold/templates/react-typescript.d.ts.map +1 -1
- package/dist/scaffold/templates/react-typescript.js +219 -206
- package/dist/scaffold/templates/react-typescript.js.map +1 -1
- package/dist/scaffold/templates/typescript-library.d.ts +3 -0
- package/dist/scaffold/templates/typescript-library.d.ts.map +1 -0
- package/dist/scaffold/templates/typescript-library.js +241 -0
- package/dist/scaffold/templates/typescript-library.js.map +1 -0
- package/dist/scaffold/types.d.ts +7 -0
- package/dist/scaffold/types.d.ts.map +1 -1
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +3 -3
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/tmux-manager.d.ts +37 -0
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +165 -5
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-config.d.ts.map +1 -1
- package/dist/tool-config.js +2 -2
- package/dist/tool-config.js.map +1 -1
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +118 -9
- package/dist/watcher.js.map +1 -1
- package/dist/websocket.d.ts +16 -2
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +758 -117
- package/dist/websocket.js.map +1 -1
- package/dist/work-group-manager.d.ts +69 -0
- package/dist/work-group-manager.d.ts.map +1 -0
- package/dist/work-group-manager.js +610 -0
- package/dist/work-group-manager.js.map +1 -0
- package/package.json +1 -1
package/dist/websocket.js
CHANGED
|
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
|
|
|
40
40
|
const path = __importStar(require("path"));
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const crypto = __importStar(require("crypto"));
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
43
44
|
const tmux_manager_1 = require("./tmux-manager");
|
|
44
45
|
const parser_1 = require("./parser");
|
|
45
46
|
const config_1 = require("./config");
|
|
@@ -47,6 +48,8 @@ const anthropic_usage_1 = require("./anthropic-usage");
|
|
|
47
48
|
const tool_config_1 = require("./tool-config");
|
|
48
49
|
const templates_1 = require("./scaffold/templates");
|
|
49
50
|
const generator_1 = require("./scaffold/generator");
|
|
51
|
+
const scorer_1 = require("./scaffold/scorer");
|
|
52
|
+
const escalation_1 = require("./escalation");
|
|
50
53
|
// File for persisting tmux session configs
|
|
51
54
|
const TMUX_CONFIGS_FILE = path.join(os.homedir(), '.companion', 'tmux-sessions.json');
|
|
52
55
|
class WebSocketHandler {
|
|
@@ -64,15 +67,19 @@ class WebSocketHandler {
|
|
|
64
67
|
MAX_CLIENT_ERRORS = 50;
|
|
65
68
|
scrollLogs = [];
|
|
66
69
|
MAX_SCROLL_LOGS = 200;
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
autoApproveSessions = new Set();
|
|
71
|
+
escalation;
|
|
72
|
+
workGroupManager;
|
|
73
|
+
constructor(server, config, watcher, injector, push, tmux, subAgentWatcher, workGroupManager) {
|
|
69
74
|
this.config = config;
|
|
70
75
|
this.token = config.token;
|
|
71
76
|
this.watcher = watcher;
|
|
72
77
|
this.subAgentWatcher = subAgentWatcher || null;
|
|
78
|
+
this.workGroupManager = workGroupManager || null;
|
|
73
79
|
this.injector = injector;
|
|
74
80
|
this.push = push;
|
|
75
81
|
this.tmux = tmux || new tmux_manager_1.TmuxManager('companion');
|
|
82
|
+
this.escalation = new escalation_1.EscalationService(this.push.getStore(), this.push);
|
|
76
83
|
this.wss = new ws_1.WebSocketServer({ server });
|
|
77
84
|
this.wss.on('connection', (ws, req) => this.handleConnection(ws, req));
|
|
78
85
|
// Forward watcher events to subscribed clients
|
|
@@ -81,12 +88,22 @@ class WebSocketHandler {
|
|
|
81
88
|
});
|
|
82
89
|
this.watcher.on('status-change', (data) => {
|
|
83
90
|
this.broadcast('status_change', data);
|
|
84
|
-
//
|
|
91
|
+
// Escalation for waiting_for_input
|
|
85
92
|
if (data.isWaitingForInput && data.lastMessage) {
|
|
86
|
-
|
|
93
|
+
const event = {
|
|
94
|
+
eventType: 'waiting_for_input',
|
|
95
|
+
sessionId: data.sessionId || 'unknown',
|
|
96
|
+
sessionName: this.injector.getActiveSession() || 'unknown',
|
|
97
|
+
content: data.lastMessage.content,
|
|
98
|
+
};
|
|
99
|
+
const result = this.escalation.handleEvent(event);
|
|
100
|
+
if (result.shouldBroadcast) {
|
|
101
|
+
console.log(`Escalation: waiting_for_input broadcast for session "${event.sessionName}"`);
|
|
102
|
+
}
|
|
87
103
|
}
|
|
88
|
-
else {
|
|
89
|
-
|
|
104
|
+
else if (!data.isWaitingForInput && data.sessionId) {
|
|
105
|
+
// Session stopped waiting — acknowledge (cancel pending push)
|
|
106
|
+
this.escalation.acknowledgeSession(data.sessionId);
|
|
90
107
|
}
|
|
91
108
|
});
|
|
92
109
|
// Notify about activity in other (non-active) sessions
|
|
@@ -97,6 +114,29 @@ class WebSocketHandler {
|
|
|
97
114
|
this.watcher.on('compaction', (data) => {
|
|
98
115
|
this.broadcast('compaction', data);
|
|
99
116
|
});
|
|
117
|
+
// Escalation-based notifications for error-detected and session-completed
|
|
118
|
+
const handleEscalationEvent = (eventType, data) => {
|
|
119
|
+
const event = {
|
|
120
|
+
eventType,
|
|
121
|
+
sessionId: data.sessionId,
|
|
122
|
+
sessionName: data.sessionName,
|
|
123
|
+
content: data.content,
|
|
124
|
+
};
|
|
125
|
+
const result = this.escalation.handleEvent(event);
|
|
126
|
+
if (result.shouldBroadcast) {
|
|
127
|
+
console.log(`Escalation: ${eventType} broadcast for session "${data.sessionName}"`);
|
|
128
|
+
}
|
|
129
|
+
// Always broadcast the event to connected web clients
|
|
130
|
+
this.broadcast(eventType, data);
|
|
131
|
+
};
|
|
132
|
+
this.watcher.on('error-detected', (data) => handleEscalationEvent('error_detected', data));
|
|
133
|
+
this.watcher.on('session-completed', (data) => handleEscalationEvent('session_completed', data));
|
|
134
|
+
// Forward work group updates to clients
|
|
135
|
+
if (this.workGroupManager) {
|
|
136
|
+
this.workGroupManager.on('work-group-update', (group) => {
|
|
137
|
+
this.broadcast('work_group_update', group);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
100
140
|
// Load saved tmux session configs
|
|
101
141
|
this.loadTmuxSessionConfigs();
|
|
102
142
|
console.log('WebSocket: Server initialized');
|
|
@@ -139,7 +179,7 @@ class WebSocketHandler {
|
|
|
139
179
|
this.saveTmuxSessionConfigs();
|
|
140
180
|
console.log(`WebSocket: Stored tmux session config for "${name}" (${workingDir})`);
|
|
141
181
|
}
|
|
142
|
-
handleConnection(ws,
|
|
182
|
+
handleConnection(ws, _req) {
|
|
143
183
|
const clientId = (0, uuid_1.v4)();
|
|
144
184
|
const client = {
|
|
145
185
|
id: clientId,
|
|
@@ -175,7 +215,6 @@ class WebSocketHandler {
|
|
|
175
215
|
}
|
|
176
216
|
handleMessage(client, message) {
|
|
177
217
|
const { type, token, payload, requestId } = message;
|
|
178
|
-
const reqStart = Date.now();
|
|
179
218
|
if (type !== 'ping') {
|
|
180
219
|
console.log(`WebSocket: >> recv ${type} (${requestId || 'no-id'}) from ${client.id}`);
|
|
181
220
|
}
|
|
@@ -243,23 +282,39 @@ class WebSocketHandler {
|
|
|
243
282
|
case 'get_highlights': {
|
|
244
283
|
const hlParams = payload;
|
|
245
284
|
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
285
|
const hlSessionId = this.watcher.getActiveSessionId();
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
286
|
+
const limit = hlParams?.limit && hlParams.limit > 0 ? hlParams.limit : 0;
|
|
287
|
+
const offset = hlParams?.offset || 0;
|
|
288
|
+
// Use conversation chain for cross-session infinite scroll
|
|
289
|
+
const chain = hlSessionId ? this.watcher.getConversationChain(hlSessionId) : [];
|
|
290
|
+
let resultHighlights;
|
|
291
|
+
let total;
|
|
292
|
+
let hasMore;
|
|
293
|
+
if (chain.length > 1 && limit > 0) {
|
|
294
|
+
// Multiple files — use chain-aware pagination
|
|
295
|
+
const result = (0, parser_1.parseConversationChain)(chain, limit, offset);
|
|
296
|
+
resultHighlights = result.highlights;
|
|
297
|
+
total = result.total;
|
|
298
|
+
hasMore = result.hasMore;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// Single file or no limit — use existing fast path
|
|
302
|
+
const messages = this.watcher.getMessages();
|
|
303
|
+
const allHighlights = (0, parser_1.extractHighlights)(messages);
|
|
304
|
+
total = allHighlights.length;
|
|
305
|
+
if (limit > 0) {
|
|
306
|
+
const startIdx = Math.max(0, total - offset - limit);
|
|
307
|
+
const endIdx = total - offset;
|
|
308
|
+
resultHighlights = allHighlights.slice(startIdx, endIdx);
|
|
309
|
+
hasMore = startIdx > 0;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
resultHighlights = allHighlights;
|
|
313
|
+
hasMore = false;
|
|
314
|
+
}
|
|
261
315
|
}
|
|
262
|
-
|
|
316
|
+
const t1 = Date.now();
|
|
317
|
+
console.log(`WebSocket: get_highlights - ${t1 - t0}ms, chain: ${chain.length} files, returning ${resultHighlights.length}/${total}`);
|
|
263
318
|
this.send(client.ws, {
|
|
264
319
|
type: 'highlights',
|
|
265
320
|
success: true,
|
|
@@ -289,7 +344,7 @@ class WebSocketHandler {
|
|
|
289
344
|
const status = this.watcher.getStatus();
|
|
290
345
|
const t1 = Date.now();
|
|
291
346
|
const statusSessionId = this.watcher.getActiveSessionId();
|
|
292
|
-
console.log(`WebSocket: get_status - ${t1 - t0}ms`);
|
|
347
|
+
console.log(`WebSocket: get_status - ${t1 - t0}ms - waiting: ${status.isWaitingForInput}, running: ${status.isRunning}, session: ${statusSessionId}`);
|
|
293
348
|
this.send(client.ws, {
|
|
294
349
|
type: 'status',
|
|
295
350
|
success: true,
|
|
@@ -301,7 +356,9 @@ class WebSocketHandler {
|
|
|
301
356
|
}
|
|
302
357
|
case 'get_server_summary':
|
|
303
358
|
// Get tmux sessions to filter - only show conversations with active tmux sessions
|
|
304
|
-
this.tmux
|
|
359
|
+
this.tmux
|
|
360
|
+
.listSessions()
|
|
361
|
+
.then(async (tmuxSessions) => {
|
|
305
362
|
const summary = await this.watcher.getServerSummary(tmuxSessions);
|
|
306
363
|
this.send(client.ws, {
|
|
307
364
|
type: 'server_summary',
|
|
@@ -309,7 +366,8 @@ class WebSocketHandler {
|
|
|
309
366
|
payload: summary,
|
|
310
367
|
requestId,
|
|
311
368
|
});
|
|
312
|
-
})
|
|
369
|
+
})
|
|
370
|
+
.catch((err) => {
|
|
313
371
|
console.error('Failed to get server summary:', err);
|
|
314
372
|
this.send(client.ws, {
|
|
315
373
|
type: 'server_summary',
|
|
@@ -335,7 +393,7 @@ class WebSocketHandler {
|
|
|
335
393
|
const tasksSessionId = tasksPayload?.sessionId || this.watcher.getActiveSessionId();
|
|
336
394
|
if (tasksSessionId) {
|
|
337
395
|
const sessionSessions = this.watcher.getSessions();
|
|
338
|
-
const session = sessionSessions.find(s => s.id === tasksSessionId);
|
|
396
|
+
const session = sessionSessions.find((s) => s.id === tasksSessionId);
|
|
339
397
|
if (session?.conversationPath) {
|
|
340
398
|
try {
|
|
341
399
|
const fs = require('fs');
|
|
@@ -378,9 +436,23 @@ class WebSocketHandler {
|
|
|
378
436
|
case 'switch_session':
|
|
379
437
|
// Handle switch_session asynchronously but await completion
|
|
380
438
|
this.handleSwitchSession(client, payload, requestId);
|
|
439
|
+
// Acknowledge session — user is viewing it, cancel push escalation
|
|
440
|
+
{
|
|
441
|
+
const switchPayload = payload;
|
|
442
|
+
if (switchPayload?.sessionId) {
|
|
443
|
+
this.escalation.acknowledgeSession(switchPayload.sessionId);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
381
446
|
break;
|
|
382
447
|
case 'send_input':
|
|
383
448
|
this.handleSendInput(client, payload, requestId);
|
|
449
|
+
// Acknowledge session — user is responding, cancel push escalation
|
|
450
|
+
{
|
|
451
|
+
const activeSessionId = this.watcher.getActiveSessionId();
|
|
452
|
+
if (activeSessionId) {
|
|
453
|
+
this.escalation.acknowledgeSession(activeSessionId);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
384
456
|
break;
|
|
385
457
|
case 'send_image':
|
|
386
458
|
this.handleSendImage(client, payload, requestId);
|
|
@@ -427,66 +499,33 @@ class WebSocketHandler {
|
|
|
427
499
|
});
|
|
428
500
|
}
|
|
429
501
|
break;
|
|
430
|
-
|
|
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;
|
|
502
|
+
// set_instant_notify removed — escalation model replaces per-device instant notify
|
|
450
503
|
case 'set_auto_approve': {
|
|
451
504
|
const autoApprovePayload = payload;
|
|
452
|
-
|
|
453
|
-
|
|
505
|
+
const targetSessionId = autoApprovePayload?.sessionId || this.watcher.getActiveSessionId();
|
|
506
|
+
const enabled = autoApprovePayload?.enabled ?? false;
|
|
507
|
+
if (targetSessionId) {
|
|
508
|
+
if (enabled) {
|
|
509
|
+
this.autoApproveSessions.add(targetSessionId);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
this.autoApproveSessions.delete(targetSessionId);
|
|
513
|
+
}
|
|
514
|
+
console.log(`Auto-approve ${enabled ? 'enabled' : 'disabled'} for session ${targetSessionId} (${this.autoApproveSessions.size} sessions active)`);
|
|
515
|
+
}
|
|
454
516
|
this.send(client.ws, {
|
|
455
517
|
type: 'auto_approve_set',
|
|
456
518
|
success: true,
|
|
457
|
-
payload: { enabled:
|
|
519
|
+
payload: { enabled, sessionId: targetSessionId },
|
|
458
520
|
requestId,
|
|
459
521
|
});
|
|
460
522
|
// When toggled ON, immediately check for pending tools that should be auto-approved
|
|
461
|
-
if (
|
|
523
|
+
if (enabled) {
|
|
462
524
|
this.watcher.checkAndEmitPendingApproval();
|
|
463
525
|
}
|
|
464
526
|
break;
|
|
465
527
|
}
|
|
466
|
-
|
|
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;
|
|
528
|
+
// set_notification_prefs removed — escalation config replaces per-device prefs
|
|
490
529
|
case 'ping':
|
|
491
530
|
if (client.deviceId) {
|
|
492
531
|
this.push.updateDeviceLastSeen(client.deviceId);
|
|
@@ -508,7 +547,8 @@ class WebSocketHandler {
|
|
|
508
547
|
case 'get_terminal_output': {
|
|
509
548
|
const termPayload = payload;
|
|
510
549
|
if (termPayload?.sessionName) {
|
|
511
|
-
this.tmux
|
|
550
|
+
this.tmux
|
|
551
|
+
.capturePane(termPayload.sessionName, termPayload.lines || 100)
|
|
512
552
|
.then((output) => {
|
|
513
553
|
this.send(client.ws, {
|
|
514
554
|
type: 'terminal_output',
|
|
@@ -536,6 +576,38 @@ class WebSocketHandler {
|
|
|
536
576
|
}
|
|
537
577
|
break;
|
|
538
578
|
}
|
|
579
|
+
case 'send_terminal_keys': {
|
|
580
|
+
const termKeysPayload = payload;
|
|
581
|
+
if (termKeysPayload?.sessionName && termKeysPayload.keys?.length) {
|
|
582
|
+
this.tmux
|
|
583
|
+
.sendRawKeys(termKeysPayload.sessionName, termKeysPayload.keys)
|
|
584
|
+
.then((ok) => {
|
|
585
|
+
this.send(client.ws, {
|
|
586
|
+
type: 'terminal_keys_sent',
|
|
587
|
+
success: ok,
|
|
588
|
+
error: ok ? undefined : 'Failed to send keys',
|
|
589
|
+
requestId,
|
|
590
|
+
});
|
|
591
|
+
})
|
|
592
|
+
.catch(() => {
|
|
593
|
+
this.send(client.ws, {
|
|
594
|
+
type: 'terminal_keys_sent',
|
|
595
|
+
success: false,
|
|
596
|
+
error: 'Failed to send terminal keys',
|
|
597
|
+
requestId,
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
this.send(client.ws, {
|
|
603
|
+
type: 'terminal_keys_sent',
|
|
604
|
+
success: false,
|
|
605
|
+
error: 'Missing sessionName or keys',
|
|
606
|
+
requestId,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
539
611
|
case 'get_tool_config':
|
|
540
612
|
this.send(client.ws, {
|
|
541
613
|
type: 'tool_config',
|
|
@@ -556,12 +628,21 @@ class WebSocketHandler {
|
|
|
556
628
|
case 'recreate_tmux_session':
|
|
557
629
|
this.handleRecreateTmuxSession(client, payload, requestId);
|
|
558
630
|
break;
|
|
631
|
+
case 'create_worktree_session':
|
|
632
|
+
this.handleCreateWorktreeSession(client, payload, requestId);
|
|
633
|
+
break;
|
|
634
|
+
case 'list_worktrees':
|
|
635
|
+
this.handleListWorktrees(client, payload, requestId);
|
|
636
|
+
break;
|
|
559
637
|
case 'browse_directories':
|
|
560
638
|
this.handleBrowseDirectories(client, payload, requestId);
|
|
561
639
|
break;
|
|
562
640
|
case 'read_file':
|
|
563
641
|
this.handleReadFile(client, payload, requestId);
|
|
564
642
|
break;
|
|
643
|
+
case 'open_in_editor':
|
|
644
|
+
this.handleOpenInEditor(client, payload, requestId);
|
|
645
|
+
break;
|
|
565
646
|
case 'download_file':
|
|
566
647
|
this.handleDownloadFile(client, payload, requestId);
|
|
567
648
|
break;
|
|
@@ -594,24 +675,84 @@ class WebSocketHandler {
|
|
|
594
675
|
this.scrollLogs = [];
|
|
595
676
|
this.send(client.ws, { type: 'scroll_logs_cleared', success: true, requestId });
|
|
596
677
|
break;
|
|
678
|
+
// Work Group endpoints
|
|
679
|
+
case 'spawn_work_group':
|
|
680
|
+
this.handleSpawnWorkGroup(client, payload, requestId);
|
|
681
|
+
break;
|
|
682
|
+
case 'get_work_groups':
|
|
683
|
+
this.handleGetWorkGroups(client, requestId);
|
|
684
|
+
break;
|
|
685
|
+
case 'get_work_group':
|
|
686
|
+
this.handleGetWorkGroup(client, payload, requestId);
|
|
687
|
+
break;
|
|
688
|
+
case 'merge_work_group':
|
|
689
|
+
this.handleMergeWorkGroup(client, payload, requestId);
|
|
690
|
+
break;
|
|
691
|
+
case 'cancel_work_group':
|
|
692
|
+
this.handleCancelWorkGroup(client, payload, requestId);
|
|
693
|
+
break;
|
|
694
|
+
case 'retry_worker':
|
|
695
|
+
this.handleRetryWorker(client, payload, requestId);
|
|
696
|
+
break;
|
|
697
|
+
case 'send_worker_input':
|
|
698
|
+
this.handleSendWorkerInput(client, payload, requestId);
|
|
699
|
+
break;
|
|
700
|
+
case 'dismiss_work_group':
|
|
701
|
+
this.handleDismissWorkGroup(client, payload, requestId);
|
|
702
|
+
break;
|
|
597
703
|
// Scaffold endpoints
|
|
598
|
-
case 'get_scaffold_templates':
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
704
|
+
case 'get_scaffold_templates': {
|
|
705
|
+
const scaffoldPayload = payload;
|
|
706
|
+
const description = scaffoldPayload?.description;
|
|
707
|
+
if (description && description.trim()) {
|
|
708
|
+
const scores = (0, scorer_1.scoreTemplates)(templates_1.templates, description);
|
|
709
|
+
const scoreMap = new Map(scores.map((s) => [s.templateId, s]));
|
|
710
|
+
// Sort templates by score descending
|
|
711
|
+
const sorted = [...templates_1.templates].sort((a, b) => {
|
|
712
|
+
const sa = scoreMap.get(a.id)?.score ?? 0;
|
|
713
|
+
const sb = scoreMap.get(b.id)?.score ?? 0;
|
|
714
|
+
return sb - sa;
|
|
715
|
+
});
|
|
716
|
+
this.send(client.ws, {
|
|
717
|
+
type: 'scaffold_templates',
|
|
718
|
+
success: true,
|
|
719
|
+
payload: {
|
|
720
|
+
templates: sorted.map((t) => {
|
|
721
|
+
const s = scoreMap.get(t.id);
|
|
722
|
+
return {
|
|
723
|
+
id: t.id,
|
|
724
|
+
name: t.name,
|
|
725
|
+
description: t.description,
|
|
726
|
+
type: t.type,
|
|
727
|
+
icon: t.icon,
|
|
728
|
+
tags: t.tags,
|
|
729
|
+
score: s?.score ?? 0,
|
|
730
|
+
matchedKeywords: s?.matchedKeywords ?? [],
|
|
731
|
+
};
|
|
732
|
+
}),
|
|
733
|
+
},
|
|
734
|
+
requestId,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
this.send(client.ws, {
|
|
739
|
+
type: 'scaffold_templates',
|
|
740
|
+
success: true,
|
|
741
|
+
payload: {
|
|
742
|
+
templates: templates_1.templates.map((t) => ({
|
|
743
|
+
id: t.id,
|
|
744
|
+
name: t.name,
|
|
745
|
+
description: t.description,
|
|
746
|
+
type: t.type,
|
|
747
|
+
icon: t.icon,
|
|
748
|
+
tags: t.tags,
|
|
749
|
+
})),
|
|
750
|
+
},
|
|
751
|
+
requestId,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
614
754
|
break;
|
|
755
|
+
}
|
|
615
756
|
case 'scaffold_preview':
|
|
616
757
|
(async () => {
|
|
617
758
|
const previewConfig = payload;
|
|
@@ -624,6 +765,153 @@ class WebSocketHandler {
|
|
|
624
765
|
});
|
|
625
766
|
})();
|
|
626
767
|
break;
|
|
768
|
+
// Escalation config endpoints (replaces notification rules CRUD)
|
|
769
|
+
case 'get_escalation_config': {
|
|
770
|
+
const store = this.push.getStore();
|
|
771
|
+
this.send(client.ws, {
|
|
772
|
+
type: 'escalation_config',
|
|
773
|
+
success: true,
|
|
774
|
+
payload: { config: store.getEscalation() },
|
|
775
|
+
requestId,
|
|
776
|
+
});
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case 'update_escalation_config': {
|
|
780
|
+
const configPayload = payload;
|
|
781
|
+
const store = this.push.getStore();
|
|
782
|
+
const updated = store.setEscalation(configPayload);
|
|
783
|
+
this.send(client.ws, {
|
|
784
|
+
type: 'escalation_config_updated',
|
|
785
|
+
success: true,
|
|
786
|
+
payload: { config: updated },
|
|
787
|
+
requestId,
|
|
788
|
+
});
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
case 'get_pending_events': {
|
|
792
|
+
const events = this.escalation.getPendingEvents();
|
|
793
|
+
this.send(client.ws, {
|
|
794
|
+
type: 'pending_events',
|
|
795
|
+
success: true,
|
|
796
|
+
payload: { events },
|
|
797
|
+
requestId,
|
|
798
|
+
});
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
// Device management
|
|
802
|
+
case 'get_devices': {
|
|
803
|
+
const store = this.push.getStore();
|
|
804
|
+
this.send(client.ws, {
|
|
805
|
+
type: 'devices',
|
|
806
|
+
success: true,
|
|
807
|
+
payload: { devices: store.getDevices() },
|
|
808
|
+
requestId,
|
|
809
|
+
});
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
case 'remove_device': {
|
|
813
|
+
const removePayload = payload;
|
|
814
|
+
if (!removePayload?.deviceId) {
|
|
815
|
+
this.send(client.ws, {
|
|
816
|
+
type: 'device_removed',
|
|
817
|
+
success: false,
|
|
818
|
+
error: 'Missing deviceId',
|
|
819
|
+
requestId,
|
|
820
|
+
});
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
const store = this.push.getStore();
|
|
824
|
+
const removed = store.removeDevice(removePayload.deviceId);
|
|
825
|
+
this.send(client.ws, {
|
|
826
|
+
type: 'device_removed',
|
|
827
|
+
success: removed,
|
|
828
|
+
error: removed ? undefined : 'Device not found',
|
|
829
|
+
requestId,
|
|
830
|
+
});
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
// Session muting
|
|
834
|
+
case 'set_session_muted': {
|
|
835
|
+
const mutePayload = payload;
|
|
836
|
+
if (!mutePayload?.sessionId || mutePayload.muted === undefined) {
|
|
837
|
+
this.send(client.ws, {
|
|
838
|
+
type: 'session_muted_set',
|
|
839
|
+
success: false,
|
|
840
|
+
error: 'Missing sessionId or muted',
|
|
841
|
+
requestId,
|
|
842
|
+
});
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
const store = this.push.getStore();
|
|
846
|
+
store.setSessionMuted(mutePayload.sessionId, mutePayload.muted);
|
|
847
|
+
this.send(client.ws, {
|
|
848
|
+
type: 'session_muted_set',
|
|
849
|
+
success: true,
|
|
850
|
+
payload: { sessionId: mutePayload.sessionId, muted: mutePayload.muted },
|
|
851
|
+
requestId,
|
|
852
|
+
});
|
|
853
|
+
// Broadcast to all clients so mute state is visible everywhere
|
|
854
|
+
this.broadcast('session_mute_changed', {
|
|
855
|
+
sessionId: mutePayload.sessionId,
|
|
856
|
+
muted: mutePayload.muted,
|
|
857
|
+
});
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case 'get_muted_sessions': {
|
|
861
|
+
const store = this.push.getStore();
|
|
862
|
+
this.send(client.ws, {
|
|
863
|
+
type: 'muted_sessions',
|
|
864
|
+
success: true,
|
|
865
|
+
payload: { sessionIds: store.getMutedSessions() },
|
|
866
|
+
requestId,
|
|
867
|
+
});
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
// Notification history
|
|
871
|
+
case 'get_notification_history': {
|
|
872
|
+
const histPayload = payload;
|
|
873
|
+
const store = this.push.getStore();
|
|
874
|
+
const history = store.getHistory(histPayload?.limit);
|
|
875
|
+
this.send(client.ws, {
|
|
876
|
+
type: 'notification_history',
|
|
877
|
+
success: true,
|
|
878
|
+
payload: history,
|
|
879
|
+
requestId,
|
|
880
|
+
});
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
case 'clear_notification_history': {
|
|
884
|
+
const store = this.push.getStore();
|
|
885
|
+
store.clearHistory();
|
|
886
|
+
this.send(client.ws, {
|
|
887
|
+
type: 'notification_history_cleared',
|
|
888
|
+
success: true,
|
|
889
|
+
requestId,
|
|
890
|
+
});
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
case 'send_test_notification': {
|
|
894
|
+
(async () => {
|
|
895
|
+
try {
|
|
896
|
+
const result = await this.push.sendTestNotification();
|
|
897
|
+
this.send(client.ws, {
|
|
898
|
+
type: 'test_notification_sent',
|
|
899
|
+
success: true,
|
|
900
|
+
payload: result,
|
|
901
|
+
requestId,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
this.send(client.ws, {
|
|
906
|
+
type: 'test_notification_sent',
|
|
907
|
+
success: false,
|
|
908
|
+
error: String(err),
|
|
909
|
+
requestId,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
})();
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
627
915
|
case 'scaffold_create':
|
|
628
916
|
(async () => {
|
|
629
917
|
try {
|
|
@@ -676,8 +964,6 @@ class WebSocketHandler {
|
|
|
676
964
|
});
|
|
677
965
|
return;
|
|
678
966
|
}
|
|
679
|
-
// Cancel any pending push notification since user is responding
|
|
680
|
-
this.push.cancelPendingNotification();
|
|
681
967
|
// Check if the target session exists before trying to send
|
|
682
968
|
const activeSession = this.injector.getActiveSession();
|
|
683
969
|
const sessionExists = await this.injector.checkSessionExists(activeSession);
|
|
@@ -691,10 +977,12 @@ class WebSocketHandler {
|
|
|
691
977
|
payload: {
|
|
692
978
|
sessionName: activeSession,
|
|
693
979
|
canRecreate: !!savedConfig,
|
|
694
|
-
savedConfig: savedConfig
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
980
|
+
savedConfig: savedConfig
|
|
981
|
+
? {
|
|
982
|
+
name: savedConfig.name,
|
|
983
|
+
workingDir: savedConfig.workingDir,
|
|
984
|
+
}
|
|
985
|
+
: undefined,
|
|
698
986
|
},
|
|
699
987
|
requestId,
|
|
700
988
|
});
|
|
@@ -727,8 +1015,6 @@ class WebSocketHandler {
|
|
|
727
1015
|
const buffer = Buffer.from(payload.base64, 'base64');
|
|
728
1016
|
fs.writeFileSync(filepath, buffer);
|
|
729
1017
|
console.log(`Image saved to: ${filepath}`);
|
|
730
|
-
// Cancel any pending push notification
|
|
731
|
-
this.push.cancelPendingNotification();
|
|
732
1018
|
// Send the file path to the coding session
|
|
733
1019
|
const success = await this.injector.sendInput(`Please look at this image: ${filepath}`);
|
|
734
1020
|
this.send(client.ws, {
|
|
@@ -793,8 +1079,6 @@ class WebSocketHandler {
|
|
|
793
1079
|
});
|
|
794
1080
|
return;
|
|
795
1081
|
}
|
|
796
|
-
// Cancel any pending push notification
|
|
797
|
-
this.push.cancelPendingNotification();
|
|
798
1082
|
// Build combined message: image paths + user message
|
|
799
1083
|
const parts = [];
|
|
800
1084
|
if (payload.imagePaths && payload.imagePaths.length > 0) {
|
|
@@ -856,10 +1140,10 @@ class WebSocketHandler {
|
|
|
856
1140
|
// 3. Find and switch to corresponding tmux session
|
|
857
1141
|
let tmuxSessionName;
|
|
858
1142
|
try {
|
|
859
|
-
const convSession = this.watcher.getSessions().find(s => s.id === sessionId);
|
|
1143
|
+
const convSession = this.watcher.getSessions().find((s) => s.id === sessionId);
|
|
860
1144
|
if (convSession?.projectPath) {
|
|
861
1145
|
const tmuxSessions = await this.tmux.listSessions();
|
|
862
|
-
const matchingTmux = tmuxSessions.find(ts => ts.workingDir === convSession.projectPath);
|
|
1146
|
+
const matchingTmux = tmuxSessions.find((ts) => ts.workingDir === convSession.projectPath);
|
|
863
1147
|
if (matchingTmux) {
|
|
864
1148
|
this.injector.setActiveSession(matchingTmux.name);
|
|
865
1149
|
tmuxSessionName = matchingTmux.name;
|
|
@@ -1019,6 +1303,12 @@ class WebSocketHandler {
|
|
|
1019
1303
|
console.log(`WebSocket: Killing tmux session "${payload.sessionName}"`);
|
|
1020
1304
|
const result = await this.tmux.killSession(payload.sessionName);
|
|
1021
1305
|
if (result.success) {
|
|
1306
|
+
// If this was a worktree session, clean up the worktree
|
|
1307
|
+
const config = this.tmuxSessionConfigs.get(payload.sessionName);
|
|
1308
|
+
if (config?.isWorktree && config.mainRepoDir) {
|
|
1309
|
+
console.log(`WebSocket: Cleaning up worktree at ${config.workingDir}`);
|
|
1310
|
+
await this.tmux.removeWorktree(config.mainRepoDir, config.workingDir);
|
|
1311
|
+
}
|
|
1022
1312
|
// If we killed the active session, switch to another
|
|
1023
1313
|
if (this.injector.getActiveSession() === payload.sessionName) {
|
|
1024
1314
|
const remaining = await this.tmux.listSessions();
|
|
@@ -1033,7 +1323,10 @@ class WebSocketHandler {
|
|
|
1033
1323
|
requestId,
|
|
1034
1324
|
});
|
|
1035
1325
|
// Broadcast to all clients
|
|
1036
|
-
this.broadcast('tmux_sessions_changed', {
|
|
1326
|
+
this.broadcast('tmux_sessions_changed', {
|
|
1327
|
+
action: 'killed',
|
|
1328
|
+
sessionName: payload.sessionName,
|
|
1329
|
+
});
|
|
1037
1330
|
}
|
|
1038
1331
|
else {
|
|
1039
1332
|
this.send(client.ws, {
|
|
@@ -1044,6 +1337,105 @@ class WebSocketHandler {
|
|
|
1044
1337
|
});
|
|
1045
1338
|
}
|
|
1046
1339
|
}
|
|
1340
|
+
async handleCreateWorktreeSession(client, payload, requestId) {
|
|
1341
|
+
if (!payload?.parentDir) {
|
|
1342
|
+
this.send(client.ws, {
|
|
1343
|
+
type: 'worktree_session_created',
|
|
1344
|
+
success: false,
|
|
1345
|
+
error: 'Missing parentDir',
|
|
1346
|
+
requestId,
|
|
1347
|
+
});
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
// Validate parent directory is a git repo
|
|
1351
|
+
if (!(await this.tmux.isGitRepo(payload.parentDir))) {
|
|
1352
|
+
this.send(client.ws, {
|
|
1353
|
+
type: 'worktree_session_created',
|
|
1354
|
+
success: false,
|
|
1355
|
+
error: 'Not a git repository',
|
|
1356
|
+
requestId,
|
|
1357
|
+
});
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
console.log(`WebSocket: Creating worktree session from ${payload.parentDir}, branch: ${payload.branch || 'auto'}`);
|
|
1361
|
+
// Create the git worktree
|
|
1362
|
+
const wtResult = await this.tmux.createWorktree(payload.parentDir, payload.branch);
|
|
1363
|
+
if (!wtResult.success || !wtResult.worktreePath) {
|
|
1364
|
+
this.send(client.ws, {
|
|
1365
|
+
type: 'worktree_session_created',
|
|
1366
|
+
success: false,
|
|
1367
|
+
error: wtResult.error || 'Failed to create worktree',
|
|
1368
|
+
requestId,
|
|
1369
|
+
});
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
// Create a tmux session in the worktree directory
|
|
1373
|
+
const sessionName = this.tmux.generateSessionName(wtResult.worktreePath);
|
|
1374
|
+
const startCli = payload.startCli !== false;
|
|
1375
|
+
const tmuxResult = await this.tmux.createSession(sessionName, wtResult.worktreePath, startCli);
|
|
1376
|
+
if (tmuxResult.success) {
|
|
1377
|
+
// Store session config with worktree metadata
|
|
1378
|
+
this.storeTmuxSessionConfig(sessionName, wtResult.worktreePath, startCli);
|
|
1379
|
+
// Also store worktree info in the config
|
|
1380
|
+
const configs = this.tmuxSessionConfigs;
|
|
1381
|
+
const config = configs.get(sessionName);
|
|
1382
|
+
if (config) {
|
|
1383
|
+
config.isWorktree = true;
|
|
1384
|
+
config.mainRepoDir = payload.parentDir;
|
|
1385
|
+
config.branch = wtResult.branch;
|
|
1386
|
+
this.saveTmuxSessionConfigs();
|
|
1387
|
+
}
|
|
1388
|
+
// Switch input target to the new session
|
|
1389
|
+
this.injector.setActiveSession(sessionName);
|
|
1390
|
+
this.watcher.clearActiveSession();
|
|
1391
|
+
await this.watcher.refreshTmuxPaths();
|
|
1392
|
+
this.send(client.ws, {
|
|
1393
|
+
type: 'worktree_session_created',
|
|
1394
|
+
success: true,
|
|
1395
|
+
payload: {
|
|
1396
|
+
sessionName,
|
|
1397
|
+
workingDir: wtResult.worktreePath,
|
|
1398
|
+
branch: wtResult.branch,
|
|
1399
|
+
mainRepoDir: payload.parentDir,
|
|
1400
|
+
},
|
|
1401
|
+
requestId,
|
|
1402
|
+
});
|
|
1403
|
+
this.broadcast('tmux_sessions_changed', {
|
|
1404
|
+
action: 'created',
|
|
1405
|
+
sessionName,
|
|
1406
|
+
isWorktree: true,
|
|
1407
|
+
branch: wtResult.branch,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
// Clean up the worktree since tmux session failed
|
|
1412
|
+
await this.tmux.removeWorktree(payload.parentDir, wtResult.worktreePath);
|
|
1413
|
+
this.send(client.ws, {
|
|
1414
|
+
type: 'worktree_session_created',
|
|
1415
|
+
success: false,
|
|
1416
|
+
error: tmuxResult.error || 'Failed to create tmux session',
|
|
1417
|
+
requestId,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
async handleListWorktrees(client, payload, requestId) {
|
|
1422
|
+
if (!payload?.dir) {
|
|
1423
|
+
this.send(client.ws, {
|
|
1424
|
+
type: 'worktrees_list',
|
|
1425
|
+
success: false,
|
|
1426
|
+
error: 'Missing dir',
|
|
1427
|
+
requestId,
|
|
1428
|
+
});
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
const worktrees = await this.tmux.listWorktrees(payload.dir);
|
|
1432
|
+
this.send(client.ws, {
|
|
1433
|
+
type: 'worktrees_list',
|
|
1434
|
+
success: true,
|
|
1435
|
+
payload: { worktrees },
|
|
1436
|
+
requestId,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1047
1439
|
async handleSwitchTmuxSession(client, payload, requestId) {
|
|
1048
1440
|
if (!payload?.sessionName) {
|
|
1049
1441
|
this.send(client.ws, {
|
|
@@ -1075,7 +1467,7 @@ class WebSocketHandler {
|
|
|
1075
1467
|
// Try to find and switch to the corresponding conversation session
|
|
1076
1468
|
// Get the tmux session's working directory
|
|
1077
1469
|
const sessions = await this.tmux.listSessions();
|
|
1078
|
-
const tmuxSession = sessions.find(s => s.name === payload.sessionName);
|
|
1470
|
+
const tmuxSession = sessions.find((s) => s.name === payload.sessionName);
|
|
1079
1471
|
let conversationSessionId;
|
|
1080
1472
|
if (tmuxSession?.workingDir) {
|
|
1081
1473
|
// Store the session config for potential recreation later
|
|
@@ -1084,7 +1476,7 @@ class WebSocketHandler {
|
|
|
1084
1476
|
const encodedPath = tmuxSession.workingDir.replace(/\//g, '-');
|
|
1085
1477
|
// Find conversation session whose ID matches or starts with this encoded path
|
|
1086
1478
|
const convSessions = this.watcher.getSessions();
|
|
1087
|
-
const matchingConv = convSessions.find(cs => cs.id === encodedPath);
|
|
1479
|
+
const matchingConv = convSessions.find((cs) => cs.id === encodedPath);
|
|
1088
1480
|
if (matchingConv) {
|
|
1089
1481
|
this.watcher.setActiveSession(matchingConv.id);
|
|
1090
1482
|
conversationSessionId = matchingConv.id;
|
|
@@ -1093,7 +1485,7 @@ class WebSocketHandler {
|
|
|
1093
1485
|
else {
|
|
1094
1486
|
// No conversation yet for this project - clear active session so old data stops flowing
|
|
1095
1487
|
this.watcher.clearActiveSession();
|
|
1096
|
-
console.log(`WebSocket: No conversation found for ${encodedPath}, cleared active session. Available: ${convSessions.map(c => c.id).join(', ')}`);
|
|
1488
|
+
console.log(`WebSocket: No conversation found for ${encodedPath}, cleared active session. Available: ${convSessions.map((c) => c.id).join(', ')}`);
|
|
1097
1489
|
}
|
|
1098
1490
|
}
|
|
1099
1491
|
else {
|
|
@@ -1265,7 +1657,7 @@ class WebSocketHandler {
|
|
|
1265
1657
|
if (activeSessionId) {
|
|
1266
1658
|
// The session ID is the encoded path like -Users-foo-project
|
|
1267
1659
|
// Match it against tmux session working directories
|
|
1268
|
-
const matchingSession = sessions.find(s => {
|
|
1660
|
+
const matchingSession = sessions.find((s) => {
|
|
1269
1661
|
if (!s.workingDir)
|
|
1270
1662
|
return false;
|
|
1271
1663
|
const encoded = s.workingDir.replace(/\//g, '-');
|
|
@@ -1280,12 +1672,8 @@ class WebSocketHandler {
|
|
|
1280
1672
|
// Normalize the path
|
|
1281
1673
|
resolvedPath = path.normalize(resolvedPath);
|
|
1282
1674
|
// Security: only allow reading files in certain directories
|
|
1283
|
-
const allowedPaths = [
|
|
1284
|
-
|
|
1285
|
-
'/tmp',
|
|
1286
|
-
'/var/tmp',
|
|
1287
|
-
];
|
|
1288
|
-
const isAllowed = allowedPaths.some(allowed => resolvedPath.startsWith(allowed));
|
|
1675
|
+
const allowedPaths = [homeDir, '/tmp', '/var/tmp'];
|
|
1676
|
+
const isAllowed = allowedPaths.some((allowed) => resolvedPath.startsWith(allowed));
|
|
1289
1677
|
if (!isAllowed) {
|
|
1290
1678
|
this.send(client.ws, {
|
|
1291
1679
|
type: 'file_content',
|
|
@@ -1333,6 +1721,94 @@ class WebSocketHandler {
|
|
|
1333
1721
|
});
|
|
1334
1722
|
}
|
|
1335
1723
|
}
|
|
1724
|
+
async handleOpenInEditor(client, payload, requestId) {
|
|
1725
|
+
const filePath = payload?.path;
|
|
1726
|
+
if (!filePath) {
|
|
1727
|
+
this.send(client.ws, {
|
|
1728
|
+
type: 'open_in_editor',
|
|
1729
|
+
success: false,
|
|
1730
|
+
error: 'No file path provided',
|
|
1731
|
+
requestId,
|
|
1732
|
+
});
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
try {
|
|
1736
|
+
const homeDir = this.tmux.getHomeDir();
|
|
1737
|
+
let resolvedPath;
|
|
1738
|
+
if (filePath.startsWith('~/')) {
|
|
1739
|
+
resolvedPath = path.join(homeDir, filePath.slice(2));
|
|
1740
|
+
}
|
|
1741
|
+
else if (filePath.startsWith('/')) {
|
|
1742
|
+
resolvedPath = filePath;
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
resolvedPath = path.resolve(homeDir, filePath);
|
|
1746
|
+
}
|
|
1747
|
+
resolvedPath = path.normalize(resolvedPath);
|
|
1748
|
+
// Security: only allow opening files in home directory or /tmp
|
|
1749
|
+
const allowedPaths = [homeDir, '/tmp', '/var/tmp'];
|
|
1750
|
+
const isAllowed = allowedPaths.some((allowed) => resolvedPath.startsWith(allowed));
|
|
1751
|
+
if (!isAllowed) {
|
|
1752
|
+
this.send(client.ws, {
|
|
1753
|
+
type: 'open_in_editor',
|
|
1754
|
+
success: false,
|
|
1755
|
+
error: `Access denied: file outside allowed directories`,
|
|
1756
|
+
requestId,
|
|
1757
|
+
});
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
// Check file exists
|
|
1761
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
1762
|
+
this.send(client.ws, {
|
|
1763
|
+
type: 'open_in_editor',
|
|
1764
|
+
success: false,
|
|
1765
|
+
error: 'File not found',
|
|
1766
|
+
requestId,
|
|
1767
|
+
});
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
// Determine the editor command
|
|
1771
|
+
// Priority: $VISUAL > $EDITOR > platform default (open/xdg-open)
|
|
1772
|
+
const editor = process.env.VISUAL || process.env.EDITOR;
|
|
1773
|
+
let cmd;
|
|
1774
|
+
let args;
|
|
1775
|
+
if (editor) {
|
|
1776
|
+
// Split editor string in case it has flags (e.g. "code --wait")
|
|
1777
|
+
const parts = editor.split(/\s+/);
|
|
1778
|
+
cmd = parts[0];
|
|
1779
|
+
args = [...parts.slice(1), resolvedPath];
|
|
1780
|
+
}
|
|
1781
|
+
else if (process.platform === 'darwin') {
|
|
1782
|
+
cmd = 'open';
|
|
1783
|
+
args = [resolvedPath];
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
cmd = 'xdg-open';
|
|
1787
|
+
args = [resolvedPath];
|
|
1788
|
+
}
|
|
1789
|
+
// Spawn detached so it doesn't block the daemon
|
|
1790
|
+
const child = (0, child_process_1.spawn)(cmd, args, {
|
|
1791
|
+
detached: true,
|
|
1792
|
+
stdio: 'ignore',
|
|
1793
|
+
});
|
|
1794
|
+
child.unref();
|
|
1795
|
+
console.log(`Open in editor: ${cmd} ${args.join(' ')}`);
|
|
1796
|
+
this.send(client.ws, {
|
|
1797
|
+
type: 'open_in_editor',
|
|
1798
|
+
success: true,
|
|
1799
|
+
payload: { path: resolvedPath, editor: cmd },
|
|
1800
|
+
requestId,
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
catch (err) {
|
|
1804
|
+
this.send(client.ws, {
|
|
1805
|
+
type: 'open_in_editor',
|
|
1806
|
+
success: false,
|
|
1807
|
+
error: `Failed to open file: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
1808
|
+
requestId,
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1336
1812
|
async handleDownloadFile(client, payload, requestId) {
|
|
1337
1813
|
const filePath = payload?.path;
|
|
1338
1814
|
if (!filePath) {
|
|
@@ -1361,7 +1837,7 @@ class WebSocketHandler {
|
|
|
1361
1837
|
resolvedPath = path.normalize(resolvedPath);
|
|
1362
1838
|
// Security: only allow downloading files in certain directories
|
|
1363
1839
|
const allowedPaths = [homeDir, '/tmp', '/var/tmp'];
|
|
1364
|
-
const isAllowed = allowedPaths.some(allowed => resolvedPath.startsWith(allowed));
|
|
1840
|
+
const isAllowed = allowedPaths.some((allowed) => resolvedPath.startsWith(allowed));
|
|
1365
1841
|
if (!isAllowed) {
|
|
1366
1842
|
this.send(client.ws, {
|
|
1367
1843
|
type: 'file_download',
|
|
@@ -1374,7 +1850,7 @@ class WebSocketHandler {
|
|
|
1374
1850
|
// Only allow specific file types for download
|
|
1375
1851
|
const allowedExtensions = ['.apk', '.ipa', '.zip', '.tar.gz', '.tgz'];
|
|
1376
1852
|
const ext = path.extname(resolvedPath).toLowerCase();
|
|
1377
|
-
const isApkOrZip = allowedExtensions.some(e => resolvedPath.toLowerCase().endsWith(e));
|
|
1853
|
+
const isApkOrZip = allowedExtensions.some((e) => resolvedPath.toLowerCase().endsWith(e));
|
|
1378
1854
|
if (!isApkOrZip) {
|
|
1379
1855
|
this.send(client.ws, {
|
|
1380
1856
|
type: 'file_download',
|
|
@@ -1666,6 +2142,171 @@ class WebSocketHandler {
|
|
|
1666
2142
|
error,
|
|
1667
2143
|
});
|
|
1668
2144
|
}
|
|
2145
|
+
// --- Work Group handlers ---
|
|
2146
|
+
async handleSpawnWorkGroup(client, payload, requestId) {
|
|
2147
|
+
if (!this.workGroupManager) {
|
|
2148
|
+
this.send(client.ws, {
|
|
2149
|
+
type: 'work_group_spawned',
|
|
2150
|
+
success: false,
|
|
2151
|
+
error: 'Work groups not enabled',
|
|
2152
|
+
requestId,
|
|
2153
|
+
});
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (!payload?.name || !payload.workers?.length) {
|
|
2157
|
+
this.send(client.ws, {
|
|
2158
|
+
type: 'work_group_spawned',
|
|
2159
|
+
success: false,
|
|
2160
|
+
error: 'Missing name or workers',
|
|
2161
|
+
requestId,
|
|
2162
|
+
});
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
try {
|
|
2166
|
+
const group = await this.workGroupManager.createWorkGroup({
|
|
2167
|
+
name: payload.name,
|
|
2168
|
+
foremanSessionId: payload.foremanSessionId,
|
|
2169
|
+
foremanTmuxSession: payload.foremanTmuxSession,
|
|
2170
|
+
parentDir: payload.parentDir,
|
|
2171
|
+
planFile: payload.planFile,
|
|
2172
|
+
workers: payload.workers,
|
|
2173
|
+
});
|
|
2174
|
+
this.send(client.ws, {
|
|
2175
|
+
type: 'work_group_spawned',
|
|
2176
|
+
success: true,
|
|
2177
|
+
payload: group,
|
|
2178
|
+
requestId,
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
catch (err) {
|
|
2182
|
+
this.send(client.ws, {
|
|
2183
|
+
type: 'work_group_spawned',
|
|
2184
|
+
success: false,
|
|
2185
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2186
|
+
requestId,
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
handleGetWorkGroups(client, requestId) {
|
|
2191
|
+
if (!this.workGroupManager) {
|
|
2192
|
+
this.send(client.ws, {
|
|
2193
|
+
type: 'work_groups',
|
|
2194
|
+
success: true,
|
|
2195
|
+
payload: { groups: [] },
|
|
2196
|
+
requestId,
|
|
2197
|
+
});
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
const groups = this.workGroupManager.getWorkGroups();
|
|
2201
|
+
this.send(client.ws, { type: 'work_groups', success: true, payload: { groups }, requestId });
|
|
2202
|
+
}
|
|
2203
|
+
handleGetWorkGroup(client, payload, requestId) {
|
|
2204
|
+
if (!this.workGroupManager || !payload?.groupId) {
|
|
2205
|
+
this.send(client.ws, {
|
|
2206
|
+
type: 'work_group',
|
|
2207
|
+
success: false,
|
|
2208
|
+
error: 'Missing groupId',
|
|
2209
|
+
requestId,
|
|
2210
|
+
});
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
const group = this.workGroupManager.getWorkGroup(payload.groupId);
|
|
2214
|
+
if (!group) {
|
|
2215
|
+
this.send(client.ws, { type: 'work_group', success: false, error: 'Not found', requestId });
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
this.send(client.ws, { type: 'work_group', success: true, payload: group, requestId });
|
|
2219
|
+
}
|
|
2220
|
+
async handleMergeWorkGroup(client, payload, requestId) {
|
|
2221
|
+
if (!this.workGroupManager || !payload?.groupId) {
|
|
2222
|
+
this.send(client.ws, {
|
|
2223
|
+
type: 'work_group_merged',
|
|
2224
|
+
success: false,
|
|
2225
|
+
error: 'Missing groupId',
|
|
2226
|
+
requestId,
|
|
2227
|
+
});
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const result = await this.workGroupManager.mergeWorkGroup(payload.groupId);
|
|
2231
|
+
this.send(client.ws, {
|
|
2232
|
+
type: 'work_group_merged',
|
|
2233
|
+
success: result.success,
|
|
2234
|
+
payload: result,
|
|
2235
|
+
requestId,
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
async handleCancelWorkGroup(client, payload, requestId) {
|
|
2239
|
+
if (!this.workGroupManager || !payload?.groupId) {
|
|
2240
|
+
this.send(client.ws, {
|
|
2241
|
+
type: 'work_group_cancelled',
|
|
2242
|
+
success: false,
|
|
2243
|
+
error: 'Missing groupId',
|
|
2244
|
+
requestId,
|
|
2245
|
+
});
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
const result = await this.workGroupManager.cancelWorkGroup(payload.groupId);
|
|
2249
|
+
this.send(client.ws, {
|
|
2250
|
+
type: 'work_group_cancelled',
|
|
2251
|
+
success: result.success,
|
|
2252
|
+
error: result.error,
|
|
2253
|
+
requestId,
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
async handleRetryWorker(client, payload, requestId) {
|
|
2257
|
+
if (!this.workGroupManager || !payload?.groupId || !payload?.workerId) {
|
|
2258
|
+
this.send(client.ws, {
|
|
2259
|
+
type: 'worker_retried',
|
|
2260
|
+
success: false,
|
|
2261
|
+
error: 'Missing groupId or workerId',
|
|
2262
|
+
requestId,
|
|
2263
|
+
});
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
const result = await this.workGroupManager.retryWorker(payload.groupId, payload.workerId);
|
|
2267
|
+
this.send(client.ws, {
|
|
2268
|
+
type: 'worker_retried',
|
|
2269
|
+
success: result.success,
|
|
2270
|
+
error: result.error,
|
|
2271
|
+
requestId,
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
async handleSendWorkerInput(client, payload, requestId) {
|
|
2275
|
+
if (!this.workGroupManager || !payload?.groupId || !payload?.workerId || !payload?.text) {
|
|
2276
|
+
this.send(client.ws, {
|
|
2277
|
+
type: 'worker_input_sent',
|
|
2278
|
+
success: false,
|
|
2279
|
+
error: 'Missing groupId, workerId, or text',
|
|
2280
|
+
requestId,
|
|
2281
|
+
});
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
const result = await this.workGroupManager.sendWorkerInput(payload.groupId, payload.workerId, payload.text);
|
|
2285
|
+
this.send(client.ws, {
|
|
2286
|
+
type: 'worker_input_sent',
|
|
2287
|
+
success: result.success,
|
|
2288
|
+
error: result.error,
|
|
2289
|
+
requestId,
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
async handleDismissWorkGroup(client, payload, requestId) {
|
|
2293
|
+
if (!this.workGroupManager || !payload?.groupId) {
|
|
2294
|
+
this.send(client.ws, {
|
|
2295
|
+
type: 'work_group_dismissed',
|
|
2296
|
+
success: false,
|
|
2297
|
+
error: 'Missing groupId',
|
|
2298
|
+
requestId,
|
|
2299
|
+
});
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
const result = await this.workGroupManager.dismissWorkGroup(payload.groupId);
|
|
2303
|
+
this.send(client.ws, {
|
|
2304
|
+
type: 'work_group_dismissed',
|
|
2305
|
+
success: result.success,
|
|
2306
|
+
error: result.success ? undefined : 'Group is not in completed or cancelled state',
|
|
2307
|
+
requestId,
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
1669
2310
|
broadcast(type, payload, sessionId) {
|
|
1670
2311
|
// Get the session ID to include in the message
|
|
1671
2312
|
const activeSessionId = sessionId || this.watcher.getActiveSessionId();
|