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