@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.
Files changed (110) hide show
  1. package/dist/__tests__/task-parser.test.js +29 -29
  2. package/dist/__tests__/task-parser.test.js.map +1 -1
  3. package/dist/anthropic-usage.d.ts.map +1 -1
  4. package/dist/anthropic-usage.js +1 -1
  5. package/dist/anthropic-usage.js.map +1 -1
  6. package/dist/cert-generator.d.ts.map +1 -1
  7. package/dist/cert-generator.js +4 -21
  8. package/dist/cert-generator.js.map +1 -1
  9. package/dist/cli.d.ts +9 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +413 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +1 -7
  15. package/dist/config.js.map +1 -1
  16. package/dist/escalation.d.ts +51 -0
  17. package/dist/escalation.d.ts.map +1 -0
  18. package/dist/escalation.js +198 -0
  19. package/dist/escalation.js.map +1 -0
  20. package/dist/index.js +67 -30
  21. package/dist/index.js.map +1 -1
  22. package/dist/input-injector.d.ts.map +1 -1
  23. package/dist/input-injector.js +9 -5
  24. package/dist/input-injector.js.map +1 -1
  25. package/dist/notification-store.d.ts +35 -0
  26. package/dist/notification-store.d.ts.map +1 -0
  27. package/dist/notification-store.js +272 -0
  28. package/dist/notification-store.js.map +1 -0
  29. package/dist/parser.d.ts +15 -1
  30. package/dist/parser.d.ts.map +1 -1
  31. package/dist/parser.js +106 -61
  32. package/dist/parser.js.map +1 -1
  33. package/dist/push.d.ts +18 -26
  34. package/dist/push.d.ts.map +1 -1
  35. package/dist/push.js +90 -184
  36. package/dist/push.js.map +1 -1
  37. package/dist/qr-server.d.ts.map +1 -1
  38. package/dist/qr-server.js +159 -139
  39. package/dist/qr-server.js.map +1 -1
  40. package/dist/rules-engine.d.ts +20 -0
  41. package/dist/rules-engine.d.ts.map +1 -0
  42. package/dist/rules-engine.js +71 -0
  43. package/dist/rules-engine.js.map +1 -0
  44. package/dist/scaffold/claude-commands.d.ts +18 -0
  45. package/dist/scaffold/claude-commands.d.ts.map +1 -0
  46. package/dist/scaffold/claude-commands.js +352 -0
  47. package/dist/scaffold/claude-commands.js.map +1 -0
  48. package/dist/scaffold/generator.d.ts.map +1 -1
  49. package/dist/scaffold/generator.js +26 -1
  50. package/dist/scaffold/generator.js.map +1 -1
  51. package/dist/scaffold/scorer.d.ts +19 -0
  52. package/dist/scaffold/scorer.d.ts.map +1 -0
  53. package/dist/scaffold/scorer.js +92 -0
  54. package/dist/scaffold/scorer.js.map +1 -0
  55. package/dist/scaffold/templates/go-cli.d.ts +3 -0
  56. package/dist/scaffold/templates/go-cli.d.ts.map +1 -0
  57. package/dist/scaffold/templates/go-cli.js +249 -0
  58. package/dist/scaffold/templates/go-cli.js.map +1 -0
  59. package/dist/scaffold/templates/index.d.ts.map +1 -1
  60. package/dist/scaffold/templates/index.js +8 -2
  61. package/dist/scaffold/templates/index.js.map +1 -1
  62. package/dist/scaffold/templates/nextjs.d.ts +3 -0
  63. package/dist/scaffold/templates/nextjs.d.ts.map +1 -0
  64. package/dist/scaffold/templates/nextjs.js +336 -0
  65. package/dist/scaffold/templates/nextjs.js.map +1 -0
  66. package/dist/scaffold/templates/node-express.d.ts.map +1 -1
  67. package/dist/scaffold/templates/node-express.js +170 -157
  68. package/dist/scaffold/templates/node-express.js.map +1 -1
  69. package/dist/scaffold/templates/python-fastapi.d.ts.map +1 -1
  70. package/dist/scaffold/templates/python-fastapi.js +234 -221
  71. package/dist/scaffold/templates/python-fastapi.js.map +1 -1
  72. package/dist/scaffold/templates/react-mui-website.d.ts.map +1 -1
  73. package/dist/scaffold/templates/react-mui-website.js +337 -324
  74. package/dist/scaffold/templates/react-mui-website.js.map +1 -1
  75. package/dist/scaffold/templates/react-typescript.d.ts.map +1 -1
  76. package/dist/scaffold/templates/react-typescript.js +219 -206
  77. package/dist/scaffold/templates/react-typescript.js.map +1 -1
  78. package/dist/scaffold/templates/typescript-library.d.ts +3 -0
  79. package/dist/scaffold/templates/typescript-library.d.ts.map +1 -0
  80. package/dist/scaffold/templates/typescript-library.js +241 -0
  81. package/dist/scaffold/templates/typescript-library.js.map +1 -0
  82. package/dist/scaffold/types.d.ts +7 -0
  83. package/dist/scaffold/types.d.ts.map +1 -1
  84. package/dist/subagent-watcher.d.ts.map +1 -1
  85. package/dist/subagent-watcher.js +3 -3
  86. package/dist/subagent-watcher.js.map +1 -1
  87. package/dist/tmux-manager.d.ts +37 -0
  88. package/dist/tmux-manager.d.ts.map +1 -1
  89. package/dist/tmux-manager.js +165 -5
  90. package/dist/tmux-manager.js.map +1 -1
  91. package/dist/tool-config.d.ts.map +1 -1
  92. package/dist/tool-config.js +2 -2
  93. package/dist/tool-config.js.map +1 -1
  94. package/dist/types.d.ts +85 -0
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js +18 -0
  97. package/dist/types.js.map +1 -1
  98. package/dist/watcher.d.ts +7 -0
  99. package/dist/watcher.d.ts.map +1 -1
  100. package/dist/watcher.js +118 -9
  101. package/dist/watcher.js.map +1 -1
  102. package/dist/websocket.d.ts +16 -2
  103. package/dist/websocket.d.ts.map +1 -1
  104. package/dist/websocket.js +758 -117
  105. package/dist/websocket.js.map +1 -1
  106. package/dist/work-group-manager.d.ts +69 -0
  107. package/dist/work-group-manager.d.ts.map +1 -0
  108. package/dist/work-group-manager.js +610 -0
  109. package/dist/work-group-manager.js.map +1 -0
  110. 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
- autoApproveEnabled = false;
68
- constructor(server, config, watcher, injector, push, tmux, subAgentWatcher) {
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
- // Schedule push notification if waiting for input (include session info)
91
+ // Escalation for waiting_for_input
85
92
  if (data.isWaitingForInput && data.lastMessage) {
86
- this.push.scheduleWaitingNotification(data.lastMessage.content, data.sessionId || undefined, this.injector.getActiveSession() || undefined);
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
- this.push.cancelPendingNotification();
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, req) {
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 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;
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
- console.log(`WebSocket: get_highlights - getMessages: ${t1 - t0}ms, extractHighlights: ${t2 - t1}ms, ${messages.length} msgs, returning ${resultHighlights.length}/${total}`);
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.listSessions().then(async (tmuxSessions) => {
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
- }).catch((err) => {
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
- 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;
502
+ // set_instant_notify removed — escalation model replaces per-device instant notify
450
503
  case 'set_auto_approve': {
451
504
  const autoApprovePayload = payload;
452
- this.autoApproveEnabled = autoApprovePayload?.enabled ?? false;
453
- console.log(`Auto-approve ${this.autoApproveEnabled ? 'enabled' : 'disabled'} by client`);
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: this.autoApproveEnabled },
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 (this.autoApproveEnabled) {
523
+ if (enabled) {
462
524
  this.watcher.checkAndEmitPendingApproval();
463
525
  }
464
526
  break;
465
527
  }
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;
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.capturePane(termPayload.sessionName, termPayload.lines || 100)
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
- 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
- });
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
- name: savedConfig.name,
696
- workingDir: savedConfig.workingDir,
697
- } : undefined,
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', { action: 'killed', sessionName: payload.sessionName });
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
- homeDir,
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();