@appoly/multiagent-chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js ADDED
@@ -0,0 +1,1149 @@
1
+ const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
2
+ const { spawn, exec } = require('child_process');
3
+ const { promisify } = require('util');
4
+ const pty = require('node-pty');
5
+ const path = require('path');
6
+ const fs = require('fs').promises;
7
+ const fsSync = require('fs');
8
+ const os = require('os');
9
+ const yaml = require('yaml');
10
+ const chokidar = require('chokidar');
11
+
12
+ // Home config directory: ~/.multiagent-chat/
13
+ const HOME_CONFIG_DIR = path.join(os.homedir(), '.multiagent-chat');
14
+ const RECENT_WORKSPACES_FILE = path.join(HOME_CONFIG_DIR, 'recent-workspaces.json');
15
+ const HOME_CONFIG_FILE = path.join(HOME_CONFIG_DIR, 'config.yaml');
16
+ const MAX_RECENT_WORKSPACES = 8;
17
+
18
+ const execAsync = promisify(exec);
19
+
20
+ let mainWindow;
21
+ let agents = [];
22
+ let config;
23
+ let workspacePath;
24
+ let agentCwd; // Parent directory of workspace - where agents are launched
25
+ let fileWatcher;
26
+ let outboxWatcher;
27
+ let customWorkspacePath = null;
28
+ let customConfigPath = null; // CLI --config path
29
+ let messageSequence = 0; // For ordering messages in chat
30
+ let agentColors = {}; // Map of agent name -> color
31
+ let sessionBaseCommit = null; // Git commit hash at session start for diff baseline
32
+
33
+ // Parse command-line arguments
34
+ // Usage: npm start /path/to/workspace
35
+ // Or: npm start --workspace /path/to/workspace
36
+ // Or: npm start --config /path/to/config.yaml
37
+ // Or: WORKSPACE=/path/to/workspace npm start
38
+ function parseCommandLineArgs() {
39
+ // Check environment variables first
40
+ if (process.env.WORKSPACE) {
41
+ customWorkspacePath = process.env.WORKSPACE;
42
+ console.log('Using workspace from environment variable:', customWorkspacePath);
43
+ }
44
+
45
+ if (process.env.CONFIG) {
46
+ customConfigPath = process.env.CONFIG;
47
+ console.log('Using config from environment variable:', customConfigPath);
48
+ }
49
+
50
+ // Then check command-line arguments
51
+ // process.argv looks like: [electron, main.js, ...args]
52
+ const args = process.argv.slice(2);
53
+
54
+ for (let i = 0; i < args.length; i++) {
55
+ // Parse --workspace flag
56
+ if (args[i] === '--workspace' && args[i + 1]) {
57
+ customWorkspacePath = args[i + 1];
58
+ console.log('Using workspace from --workspace flag:', customWorkspacePath);
59
+ i++; // Skip next arg
60
+ }
61
+ // Parse --config flag
62
+ else if (args[i] === '--config' && args[i + 1]) {
63
+ customConfigPath = args[i + 1];
64
+ console.log('Using config from --config flag:', customConfigPath);
65
+ i++; // Skip next arg
66
+ }
67
+ // Positional arg (assume workspace path if not a flag)
68
+ else if (!args[i].startsWith('--') && !customWorkspacePath) {
69
+ customWorkspacePath = args[i];
70
+ console.log('Using workspace from positional argument:', customWorkspacePath);
71
+ }
72
+ }
73
+ }
74
+
75
+ // Ensure home config directory exists and set up first-run defaults
76
+ async function ensureHomeConfigDir() {
77
+ try {
78
+ // Create ~/.multiagent-chat/ if it doesn't exist
79
+ await fs.mkdir(HOME_CONFIG_DIR, { recursive: true });
80
+ console.log('Home config directory ensured:', HOME_CONFIG_DIR);
81
+
82
+ // Migration: check for existing data in Electron userData
83
+ const userDataDir = app.getPath('userData');
84
+ const oldRecentsFile = path.join(userDataDir, 'recent-workspaces.json');
85
+
86
+ // Check if config.yaml exists in home dir, if not copy default
87
+ try {
88
+ await fs.access(HOME_CONFIG_FILE);
89
+ console.log('Home config exists:', HOME_CONFIG_FILE);
90
+ } catch (e) {
91
+ // Copy bundled default config to home dir
92
+ const bundledConfig = path.join(__dirname, 'config.yaml');
93
+ try {
94
+ await fs.copyFile(bundledConfig, HOME_CONFIG_FILE);
95
+ console.log('Copied default config to:', HOME_CONFIG_FILE);
96
+ } catch (copyError) {
97
+ console.warn('Could not copy default config:', copyError.message);
98
+ }
99
+ }
100
+
101
+ // Initialize or migrate recent-workspaces.json
102
+ try {
103
+ await fs.access(RECENT_WORKSPACES_FILE);
104
+ } catch (e) {
105
+ // Try to migrate from old location first
106
+ try {
107
+ await fs.access(oldRecentsFile);
108
+ await fs.copyFile(oldRecentsFile, RECENT_WORKSPACES_FILE);
109
+ console.log('Migrated recent workspaces from:', oldRecentsFile);
110
+ } catch (migrateError) {
111
+ // No old file, create new empty one
112
+ await fs.writeFile(RECENT_WORKSPACES_FILE, JSON.stringify({ recents: [] }, null, 2));
113
+ console.log('Initialized recent workspaces file:', RECENT_WORKSPACES_FILE);
114
+ }
115
+ }
116
+ } catch (error) {
117
+ console.error('Error setting up home config directory:', error);
118
+ }
119
+ }
120
+
121
+ // Load recent workspaces from JSON file
122
+ async function loadRecentWorkspaces() {
123
+ try {
124
+ const content = await fs.readFile(RECENT_WORKSPACES_FILE, 'utf8');
125
+ const data = JSON.parse(content);
126
+ return data.recents || [];
127
+ } catch (error) {
128
+ console.warn('Could not load recent workspaces:', error.message);
129
+ return [];
130
+ }
131
+ }
132
+
133
+ // Save recent workspaces to JSON file
134
+ async function saveRecentWorkspaces(recents) {
135
+ try {
136
+ await fs.writeFile(RECENT_WORKSPACES_FILE, JSON.stringify({ recents }, null, 2));
137
+ } catch (error) {
138
+ console.error('Error saving recent workspaces:', error);
139
+ }
140
+ }
141
+
142
+ // Add or update a workspace in recents
143
+ async function addRecentWorkspace(workspacePath) {
144
+ const recents = await loadRecentWorkspaces();
145
+ const now = new Date().toISOString();
146
+
147
+ // Remove existing entry with same path (case-insensitive on Windows)
148
+ const filtered = recents.filter(r =>
149
+ r.path.toLowerCase() !== workspacePath.toLowerCase()
150
+ );
151
+
152
+ // Add new entry at the beginning
153
+ filtered.unshift({
154
+ path: workspacePath,
155
+ lastUsed: now
156
+ });
157
+
158
+ // Limit to max entries
159
+ const limited = filtered.slice(0, MAX_RECENT_WORKSPACES);
160
+
161
+ await saveRecentWorkspaces(limited);
162
+ return limited;
163
+ }
164
+
165
+ // Remove a workspace from recents
166
+ async function removeRecentWorkspace(workspacePath) {
167
+ const recents = await loadRecentWorkspaces();
168
+ const filtered = recents.filter(r =>
169
+ r.path.toLowerCase() !== workspacePath.toLowerCase()
170
+ );
171
+ await saveRecentWorkspaces(filtered);
172
+ return filtered;
173
+ }
174
+
175
+ // Update path of a workspace in recents (for "Locate" functionality)
176
+ async function updateRecentWorkspacePath(oldPath, newPath) {
177
+ const recents = await loadRecentWorkspaces();
178
+ const now = new Date().toISOString();
179
+
180
+ const updated = recents.map(r => {
181
+ if (r.path.toLowerCase() === oldPath.toLowerCase()) {
182
+ return { path: newPath, lastUsed: now };
183
+ }
184
+ return r;
185
+ });
186
+
187
+ await saveRecentWorkspaces(updated);
188
+ return updated;
189
+ }
190
+
191
+ // Validate if a workspace path exists and is a directory
192
+ async function validateWorkspacePath(workspacePath) {
193
+ try {
194
+ const stats = await fs.stat(workspacePath);
195
+ return stats.isDirectory();
196
+ } catch (error) {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ // Get current working directory info
202
+ function getCurrentDirectoryInfo() {
203
+ const cwd = process.cwd();
204
+ const appDir = __dirname;
205
+
206
+ // Check if cwd is different from app directory and exists
207
+ const isUsable = cwd !== appDir && fsSync.existsSync(cwd);
208
+
209
+ return {
210
+ path: cwd,
211
+ isUsable,
212
+ appDir
213
+ };
214
+ }
215
+
216
+ // Create the browser window
217
+ function createWindow() {
218
+ console.log('Creating window...');
219
+
220
+ const iconPath = path.join(__dirname, 'robot.png');
221
+
222
+ mainWindow = new BrowserWindow({
223
+ width: 1400,
224
+ height: 900,
225
+ icon: iconPath,
226
+ webPreferences: {
227
+ preload: path.join(__dirname, 'preload.js'),
228
+ contextIsolation: true,
229
+ nodeIntegration: false
230
+ }
231
+ });
232
+
233
+ // Set dock icon on macOS
234
+ if (process.platform === 'darwin' && app.dock) {
235
+ app.dock.setIcon(iconPath);
236
+ }
237
+
238
+ console.log('Window created, loading index.html...');
239
+ mainWindow.loadFile('index.html');
240
+
241
+ mainWindow.webContents.on('did-finish-load', () => {
242
+ console.log('Page loaded successfully');
243
+ });
244
+
245
+ console.log('Window setup complete');
246
+ }
247
+
248
+ // Load configuration with priority: CLI arg > home config > bundled default
249
+ async function loadConfig(configPath = null) {
250
+ try {
251
+ let fullPath;
252
+
253
+ // Priority 1: CLI argument (--config flag or CONFIG env var)
254
+ if (configPath) {
255
+ fullPath = path.isAbsolute(configPath) ? configPath : path.join(process.cwd(), configPath);
256
+ console.log('Loading config from CLI arg:', fullPath);
257
+ }
258
+ // Priority 2: Home directory config (~/.multiagent-chat/config.yaml)
259
+ else if (fsSync.existsSync(HOME_CONFIG_FILE)) {
260
+ fullPath = HOME_CONFIG_FILE;
261
+ console.log('Loading config from home dir:', fullPath);
262
+ }
263
+ // Priority 3: Bundled default (fallback)
264
+ else {
265
+ fullPath = path.join(__dirname, 'config.yaml');
266
+ console.log('Loading bundled config from:', fullPath);
267
+ }
268
+
269
+ const configFile = await fs.readFile(fullPath, 'utf8');
270
+ config = yaml.parse(configFile);
271
+ console.log('Config loaded successfully');
272
+ return config;
273
+ } catch (error) {
274
+ console.error('Error loading config:', error);
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ // Setup workspace directory and files
280
+ // customPath = project root selected by user (or from CLI)
281
+ async function setupWorkspace(customPath = null) {
282
+ // Determine project root (agentCwd) and workspace path (.multiagent-chat inside it)
283
+ let projectRoot;
284
+
285
+ if (customPath && path.isAbsolute(customPath)) {
286
+ projectRoot = customPath;
287
+ } else if (customPath) {
288
+ projectRoot = path.join(process.cwd(), customPath);
289
+ } else {
290
+ // Fallback: use current directory as project root
291
+ projectRoot = process.cwd();
292
+ }
293
+
294
+ // Set agent working directory to the project root
295
+ agentCwd = projectRoot;
296
+
297
+ // Set workspace path to .multiagent-chat inside the project root
298
+ const workspaceName = config.workspace || '.multiagent-chat';
299
+ workspacePath = path.join(projectRoot, workspaceName);
300
+
301
+ try {
302
+ await fs.mkdir(workspacePath, { recursive: true });
303
+
304
+ // Initialize chat.jsonl (empty file - JSONL format)
305
+ const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
306
+ await fs.writeFile(chatPath, '');
307
+
308
+ // Clear PLAN_FINAL.md if it exists
309
+ const planPath = path.join(workspacePath, config.plan_file || 'PLAN_FINAL.md');
310
+ await fs.writeFile(planPath, '');
311
+
312
+ // Create outbox directory and per-agent outbox files
313
+ const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
314
+ await fs.mkdir(outboxDir, { recursive: true });
315
+
316
+ // Build agent colors map from config
317
+ const defaultColors = config.default_agent_colors || ['#667eea', '#f093fb', '#4fd1c5', '#f6ad55', '#68d391', '#fc8181'];
318
+ agentColors = {};
319
+ config.agents.forEach((agentConfig, index) => {
320
+ agentColors[agentConfig.name.toLowerCase()] = agentConfig.color || defaultColors[index % defaultColors.length];
321
+ });
322
+ // Add user color
323
+ agentColors['user'] = config.user_color || '#a0aec0';
324
+
325
+ // Create empty outbox file for each agent
326
+ for (const agentConfig of config.agents) {
327
+ const outboxFile = path.join(outboxDir, `${agentConfig.name.toLowerCase()}.md`);
328
+ await fs.writeFile(outboxFile, '');
329
+ }
330
+
331
+ // Reset message sequence
332
+ messageSequence = 0;
333
+
334
+ // Capture git base commit for diff baseline
335
+ try {
336
+ const { stdout } = await execAsync('git rev-parse HEAD', { cwd: agentCwd });
337
+ sessionBaseCommit = stdout.trim();
338
+ console.log('Session base commit:', sessionBaseCommit);
339
+ } catch (error) {
340
+ // Check if it's a git repo with no commits yet
341
+ try {
342
+ await execAsync('git rev-parse --git-dir', { cwd: agentCwd });
343
+ // It's a git repo but no commits - use empty tree hash
344
+ sessionBaseCommit = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
345
+ console.log('Git repo with no commits, using empty tree hash for diff baseline');
346
+ } catch (e) {
347
+ console.log('Not a git repository:', e.message);
348
+ sessionBaseCommit = null;
349
+ }
350
+ }
351
+
352
+ console.log('Workspace setup complete:', workspacePath);
353
+ console.log('Agent working directory:', agentCwd);
354
+ console.log('Outbox directory created:', outboxDir);
355
+ console.log('Agent colors:', agentColors);
356
+ return workspacePath;
357
+ } catch (error) {
358
+ console.error('Error setting up workspace:', error);
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ // Agent Process Management
364
+ class AgentProcess {
365
+ constructor(agentConfig, index) {
366
+ this.name = agentConfig.name;
367
+ this.command = agentConfig.command;
368
+ this.args = agentConfig.args || [];
369
+ this.use_pty = agentConfig.use_pty || false;
370
+ this.index = index;
371
+ this.process = null;
372
+ this.outputBuffer = [];
373
+ }
374
+
375
+ async start(prompt) {
376
+ return new Promise((resolve, reject) => {
377
+ console.log(`Starting agent ${this.name} with PTY: ${this.use_pty}`);
378
+
379
+ if (this.use_pty) {
380
+ // Use PTY for interactive TUI agents
381
+ const shell = process.env.SHELL || '/bin/bash';
382
+
383
+ this.process = pty.spawn(this.command, this.args, {
384
+ name: 'xterm-256color',
385
+ cols: 120,
386
+ rows: 40,
387
+ cwd: agentCwd,
388
+ env: {
389
+ ...process.env,
390
+ AGENT_NAME: this.name,
391
+ TERM: 'xterm-256color',
392
+ PATH: process.env.PATH,
393
+ HOME: process.env.HOME,
394
+ SHELL: shell,
395
+ LINES: '40',
396
+ COLUMNS: '120'
397
+ },
398
+ handleFlowControl: true
399
+ });
400
+
401
+ console.log(`PTY spawned for ${this.name}, PID: ${this.process.pid}`);
402
+
403
+ // Respond to cursor position query immediately
404
+ // This helps with terminal capability detection (needed for Codex)
405
+ setTimeout(() => {
406
+ this.process.write('\x1b[1;1R'); // Report cursor at position 1,1
407
+ }, 100);
408
+
409
+ // Capture all output from PTY
410
+ this.process.onData((data) => {
411
+ const output = data.toString();
412
+ this.outputBuffer.push(output);
413
+
414
+ if (mainWindow) {
415
+ mainWindow.webContents.send('agent-output', {
416
+ agentName: this.name,
417
+ output: output,
418
+ isPty: true
419
+ });
420
+ }
421
+ });
422
+
423
+ // Handle exit - trigger resume if enabled
424
+ this.process.onExit(({ exitCode, signal }) => {
425
+ console.log(`Agent ${this.name} exited with code ${exitCode}, signal ${signal}`);
426
+ this.handleExit(exitCode);
427
+ });
428
+
429
+ // Inject prompt via PTY after TUI initializes (original working pattern)
430
+ const initDelay = this.name === 'Codex' ? 5000 : 3000;
431
+ setTimeout(() => {
432
+ console.log(`Injecting prompt into ${this.name} PTY`);
433
+ this.process.write(prompt + '\n');
434
+
435
+ // Send Enter key after a brief delay to submit
436
+ setTimeout(() => {
437
+ this.process.write('\r');
438
+ }, 500);
439
+
440
+ resolve();
441
+ }, initDelay);
442
+
443
+ } else {
444
+ // Use regular spawn for non-interactive agents
445
+ const options = {
446
+ cwd: agentCwd,
447
+ env: {
448
+ ...process.env,
449
+ AGENT_NAME: this.name
450
+ }
451
+ };
452
+
453
+ this.process = spawn(this.command, this.args, options);
454
+
455
+ console.log(`Process spawned for ${this.name}, PID: ${this.process.pid}`);
456
+
457
+ // Capture stdout
458
+ this.process.stdout.on('data', (data) => {
459
+ const output = data.toString();
460
+ this.outputBuffer.push(output);
461
+
462
+ if (mainWindow) {
463
+ mainWindow.webContents.send('agent-output', {
464
+ agentName: this.name,
465
+ output: output,
466
+ isPty: false
467
+ });
468
+ }
469
+ });
470
+
471
+ // Capture stderr
472
+ this.process.stderr.on('data', (data) => {
473
+ const output = data.toString();
474
+ this.outputBuffer.push(`[stderr] ${output}`);
475
+
476
+ if (mainWindow) {
477
+ mainWindow.webContents.send('agent-output', {
478
+ agentName: this.name,
479
+ output: `[stderr] ${output}`,
480
+ isPty: false
481
+ });
482
+ }
483
+ });
484
+
485
+ // Handle process exit - trigger resume if enabled
486
+ this.process.on('close', (code) => {
487
+ console.log(`Agent ${this.name} exited with code ${code}`);
488
+ this.handleExit(code);
489
+ });
490
+
491
+ // Handle errors
492
+ this.process.on('error', (error) => {
493
+ console.error(`Error starting agent ${this.name}:`, error);
494
+ reject(error);
495
+ });
496
+
497
+ resolve();
498
+ }
499
+ });
500
+ }
501
+
502
+ // Handle agent exit
503
+ handleExit(exitCode) {
504
+ if (mainWindow) {
505
+ mainWindow.webContents.send('agent-status', {
506
+ agentName: this.name,
507
+ status: 'stopped',
508
+ exitCode: exitCode
509
+ });
510
+ }
511
+ }
512
+
513
+ sendMessage(message) {
514
+ if (this.use_pty) {
515
+ if (this.process && this.process.write) {
516
+ this.process.write(message + '\n');
517
+ // Send Enter key to submit for PTY
518
+ setTimeout(() => {
519
+ this.process.write('\r');
520
+ }, 300);
521
+ }
522
+ } else {
523
+ if (this.process && this.process.stdin) {
524
+ this.process.stdin.write(message + '\n');
525
+ }
526
+ }
527
+ }
528
+
529
+ stop() {
530
+ if (this.process) {
531
+ if (this.use_pty) {
532
+ this.process.kill();
533
+ } else {
534
+ this.process.kill('SIGTERM');
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ // Initialize agents from config
541
+ function initializeAgents() {
542
+ agents = config.agents.map((agentConfig, index) => {
543
+ return new AgentProcess(agentConfig, index);
544
+ });
545
+
546
+ console.log(`Initialized ${agents.length} agents`);
547
+ return agents;
548
+ }
549
+
550
+ // Get agent by name
551
+ function getAgentByName(name) {
552
+ return agents.find(a => a.name.toLowerCase() === name.toLowerCase());
553
+ }
554
+
555
+ // Send a message to all agents EXCEPT the sender
556
+ function sendMessageToOtherAgents(senderName, message) {
557
+ const workspaceFolder = path.basename(workspacePath);
558
+ const outboxDir = config.outbox_dir || 'outbox';
559
+
560
+ for (const agent of agents) {
561
+ if (agent.name.toLowerCase() !== senderName.toLowerCase()) {
562
+ // Path relative to agentCwd (includes workspace folder)
563
+ const outboxFile = `${workspaceFolder}/${outboxDir}/${agent.name.toLowerCase()}.md`;
564
+ const formattedMessage = `\n---\n📨 MESSAGE FROM ${senderName.toUpperCase()}:\n\n${message}\n\n---\n(Respond via: cat << 'EOF' > ${outboxFile})\n`;
565
+
566
+ console.log(`Delivering message from ${senderName} to ${agent.name}`);
567
+ agent.sendMessage(formattedMessage);
568
+ }
569
+ }
570
+ }
571
+
572
+ // Send a message to ALL agents (for user messages)
573
+ function sendMessageToAllAgents(message) {
574
+ const workspaceFolder = path.basename(workspacePath);
575
+ const outboxDir = config.outbox_dir || 'outbox';
576
+
577
+ for (const agent of agents) {
578
+ // Path relative to agentCwd (includes workspace folder)
579
+ const outboxFile = `${workspaceFolder}/${outboxDir}/${agent.name.toLowerCase()}.md`;
580
+ const formattedMessage = `\n---\n📨 MESSAGE FROM USER:\n\n${message}\n\n---\n(Respond via: cat << 'EOF' > ${outboxFile})\n`;
581
+
582
+ console.log(`Delivering user message to ${agent.name}`);
583
+ agent.sendMessage(formattedMessage);
584
+ }
585
+ }
586
+
587
+ // Build prompt for a specific agent
588
+ function buildAgentPrompt(challenge, agentName) {
589
+ const workspaceFolder = path.basename(workspacePath);
590
+ const outboxDir = config.outbox_dir || 'outbox';
591
+ // Path relative to agentCwd (includes workspace folder)
592
+ const outboxFile = `${workspaceFolder}/${outboxDir}/${agentName.toLowerCase()}.md`;
593
+ const planFile = `${workspaceFolder}/${config.plan_file || 'PLAN_FINAL.md'}`;
594
+
595
+ return config.prompt_template
596
+ .replace('{challenge}', challenge)
597
+ .replace('{workspace}', workspacePath)
598
+ .replace(/{outbox_file}/g, outboxFile) // Replace all occurrences
599
+ .replace(/{plan_file}/g, planFile) // Replace all occurrences
600
+ .replace('{agent_names}', agents.map(a => a.name).join(', '))
601
+ .replace('{agent_name}', agentName);
602
+ }
603
+
604
+ // Start all agents with their individual prompts
605
+ async function startAgents(challenge) {
606
+ console.log('Starting agents with prompts...');
607
+
608
+ for (const agent of agents) {
609
+ try {
610
+ const prompt = buildAgentPrompt(challenge, agent.name);
611
+ await agent.start(prompt);
612
+ console.log(`Started agent: ${agent.name}`);
613
+
614
+ if (mainWindow) {
615
+ mainWindow.webContents.send('agent-status', {
616
+ agentName: agent.name,
617
+ status: 'running'
618
+ });
619
+ }
620
+ } catch (error) {
621
+ console.error(`Failed to start agent ${agent.name}:`, error);
622
+
623
+ if (mainWindow) {
624
+ mainWindow.webContents.send('agent-status', {
625
+ agentName: agent.name,
626
+ status: 'error',
627
+ error: error.message
628
+ });
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ // Watch chat.jsonl for changes (backup - real-time updates via chat-message event)
635
+ function startFileWatcher() {
636
+ const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
637
+
638
+ fileWatcher = chokidar.watch(chatPath, {
639
+ persistent: true,
640
+ ignoreInitial: true
641
+ });
642
+
643
+ // Note: Primary updates happen via 'chat-message' events sent when outbox is processed
644
+ // This watcher is a backup for any external modifications
645
+ fileWatcher.on('change', async () => {
646
+ try {
647
+ const messages = await getChatContent();
648
+ if (mainWindow) {
649
+ mainWindow.webContents.send('chat-updated', messages);
650
+ }
651
+ } catch (error) {
652
+ console.error('Error reading chat file:', error);
653
+ }
654
+ });
655
+
656
+ console.log('File watcher started for:', chatPath);
657
+ }
658
+
659
+ // Watch outbox directory and merge messages into chat.jsonl
660
+ function startOutboxWatcher() {
661
+ const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
662
+ const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
663
+
664
+ // Track which files we're currently processing to avoid race conditions
665
+ const processing = new Set();
666
+
667
+ outboxWatcher = chokidar.watch(outboxDir, {
668
+ persistent: true,
669
+ ignoreInitial: true,
670
+ awaitWriteFinish: {
671
+ stabilityThreshold: 500, // Wait for file to be stable for 500ms
672
+ pollInterval: 100
673
+ }
674
+ });
675
+
676
+ outboxWatcher.on('change', async (filePath) => {
677
+ // Only process .md files
678
+ if (!filePath.endsWith('.md')) return;
679
+
680
+ // Avoid processing the same file concurrently
681
+ if (processing.has(filePath)) return;
682
+ processing.add(filePath);
683
+
684
+ try {
685
+ // Read the outbox file
686
+ const content = await fs.readFile(filePath, 'utf8');
687
+ const trimmedContent = content.trim();
688
+
689
+ // Skip if empty
690
+ if (!trimmedContent) {
691
+ processing.delete(filePath);
692
+ return;
693
+ }
694
+
695
+ // Extract agent name from filename (e.g., "claude.md" -> "Claude")
696
+ const filename = path.basename(filePath, '.md');
697
+ const agentName = filename.charAt(0).toUpperCase() + filename.slice(1);
698
+
699
+ // Increment sequence and create message object
700
+ messageSequence++;
701
+ const timestamp = new Date().toISOString();
702
+ const message = {
703
+ seq: messageSequence,
704
+ type: 'agent',
705
+ agent: agentName,
706
+ timestamp: timestamp,
707
+ content: trimmedContent,
708
+ color: agentColors[agentName.toLowerCase()] || '#667eea'
709
+ };
710
+
711
+ // Append to chat.jsonl
712
+ await fs.appendFile(chatPath, JSON.stringify(message) + '\n');
713
+ console.log(`Merged message from ${agentName} (#${messageSequence}) into chat.jsonl`);
714
+
715
+ // Clear the outbox file
716
+ await fs.writeFile(filePath, '');
717
+
718
+ // PUSH message to other agents' PTYs
719
+ sendMessageToOtherAgents(agentName, trimmedContent);
720
+
721
+ // Notify renderer with the new message
722
+ if (mainWindow) {
723
+ mainWindow.webContents.send('chat-message', message);
724
+ }
725
+
726
+ } catch (error) {
727
+ console.error(`Error processing outbox file ${filePath}:`, error);
728
+ } finally {
729
+ processing.delete(filePath);
730
+ }
731
+ });
732
+
733
+ console.log('Outbox watcher started for:', outboxDir);
734
+ }
735
+
736
+ // Stop outbox watcher
737
+ function stopOutboxWatcher() {
738
+ if (outboxWatcher) {
739
+ outboxWatcher.close();
740
+ outboxWatcher = null;
741
+ }
742
+ }
743
+
744
+ // Append user message to chat.jsonl and push to all agents
745
+ async function sendUserMessage(messageText) {
746
+ const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
747
+ messageSequence++;
748
+ const timestamp = new Date().toISOString();
749
+
750
+ const message = {
751
+ seq: messageSequence,
752
+ type: 'user',
753
+ agent: 'User',
754
+ timestamp: timestamp,
755
+ content: messageText,
756
+ color: agentColors['user'] || '#a0aec0'
757
+ };
758
+
759
+ try {
760
+ // Append to chat.jsonl
761
+ await fs.appendFile(chatPath, JSON.stringify(message) + '\n');
762
+ console.log(`User message #${messageSequence} appended to chat`);
763
+
764
+ // PUSH message to all agents' PTYs
765
+ sendMessageToAllAgents(messageText);
766
+
767
+ // Notify renderer with the new message
768
+ if (mainWindow) {
769
+ mainWindow.webContents.send('chat-message', message);
770
+ }
771
+
772
+ } catch (error) {
773
+ console.error('Error appending user message:', error);
774
+ throw error;
775
+ }
776
+ }
777
+
778
+ // Read current chat content (returns array of message objects)
779
+ async function getChatContent() {
780
+ const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
781
+ try {
782
+ const content = await fs.readFile(chatPath, 'utf8');
783
+ if (!content.trim()) return [];
784
+
785
+ // Parse JSONL (one JSON object per line)
786
+ const messages = content.trim().split('\n').map(line => {
787
+ try {
788
+ return JSON.parse(line);
789
+ } catch (e) {
790
+ console.error('Failed to parse chat line:', line);
791
+ return null;
792
+ }
793
+ }).filter(Boolean);
794
+
795
+ return messages;
796
+ } catch (error) {
797
+ console.error('Error reading chat:', error);
798
+ return [];
799
+ }
800
+ }
801
+
802
+ // Read final plan
803
+ async function getPlanContent() {
804
+ const planPath = path.join(workspacePath, config.plan_file || 'PLAN_FINAL.md');
805
+ try {
806
+ return await fs.readFile(planPath, 'utf8');
807
+ } catch (error) {
808
+ return '';
809
+ }
810
+ }
811
+
812
+ // Get git diff - shows uncommitted changes only (git diff HEAD)
813
+ async function getGitDiff() {
814
+ // Not a git repo or session hasn't started
815
+ if (!agentCwd) {
816
+ return { isGitRepo: false, error: 'No session active' };
817
+ }
818
+
819
+ // Check if git repo
820
+ try {
821
+ await execAsync('git rev-parse --git-dir', { cwd: agentCwd });
822
+ } catch (error) {
823
+ return { isGitRepo: false, error: 'Not a git repository' };
824
+ }
825
+
826
+ try {
827
+ const result = {
828
+ isGitRepo: true,
829
+ stats: { filesChanged: 0, insertions: 0, deletions: 0 },
830
+ diff: '',
831
+ untracked: []
832
+ };
833
+
834
+ // Check if HEAD exists (repo might have no commits)
835
+ let hasHead = true;
836
+ try {
837
+ await execAsync('git rev-parse HEAD', { cwd: agentCwd });
838
+ } catch (e) {
839
+ hasHead = false;
840
+ }
841
+
842
+ // Determine diff target - use empty tree hash if no commits yet
843
+ const diffTarget = hasHead ? 'HEAD' : '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
844
+
845
+ // Get diff stats
846
+ try {
847
+ const { stdout: statOutput } = await execAsync(
848
+ `git diff ${diffTarget} --stat`,
849
+ { cwd: agentCwd, maxBuffer: 10 * 1024 * 1024 }
850
+ );
851
+
852
+ // Parse stats from last line (e.g., "3 files changed, 10 insertions(+), 5 deletions(-)")
853
+ const statMatch = statOutput.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
854
+ if (statMatch) {
855
+ result.stats.filesChanged = parseInt(statMatch[1]) || 0;
856
+ result.stats.insertions = parseInt(statMatch[2]) || 0;
857
+ result.stats.deletions = parseInt(statMatch[3]) || 0;
858
+ }
859
+ } catch (e) {
860
+ // No changes or other error
861
+ }
862
+
863
+ // Get full diff
864
+ try {
865
+ const { stdout: diffOutput } = await execAsync(
866
+ `git diff ${diffTarget}`,
867
+ { cwd: agentCwd, maxBuffer: 10 * 1024 * 1024 }
868
+ );
869
+ result.diff = diffOutput;
870
+ } catch (e) {
871
+ result.diff = '';
872
+ }
873
+
874
+ // Get untracked files
875
+ try {
876
+ const { stdout: untrackedOutput } = await execAsync(
877
+ 'git ls-files --others --exclude-standard',
878
+ { cwd: agentCwd }
879
+ );
880
+ result.untracked = untrackedOutput.trim().split('\n').filter(Boolean);
881
+ } catch (e) {
882
+ result.untracked = [];
883
+ }
884
+
885
+ return result;
886
+ } catch (error) {
887
+ console.error('Error getting git diff:', error);
888
+ return { isGitRepo: true, error: error.message };
889
+ }
890
+ }
891
+
892
+ // Stop all agents and watchers
893
+ function stopAllAgents() {
894
+ agents.forEach(agent => agent.stop());
895
+ if (fileWatcher) {
896
+ fileWatcher.close();
897
+ fileWatcher = null;
898
+ }
899
+ stopOutboxWatcher();
900
+ }
901
+
902
+ // IPC Handlers
903
+ ipcMain.handle('load-config', async () => {
904
+ try {
905
+ console.log('IPC: load-config called');
906
+ await loadConfig(customConfigPath);
907
+ console.log('IPC: load-config returning:', config);
908
+ return config;
909
+ } catch (error) {
910
+ console.error('IPC: load-config error:', error);
911
+ throw error;
912
+ }
913
+ });
914
+
915
+ ipcMain.handle('start-session', async (event, { challenge, workspace: selectedWorkspace }) => {
916
+ try {
917
+ // Use selected workspace if provided, otherwise fall back to customWorkspacePath
918
+ const workspaceToUse = selectedWorkspace || customWorkspacePath;
919
+ await setupWorkspace(workspaceToUse);
920
+ initializeAgents();
921
+ await startAgents(challenge);
922
+ startFileWatcher();
923
+ startOutboxWatcher(); // Watch for agent messages and merge into chat.jsonl
924
+
925
+ // Add workspace to recents (agentCwd is the project root)
926
+ if (agentCwd) {
927
+ await addRecentWorkspace(agentCwd);
928
+ }
929
+
930
+ // Check if this session was started with CLI workspace (and user didn't change it)
931
+ const isFromCli = customWorkspacePath && (
932
+ workspaceToUse === customWorkspacePath ||
933
+ agentCwd === customWorkspacePath ||
934
+ agentCwd === path.resolve(customWorkspacePath)
935
+ );
936
+
937
+ return {
938
+ success: true,
939
+ agents: agents.map(a => ({ name: a.name, use_pty: a.use_pty })),
940
+ workspace: agentCwd, // Show the project root, not the internal .multiagent-chat folder
941
+ colors: agentColors,
942
+ fromCli: isFromCli
943
+ };
944
+ } catch (error) {
945
+ console.error('Error starting session:', error);
946
+ return {
947
+ success: false,
948
+ error: error.message
949
+ };
950
+ }
951
+ });
952
+
953
+ ipcMain.handle('send-user-message', async (event, message) => {
954
+ try {
955
+ await sendUserMessage(message);
956
+ return { success: true };
957
+ } catch (error) {
958
+ return { success: false, error: error.message };
959
+ }
960
+ });
961
+
962
+ ipcMain.handle('get-chat-content', async () => {
963
+ return await getChatContent();
964
+ });
965
+
966
+ ipcMain.handle('get-plan-content', async () => {
967
+ return await getPlanContent();
968
+ });
969
+
970
+ ipcMain.handle('get-git-diff', async () => {
971
+ return await getGitDiff();
972
+ });
973
+
974
+ ipcMain.handle('stop-agents', async () => {
975
+ stopAllAgents();
976
+ return { success: true };
977
+ });
978
+
979
+ ipcMain.handle('reset-session', async () => {
980
+ try {
981
+ // Stop all agents and watchers
982
+ stopAllAgents();
983
+
984
+ // Clear chat file (handle missing file gracefully)
985
+ if (workspacePath) {
986
+ const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
987
+ try {
988
+ await fs.writeFile(chatPath, '');
989
+ } catch (e) {
990
+ if (e.code !== 'ENOENT') throw e;
991
+ }
992
+
993
+ // Clear plan file (handle missing file gracefully)
994
+ const planPath = path.join(workspacePath, config.plan_file || 'PLAN_FINAL.md');
995
+ try {
996
+ await fs.writeFile(planPath, '');
997
+ } catch (e) {
998
+ if (e.code !== 'ENOENT') throw e;
999
+ }
1000
+
1001
+ // Clear outbox files
1002
+ const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
1003
+ try {
1004
+ const files = await fs.readdir(outboxDir);
1005
+ for (const file of files) {
1006
+ if (file.endsWith('.md')) {
1007
+ await fs.writeFile(path.join(outboxDir, file), '');
1008
+ }
1009
+ }
1010
+ } catch (e) {
1011
+ if (e.code !== 'ENOENT') throw e;
1012
+ }
1013
+ }
1014
+
1015
+ // Reset state
1016
+ messageSequence = 0;
1017
+ agents = [];
1018
+ sessionBaseCommit = null;
1019
+
1020
+ return { success: true };
1021
+ } catch (error) {
1022
+ console.error('Error resetting session:', error);
1023
+ return { success: false, error: error.message };
1024
+ }
1025
+ });
1026
+
1027
+ // Handle start implementation request
1028
+ ipcMain.handle('start-implementation', async (event, selectedAgent, otherAgents) => {
1029
+ try {
1030
+ // Get the implementation handoff prompt from config
1031
+ const promptTemplate = config.prompts?.implementation_handoff ||
1032
+ '{selected_agent}, please now implement this plan. {other_agents} please wait for confirmation from {selected_agent} that they have completed the implementation. You should then check the changes, and provide feedback if necessary. Keep iterating together until you are all happy with the implementation.';
1033
+
1034
+ // Substitute placeholders
1035
+ const prompt = promptTemplate
1036
+ .replace(/{selected_agent}/g, selectedAgent)
1037
+ .replace(/{other_agents}/g, otherAgents.join(', '));
1038
+
1039
+ // Send as user message (this handles chat log + delivery to all agents)
1040
+ await sendUserMessage(prompt);
1041
+
1042
+ console.log(`Implementation started with ${selectedAgent} as implementer`);
1043
+ return { success: true };
1044
+ } catch (error) {
1045
+ console.error('Error starting implementation:', error);
1046
+ return { success: false, error: error.message };
1047
+ }
1048
+ });
1049
+
1050
+ // Workspace Management IPC Handlers
1051
+ ipcMain.handle('get-recent-workspaces', async () => {
1052
+ const recents = await loadRecentWorkspaces();
1053
+ // Add validation info for each workspace
1054
+ const withValidation = await Promise.all(
1055
+ recents.map(async (r) => ({
1056
+ ...r,
1057
+ exists: await validateWorkspacePath(r.path),
1058
+ name: path.basename(r.path)
1059
+ }))
1060
+ );
1061
+ return withValidation;
1062
+ });
1063
+
1064
+ ipcMain.handle('add-recent-workspace', async (event, workspacePath) => {
1065
+ return await addRecentWorkspace(workspacePath);
1066
+ });
1067
+
1068
+ ipcMain.handle('remove-recent-workspace', async (event, workspacePath) => {
1069
+ return await removeRecentWorkspace(workspacePath);
1070
+ });
1071
+
1072
+ ipcMain.handle('update-recent-workspace-path', async (event, oldPath, newPath) => {
1073
+ return await updateRecentWorkspacePath(oldPath, newPath);
1074
+ });
1075
+
1076
+ ipcMain.handle('validate-workspace-path', async (event, workspacePath) => {
1077
+ return await validateWorkspacePath(workspacePath);
1078
+ });
1079
+
1080
+ ipcMain.handle('get-current-directory', async () => {
1081
+ return getCurrentDirectoryInfo();
1082
+ });
1083
+
1084
+ ipcMain.handle('browse-for-workspace', async () => {
1085
+ const result = await dialog.showOpenDialog(mainWindow, {
1086
+ properties: ['openDirectory'],
1087
+ title: 'Select Workspace Directory'
1088
+ });
1089
+
1090
+ if (result.canceled || result.filePaths.length === 0) {
1091
+ return { canceled: true };
1092
+ }
1093
+
1094
+ return {
1095
+ canceled: false,
1096
+ path: result.filePaths[0]
1097
+ };
1098
+ });
1099
+
1100
+ ipcMain.handle('open-config-folder', async () => {
1101
+ try {
1102
+ await shell.openPath(HOME_CONFIG_DIR);
1103
+ return { success: true };
1104
+ } catch (error) {
1105
+ return { success: false, error: error.message };
1106
+ }
1107
+ });
1108
+
1109
+ ipcMain.handle('get-home-config-path', async () => {
1110
+ return HOME_CONFIG_DIR;
1111
+ });
1112
+
1113
+ ipcMain.handle('get-cli-workspace', async () => {
1114
+ return customWorkspacePath;
1115
+ });
1116
+
1117
+ // Handle PTY input from renderer (user typing into terminal)
1118
+ ipcMain.on('pty-input', (event, { agentName, data }) => {
1119
+ const agent = getAgentByName(agentName);
1120
+ if (agent && agent.use_pty && agent.process) {
1121
+ agent.process.write(data);
1122
+ }
1123
+ });
1124
+
1125
+ // App lifecycle
1126
+ app.whenReady().then(async () => {
1127
+ console.log('App ready, setting up...');
1128
+ parseCommandLineArgs();
1129
+ await ensureHomeConfigDir();
1130
+ createWindow();
1131
+ });
1132
+
1133
+ app.on('window-all-closed', () => {
1134
+ stopAllAgents();
1135
+ if (process.platform !== 'darwin') {
1136
+ app.quit();
1137
+ }
1138
+ });
1139
+
1140
+ app.on('activate', () => {
1141
+ if (BrowserWindow.getAllWindows().length === 0) {
1142
+ createWindow();
1143
+ }
1144
+ });
1145
+
1146
+ // Cleanup on quit
1147
+ app.on('before-quit', () => {
1148
+ stopAllAgents();
1149
+ });