@appoly/multiagent-chat 1.0.3 → 1.0.7
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/README.md +8 -18
- package/config.yaml +58 -1
- package/index.html +66 -110
- package/main.js +871 -397
- package/package.json +2 -2
- package/preload.js +10 -5
- package/renderer.js +633 -838
- package/styles.css +547 -760
package/main.js
CHANGED
|
@@ -22,6 +22,7 @@ const fsSync = require('fs');
|
|
|
22
22
|
const os = require('os');
|
|
23
23
|
const yaml = require('yaml');
|
|
24
24
|
const chokidar = require('chokidar');
|
|
25
|
+
const crypto = require('crypto');
|
|
25
26
|
|
|
26
27
|
// Home config directory: ~/.multiagent-chat/
|
|
27
28
|
const HOME_CONFIG_DIR = path.join(os.homedir(), '.multiagent-chat');
|
|
@@ -34,8 +35,9 @@ const execAsync = promisify(exec);
|
|
|
34
35
|
let mainWindow;
|
|
35
36
|
let agents = [];
|
|
36
37
|
let config;
|
|
37
|
-
let workspacePath;
|
|
38
|
-
let
|
|
38
|
+
let workspacePath; // Points to current session dir (e.g. .multiagent-chat/sessions/<id>/)
|
|
39
|
+
let workspaceBasePath; // Points to .multiagent-chat/ inside project root
|
|
40
|
+
let agentCwd; // Parent directory of workspace - where agents are launched
|
|
39
41
|
let fileWatcher;
|
|
40
42
|
let outboxWatcher;
|
|
41
43
|
let customWorkspacePath = null;
|
|
@@ -43,14 +45,355 @@ let customConfigPath = null; // CLI --config path
|
|
|
43
45
|
let messageSequence = 0; // For ordering messages in chat
|
|
44
46
|
let agentColors = {}; // Map of agent name -> color
|
|
45
47
|
let sessionBaseCommit = null; // Git commit hash at session start for diff baseline
|
|
48
|
+
let currentSessionId = null; // Current active session ID
|
|
46
49
|
|
|
50
|
+
function sendToRenderer(channel, payload) {
|
|
51
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
52
|
+
const wc = mainWindow.webContents;
|
|
53
|
+
if (!wc || wc.isDestroyed()) return;
|
|
54
|
+
wc.send(channel, payload);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ═══════════════════════════════════════════════════════════
|
|
58
|
+
// Session Storage Functions
|
|
59
|
+
// ═══════════════════════════════════════════════════════════
|
|
60
|
+
|
|
61
|
+
function generateSessionId() {
|
|
62
|
+
const now = new Date();
|
|
63
|
+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
64
|
+
const hex = crypto.randomBytes(2).toString('hex');
|
|
65
|
+
return `${ts}-${hex}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function generateSessionTitle(prompt) {
|
|
69
|
+
if (!prompt) return 'Untitled Session';
|
|
70
|
+
// Truncate at ~60 chars on a word boundary
|
|
71
|
+
if (prompt.length <= 60) return prompt.split('\n')[0];
|
|
72
|
+
const truncated = prompt.slice(0, 60);
|
|
73
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
74
|
+
return (lastSpace > 30 ? truncated.slice(0, lastSpace) : truncated) + '...';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getSessionsIndexPath(projectRoot) {
|
|
78
|
+
const wsName = (config && config.workspace) || '.multiagent-chat';
|
|
79
|
+
return path.join(projectRoot, wsName, 'sessions.json');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getSessionsDir(projectRoot) {
|
|
83
|
+
const wsName = (config && config.workspace) || '.multiagent-chat';
|
|
84
|
+
return path.join(projectRoot, wsName, 'sessions');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function loadSessionsIndex(projectRoot) {
|
|
88
|
+
const indexPath = getSessionsIndexPath(projectRoot);
|
|
89
|
+
try {
|
|
90
|
+
const content = await fs.readFile(indexPath, 'utf8');
|
|
91
|
+
return JSON.parse(content);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof SyntaxError) {
|
|
94
|
+
console.error('Corrupt sessions.json - failed to parse:', indexPath, error.message);
|
|
95
|
+
}
|
|
96
|
+
return { version: 1, sessions: [] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function saveSessionsIndex(projectRoot, index) {
|
|
101
|
+
const indexPath = getSessionsIndexPath(projectRoot);
|
|
102
|
+
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
|
103
|
+
// Atomic write: write to temp file then rename (rename is atomic on all platforms)
|
|
104
|
+
const tmpPath = indexPath + '.tmp';
|
|
105
|
+
await fs.writeFile(tmpPath, JSON.stringify(index, null, 2));
|
|
106
|
+
await fs.rename(tmpPath, indexPath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Async mutex to serialize all sessions.json read-modify-write cycles
|
|
110
|
+
let sessionsWriteQueue = Promise.resolve();
|
|
111
|
+
|
|
112
|
+
function withSessionsLock(fn) {
|
|
113
|
+
const next = sessionsWriteQueue.then(fn, fn);
|
|
114
|
+
sessionsWriteQueue = next.catch(() => {});
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function addSessionToIndex(projectRoot, sessionMeta) {
|
|
119
|
+
return withSessionsLock(async () => {
|
|
120
|
+
const index = await loadSessionsIndex(projectRoot);
|
|
121
|
+
index.sessions.unshift(sessionMeta);
|
|
122
|
+
await saveSessionsIndex(projectRoot, index);
|
|
123
|
+
return index;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function updateSessionInIndex(projectRoot, sessionId, updates) {
|
|
128
|
+
return withSessionsLock(async () => {
|
|
129
|
+
const index = await loadSessionsIndex(projectRoot);
|
|
130
|
+
const session = index.sessions.find(s => s.id === sessionId);
|
|
131
|
+
if (session) {
|
|
132
|
+
Object.assign(session, updates);
|
|
133
|
+
await saveSessionsIndex(projectRoot, index);
|
|
134
|
+
}
|
|
135
|
+
return index;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function setupSessionDirectory(projectRoot, sessionId) {
|
|
140
|
+
const sessionsDir = getSessionsDir(projectRoot);
|
|
141
|
+
const sessionDir = path.join(sessionsDir, sessionId);
|
|
142
|
+
|
|
143
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
// Initialize chat.jsonl
|
|
146
|
+
const chatPath = path.join(sessionDir, config.chat_file || 'chat.jsonl');
|
|
147
|
+
await fs.writeFile(chatPath, '');
|
|
148
|
+
|
|
149
|
+
// Initialize PLAN_FINAL.md
|
|
150
|
+
const planPath = path.join(sessionDir, config.plan_file || 'PLAN_FINAL.md');
|
|
151
|
+
await fs.writeFile(planPath, '');
|
|
152
|
+
|
|
153
|
+
// Create outbox directory and per-agent outbox files
|
|
154
|
+
const outboxDir = path.join(sessionDir, config.outbox_dir || 'outbox');
|
|
155
|
+
await fs.mkdir(outboxDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
for (const agentConfig of config.agents) {
|
|
158
|
+
const outboxFile = path.join(outboxDir, `${agentConfig.name.toLowerCase()}.md`);
|
|
159
|
+
await fs.writeFile(outboxFile, '');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return sessionDir;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function loadSessionData(projectRoot, sessionId) {
|
|
166
|
+
const sessionsDir = getSessionsDir(projectRoot);
|
|
167
|
+
const sessionDir = path.join(sessionsDir, sessionId);
|
|
168
|
+
|
|
169
|
+
// Read chat
|
|
170
|
+
let messages = [];
|
|
171
|
+
const chatPath = path.join(sessionDir, config.chat_file || 'chat.jsonl');
|
|
172
|
+
try {
|
|
173
|
+
const content = await fs.readFile(chatPath, 'utf8');
|
|
174
|
+
if (content.trim()) {
|
|
175
|
+
messages = content.trim().split('\n').map(line => {
|
|
176
|
+
try { return JSON.parse(line); }
|
|
177
|
+
catch (e) { return null; }
|
|
178
|
+
}).filter(Boolean);
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// No chat file
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Read plan
|
|
185
|
+
let plan = '';
|
|
186
|
+
const planPath = path.join(sessionDir, config.plan_file || 'PLAN_FINAL.md');
|
|
187
|
+
try {
|
|
188
|
+
plan = await fs.readFile(planPath, 'utf8');
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// No plan file
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { messages, plan, sessionDir };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Migrate from old flat .multiagent-chat/ to session-based structure
|
|
197
|
+
async function migrateFromFlatWorkspace(projectRoot) {
|
|
198
|
+
const wsName = (config && config.workspace) || '.multiagent-chat';
|
|
199
|
+
const wsBase = path.join(projectRoot, wsName);
|
|
200
|
+
|
|
201
|
+
// Check if old flat structure exists (chat.jsonl directly in .multiagent-chat/)
|
|
202
|
+
const oldChatPath = path.join(wsBase, config.chat_file || 'chat.jsonl');
|
|
203
|
+
const sessionsDir = path.join(wsBase, 'sessions');
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await fs.access(oldChatPath);
|
|
207
|
+
// Old flat structure exists - check if sessions dir already exists
|
|
208
|
+
try {
|
|
209
|
+
await fs.access(sessionsDir);
|
|
210
|
+
// Sessions dir exists, already migrated or mixed state - skip
|
|
211
|
+
return;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// Sessions dir doesn't exist, migrate
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// No old chat file, nothing to migrate
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log('Migrating flat workspace to session-based structure...');
|
|
221
|
+
|
|
222
|
+
// Read old chat to determine session metadata
|
|
223
|
+
let oldMessages = [];
|
|
224
|
+
try {
|
|
225
|
+
const content = await fs.readFile(oldChatPath, 'utf8');
|
|
226
|
+
if (content.trim()) {
|
|
227
|
+
oldMessages = content.trim().split('\n').map(line => {
|
|
228
|
+
try { return JSON.parse(line); }
|
|
229
|
+
catch (e) { return null; }
|
|
230
|
+
}).filter(Boolean);
|
|
231
|
+
}
|
|
232
|
+
} catch (e) {
|
|
233
|
+
// Empty or unreadable
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Create a session for the old data
|
|
237
|
+
const sessionId = generateSessionId();
|
|
238
|
+
const sessionDir = path.join(sessionsDir, sessionId);
|
|
239
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
240
|
+
|
|
241
|
+
// Move chat.jsonl
|
|
242
|
+
try {
|
|
243
|
+
await fs.rename(oldChatPath, path.join(sessionDir, config.chat_file || 'chat.jsonl'));
|
|
244
|
+
} catch (e) {
|
|
245
|
+
// Copy instead if rename fails (cross-device)
|
|
246
|
+
try {
|
|
247
|
+
await fs.copyFile(oldChatPath, path.join(sessionDir, config.chat_file || 'chat.jsonl'));
|
|
248
|
+
await fs.unlink(oldChatPath);
|
|
249
|
+
} catch (copyErr) {
|
|
250
|
+
console.warn('Could not migrate chat file:', copyErr.message);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Move PLAN_FINAL.md
|
|
255
|
+
const oldPlanPath = path.join(wsBase, config.plan_file || 'PLAN_FINAL.md');
|
|
256
|
+
try {
|
|
257
|
+
await fs.rename(oldPlanPath, path.join(sessionDir, config.plan_file || 'PLAN_FINAL.md'));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// Create empty if not found
|
|
260
|
+
await fs.writeFile(path.join(sessionDir, config.plan_file || 'PLAN_FINAL.md'), '');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Move outbox directory
|
|
264
|
+
const oldOutboxDir = path.join(wsBase, config.outbox_dir || 'outbox');
|
|
265
|
+
const newOutboxDir = path.join(sessionDir, config.outbox_dir || 'outbox');
|
|
266
|
+
try {
|
|
267
|
+
await fs.rename(oldOutboxDir, newOutboxDir);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// Create fresh outbox
|
|
270
|
+
await fs.mkdir(newOutboxDir, { recursive: true });
|
|
271
|
+
for (const agentConfig of config.agents) {
|
|
272
|
+
await fs.writeFile(path.join(newOutboxDir, `${agentConfig.name.toLowerCase()}.md`), '');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Create sessions.json index
|
|
277
|
+
const firstPrompt = oldMessages.find(m => m.type === 'user')?.content || '';
|
|
278
|
+
const firstTs = oldMessages.length > 0 ? oldMessages[0].timestamp : new Date().toISOString();
|
|
279
|
+
const lastTs = oldMessages.length > 0 ? oldMessages[oldMessages.length - 1].timestamp : new Date().toISOString();
|
|
280
|
+
|
|
281
|
+
const sessionMeta = {
|
|
282
|
+
id: sessionId,
|
|
283
|
+
title: generateSessionTitle(firstPrompt),
|
|
284
|
+
firstPrompt: firstPrompt.slice(0, 200),
|
|
285
|
+
workspace: projectRoot,
|
|
286
|
+
createdAt: firstTs,
|
|
287
|
+
lastActiveAt: lastTs,
|
|
288
|
+
messageCount: oldMessages.length,
|
|
289
|
+
status: 'completed'
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
await withSessionsLock(async () => {
|
|
293
|
+
await saveSessionsIndex(projectRoot, { version: 1, sessions: [sessionMeta] });
|
|
294
|
+
});
|
|
295
|
+
console.log('Migration complete. Created session:', sessionId);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function generateChatSummary(messages) {
|
|
299
|
+
// Take last ~20 messages and create a condensed summary
|
|
300
|
+
const recent = messages.slice(-20);
|
|
301
|
+
if (recent.length === 0) return 'No previous messages.';
|
|
302
|
+
|
|
303
|
+
return recent.map(m => {
|
|
304
|
+
const content = (m.content || '').slice(0, 200);
|
|
305
|
+
return `[${m.agent}]: ${content}${m.content && m.content.length > 200 ? '...' : ''}`;
|
|
306
|
+
}).join('\n\n');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildResumePrompt(chatSummary, plan, newMessage, agentName) {
|
|
310
|
+
const template = config.resume_template || `## Multi-Agent Collaboration Session (Resumed)
|
|
311
|
+
**You are: {agent_name}**
|
|
312
|
+
You are collaborating with: {agent_names}
|
|
313
|
+
|
|
314
|
+
### Previous Discussion Summary
|
|
315
|
+
{chat_summary}
|
|
316
|
+
|
|
317
|
+
### Existing Plan
|
|
318
|
+
{existing_plan}
|
|
319
|
+
|
|
320
|
+
### How to Send Messages
|
|
321
|
+
\`\`\`bash
|
|
322
|
+
cat << 'EOF' > {outbox_file}
|
|
323
|
+
Your message here.
|
|
324
|
+
EOF
|
|
325
|
+
\`\`\`
|
|
326
|
+
|
|
327
|
+
## New Message from User
|
|
328
|
+
{new_message}
|
|
329
|
+
|
|
330
|
+
### Behavior on Resume
|
|
331
|
+
- Default to discussion-first collaboration with the other agents
|
|
332
|
+
- Do NOT implement or edit files unless the newest user message explicitly asks for implementation
|
|
333
|
+
- If intent is ambiguous, ask a quick clarification before making code changes
|
|
334
|
+
|
|
335
|
+
Please respond taking into account the context above.`;
|
|
336
|
+
|
|
337
|
+
const relFromProject = path.relative(agentCwd, workspacePath);
|
|
338
|
+
const outboxDir = config.outbox_dir || 'outbox';
|
|
339
|
+
const outboxFile = `${relFromProject}/${outboxDir}/${agentName.toLowerCase()}.md`;
|
|
340
|
+
const planFile = `${relFromProject}/${config.plan_file || 'PLAN_FINAL.md'}`;
|
|
341
|
+
|
|
342
|
+
return template
|
|
343
|
+
.replace(/{agent_name}/g, agentName)
|
|
344
|
+
.replace(/{agent_names}/g, config.agents.map(a => a.name).join(', '))
|
|
345
|
+
.replace(/{chat_summary}/g, chatSummary)
|
|
346
|
+
.replace(/{existing_plan}/g, plan || 'No plan yet.')
|
|
347
|
+
.replace(/{new_message}/g, newMessage)
|
|
348
|
+
.replace(/{outbox_file}/g, outboxFile)
|
|
349
|
+
.replace(/{plan_file}/g, planFile);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ═══════════════════════════════════════════════════════════
|
|
353
|
+
// Init workspace base (just ensures dirs exist, sets agentCwd)
|
|
354
|
+
// ═══════════════════════════════════════════════════════════
|
|
355
|
+
|
|
356
|
+
async function initWorkspaceBase(projectRoot) {
|
|
357
|
+
agentCwd = projectRoot;
|
|
358
|
+
|
|
359
|
+
const wsName = (config && config.workspace) || '.multiagent-chat';
|
|
360
|
+
workspaceBasePath = path.join(projectRoot, wsName);
|
|
361
|
+
await fs.mkdir(workspaceBasePath, { recursive: true });
|
|
362
|
+
await fs.mkdir(path.join(workspaceBasePath, 'sessions'), { recursive: true });
|
|
363
|
+
|
|
364
|
+
// Build agent colors map from config
|
|
365
|
+
const defaultColors = config.default_agent_colors || ['#667eea', '#f093fb', '#4fd1c5', '#f6ad55', '#68d391', '#fc8181'];
|
|
366
|
+
agentColors = {};
|
|
367
|
+
config.agents.forEach((agentConfig, index) => {
|
|
368
|
+
agentColors[agentConfig.name.toLowerCase()] = agentConfig.color || defaultColors[index % defaultColors.length];
|
|
369
|
+
});
|
|
370
|
+
agentColors['user'] = config.user_color || '#a0aec0';
|
|
371
|
+
|
|
372
|
+
// Capture git base commit for diff baseline
|
|
373
|
+
try {
|
|
374
|
+
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: agentCwd });
|
|
375
|
+
sessionBaseCommit = stdout.trim();
|
|
376
|
+
console.log('Session base commit:', sessionBaseCommit);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
try {
|
|
379
|
+
await execAsync('git rev-parse --git-dir', { cwd: agentCwd });
|
|
380
|
+
sessionBaseCommit = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
381
|
+
console.log('Git repo with no commits, using empty tree hash for diff baseline');
|
|
382
|
+
} catch (e) {
|
|
383
|
+
console.log('Not a git repository:', e.message);
|
|
384
|
+
sessionBaseCommit = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
console.log('Workspace base initialized:', workspaceBasePath);
|
|
389
|
+
console.log('Agent working directory:', agentCwd);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ═══════════════════════════════════════════════════════════
|
|
47
393
|
// Parse command-line arguments
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
// Or: npm start --config /path/to/config.yaml
|
|
51
|
-
// Or: WORKSPACE=/path/to/workspace npm start
|
|
394
|
+
// ═══════════════════════════════════════════════════════════
|
|
395
|
+
|
|
52
396
|
function parseCommandLineArgs() {
|
|
53
|
-
// Check environment variables first
|
|
54
397
|
if (process.env.WORKSPACE) {
|
|
55
398
|
customWorkspacePath = process.env.WORKSPACE;
|
|
56
399
|
console.log('Using workspace from environment variable:', customWorkspacePath);
|
|
@@ -61,48 +404,40 @@ function parseCommandLineArgs() {
|
|
|
61
404
|
console.log('Using config from environment variable:', customConfigPath);
|
|
62
405
|
}
|
|
63
406
|
|
|
64
|
-
// Then check command-line arguments
|
|
65
|
-
// process.argv looks like: [electron, main.js, ...args]
|
|
66
407
|
const args = process.argv.slice(2);
|
|
67
408
|
|
|
68
409
|
for (let i = 0; i < args.length; i++) {
|
|
69
|
-
// Parse --workspace flag
|
|
70
410
|
if (args[i] === '--workspace' && args[i + 1]) {
|
|
71
411
|
customWorkspacePath = args[i + 1];
|
|
72
412
|
console.log('Using workspace from --workspace flag:', customWorkspacePath);
|
|
73
|
-
i++;
|
|
74
|
-
}
|
|
75
|
-
// Parse --config flag
|
|
76
|
-
else if (args[i] === '--config' && args[i + 1]) {
|
|
413
|
+
i++;
|
|
414
|
+
} else if (args[i] === '--config' && args[i + 1]) {
|
|
77
415
|
customConfigPath = args[i + 1];
|
|
78
416
|
console.log('Using config from --config flag:', customConfigPath);
|
|
79
|
-
i++;
|
|
80
|
-
}
|
|
81
|
-
// Positional arg (assume workspace path if not a flag)
|
|
82
|
-
else if (!args[i].startsWith('--') && !customWorkspacePath) {
|
|
417
|
+
i++;
|
|
418
|
+
} else if (!args[i].startsWith('--') && !customWorkspacePath) {
|
|
83
419
|
customWorkspacePath = args[i];
|
|
84
420
|
console.log('Using workspace from positional argument:', customWorkspacePath);
|
|
85
421
|
}
|
|
86
422
|
}
|
|
87
423
|
}
|
|
88
424
|
|
|
89
|
-
//
|
|
425
|
+
// ═══════════════════════════════════════════════════════════
|
|
426
|
+
// Home config directory setup
|
|
427
|
+
// ═══════════════════════════════════════════════════════════
|
|
428
|
+
|
|
90
429
|
async function ensureHomeConfigDir() {
|
|
91
430
|
try {
|
|
92
|
-
// Create ~/.multiagent-chat/ if it doesn't exist
|
|
93
431
|
await fs.mkdir(HOME_CONFIG_DIR, { recursive: true });
|
|
94
432
|
console.log('Home config directory ensured:', HOME_CONFIG_DIR);
|
|
95
433
|
|
|
96
|
-
// Migration: check for existing data in Electron userData
|
|
97
434
|
const userDataDir = app.getPath('userData');
|
|
98
435
|
const oldRecentsFile = path.join(userDataDir, 'recent-workspaces.json');
|
|
99
436
|
|
|
100
|
-
// Check if config.yaml exists in home dir, if not copy default
|
|
101
437
|
try {
|
|
102
438
|
await fs.access(HOME_CONFIG_FILE);
|
|
103
439
|
console.log('Home config exists:', HOME_CONFIG_FILE);
|
|
104
440
|
} catch (e) {
|
|
105
|
-
// Copy bundled default config to home dir
|
|
106
441
|
const bundledConfig = path.join(__dirname, 'config.yaml');
|
|
107
442
|
try {
|
|
108
443
|
await fs.copyFile(bundledConfig, HOME_CONFIG_FILE);
|
|
@@ -112,17 +447,14 @@ async function ensureHomeConfigDir() {
|
|
|
112
447
|
}
|
|
113
448
|
}
|
|
114
449
|
|
|
115
|
-
// Initialize or migrate recent-workspaces.json
|
|
116
450
|
try {
|
|
117
451
|
await fs.access(RECENT_WORKSPACES_FILE);
|
|
118
452
|
} catch (e) {
|
|
119
|
-
// Try to migrate from old location first
|
|
120
453
|
try {
|
|
121
454
|
await fs.access(oldRecentsFile);
|
|
122
455
|
await fs.copyFile(oldRecentsFile, RECENT_WORKSPACES_FILE);
|
|
123
456
|
console.log('Migrated recent workspaces from:', oldRecentsFile);
|
|
124
457
|
} catch (migrateError) {
|
|
125
|
-
// No old file, create new empty one
|
|
126
458
|
await fs.writeFile(RECENT_WORKSPACES_FILE, JSON.stringify({ recents: [] }, null, 2));
|
|
127
459
|
console.log('Initialized recent workspaces file:', RECENT_WORKSPACES_FILE);
|
|
128
460
|
}
|
|
@@ -132,7 +464,10 @@ async function ensureHomeConfigDir() {
|
|
|
132
464
|
}
|
|
133
465
|
}
|
|
134
466
|
|
|
135
|
-
//
|
|
467
|
+
// ═══════════════════════════════════════════════════════════
|
|
468
|
+
// Recent workspaces (kept for backward compat / sidebar)
|
|
469
|
+
// ═══════════════════════════════════════════════════════════
|
|
470
|
+
|
|
136
471
|
async function loadRecentWorkspaces() {
|
|
137
472
|
try {
|
|
138
473
|
const content = await fs.readFile(RECENT_WORKSPACES_FILE, 'utf8');
|
|
@@ -144,7 +479,6 @@ async function loadRecentWorkspaces() {
|
|
|
144
479
|
}
|
|
145
480
|
}
|
|
146
481
|
|
|
147
|
-
// Save recent workspaces to JSON file
|
|
148
482
|
async function saveRecentWorkspaces(recents) {
|
|
149
483
|
try {
|
|
150
484
|
await fs.writeFile(RECENT_WORKSPACES_FILE, JSON.stringify({ recents }, null, 2));
|
|
@@ -153,98 +487,68 @@ async function saveRecentWorkspaces(recents) {
|
|
|
153
487
|
}
|
|
154
488
|
}
|
|
155
489
|
|
|
156
|
-
|
|
157
|
-
async function addRecentWorkspace(workspacePath) {
|
|
490
|
+
async function addRecentWorkspace(wsPath) {
|
|
158
491
|
const recents = await loadRecentWorkspaces();
|
|
159
492
|
const now = new Date().toISOString();
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const filtered = recents.filter(r =>
|
|
163
|
-
r.path.toLowerCase() !== workspacePath.toLowerCase()
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
// Add new entry at the beginning
|
|
167
|
-
filtered.unshift({
|
|
168
|
-
path: workspacePath,
|
|
169
|
-
lastUsed: now
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Limit to max entries
|
|
493
|
+
const filtered = recents.filter(r => r.path.toLowerCase() !== wsPath.toLowerCase());
|
|
494
|
+
filtered.unshift({ path: wsPath, lastUsed: now });
|
|
173
495
|
const limited = filtered.slice(0, MAX_RECENT_WORKSPACES);
|
|
174
|
-
|
|
175
496
|
await saveRecentWorkspaces(limited);
|
|
176
497
|
return limited;
|
|
177
498
|
}
|
|
178
499
|
|
|
179
|
-
|
|
180
|
-
async function removeRecentWorkspace(workspacePath) {
|
|
500
|
+
async function removeRecentWorkspace(wsPath) {
|
|
181
501
|
const recents = await loadRecentWorkspaces();
|
|
182
|
-
const filtered = recents.filter(r =>
|
|
183
|
-
r.path.toLowerCase() !== workspacePath.toLowerCase()
|
|
184
|
-
);
|
|
502
|
+
const filtered = recents.filter(r => r.path.toLowerCase() !== wsPath.toLowerCase());
|
|
185
503
|
await saveRecentWorkspaces(filtered);
|
|
186
504
|
return filtered;
|
|
187
505
|
}
|
|
188
506
|
|
|
189
|
-
|
|
190
|
-
async function updateRecentWorkspacePath(oldPath, newPath) {
|
|
191
|
-
const recents = await loadRecentWorkspaces();
|
|
192
|
-
const now = new Date().toISOString();
|
|
193
|
-
|
|
194
|
-
const updated = recents.map(r => {
|
|
195
|
-
if (r.path.toLowerCase() === oldPath.toLowerCase()) {
|
|
196
|
-
return { path: newPath, lastUsed: now };
|
|
197
|
-
}
|
|
198
|
-
return r;
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
await saveRecentWorkspaces(updated);
|
|
202
|
-
return updated;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Validate if a workspace path exists and is a directory
|
|
206
|
-
async function validateWorkspacePath(workspacePath) {
|
|
507
|
+
async function validateWorkspacePath(wsPath) {
|
|
207
508
|
try {
|
|
208
|
-
const stats = await fs.stat(
|
|
509
|
+
const stats = await fs.stat(wsPath);
|
|
209
510
|
return stats.isDirectory();
|
|
210
511
|
} catch (error) {
|
|
211
512
|
return false;
|
|
212
513
|
}
|
|
213
514
|
}
|
|
214
515
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const appDir = __dirname;
|
|
219
|
-
|
|
220
|
-
// Check if cwd is different from app directory and exists
|
|
221
|
-
const isUsable = cwd !== appDir && fsSync.existsSync(cwd);
|
|
516
|
+
// ═══════════════════════════════════════════════════════════
|
|
517
|
+
// Browser window
|
|
518
|
+
// ═══════════════════════════════════════════════════════════
|
|
222
519
|
|
|
223
|
-
return {
|
|
224
|
-
path: cwd,
|
|
225
|
-
isUsable,
|
|
226
|
-
appDir
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Create the browser window
|
|
231
520
|
function createWindow() {
|
|
232
521
|
console.log('Creating window...');
|
|
233
522
|
|
|
234
523
|
const iconPath = path.join(__dirname, 'robot.png');
|
|
235
524
|
|
|
236
|
-
|
|
525
|
+
const windowOptions = {
|
|
237
526
|
width: 1400,
|
|
238
527
|
height: 900,
|
|
239
528
|
icon: iconPath,
|
|
529
|
+
show: false,
|
|
530
|
+
backgroundColor: '#0b0e11',
|
|
240
531
|
webPreferences: {
|
|
241
532
|
preload: path.join(__dirname, 'preload.js'),
|
|
242
533
|
contextIsolation: true,
|
|
243
534
|
nodeIntegration: false
|
|
244
535
|
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
if (process.platform === 'darwin') {
|
|
539
|
+
windowOptions.titleBarStyle = 'hiddenInset';
|
|
540
|
+
windowOptions.trafficLightPosition = { x: 16, y: 16 };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
mainWindow = new BrowserWindow(windowOptions);
|
|
544
|
+
|
|
545
|
+
mainWindow.once('ready-to-show', () => {
|
|
546
|
+
mainWindow.show();
|
|
547
|
+
});
|
|
548
|
+
mainWindow.on('closed', () => {
|
|
549
|
+
mainWindow = null;
|
|
245
550
|
});
|
|
246
551
|
|
|
247
|
-
// Set dock icon on macOS
|
|
248
552
|
if (process.platform === 'darwin' && app.dock) {
|
|
249
553
|
app.dock.setIcon(iconPath);
|
|
250
554
|
}
|
|
@@ -259,122 +563,143 @@ function createWindow() {
|
|
|
259
563
|
console.log('Window setup complete');
|
|
260
564
|
}
|
|
261
565
|
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
566
|
+
// ═══════════════════════════════════════════════════════════
|
|
567
|
+
// Config loading
|
|
568
|
+
// ═══════════════════════════════════════════════════════════
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Deep-merge two objects. Objects are merged recursively;
|
|
572
|
+
* arrays and primitives from `override` replace `base` wholesale.
|
|
573
|
+
*/
|
|
574
|
+
function deepMerge(base, override) {
|
|
575
|
+
const result = { ...base };
|
|
576
|
+
for (const key of Object.keys(override)) {
|
|
577
|
+
if (
|
|
578
|
+
override[key] !== null &&
|
|
579
|
+
typeof override[key] === 'object' &&
|
|
580
|
+
!Array.isArray(override[key]) &&
|
|
581
|
+
typeof result[key] === 'object' &&
|
|
582
|
+
result[key] !== null &&
|
|
583
|
+
!Array.isArray(result[key])
|
|
584
|
+
) {
|
|
585
|
+
result[key] = deepMerge(result[key], override[key]);
|
|
586
|
+
} else {
|
|
587
|
+
result[key] = override[key];
|
|
281
588
|
}
|
|
282
|
-
|
|
283
|
-
const configFile = await fs.readFile(fullPath, 'utf8');
|
|
284
|
-
config = yaml.parse(configFile);
|
|
285
|
-
console.log('Config loaded successfully');
|
|
286
|
-
return config;
|
|
287
|
-
} catch (error) {
|
|
288
|
-
console.error('Error loading config:', error);
|
|
289
|
-
throw error;
|
|
290
589
|
}
|
|
590
|
+
return result;
|
|
291
591
|
}
|
|
292
592
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
593
|
+
/**
|
|
594
|
+
* Collect dot-paths present in `base` but missing from `obj`.
|
|
595
|
+
*/
|
|
596
|
+
function findMissingKeys(base, obj, prefix = '') {
|
|
597
|
+
const missing = [];
|
|
598
|
+
for (const key of Object.keys(base)) {
|
|
599
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
600
|
+
if (!(key in obj)) {
|
|
601
|
+
missing.push(fullKey);
|
|
602
|
+
} else if (
|
|
603
|
+
typeof base[key] === 'object' &&
|
|
604
|
+
base[key] !== null &&
|
|
605
|
+
!Array.isArray(base[key]) &&
|
|
606
|
+
typeof obj[key] === 'object' &&
|
|
607
|
+
obj[key] !== null &&
|
|
608
|
+
!Array.isArray(obj[key])
|
|
609
|
+
) {
|
|
610
|
+
missing.push(...findMissingKeys(base[key], obj[key], fullKey));
|
|
611
|
+
}
|
|
306
612
|
}
|
|
613
|
+
return missing;
|
|
614
|
+
}
|
|
307
615
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
616
|
+
/**
|
|
617
|
+
* Validate that required config fields are present after merge.
|
|
618
|
+
*/
|
|
619
|
+
function validateConfig(cfg) {
|
|
620
|
+
if (!cfg.agents || !Array.isArray(cfg.agents) || cfg.agents.length === 0) {
|
|
621
|
+
throw new Error('Config validation failed: "agents" must be a non-empty array.');
|
|
622
|
+
}
|
|
623
|
+
for (let i = 0; i < cfg.agents.length; i++) {
|
|
624
|
+
const agent = cfg.agents[i];
|
|
625
|
+
if (!agent.name || typeof agent.name !== 'string') {
|
|
626
|
+
throw new Error(`Config validation failed: agents[${i}] is missing a valid "name".`);
|
|
627
|
+
}
|
|
628
|
+
if (!agent.command || typeof agent.command !== 'string') {
|
|
629
|
+
throw new Error(`Config validation failed: agents[${i}] ("${agent.name}") is missing a valid "command".`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
314
633
|
|
|
634
|
+
async function loadConfig(configPath = null) {
|
|
315
635
|
try {
|
|
316
|
-
|
|
636
|
+
// Always load bundled config as the defaults base
|
|
637
|
+
const bundledPath = path.join(__dirname, 'config.yaml');
|
|
638
|
+
const bundledFile = await fs.readFile(bundledPath, 'utf8');
|
|
639
|
+
const defaults = yaml.parse(bundledFile);
|
|
640
|
+
console.log('Loaded bundled defaults from:', bundledPath);
|
|
317
641
|
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// Clear PLAN_FINAL.md if it exists
|
|
323
|
-
const planPath = path.join(workspacePath, config.plan_file || 'PLAN_FINAL.md');
|
|
324
|
-
await fs.writeFile(planPath, '');
|
|
325
|
-
|
|
326
|
-
// Create outbox directory and per-agent outbox files
|
|
327
|
-
const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
|
|
328
|
-
await fs.mkdir(outboxDir, { recursive: true });
|
|
329
|
-
|
|
330
|
-
// Build agent colors map from config
|
|
331
|
-
const defaultColors = config.default_agent_colors || ['#667eea', '#f093fb', '#4fd1c5', '#f6ad55', '#68d391', '#fc8181'];
|
|
332
|
-
agentColors = {};
|
|
333
|
-
config.agents.forEach((agentConfig, index) => {
|
|
334
|
-
agentColors[agentConfig.name.toLowerCase()] = agentConfig.color || defaultColors[index % defaultColors.length];
|
|
335
|
-
});
|
|
336
|
-
// Add user color
|
|
337
|
-
agentColors['user'] = config.user_color || '#a0aec0';
|
|
642
|
+
// Determine the user config path
|
|
643
|
+
let userConfigPath = null;
|
|
644
|
+
let isHomeConfig = false;
|
|
338
645
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
646
|
+
if (configPath) {
|
|
647
|
+
userConfigPath = path.isAbsolute(configPath) ? configPath : path.join(process.cwd(), configPath);
|
|
648
|
+
console.log('Loading user config from CLI arg:', userConfigPath);
|
|
649
|
+
} else if (fsSync.existsSync(HOME_CONFIG_FILE)) {
|
|
650
|
+
userConfigPath = HOME_CONFIG_FILE;
|
|
651
|
+
isHomeConfig = true;
|
|
652
|
+
console.log('Loading user config from home dir:', userConfigPath);
|
|
343
653
|
}
|
|
344
654
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
655
|
+
if (userConfigPath) {
|
|
656
|
+
const userFile = await fs.readFile(userConfigPath, 'utf8');
|
|
657
|
+
const userConfig = yaml.parse(userFile);
|
|
658
|
+
|
|
659
|
+
// Detect keys that will be backfilled from defaults
|
|
660
|
+
const missingKeys = findMissingKeys(defaults, userConfig);
|
|
661
|
+
if (missingKeys.length > 0) {
|
|
662
|
+
console.log('Config: backfilling missing keys from defaults:', missingKeys.join(', '));
|
|
663
|
+
|
|
664
|
+
// Write back merged config for home config only (not --config custom paths)
|
|
665
|
+
if (isHomeConfig) {
|
|
666
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
667
|
+
const backupPath = path.join(HOME_CONFIG_DIR, `config.yaml.bak.${timestamp}`);
|
|
668
|
+
await fs.copyFile(HOME_CONFIG_FILE, backupPath);
|
|
669
|
+
console.log('Config: backed up existing config to:', backupPath);
|
|
670
|
+
|
|
671
|
+
const merged = deepMerge(defaults, userConfig);
|
|
672
|
+
const tmpPath = HOME_CONFIG_FILE + '.tmp';
|
|
673
|
+
const yamlStr = '# Multi-Agent Chat Configuration\n'
|
|
674
|
+
+ '# Auto-updated with new defaults. Your values have been preserved.\n'
|
|
675
|
+
+ '# Backup of previous config: ' + path.basename(backupPath) + '\n\n'
|
|
676
|
+
+ yaml.stringify(merged, { indent: 2, lineWidth: 0 });
|
|
677
|
+
await fs.writeFile(tmpPath, yamlStr, 'utf8');
|
|
678
|
+
await fs.rename(tmpPath, HOME_CONFIG_FILE);
|
|
679
|
+
console.log('Config: wrote merged config back to:', HOME_CONFIG_FILE);
|
|
680
|
+
}
|
|
363
681
|
}
|
|
682
|
+
|
|
683
|
+
// Deep-merge: user values override defaults
|
|
684
|
+
config = deepMerge(defaults, userConfig);
|
|
685
|
+
} else {
|
|
686
|
+
console.log('No user config found, using bundled defaults');
|
|
687
|
+
config = defaults;
|
|
364
688
|
}
|
|
365
689
|
|
|
366
|
-
|
|
367
|
-
console.log('
|
|
368
|
-
|
|
369
|
-
console.log('Agent colors:', agentColors);
|
|
370
|
-
return workspacePath;
|
|
690
|
+
validateConfig(config);
|
|
691
|
+
console.log('Config loaded and validated successfully');
|
|
692
|
+
return config;
|
|
371
693
|
} catch (error) {
|
|
372
|
-
console.error('Error
|
|
694
|
+
console.error('Error loading config:', error);
|
|
373
695
|
throw error;
|
|
374
696
|
}
|
|
375
697
|
}
|
|
376
698
|
|
|
699
|
+
// ═══════════════════════════════════════════════════════════
|
|
377
700
|
// Agent Process Management
|
|
701
|
+
// ═══════════════════════════════════════════════════════════
|
|
702
|
+
|
|
378
703
|
class AgentProcess {
|
|
379
704
|
constructor(agentConfig, index) {
|
|
380
705
|
this.name = agentConfig.name;
|
|
@@ -384,14 +709,21 @@ class AgentProcess {
|
|
|
384
709
|
this.index = index;
|
|
385
710
|
this.process = null;
|
|
386
711
|
this.outputBuffer = [];
|
|
712
|
+
this.lastPrompt = null;
|
|
713
|
+
this.intentionalStop = false;
|
|
714
|
+
this.restartCount = 0;
|
|
715
|
+
this.maxRestarts = 3;
|
|
716
|
+
this.initDelay = agentConfig.init_delay_ms || (agentConfig.name === 'Codex' ? 5000 : 3000);
|
|
387
717
|
}
|
|
388
718
|
|
|
389
719
|
async start(prompt) {
|
|
720
|
+
this.lastPrompt = prompt;
|
|
721
|
+
this.intentionalStop = false;
|
|
722
|
+
|
|
390
723
|
return new Promise((resolve, reject) => {
|
|
391
724
|
console.log(`Starting agent ${this.name} with PTY: ${this.use_pty}`);
|
|
392
725
|
|
|
393
726
|
if (this.use_pty) {
|
|
394
|
-
// Use PTY for interactive TUI agents
|
|
395
727
|
const shell = process.env.SHELL || '/bin/bash';
|
|
396
728
|
|
|
397
729
|
this.process = pty.spawn(this.command, this.args, {
|
|
@@ -414,95 +746,60 @@ class AgentProcess {
|
|
|
414
746
|
|
|
415
747
|
console.log(`PTY spawned for ${this.name}, PID: ${this.process.pid}`);
|
|
416
748
|
|
|
417
|
-
// Respond to cursor position query immediately
|
|
418
|
-
// This helps with terminal capability detection (needed for Codex)
|
|
419
749
|
setTimeout(() => {
|
|
420
|
-
this.process.write('\x1b[1;1R');
|
|
750
|
+
this.process.write('\x1b[1;1R');
|
|
421
751
|
}, 100);
|
|
422
752
|
|
|
423
|
-
// Capture all output from PTY
|
|
424
753
|
this.process.onData((data) => {
|
|
425
754
|
const output = data.toString();
|
|
426
755
|
this.outputBuffer.push(output);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
isPty: true
|
|
433
|
-
});
|
|
434
|
-
}
|
|
756
|
+
sendToRenderer('agent-output', {
|
|
757
|
+
agentName: this.name,
|
|
758
|
+
output: output,
|
|
759
|
+
isPty: true
|
|
760
|
+
});
|
|
435
761
|
});
|
|
436
762
|
|
|
437
|
-
// Handle exit - trigger resume if enabled
|
|
438
763
|
this.process.onExit(({ exitCode, signal }) => {
|
|
439
764
|
console.log(`Agent ${this.name} exited with code ${exitCode}, signal ${signal}`);
|
|
440
765
|
this.handleExit(exitCode);
|
|
441
766
|
});
|
|
442
767
|
|
|
443
|
-
// Inject prompt via PTY after TUI initializes (original working pattern)
|
|
444
|
-
const initDelay = this.name === 'Codex' ? 5000 : 3000;
|
|
445
768
|
setTimeout(() => {
|
|
446
769
|
console.log(`Injecting prompt into ${this.name} PTY`);
|
|
447
770
|
this.process.write(prompt + '\n');
|
|
448
|
-
|
|
449
|
-
// Send Enter key after a brief delay to submit
|
|
450
771
|
setTimeout(() => {
|
|
451
772
|
this.process.write('\r');
|
|
452
773
|
}, 500);
|
|
453
|
-
|
|
454
774
|
resolve();
|
|
455
|
-
}, initDelay);
|
|
775
|
+
}, this.initDelay);
|
|
456
776
|
|
|
457
777
|
} else {
|
|
458
|
-
// Use regular spawn for non-interactive agents
|
|
459
778
|
const options = {
|
|
460
779
|
cwd: agentCwd,
|
|
461
|
-
env: {
|
|
462
|
-
...process.env,
|
|
463
|
-
AGENT_NAME: this.name
|
|
464
|
-
}
|
|
780
|
+
env: { ...process.env, AGENT_NAME: this.name }
|
|
465
781
|
};
|
|
466
782
|
|
|
467
783
|
this.process = spawn(this.command, this.args, options);
|
|
468
|
-
|
|
469
784
|
console.log(`Process spawned for ${this.name}, PID: ${this.process.pid}`);
|
|
470
785
|
|
|
471
|
-
// Capture stdout
|
|
472
786
|
this.process.stdout.on('data', (data) => {
|
|
473
787
|
const output = data.toString();
|
|
474
788
|
this.outputBuffer.push(output);
|
|
475
|
-
|
|
476
|
-
if (mainWindow) {
|
|
477
|
-
mainWindow.webContents.send('agent-output', {
|
|
478
|
-
agentName: this.name,
|
|
479
|
-
output: output,
|
|
480
|
-
isPty: false
|
|
481
|
-
});
|
|
482
|
-
}
|
|
789
|
+
sendToRenderer('agent-output', { agentName: this.name, output, isPty: false });
|
|
483
790
|
});
|
|
484
791
|
|
|
485
|
-
// Capture stderr
|
|
486
792
|
this.process.stderr.on('data', (data) => {
|
|
487
793
|
const output = data.toString();
|
|
488
794
|
this.outputBuffer.push(`[stderr] ${output}`);
|
|
489
|
-
|
|
490
|
-
if (mainWindow) {
|
|
491
|
-
mainWindow.webContents.send('agent-output', {
|
|
492
|
-
agentName: this.name,
|
|
493
|
-
output: `[stderr] ${output}`,
|
|
494
|
-
isPty: false
|
|
495
|
-
});
|
|
496
|
-
}
|
|
795
|
+
sendToRenderer('agent-output', { agentName: this.name, output: `[stderr] ${output}`, isPty: false });
|
|
497
796
|
});
|
|
498
797
|
|
|
499
|
-
// Handle process exit - trigger resume if enabled
|
|
500
798
|
this.process.on('close', (code) => {
|
|
501
799
|
console.log(`Agent ${this.name} exited with code ${code}`);
|
|
502
800
|
this.handleExit(code);
|
|
503
801
|
});
|
|
504
802
|
|
|
505
|
-
// Handle errors
|
|
506
803
|
this.process.on('error', (error) => {
|
|
507
804
|
console.error(`Error starting agent ${this.name}:`, error);
|
|
508
805
|
reject(error);
|
|
@@ -513,10 +810,51 @@ class AgentProcess {
|
|
|
513
810
|
});
|
|
514
811
|
}
|
|
515
812
|
|
|
516
|
-
// Handle agent exit
|
|
517
813
|
handleExit(exitCode) {
|
|
518
|
-
if (
|
|
519
|
-
|
|
814
|
+
if (this.intentionalStop) {
|
|
815
|
+
console.log(`Agent ${this.name} stopped intentionally`);
|
|
816
|
+
sendToRenderer('agent-status', {
|
|
817
|
+
agentName: this.name,
|
|
818
|
+
status: 'stopped',
|
|
819
|
+
exitCode: exitCode
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Unexpected exit - attempt relaunch
|
|
825
|
+
if (this.restartCount < this.maxRestarts && this.lastPrompt) {
|
|
826
|
+
this.restartCount++;
|
|
827
|
+
console.log(`Agent ${this.name} exited unexpectedly (code ${exitCode}), restarting (attempt ${this.restartCount}/${this.maxRestarts})...`);
|
|
828
|
+
|
|
829
|
+
sendToRenderer('agent-status', {
|
|
830
|
+
agentName: this.name,
|
|
831
|
+
status: 'restarting',
|
|
832
|
+
exitCode: exitCode,
|
|
833
|
+
restartCount: this.restartCount
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// Wait a moment before relaunching (give time for auto-updates etc.)
|
|
837
|
+
const delay = 2000 * this.restartCount;
|
|
838
|
+
setTimeout(() => {
|
|
839
|
+
this.start(this.lastPrompt).then(() => {
|
|
840
|
+
console.log(`Agent ${this.name} restarted successfully (attempt ${this.restartCount})`);
|
|
841
|
+
sendToRenderer('agent-status', {
|
|
842
|
+
agentName: this.name,
|
|
843
|
+
status: 'running'
|
|
844
|
+
});
|
|
845
|
+
}).catch(err => {
|
|
846
|
+
console.error(`Failed to restart agent ${this.name}:`, err);
|
|
847
|
+
sendToRenderer('agent-status', {
|
|
848
|
+
agentName: this.name,
|
|
849
|
+
status: 'stopped',
|
|
850
|
+
exitCode: exitCode,
|
|
851
|
+
error: `Restart failed: ${err.message}`
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
}, delay);
|
|
855
|
+
} else {
|
|
856
|
+
console.log(`Agent ${this.name} exited (code ${exitCode}), max restarts reached or no prompt stored`);
|
|
857
|
+
sendToRenderer('agent-status', {
|
|
520
858
|
agentName: this.name,
|
|
521
859
|
status: 'stopped',
|
|
522
860
|
exitCode: exitCode
|
|
@@ -528,10 +866,7 @@ class AgentProcess {
|
|
|
528
866
|
if (this.use_pty) {
|
|
529
867
|
if (this.process && this.process.write) {
|
|
530
868
|
this.process.write(message + '\n');
|
|
531
|
-
|
|
532
|
-
setTimeout(() => {
|
|
533
|
-
this.process.write('\r');
|
|
534
|
-
}, 300);
|
|
869
|
+
setTimeout(() => { this.process.write('\r'); }, 300);
|
|
535
870
|
}
|
|
536
871
|
} else {
|
|
537
872
|
if (this.process && this.process.stdin) {
|
|
@@ -541,6 +876,7 @@ class AgentProcess {
|
|
|
541
876
|
}
|
|
542
877
|
|
|
543
878
|
stop() {
|
|
879
|
+
this.intentionalStop = true;
|
|
544
880
|
if (this.process) {
|
|
545
881
|
if (this.use_pty) {
|
|
546
882
|
this.process.kill();
|
|
@@ -551,71 +887,69 @@ class AgentProcess {
|
|
|
551
887
|
}
|
|
552
888
|
}
|
|
553
889
|
|
|
554
|
-
// Initialize agents from config
|
|
555
890
|
function initializeAgents() {
|
|
556
891
|
agents = config.agents.map((agentConfig, index) => {
|
|
557
892
|
return new AgentProcess(agentConfig, index);
|
|
558
893
|
});
|
|
559
|
-
|
|
560
894
|
console.log(`Initialized ${agents.length} agents`);
|
|
561
895
|
return agents;
|
|
562
896
|
}
|
|
563
897
|
|
|
564
|
-
// Get agent by name
|
|
565
898
|
function getAgentByName(name) {
|
|
566
899
|
return agents.find(a => a.name.toLowerCase() === name.toLowerCase());
|
|
567
900
|
}
|
|
568
901
|
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
902
|
+
// ═══════════════════════════════════════════════════════════
|
|
903
|
+
// Message routing
|
|
904
|
+
// ═══════════════════════════════════════════════════════════
|
|
905
|
+
|
|
906
|
+
function getOutboxRelativePath(agentName) {
|
|
907
|
+
// Build path relative to agentCwd: sessions/<id>/outbox/<agent>.md
|
|
908
|
+
// But we need it relative from agentCwd perspective
|
|
909
|
+
const relFromProject = path.relative(agentCwd, workspacePath);
|
|
572
910
|
const outboxDir = config.outbox_dir || 'outbox';
|
|
911
|
+
return `${relFromProject}/${outboxDir}/${agentName.toLowerCase()}.md`;
|
|
912
|
+
}
|
|
573
913
|
|
|
914
|
+
function sendMessageToOtherAgents(senderName, message) {
|
|
915
|
+
const timestamp = new Date().toISOString();
|
|
574
916
|
for (const agent of agents) {
|
|
575
917
|
if (agent.name.toLowerCase() !== senderName.toLowerCase()) {
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
const formattedMessage = `\n---\n📨 MESSAGE FROM ${senderName.toUpperCase()}:\n\n${message}\n\n---\n(Respond via: cat << 'EOF' > ${outboxFile})\n`;
|
|
579
|
-
|
|
918
|
+
const outboxFile = getOutboxRelativePath(agent.name);
|
|
919
|
+
const formattedMessage = `\n---\n📨 MESSAGE FROM ${senderName.toUpperCase()}:\n🕐 ${timestamp}\n\n${message}\n\n---\n(Respond via: cat << 'EOF' > ${outboxFile})\n`;
|
|
580
920
|
console.log(`Delivering message from ${senderName} to ${agent.name}`);
|
|
581
921
|
agent.sendMessage(formattedMessage);
|
|
582
922
|
}
|
|
583
923
|
}
|
|
584
924
|
}
|
|
585
925
|
|
|
586
|
-
// Send a message to ALL agents (for user messages)
|
|
587
926
|
function sendMessageToAllAgents(message) {
|
|
588
|
-
const
|
|
589
|
-
const outboxDir = config.outbox_dir || 'outbox';
|
|
590
|
-
|
|
927
|
+
const timestamp = new Date().toISOString();
|
|
591
928
|
for (const agent of agents) {
|
|
592
|
-
|
|
593
|
-
const
|
|
594
|
-
const formattedMessage = `\n---\n📨 MESSAGE FROM USER:\n\n${message}\n\n---\n(Respond via: cat << 'EOF' > ${outboxFile})\n`;
|
|
595
|
-
|
|
929
|
+
const outboxFile = getOutboxRelativePath(agent.name);
|
|
930
|
+
const formattedMessage = `\n---\n📨 MESSAGE FROM USER:\n🕐 ${timestamp}\n\n${message}\n\n---\n(Respond via: cat << 'EOF' > ${outboxFile})\n`;
|
|
596
931
|
console.log(`Delivering user message to ${agent.name}`);
|
|
597
932
|
agent.sendMessage(formattedMessage);
|
|
598
933
|
}
|
|
599
934
|
}
|
|
600
935
|
|
|
601
|
-
// Build prompt for a specific agent
|
|
602
936
|
function buildAgentPrompt(challenge, agentName) {
|
|
603
|
-
const
|
|
937
|
+
const relFromProject = path.relative(agentCwd, workspacePath);
|
|
604
938
|
const outboxDir = config.outbox_dir || 'outbox';
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
|
|
939
|
+
const outboxFile = `${relFromProject}/${outboxDir}/${agentName.toLowerCase()}.md`;
|
|
940
|
+
const planFile = `${relFromProject}/${config.plan_file || 'PLAN_FINAL.md'}`;
|
|
941
|
+
|
|
942
|
+
const template = config.prompt_template || `## Multi-Agent Collaboration Session\n\n**You are: {agent_name}**\nYou are collaborating with: {agent_names}\n\n## Topic\n\n{challenge}\n\nSend messages by writing to: {outbox_file}\nFinal plan goes in: {plan_file}\n`;
|
|
608
943
|
|
|
609
|
-
return
|
|
944
|
+
return template
|
|
610
945
|
.replace('{challenge}', challenge)
|
|
611
946
|
.replace('{workspace}', workspacePath)
|
|
612
|
-
.replace(/{outbox_file}/g, outboxFile)
|
|
613
|
-
.replace(/{plan_file}/g, planFile)
|
|
947
|
+
.replace(/{outbox_file}/g, outboxFile)
|
|
948
|
+
.replace(/{plan_file}/g, planFile)
|
|
614
949
|
.replace('{agent_names}', agents.map(a => a.name).join(', '))
|
|
615
950
|
.replace('{agent_name}', agentName);
|
|
616
951
|
}
|
|
617
952
|
|
|
618
|
-
// Start all agents with their individual prompts
|
|
619
953
|
async function startAgents(challenge) {
|
|
620
954
|
console.log('Starting agents with prompts...');
|
|
621
955
|
|
|
@@ -625,27 +959,25 @@ async function startAgents(challenge) {
|
|
|
625
959
|
await agent.start(prompt);
|
|
626
960
|
console.log(`Started agent: ${agent.name}`);
|
|
627
961
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
});
|
|
633
|
-
}
|
|
962
|
+
sendToRenderer('agent-status', {
|
|
963
|
+
agentName: agent.name,
|
|
964
|
+
status: 'running'
|
|
965
|
+
});
|
|
634
966
|
} catch (error) {
|
|
635
967
|
console.error(`Failed to start agent ${agent.name}:`, error);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
error: error.message
|
|
642
|
-
});
|
|
643
|
-
}
|
|
968
|
+
sendToRenderer('agent-status', {
|
|
969
|
+
agentName: agent.name,
|
|
970
|
+
status: 'error',
|
|
971
|
+
error: error.message
|
|
972
|
+
});
|
|
644
973
|
}
|
|
645
974
|
}
|
|
646
975
|
}
|
|
647
976
|
|
|
648
|
-
//
|
|
977
|
+
// ═══════════════════════════════════════════════════════════
|
|
978
|
+
// File watchers
|
|
979
|
+
// ═══════════════════════════════════════════════════════════
|
|
980
|
+
|
|
649
981
|
function startFileWatcher() {
|
|
650
982
|
const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
|
|
651
983
|
|
|
@@ -654,14 +986,10 @@ function startFileWatcher() {
|
|
|
654
986
|
ignoreInitial: true
|
|
655
987
|
});
|
|
656
988
|
|
|
657
|
-
// Note: Primary updates happen via 'chat-message' events sent when outbox is processed
|
|
658
|
-
// This watcher is a backup for any external modifications
|
|
659
989
|
fileWatcher.on('change', async () => {
|
|
660
990
|
try {
|
|
661
991
|
const messages = await getChatContent();
|
|
662
|
-
|
|
663
|
-
mainWindow.webContents.send('chat-updated', messages);
|
|
664
|
-
}
|
|
992
|
+
sendToRenderer('chat-updated', messages);
|
|
665
993
|
} catch (error) {
|
|
666
994
|
console.error('Error reading chat file:', error);
|
|
667
995
|
}
|
|
@@ -670,47 +998,38 @@ function startFileWatcher() {
|
|
|
670
998
|
console.log('File watcher started for:', chatPath);
|
|
671
999
|
}
|
|
672
1000
|
|
|
673
|
-
// Watch outbox directory and merge messages into chat.jsonl
|
|
674
1001
|
function startOutboxWatcher() {
|
|
675
1002
|
const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
|
|
676
1003
|
const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
|
|
677
1004
|
|
|
678
|
-
// Track which files we're currently processing to avoid race conditions
|
|
679
1005
|
const processing = new Set();
|
|
680
1006
|
|
|
681
1007
|
outboxWatcher = chokidar.watch(outboxDir, {
|
|
682
1008
|
persistent: true,
|
|
683
1009
|
ignoreInitial: true,
|
|
684
1010
|
awaitWriteFinish: {
|
|
685
|
-
stabilityThreshold: 500,
|
|
1011
|
+
stabilityThreshold: 500,
|
|
686
1012
|
pollInterval: 100
|
|
687
1013
|
}
|
|
688
1014
|
});
|
|
689
1015
|
|
|
690
1016
|
outboxWatcher.on('change', async (filePath) => {
|
|
691
|
-
// Only process .md files
|
|
692
1017
|
if (!filePath.endsWith('.md')) return;
|
|
693
|
-
|
|
694
|
-
// Avoid processing the same file concurrently
|
|
695
1018
|
if (processing.has(filePath)) return;
|
|
696
1019
|
processing.add(filePath);
|
|
697
1020
|
|
|
698
1021
|
try {
|
|
699
|
-
// Read the outbox file
|
|
700
1022
|
const content = await fs.readFile(filePath, 'utf8');
|
|
701
1023
|
const trimmedContent = content.trim();
|
|
702
1024
|
|
|
703
|
-
// Skip if empty
|
|
704
1025
|
if (!trimmedContent) {
|
|
705
1026
|
processing.delete(filePath);
|
|
706
1027
|
return;
|
|
707
1028
|
}
|
|
708
1029
|
|
|
709
|
-
// Extract agent name from filename (e.g., "claude.md" -> "Claude")
|
|
710
1030
|
const filename = path.basename(filePath, '.md');
|
|
711
1031
|
const agentName = filename.charAt(0).toUpperCase() + filename.slice(1);
|
|
712
1032
|
|
|
713
|
-
// Increment sequence and create message object
|
|
714
1033
|
messageSequence++;
|
|
715
1034
|
const timestamp = new Date().toISOString();
|
|
716
1035
|
const message = {
|
|
@@ -722,21 +1041,22 @@ function startOutboxWatcher() {
|
|
|
722
1041
|
color: agentColors[agentName.toLowerCase()] || '#667eea'
|
|
723
1042
|
};
|
|
724
1043
|
|
|
725
|
-
// Append to chat.jsonl
|
|
726
1044
|
await fs.appendFile(chatPath, JSON.stringify(message) + '\n');
|
|
727
1045
|
console.log(`Merged message from ${agentName} (#${messageSequence}) into chat.jsonl`);
|
|
728
1046
|
|
|
729
|
-
// Clear the outbox file
|
|
730
1047
|
await fs.writeFile(filePath, '');
|
|
731
1048
|
|
|
732
|
-
// PUSH message to other agents' PTYs
|
|
733
1049
|
sendMessageToOtherAgents(agentName, trimmedContent);
|
|
734
1050
|
|
|
735
|
-
//
|
|
736
|
-
if (
|
|
737
|
-
|
|
1051
|
+
// Update session index with message count and lastActiveAt
|
|
1052
|
+
if (currentSessionId && agentCwd) {
|
|
1053
|
+
updateSessionInIndex(agentCwd, currentSessionId, {
|
|
1054
|
+
lastActiveAt: timestamp,
|
|
1055
|
+
messageCount: messageSequence
|
|
1056
|
+
}).catch(e => console.warn('Failed to update session index:', e.message));
|
|
738
1057
|
}
|
|
739
1058
|
|
|
1059
|
+
sendToRenderer('chat-message', message);
|
|
740
1060
|
} catch (error) {
|
|
741
1061
|
console.error(`Error processing outbox file ${filePath}:`, error);
|
|
742
1062
|
} finally {
|
|
@@ -747,7 +1067,6 @@ function startOutboxWatcher() {
|
|
|
747
1067
|
console.log('Outbox watcher started for:', outboxDir);
|
|
748
1068
|
}
|
|
749
1069
|
|
|
750
|
-
// Stop outbox watcher
|
|
751
1070
|
function stopOutboxWatcher() {
|
|
752
1071
|
if (outboxWatcher) {
|
|
753
1072
|
outboxWatcher.close();
|
|
@@ -755,7 +1074,10 @@ function stopOutboxWatcher() {
|
|
|
755
1074
|
}
|
|
756
1075
|
}
|
|
757
1076
|
|
|
758
|
-
//
|
|
1077
|
+
// ═══════════════════════════════════════════════════════════
|
|
1078
|
+
// Chat / Plan / Diff
|
|
1079
|
+
// ═══════════════════════════════════════════════════════════
|
|
1080
|
+
|
|
759
1081
|
async function sendUserMessage(messageText) {
|
|
760
1082
|
const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
|
|
761
1083
|
messageSequence++;
|
|
@@ -771,39 +1093,35 @@ async function sendUserMessage(messageText) {
|
|
|
771
1093
|
};
|
|
772
1094
|
|
|
773
1095
|
try {
|
|
774
|
-
// Append to chat.jsonl
|
|
775
1096
|
await fs.appendFile(chatPath, JSON.stringify(message) + '\n');
|
|
776
1097
|
console.log(`User message #${messageSequence} appended to chat`);
|
|
777
1098
|
|
|
778
|
-
// PUSH message to all agents' PTYs
|
|
779
1099
|
sendMessageToAllAgents(messageText);
|
|
780
1100
|
|
|
781
|
-
//
|
|
782
|
-
if (
|
|
783
|
-
|
|
1101
|
+
// Update session index
|
|
1102
|
+
if (currentSessionId && agentCwd) {
|
|
1103
|
+
updateSessionInIndex(agentCwd, currentSessionId, {
|
|
1104
|
+
lastActiveAt: timestamp,
|
|
1105
|
+
messageCount: messageSequence
|
|
1106
|
+
}).catch(e => console.warn('Failed to update session index:', e.message));
|
|
784
1107
|
}
|
|
785
1108
|
|
|
1109
|
+
sendToRenderer('chat-message', message);
|
|
786
1110
|
} catch (error) {
|
|
787
1111
|
console.error('Error appending user message:', error);
|
|
788
1112
|
throw error;
|
|
789
1113
|
}
|
|
790
1114
|
}
|
|
791
1115
|
|
|
792
|
-
// Read current chat content (returns array of message objects)
|
|
793
1116
|
async function getChatContent() {
|
|
794
1117
|
const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
|
|
795
1118
|
try {
|
|
796
1119
|
const content = await fs.readFile(chatPath, 'utf8');
|
|
797
1120
|
if (!content.trim()) return [];
|
|
798
1121
|
|
|
799
|
-
// Parse JSONL (one JSON object per line)
|
|
800
1122
|
const messages = content.trim().split('\n').map(line => {
|
|
801
|
-
try {
|
|
802
|
-
|
|
803
|
-
} catch (e) {
|
|
804
|
-
console.error('Failed to parse chat line:', line);
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
1123
|
+
try { return JSON.parse(line); }
|
|
1124
|
+
catch (e) { console.error('Failed to parse chat line:', line); return null; }
|
|
807
1125
|
}).filter(Boolean);
|
|
808
1126
|
|
|
809
1127
|
return messages;
|
|
@@ -813,7 +1131,6 @@ async function getChatContent() {
|
|
|
813
1131
|
}
|
|
814
1132
|
}
|
|
815
1133
|
|
|
816
|
-
// Read final plan
|
|
817
1134
|
async function getPlanContent() {
|
|
818
1135
|
const planPath = path.join(workspacePath, config.plan_file || 'PLAN_FINAL.md');
|
|
819
1136
|
try {
|
|
@@ -823,14 +1140,11 @@ async function getPlanContent() {
|
|
|
823
1140
|
}
|
|
824
1141
|
}
|
|
825
1142
|
|
|
826
|
-
// Get git diff - shows uncommitted changes only (git diff HEAD)
|
|
827
1143
|
async function getGitDiff() {
|
|
828
|
-
// Not a git repo or session hasn't started
|
|
829
1144
|
if (!agentCwd) {
|
|
830
1145
|
return { isGitRepo: false, error: 'No session active' };
|
|
831
1146
|
}
|
|
832
1147
|
|
|
833
|
-
// Check if git repo
|
|
834
1148
|
try {
|
|
835
1149
|
await execAsync('git rev-parse --git-dir', { cwd: agentCwd });
|
|
836
1150
|
} catch (error) {
|
|
@@ -845,7 +1159,6 @@ async function getGitDiff() {
|
|
|
845
1159
|
untracked: []
|
|
846
1160
|
};
|
|
847
1161
|
|
|
848
|
-
// Check if HEAD exists (repo might have no commits)
|
|
849
1162
|
let hasHead = true;
|
|
850
1163
|
try {
|
|
851
1164
|
await execAsync('git rev-parse HEAD', { cwd: agentCwd });
|
|
@@ -853,17 +1166,13 @@ async function getGitDiff() {
|
|
|
853
1166
|
hasHead = false;
|
|
854
1167
|
}
|
|
855
1168
|
|
|
856
|
-
// Determine diff target - use empty tree hash if no commits yet
|
|
857
1169
|
const diffTarget = hasHead ? 'HEAD' : '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
858
1170
|
|
|
859
|
-
// Get diff stats
|
|
860
1171
|
try {
|
|
861
1172
|
const { stdout: statOutput } = await execAsync(
|
|
862
1173
|
`git diff ${diffTarget} --stat`,
|
|
863
1174
|
{ cwd: agentCwd, maxBuffer: 10 * 1024 * 1024 }
|
|
864
1175
|
);
|
|
865
|
-
|
|
866
|
-
// Parse stats from last line (e.g., "3 files changed, 10 insertions(+), 5 deletions(-)")
|
|
867
1176
|
const statMatch = statOutput.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
|
|
868
1177
|
if (statMatch) {
|
|
869
1178
|
result.stats.filesChanged = parseInt(statMatch[1]) || 0;
|
|
@@ -871,10 +1180,9 @@ async function getGitDiff() {
|
|
|
871
1180
|
result.stats.deletions = parseInt(statMatch[3]) || 0;
|
|
872
1181
|
}
|
|
873
1182
|
} catch (e) {
|
|
874
|
-
// No changes
|
|
1183
|
+
// No changes
|
|
875
1184
|
}
|
|
876
1185
|
|
|
877
|
-
// Get full diff
|
|
878
1186
|
try {
|
|
879
1187
|
const { stdout: diffOutput } = await execAsync(
|
|
880
1188
|
`git diff ${diffTarget}`,
|
|
@@ -885,7 +1193,6 @@ async function getGitDiff() {
|
|
|
885
1193
|
result.diff = '';
|
|
886
1194
|
}
|
|
887
1195
|
|
|
888
|
-
// Get untracked files
|
|
889
1196
|
try {
|
|
890
1197
|
const { stdout: untrackedOutput } = await execAsync(
|
|
891
1198
|
'git ls-files --others --exclude-standard',
|
|
@@ -903,7 +1210,10 @@ async function getGitDiff() {
|
|
|
903
1210
|
}
|
|
904
1211
|
}
|
|
905
1212
|
|
|
1213
|
+
// ═══════════════════════════════════════════════════════════
|
|
906
1214
|
// Stop all agents and watchers
|
|
1215
|
+
// ═══════════════════════════════════════════════════════════
|
|
1216
|
+
|
|
907
1217
|
function stopAllAgents() {
|
|
908
1218
|
agents.forEach(agent => agent.stop());
|
|
909
1219
|
if (fileWatcher) {
|
|
@@ -913,7 +1223,10 @@ function stopAllAgents() {
|
|
|
913
1223
|
stopOutboxWatcher();
|
|
914
1224
|
}
|
|
915
1225
|
|
|
1226
|
+
// ═══════════════════════════════════════════════════════════
|
|
916
1227
|
// IPC Handlers
|
|
1228
|
+
// ═══════════════════════════════════════════════════════════
|
|
1229
|
+
|
|
917
1230
|
ipcMain.handle('load-config', async () => {
|
|
918
1231
|
try {
|
|
919
1232
|
console.log('IPC: load-config called');
|
|
@@ -926,41 +1239,232 @@ ipcMain.handle('load-config', async () => {
|
|
|
926
1239
|
}
|
|
927
1240
|
});
|
|
928
1241
|
|
|
929
|
-
|
|
1242
|
+
// Returns CWD (or CLI workspace) as the default workspace
|
|
1243
|
+
ipcMain.handle('get-current-workspace', async () => {
|
|
1244
|
+
const ws = customWorkspacePath || process.cwd();
|
|
1245
|
+
const resolved = path.isAbsolute(ws) ? ws : path.resolve(ws);
|
|
1246
|
+
return {
|
|
1247
|
+
path: resolved,
|
|
1248
|
+
name: path.basename(resolved)
|
|
1249
|
+
};
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// Returns sessions for a given workspace/project root
|
|
1253
|
+
ipcMain.handle('get-sessions-for-workspace', async (event, projectRoot) => {
|
|
930
1254
|
try {
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
1255
|
+
// Migrate from flat workspace if needed
|
|
1256
|
+
await migrateFromFlatWorkspace(projectRoot);
|
|
1257
|
+
|
|
1258
|
+
const index = await withSessionsLock(async () => {
|
|
1259
|
+
const idx = await loadSessionsIndex(projectRoot);
|
|
1260
|
+
|
|
1261
|
+
// Reconcile stale active sessions: if no agents are running for a session,
|
|
1262
|
+
// downgrade it from 'active' to 'completed'
|
|
1263
|
+
let reconciled = false;
|
|
1264
|
+
for (const session of idx.sessions) {
|
|
1265
|
+
if (session.status === 'active' && session.id !== currentSessionId) {
|
|
1266
|
+
session.status = 'completed';
|
|
1267
|
+
reconciled = true;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (reconciled) {
|
|
1271
|
+
await saveSessionsIndex(projectRoot, idx);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return idx;
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
return index.sessions || [];
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
console.error('Error getting sessions:', error);
|
|
1280
|
+
return [];
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// Load session data (chat, plan) without starting agents
|
|
1285
|
+
ipcMain.handle('load-session', async (event, { projectRoot, sessionId }) => {
|
|
1286
|
+
try {
|
|
1287
|
+
const data = await loadSessionData(projectRoot, sessionId);
|
|
1288
|
+
|
|
1289
|
+
// Set workspace path to session dir so getChatContent/getPlanContent work
|
|
1290
|
+
workspacePath = data.sessionDir;
|
|
1291
|
+
agentCwd = projectRoot;
|
|
1292
|
+
currentSessionId = sessionId;
|
|
1293
|
+
|
|
1294
|
+
// Build agent colors
|
|
1295
|
+
const defaultColors = config.default_agent_colors || ['#667eea', '#f093fb', '#4fd1c5', '#f6ad55', '#68d391', '#fc8181'];
|
|
1296
|
+
agentColors = {};
|
|
1297
|
+
config.agents.forEach((agentConfig, index) => {
|
|
1298
|
+
agentColors[agentConfig.name.toLowerCase()] = agentConfig.color || defaultColors[index % defaultColors.length];
|
|
1299
|
+
});
|
|
1300
|
+
agentColors['user'] = config.user_color || '#a0aec0';
|
|
1301
|
+
|
|
1302
|
+
// Set messageSequence to last message's seq
|
|
1303
|
+
if (data.messages.length > 0) {
|
|
1304
|
+
messageSequence = data.messages[data.messages.length - 1].seq || data.messages.length;
|
|
1305
|
+
} else {
|
|
1306
|
+
messageSequence = 0;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
return {
|
|
1310
|
+
success: true,
|
|
1311
|
+
messages: data.messages,
|
|
1312
|
+
plan: data.plan,
|
|
1313
|
+
colors: agentColors,
|
|
1314
|
+
sessionDir: data.sessionDir
|
|
1315
|
+
};
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
console.error('Error loading session:', error);
|
|
1318
|
+
return { success: false, error: error.message };
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// Resume session - initialize agents with resume context
|
|
1323
|
+
ipcMain.handle('resume-session', async (event, { projectRoot, sessionId, newMessage }) => {
|
|
1324
|
+
try {
|
|
1325
|
+
const data = await loadSessionData(projectRoot, sessionId);
|
|
1326
|
+
|
|
1327
|
+
// Set workspace path and agentCwd
|
|
1328
|
+
workspacePath = data.sessionDir;
|
|
1329
|
+
agentCwd = projectRoot;
|
|
1330
|
+
currentSessionId = sessionId;
|
|
1331
|
+
|
|
1332
|
+
await initWorkspaceBase(projectRoot);
|
|
1333
|
+
|
|
1334
|
+
// Set messageSequence
|
|
1335
|
+
if (data.messages.length > 0) {
|
|
1336
|
+
messageSequence = data.messages[data.messages.length - 1].seq || data.messages.length;
|
|
1337
|
+
} else {
|
|
1338
|
+
messageSequence = 0;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Ensure outbox files are clean
|
|
1342
|
+
const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
|
|
1343
|
+
await fs.mkdir(outboxDir, { recursive: true });
|
|
1344
|
+
for (const agentConfig of config.agents) {
|
|
1345
|
+
const outboxFile = path.join(outboxDir, `${agentConfig.name.toLowerCase()}.md`);
|
|
1346
|
+
await fs.writeFile(outboxFile, '');
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Initialize agents
|
|
934
1350
|
initializeAgents();
|
|
935
|
-
await startAgents(challenge);
|
|
936
|
-
startFileWatcher();
|
|
937
|
-
startOutboxWatcher(); // Watch for agent messages and merge into chat.jsonl
|
|
938
1351
|
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
1352
|
+
// Build resume prompts and start agents
|
|
1353
|
+
const chatSummary = generateChatSummary(data.messages);
|
|
1354
|
+
console.log('Starting agents with resume prompts...');
|
|
1355
|
+
|
|
1356
|
+
for (const agent of agents) {
|
|
1357
|
+
try {
|
|
1358
|
+
const prompt = buildResumePrompt(chatSummary, data.plan, newMessage, agent.name);
|
|
1359
|
+
await agent.start(prompt);
|
|
1360
|
+
console.log(`Started agent: ${agent.name} (resumed)`);
|
|
1361
|
+
|
|
1362
|
+
sendToRenderer('agent-status', {
|
|
1363
|
+
agentName: agent.name,
|
|
1364
|
+
status: 'running'
|
|
1365
|
+
});
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
console.error(`Failed to start agent ${agent.name}:`, error);
|
|
1368
|
+
sendToRenderer('agent-status', {
|
|
1369
|
+
agentName: agent.name,
|
|
1370
|
+
status: 'error',
|
|
1371
|
+
error: error.message
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
942
1374
|
}
|
|
943
1375
|
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
);
|
|
1376
|
+
// Append user message to chat
|
|
1377
|
+
await sendUserMessage(newMessage);
|
|
1378
|
+
|
|
1379
|
+
// Start watchers
|
|
1380
|
+
startFileWatcher();
|
|
1381
|
+
startOutboxWatcher();
|
|
1382
|
+
|
|
1383
|
+
// Update session index
|
|
1384
|
+
await updateSessionInIndex(projectRoot, sessionId, {
|
|
1385
|
+
lastActiveAt: new Date().toISOString(),
|
|
1386
|
+
status: 'active'
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// Add to recent workspaces
|
|
1390
|
+
await addRecentWorkspace(projectRoot);
|
|
950
1391
|
|
|
951
1392
|
return {
|
|
952
1393
|
success: true,
|
|
953
1394
|
agents: agents.map(a => ({ name: a.name, use_pty: a.use_pty })),
|
|
954
|
-
workspace: agentCwd,
|
|
1395
|
+
workspace: agentCwd,
|
|
955
1396
|
colors: agentColors,
|
|
956
|
-
|
|
1397
|
+
sessionId: sessionId
|
|
957
1398
|
};
|
|
958
1399
|
} catch (error) {
|
|
959
|
-
console.error('Error
|
|
1400
|
+
console.error('Error resuming session:', error);
|
|
1401
|
+
return { success: false, error: error.message };
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
// Start new session - creates session dir, starts agents
|
|
1406
|
+
ipcMain.handle('start-session', async (event, { challenge, workspace: selectedWorkspace }) => {
|
|
1407
|
+
try {
|
|
1408
|
+
const projectRoot = selectedWorkspace || customWorkspacePath || process.cwd();
|
|
1409
|
+
const resolvedRoot = path.isAbsolute(projectRoot) ? projectRoot : path.resolve(projectRoot);
|
|
1410
|
+
|
|
1411
|
+
await initWorkspaceBase(resolvedRoot);
|
|
1412
|
+
|
|
1413
|
+
// Migrate if needed
|
|
1414
|
+
await migrateFromFlatWorkspace(resolvedRoot);
|
|
1415
|
+
|
|
1416
|
+
// Create new session
|
|
1417
|
+
const sessionId = generateSessionId();
|
|
1418
|
+
currentSessionId = sessionId;
|
|
1419
|
+
const sessionDir = await setupSessionDirectory(resolvedRoot, sessionId);
|
|
1420
|
+
workspacePath = sessionDir;
|
|
1421
|
+
const initialTimestamp = new Date().toISOString();
|
|
1422
|
+
|
|
1423
|
+
// Seed chat history with the initial user challenge so it appears in UI immediately.
|
|
1424
|
+
messageSequence = 1;
|
|
1425
|
+
const initialMessage = {
|
|
1426
|
+
seq: messageSequence,
|
|
1427
|
+
type: 'user',
|
|
1428
|
+
agent: 'User',
|
|
1429
|
+
timestamp: initialTimestamp,
|
|
1430
|
+
content: challenge,
|
|
1431
|
+
color: agentColors['user'] || '#a0aec0'
|
|
1432
|
+
};
|
|
1433
|
+
const chatPath = path.join(workspacePath, config.chat_file || 'chat.jsonl');
|
|
1434
|
+
await fs.appendFile(chatPath, JSON.stringify(initialMessage) + '\n');
|
|
1435
|
+
|
|
1436
|
+
// Add to session index
|
|
1437
|
+
const sessionMeta = {
|
|
1438
|
+
id: sessionId,
|
|
1439
|
+
title: generateSessionTitle(challenge),
|
|
1440
|
+
firstPrompt: challenge.slice(0, 200),
|
|
1441
|
+
workspace: resolvedRoot,
|
|
1442
|
+
createdAt: initialTimestamp,
|
|
1443
|
+
lastActiveAt: initialTimestamp,
|
|
1444
|
+
messageCount: 1,
|
|
1445
|
+
status: 'active'
|
|
1446
|
+
};
|
|
1447
|
+
await addSessionToIndex(resolvedRoot, sessionMeta);
|
|
1448
|
+
|
|
1449
|
+
initializeAgents();
|
|
1450
|
+
await startAgents(challenge);
|
|
1451
|
+
startFileWatcher();
|
|
1452
|
+
startOutboxWatcher();
|
|
1453
|
+
|
|
1454
|
+
// Add workspace to recents
|
|
1455
|
+
await addRecentWorkspace(resolvedRoot);
|
|
1456
|
+
|
|
960
1457
|
return {
|
|
961
|
-
success:
|
|
962
|
-
|
|
1458
|
+
success: true,
|
|
1459
|
+
agents: agents.map(a => ({ name: a.name, use_pty: a.use_pty })),
|
|
1460
|
+
workspace: agentCwd,
|
|
1461
|
+
colors: agentColors,
|
|
1462
|
+
sessionId: sessionId,
|
|
1463
|
+
initialMessage
|
|
963
1464
|
};
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
console.error('Error starting session:', error);
|
|
1467
|
+
return { success: false, error: error.message };
|
|
964
1468
|
}
|
|
965
1469
|
});
|
|
966
1470
|
|
|
@@ -990,46 +1494,24 @@ ipcMain.handle('stop-agents', async () => {
|
|
|
990
1494
|
return { success: true };
|
|
991
1495
|
});
|
|
992
1496
|
|
|
1497
|
+
// Reset session - marks current session as completed, stops agents
|
|
993
1498
|
ipcMain.handle('reset-session', async () => {
|
|
994
1499
|
try {
|
|
995
|
-
// Stop all agents and watchers
|
|
996
1500
|
stopAllAgents();
|
|
997
1501
|
|
|
998
|
-
//
|
|
999
|
-
if (
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
}
|
|
1004
|
-
if (e.code !== 'ENOENT') throw e;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// Clear plan file (handle missing file gracefully)
|
|
1008
|
-
const planPath = path.join(workspacePath, config.plan_file || 'PLAN_FINAL.md');
|
|
1009
|
-
try {
|
|
1010
|
-
await fs.writeFile(planPath, '');
|
|
1011
|
-
} catch (e) {
|
|
1012
|
-
if (e.code !== 'ENOENT') throw e;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
// Clear outbox files
|
|
1016
|
-
const outboxDir = path.join(workspacePath, config.outbox_dir || 'outbox');
|
|
1017
|
-
try {
|
|
1018
|
-
const files = await fs.readdir(outboxDir);
|
|
1019
|
-
for (const file of files) {
|
|
1020
|
-
if (file.endsWith('.md')) {
|
|
1021
|
-
await fs.writeFile(path.join(outboxDir, file), '');
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
} catch (e) {
|
|
1025
|
-
if (e.code !== 'ENOENT') throw e;
|
|
1026
|
-
}
|
|
1502
|
+
// Mark current session as completed in index
|
|
1503
|
+
if (currentSessionId && agentCwd) {
|
|
1504
|
+
await updateSessionInIndex(agentCwd, currentSessionId, {
|
|
1505
|
+
status: 'completed',
|
|
1506
|
+
lastActiveAt: new Date().toISOString()
|
|
1507
|
+
});
|
|
1027
1508
|
}
|
|
1028
1509
|
|
|
1029
1510
|
// Reset state
|
|
1030
1511
|
messageSequence = 0;
|
|
1031
1512
|
agents = [];
|
|
1032
1513
|
sessionBaseCommit = null;
|
|
1514
|
+
currentSessionId = null;
|
|
1033
1515
|
|
|
1034
1516
|
return { success: true };
|
|
1035
1517
|
} catch (error) {
|
|
@@ -1038,19 +1520,15 @@ ipcMain.handle('reset-session', async () => {
|
|
|
1038
1520
|
}
|
|
1039
1521
|
});
|
|
1040
1522
|
|
|
1041
|
-
// Handle start implementation request
|
|
1042
1523
|
ipcMain.handle('start-implementation', async (event, selectedAgent, otherAgents) => {
|
|
1043
1524
|
try {
|
|
1044
|
-
// Get the implementation handoff prompt from config
|
|
1045
1525
|
const promptTemplate = config.prompts?.implementation_handoff ||
|
|
1046
1526
|
'{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.';
|
|
1047
1527
|
|
|
1048
|
-
// Substitute placeholders
|
|
1049
1528
|
const prompt = promptTemplate
|
|
1050
1529
|
.replace(/{selected_agent}/g, selectedAgent)
|
|
1051
1530
|
.replace(/{other_agents}/g, otherAgents.join(', '));
|
|
1052
1531
|
|
|
1053
|
-
// Send as user message (this handles chat log + delivery to all agents)
|
|
1054
1532
|
await sendUserMessage(prompt);
|
|
1055
1533
|
|
|
1056
1534
|
console.log(`Implementation started with ${selectedAgent} as implementer`);
|
|
@@ -1064,7 +1542,6 @@ ipcMain.handle('start-implementation', async (event, selectedAgent, otherAgents)
|
|
|
1064
1542
|
// Workspace Management IPC Handlers
|
|
1065
1543
|
ipcMain.handle('get-recent-workspaces', async () => {
|
|
1066
1544
|
const recents = await loadRecentWorkspaces();
|
|
1067
|
-
// Add validation info for each workspace
|
|
1068
1545
|
const withValidation = await Promise.all(
|
|
1069
1546
|
recents.map(async (r) => ({
|
|
1070
1547
|
...r,
|
|
@@ -1075,24 +1552,22 @@ ipcMain.handle('get-recent-workspaces', async () => {
|
|
|
1075
1552
|
return withValidation;
|
|
1076
1553
|
});
|
|
1077
1554
|
|
|
1078
|
-
ipcMain.handle('add-recent-workspace', async (event,
|
|
1079
|
-
return await addRecentWorkspace(
|
|
1080
|
-
});
|
|
1081
|
-
|
|
1082
|
-
ipcMain.handle('remove-recent-workspace', async (event, workspacePath) => {
|
|
1083
|
-
return await removeRecentWorkspace(workspacePath);
|
|
1555
|
+
ipcMain.handle('add-recent-workspace', async (event, wsPath) => {
|
|
1556
|
+
return await addRecentWorkspace(wsPath);
|
|
1084
1557
|
});
|
|
1085
1558
|
|
|
1086
|
-
ipcMain.handle('
|
|
1087
|
-
return await
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
ipcMain.handle('validate-workspace-path', async (event, workspacePath) => {
|
|
1091
|
-
return await validateWorkspacePath(workspacePath);
|
|
1559
|
+
ipcMain.handle('remove-recent-workspace', async (event, wsPath) => {
|
|
1560
|
+
return await removeRecentWorkspace(wsPath);
|
|
1092
1561
|
});
|
|
1093
1562
|
|
|
1094
1563
|
ipcMain.handle('get-current-directory', async () => {
|
|
1095
|
-
|
|
1564
|
+
const cwd = process.cwd();
|
|
1565
|
+
const appDir = __dirname;
|
|
1566
|
+
return {
|
|
1567
|
+
path: cwd,
|
|
1568
|
+
isUsable: cwd !== appDir && fsSync.existsSync(cwd),
|
|
1569
|
+
appDir
|
|
1570
|
+
};
|
|
1096
1571
|
});
|
|
1097
1572
|
|
|
1098
1573
|
ipcMain.handle('browse-for-workspace', async () => {
|
|
@@ -1105,10 +1580,7 @@ ipcMain.handle('browse-for-workspace', async () => {
|
|
|
1105
1580
|
return { canceled: true };
|
|
1106
1581
|
}
|
|
1107
1582
|
|
|
1108
|
-
return {
|
|
1109
|
-
canceled: false,
|
|
1110
|
-
path: result.filePaths[0]
|
|
1111
|
-
};
|
|
1583
|
+
return { canceled: false, path: result.filePaths[0] };
|
|
1112
1584
|
});
|
|
1113
1585
|
|
|
1114
1586
|
ipcMain.handle('open-config-folder', async () => {
|
|
@@ -1136,7 +1608,10 @@ ipcMain.on('pty-input', (event, { agentName, data }) => {
|
|
|
1136
1608
|
}
|
|
1137
1609
|
});
|
|
1138
1610
|
|
|
1611
|
+
// ═══════════════════════════════════════════════════════════
|
|
1139
1612
|
// App lifecycle
|
|
1613
|
+
// ═══════════════════════════════════════════════════════════
|
|
1614
|
+
|
|
1140
1615
|
app.whenReady().then(async () => {
|
|
1141
1616
|
console.log('App ready, setting up...');
|
|
1142
1617
|
parseCommandLineArgs();
|
|
@@ -1157,7 +1632,6 @@ app.on('activate', () => {
|
|
|
1157
1632
|
}
|
|
1158
1633
|
});
|
|
1159
1634
|
|
|
1160
|
-
// Cleanup on quit
|
|
1161
1635
|
app.on('before-quit', () => {
|
|
1162
1636
|
stopAllAgents();
|
|
1163
1637
|
});
|