vibeteam 0.2.2 → 0.2.4
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/bin/cli.js +334 -38
- package/dist/server/server/index.js +2078 -1822
- package/mcp/server.js +389 -0
- package/package.json +5 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/index-DURW-QlU.js +4330 -0
- package/public/assets/index-DgQWq5c0.css +1 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +14 -0
- package/public/index.html +21 -0
- package/public/logo.svg +22 -0
- package/public/models/.gitkeep +0 -0
- package/public/models/models/.gitkeep +0 -0
- package/public/site.webmanifest +25 -0
- package/public/sounds/sounds/yoshi.mp3 +0 -0
- package/public/sounds/yoshi.mp3 +0 -0
- package/dist/server/server/CommitWatcher.js +0 -307
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
import { createServer } from 'http';
|
|
12
12
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
13
13
|
import { watch } from 'chokidar';
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, unlinkSync, statSync } from 'fs';
|
|
15
|
-
import { exec, execFile } from 'child_process';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, unlinkSync, statSync, readdirSync } from 'fs';
|
|
15
|
+
import { exec, execFile, spawn } from 'child_process';
|
|
16
16
|
import { dirname, resolve, join, extname } from 'path';
|
|
17
17
|
import { hostname } from 'os';
|
|
18
18
|
import { randomUUID, randomBytes } from 'crypto';
|
|
@@ -20,7 +20,6 @@ import { createClient, LiveTranscriptionEvents } from '@deepgram/sdk';
|
|
|
20
20
|
import { DEFAULTS } from '../shared/defaults.js';
|
|
21
21
|
import { GitStatusManager } from './GitStatusManager.js';
|
|
22
22
|
import { ProjectsManager } from './ProjectsManager.js';
|
|
23
|
-
import { CommitWatcher } from './CommitWatcher.js';
|
|
24
23
|
import { fileURLToPath } from 'url';
|
|
25
24
|
// ============================================================================
|
|
26
25
|
// Version (read from package.json)
|
|
@@ -66,7 +65,7 @@ const TMUX_SESSION = process.env.VIBETEAM_TMUX_SESSION ?? DEFAULTS.TMUX_SESSION;
|
|
|
66
65
|
const SESSIONS_FILE = resolve(expandHome(process.env.VIBETEAM_SESSIONS_FILE ?? DEFAULTS.SESSIONS_FILE));
|
|
67
66
|
const TILES_FILE = resolve(expandHome(process.env.VIBETEAM_TILES_FILE ?? '~/.vibeteam/data/tiles.json'));
|
|
68
67
|
/** Time before a "working" session auto-transitions to idle (failsafe for missed events) */
|
|
69
|
-
const WORKING_TIMEOUT_MS =
|
|
68
|
+
const WORKING_TIMEOUT_MS = 300_000; // 5 minutes (increased to accommodate long Claude thinking periods)
|
|
70
69
|
/** Maximum request body size (1MB) - prevents DoS via memory exhaustion */
|
|
71
70
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
72
71
|
/** How often to check for stale "working" sessions */
|
|
@@ -193,20 +192,25 @@ function collectRequestBody(req, maxSize = MAX_BODY_SIZE) {
|
|
|
193
192
|
async function sendToTmuxSafe(tmuxSession, text) {
|
|
194
193
|
// Validate session name
|
|
195
194
|
validateTmuxSession(tmuxSession);
|
|
195
|
+
// Use a unique named buffer to avoid race conditions with tmux's default buffer
|
|
196
|
+
const bufferName = `vt-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
196
197
|
// Create temp file with cryptographically secure random name
|
|
197
198
|
const tempFile = `/tmp/vibeteam-prompt-${Date.now()}-${randomBytes(16).toString('hex')}.txt`;
|
|
198
199
|
writeFileSync(tempFile, text);
|
|
199
200
|
try {
|
|
200
|
-
// Load text into tmux buffer
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// Load text into a NAMED tmux buffer (not the default one, to avoid races)
|
|
202
|
+
log(`[sendToTmuxSafe] Loading buffer ${bufferName} for ${tmuxSession} (${text.length} chars)`);
|
|
203
|
+
await execFileAsync('tmux', ['load-buffer', '-b', bufferName, tempFile]);
|
|
204
|
+
// Paste named buffer into session
|
|
205
|
+
log(`[sendToTmuxSafe] Pasting buffer into ${tmuxSession}`);
|
|
206
|
+
await execFileAsync('tmux', ['paste-buffer', '-b', bufferName, '-t', tmuxSession]);
|
|
204
207
|
// Send Enter to submit - longer delay for multi-line prompts to fully render
|
|
205
208
|
await new Promise(r => setTimeout(r, 500));
|
|
206
209
|
await execFileAsync('tmux', ['send-keys', '-t', tmuxSession, 'Enter']);
|
|
207
210
|
// Second Enter as failsafe (Claude CLI sometimes needs it after large pastes)
|
|
208
211
|
await new Promise(r => setTimeout(r, 100));
|
|
209
212
|
await execFileAsync('tmux', ['send-keys', '-t', tmuxSession, 'Enter']);
|
|
213
|
+
log(`[sendToTmuxSafe] Complete for ${tmuxSession}`);
|
|
210
214
|
}
|
|
211
215
|
finally {
|
|
212
216
|
// Clean up temp file
|
|
@@ -216,8 +220,180 @@ async function sendToTmuxSafe(tmuxSession, text) {
|
|
|
216
220
|
catch {
|
|
217
221
|
// Ignore cleanup errors
|
|
218
222
|
}
|
|
223
|
+
// Clean up named buffer
|
|
224
|
+
try {
|
|
225
|
+
await execFileAsync('tmux', ['delete-buffer', '-b', bufferName]);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Ignore cleanup errors
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Fallback prompt sending method: uses tmux send-keys -l (literal) instead of paste-buffer.
|
|
234
|
+
* This is slower but more reliable as it doesn't depend on tmux's paste buffer mechanism.
|
|
235
|
+
*/
|
|
236
|
+
async function sendPromptFallback(session, prompt) {
|
|
237
|
+
try {
|
|
238
|
+
validateTmuxSession(session.tmuxSession);
|
|
239
|
+
log(`[sendPromptFallback] Sending via send-keys to ${session.tmuxSession} (${prompt.length} chars)`);
|
|
240
|
+
// Use send-keys with -l flag to send text literally (as keystrokes)
|
|
241
|
+
// For very long prompts, split into chunks to avoid tmux buffer limits
|
|
242
|
+
const CHUNK_SIZE = 500;
|
|
243
|
+
for (let i = 0; i < prompt.length; i += CHUNK_SIZE) {
|
|
244
|
+
const chunk = prompt.slice(i, i + CHUNK_SIZE);
|
|
245
|
+
await execFileAsync('tmux', ['send-keys', '-t', session.tmuxSession, '-l', chunk]);
|
|
246
|
+
// Small delay between chunks to let tmux process
|
|
247
|
+
if (i + CHUNK_SIZE < prompt.length) {
|
|
248
|
+
await new Promise(r => setTimeout(r, 50));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Send Enter to submit
|
|
252
|
+
await new Promise(r => setTimeout(r, 300));
|
|
253
|
+
await execFileAsync('tmux', ['send-keys', '-t', session.tmuxSession, 'Enter']);
|
|
254
|
+
await new Promise(r => setTimeout(r, 100));
|
|
255
|
+
await execFileAsync('tmux', ['send-keys', '-t', session.tmuxSession, 'Enter']);
|
|
256
|
+
|
|
257
|
+
// Mark session as working
|
|
258
|
+
session.lastActivity = Date.now();
|
|
259
|
+
session.lastPromptTime = Date.now();
|
|
260
|
+
const prevStatus = session.status;
|
|
261
|
+
session.status = 'working';
|
|
262
|
+
session.currentTool = undefined;
|
|
263
|
+
if (prevStatus !== 'working') {
|
|
264
|
+
session.statusVersion = (session.statusVersion || 0) + 1;
|
|
265
|
+
broadcastSessions();
|
|
266
|
+
}
|
|
267
|
+
log(`[sendPromptFallback] Complete for ${session.tmuxSession}`);
|
|
268
|
+
return { ok: true };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
271
|
+
log(`[sendPromptFallback] Failed for ${session.tmuxSession}: ${msg}`);
|
|
272
|
+
return { ok: false, error: msg };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Wait for a tmux session to be ready for input by polling the pane content.
|
|
278
|
+
* Claude CLI takes a variable amount of time to start rendering its UI.
|
|
279
|
+
* Returns true when ready, false if timed out.
|
|
280
|
+
*/
|
|
281
|
+
async function waitForSessionReady(tmuxSession, timeoutMs = 30000) {
|
|
282
|
+
const start = Date.now();
|
|
283
|
+
const pollInterval = 500;
|
|
284
|
+
log(`[waitForSessionReady] Polling ${tmuxSession} (timeout: ${timeoutMs}ms)`);
|
|
285
|
+
while (Date.now() - start < timeoutMs) {
|
|
286
|
+
try {
|
|
287
|
+
const output = await new Promise((resolve, reject) => {
|
|
288
|
+
execFile('tmux', ['capture-pane', '-t', tmuxSession, '-p', '-S', '-50'],
|
|
289
|
+
{ ...EXEC_OPTIONS, maxBuffer: 1024 * 1024 },
|
|
290
|
+
(error, stdout) => {
|
|
291
|
+
if (error) reject(error);
|
|
292
|
+
else resolve(stdout);
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
// Check for Claude CLI's actual input prompt indicator (❯) or bypass permissions banner
|
|
297
|
+
// Just checking for "any content" triggers too early (shell startup output)
|
|
298
|
+
const hasPrompt = output.includes('❯') || output.includes('bypass permissions');
|
|
299
|
+
if (hasPrompt) {
|
|
300
|
+
const elapsed = Date.now() - start;
|
|
301
|
+
// Log last few lines to confirm what we saw
|
|
302
|
+
const lastLines = output.trim().split('\n').slice(-5).join(' | ');
|
|
303
|
+
log(`[waitForSessionReady] ${tmuxSession} ready after ${elapsed}ms. Last lines: ${lastLines}`);
|
|
304
|
+
// Buffer for the CLI to finish rendering (increased from 300ms)
|
|
305
|
+
await new Promise(r => setTimeout(r, 500));
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {
|
|
309
|
+
log(`[waitForSessionReady] ${tmuxSession} poll error: ${e.message}`);
|
|
310
|
+
}
|
|
311
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
312
|
+
}
|
|
313
|
+
log(`[waitForSessionReady] ${tmuxSession} TIMED OUT after ${timeoutMs}ms`);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Capture tmux pane content for verification.
|
|
319
|
+
*/
|
|
320
|
+
async function captureTmuxPane(tmuxSession) {
|
|
321
|
+
return new Promise((resolve) => {
|
|
322
|
+
execFile('tmux', ['capture-pane', '-t', tmuxSession, '-p', '-S', '-30'],
|
|
323
|
+
{ ...EXEC_OPTIONS, maxBuffer: 1024 * 1024 },
|
|
324
|
+
(error, stdout) => {
|
|
325
|
+
resolve(error ? '' : stdout);
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Wait for a session to be ready, send a prompt, and VERIFY it was received.
|
|
333
|
+
* Uses a verify-and-retry loop: after sending, checks if Claude CLI started
|
|
334
|
+
* processing. If it's still idle at the ❯ prompt, retries the send.
|
|
335
|
+
* Returns true if the prompt was verified as received, false otherwise.
|
|
336
|
+
*/
|
|
337
|
+
async function sendPromptWhenReady(session, prompt, label) {
|
|
338
|
+
const maxAttempts = 3;
|
|
339
|
+
log(`${label}: sendPromptWhenReady called for tmux:${session.tmuxSession} session:${session.id.slice(0, 8)}, prompt length: ${prompt.length}`);
|
|
340
|
+
|
|
341
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
342
|
+
log(`${label}: Attempt ${attempt}/${maxAttempts}`);
|
|
343
|
+
|
|
344
|
+
// Wait for Claude CLI to be ready (only on first attempt; later attempts already waited)
|
|
345
|
+
if (attempt === 1) {
|
|
346
|
+
const ready = await waitForSessionReady(session.tmuxSession);
|
|
347
|
+
log(`${label}: waitForSessionReady returned ${ready}`);
|
|
348
|
+
if (!ready) {
|
|
349
|
+
log(`${label}: Timed out waiting for session to be ready, trying to send anyway`);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
// On retry, give the CLI a moment to stabilize
|
|
353
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
354
|
+
// Re-check readiness
|
|
355
|
+
const ready = await waitForSessionReady(session.tmuxSession, 10000);
|
|
356
|
+
log(`${label}: Retry readiness check returned ${ready}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Send the prompt (use fallback method on retries)
|
|
360
|
+
const useFallback = attempt > 1;
|
|
361
|
+
log(`${label}: Calling sendPromptToSession (attempt ${attempt})${useFallback ? ' [FALLBACK: send-keys]' : ''}`);
|
|
362
|
+
const result = useFallback
|
|
363
|
+
? await sendPromptFallback(session, prompt)
|
|
364
|
+
: await sendPromptToSession(session.id, prompt);
|
|
365
|
+
log(`${label}: sendPromptToSession returned ok=${result.ok}${result.error ? ` error=${result.error}` : ''}`);
|
|
366
|
+
|
|
367
|
+
if (!result.ok) {
|
|
368
|
+
log(`${label}: Send failed: ${result.error}`);
|
|
369
|
+
continue; // Try again
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// VERIFY: Wait then check if Claude CLI actually started processing
|
|
373
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
374
|
+
const paneContent = await captureTmuxPane(session.tmuxSession);
|
|
375
|
+
const lastLines = paneContent.trim().split('\n').slice(-10).join('\n');
|
|
376
|
+
|
|
377
|
+
// Check if agent is still sitting idle at the ❯ prompt with placeholder text
|
|
378
|
+
const hasPlaceholder = paneContent.includes('Try "');
|
|
379
|
+
const hasActivity = paneContent.includes('⏺') || paneContent.includes('Thinking') ||
|
|
380
|
+
paneContent.includes('thinking') || paneContent.includes('Reading') ||
|
|
381
|
+
paneContent.includes('esc to interrupt');
|
|
382
|
+
const isStillIdle = hasPlaceholder && !hasActivity;
|
|
383
|
+
|
|
384
|
+
if (!isStillIdle) {
|
|
385
|
+
log(`${label}: VERIFIED - prompt received (attempt ${attempt}). Agent is active.`);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
log(`${label}: VERIFICATION FAILED (attempt ${attempt}) - agent still idle at ❯ prompt`);
|
|
390
|
+
log(`${label}: Pane last lines:\n${lastLines}`);
|
|
219
391
|
}
|
|
392
|
+
|
|
393
|
+
log(`${label}: All ${maxAttempts} attempts failed - prompt never received`);
|
|
394
|
+
return false;
|
|
220
395
|
}
|
|
396
|
+
|
|
221
397
|
// ============================================================================
|
|
222
398
|
// Git Worktree Helpers
|
|
223
399
|
// ============================================================================
|
|
@@ -375,7 +551,7 @@ async function listWorktreesFromDisk(repoPath) {
|
|
|
375
551
|
function matchWorktreesWithAgents(worktrees, projectId) {
|
|
376
552
|
// Get all agents for this project
|
|
377
553
|
const projectAgents = Array.from(managedSessions.values())
|
|
378
|
-
.filter(s => s.projectId === projectId
|
|
554
|
+
.filter(s => s.projectId === projectId);
|
|
379
555
|
|
|
380
556
|
return worktrees.map(wt => {
|
|
381
557
|
// Find agent using this worktree
|
|
@@ -509,8 +685,7 @@ async function getTmuxSessionsInfo(projectId) {
|
|
|
509
685
|
const projectPath = projectsManager.getProject(projectId)?.path;
|
|
510
686
|
const filteredSessions = sessions.filter(s =>
|
|
511
687
|
s.agentId ||
|
|
512
|
-
(projectPath && s.cwd?.startsWith(projectPath))
|
|
513
|
-
s.sessionName.includes('vibeteam')
|
|
688
|
+
(projectPath && s.cwd?.startsWith(projectPath))
|
|
514
689
|
);
|
|
515
690
|
|
|
516
691
|
resolve(filteredSessions);
|
|
@@ -570,7 +745,7 @@ async function getProjectState(project) {
|
|
|
570
745
|
|
|
571
746
|
// Get agents for health summary
|
|
572
747
|
const projectAgents = Array.from(managedSessions.values())
|
|
573
|
-
.filter(s => s.projectId === project.id
|
|
748
|
+
.filter(s => s.projectId === project.id);
|
|
574
749
|
|
|
575
750
|
const healthSummary = {
|
|
576
751
|
totalWorktrees: worktrees.length,
|
|
@@ -921,823 +1096,1222 @@ const textTiles = new Map();
|
|
|
921
1096
|
const gitStatusManager = new GitStatusManager();
|
|
922
1097
|
/** Project directories manager */
|
|
923
1098
|
const projectsManager = new ProjectsManager();
|
|
924
|
-
/** Commit watcher for Lead agents */
|
|
925
|
-
const commitWatcher = new CommitWatcher();
|
|
926
|
-
/** Project leads registry: projectId -> leadId[] */
|
|
927
|
-
const projectLeads = new Map();
|
|
928
|
-
/** Lead work queues: leadId -> LeadWorkItem[] */
|
|
929
|
-
const leadWorkQueues = new Map();
|
|
930
|
-
/** Lead processing timers: leadId -> timeoutId (for cleanup on delete/pause) */
|
|
931
|
-
const leadTimers = new Map();
|
|
932
|
-
/** Lead roles with default prompts */
|
|
933
|
-
const LEAD_ROLE_PROMPTS = {
|
|
934
|
-
security: `You are a Security Lead agent. Your role is to review code changes for security vulnerabilities.
|
|
935
|
-
|
|
936
|
-
When triggered by a commit, analyze the changes for:
|
|
937
|
-
- Exposed secrets, API keys, passwords, or tokens
|
|
938
|
-
- SQL injection vulnerabilities
|
|
939
|
-
- XSS (Cross-Site Scripting) risks
|
|
940
|
-
- Command injection risks
|
|
941
|
-
- Insecure dependencies
|
|
942
|
-
- Authentication/authorization issues
|
|
943
|
-
- Data exposure risks
|
|
944
|
-
- Insecure file operations
|
|
945
|
-
- CSRF vulnerabilities
|
|
946
|
-
|
|
947
|
-
Provide a concise security assessment with:
|
|
948
|
-
1. Severity rating (Critical, High, Medium, Low, None)
|
|
949
|
-
2. Specific issues found with file/line references
|
|
950
|
-
3. Recommended fixes
|
|
951
|
-
|
|
952
|
-
Be thorough but concise. Focus on actionable findings.`,
|
|
953
|
-
|
|
954
|
-
build: `You are a Build Lead agent. Your role is to ensure builds and tests pass after code changes.
|
|
955
|
-
|
|
956
|
-
When triggered by a commit:
|
|
957
|
-
1. Run the project's build command (npm run build, cargo build, etc.)
|
|
958
|
-
2. Run the test suite
|
|
959
|
-
3. If failures occur:
|
|
960
|
-
- Analyze the error messages
|
|
961
|
-
- Identify the root cause
|
|
962
|
-
- Propose or implement fixes
|
|
963
|
-
|
|
964
|
-
Report:
|
|
965
|
-
1. Build status (Pass/Fail)
|
|
966
|
-
2. Test results summary
|
|
967
|
-
3. Any failures with details
|
|
968
|
-
4. Actions taken or recommended
|
|
969
|
-
|
|
970
|
-
Keep the codebase in a deployable state.`,
|
|
971
|
-
|
|
972
|
-
optimization: `You are an Optimization Lead agent. Your role is to review code changes for performance issues.
|
|
973
|
-
|
|
974
|
-
When triggered by a commit, analyze the changes for:
|
|
975
|
-
- N+1 query patterns
|
|
976
|
-
- Unnecessary re-renders (React)
|
|
977
|
-
- Memory leaks
|
|
978
|
-
- Inefficient algorithms (O(n^2) when O(n) is possible)
|
|
979
|
-
- Missing caching opportunities
|
|
980
|
-
- Bundle size impacts
|
|
981
|
-
- Unnecessary dependencies
|
|
982
|
-
- Synchronous operations that could be async
|
|
983
|
-
|
|
984
|
-
Provide an optimization assessment with:
|
|
985
|
-
1. Performance impact rating (Critical, Significant, Minor, None)
|
|
986
|
-
2. Specific issues with explanations
|
|
987
|
-
3. Suggested optimizations with code examples if helpful
|
|
988
|
-
|
|
989
|
-
Focus on impactful, practical improvements.`,
|
|
990
|
-
|
|
991
|
-
design: `You are a Design Lead agent. Your role is to review UI/UX changes for quality and consistency.
|
|
992
|
-
|
|
993
|
-
When triggered by a commit affecting UI code, check for:
|
|
994
|
-
- Accessibility issues (ARIA labels, keyboard navigation, color contrast)
|
|
995
|
-
- Inconsistent styling or component usage
|
|
996
|
-
- Responsive design issues
|
|
997
|
-
- UX anti-patterns
|
|
998
|
-
- Missing loading/error states
|
|
999
|
-
- Inconsistent spacing or typography
|
|
1000
|
-
- Broken layouts
|
|
1001
|
-
|
|
1002
|
-
Provide a design assessment with:
|
|
1003
|
-
1. Quality rating (Needs Work, Acceptable, Good, Excellent)
|
|
1004
|
-
2. Accessibility issues (must fix)
|
|
1005
|
-
3. Consistency issues
|
|
1006
|
-
4. UX recommendations
|
|
1007
|
-
|
|
1008
|
-
Prioritize accessibility and user experience.`,
|
|
1009
|
-
|
|
1010
|
-
product_designer: `You are a Product Designer agent. When given a feature request:
|
|
1011
|
-
|
|
1012
|
-
1. Analyze the requirements thoroughly
|
|
1013
|
-
2. Research the existing codebase to understand patterns and conventions
|
|
1014
|
-
3. Design a detailed implementation plan including:
|
|
1015
|
-
- Component architecture
|
|
1016
|
-
- Data flow and state management
|
|
1017
|
-
- API changes needed
|
|
1018
|
-
- UI/UX considerations
|
|
1019
|
-
- File changes required
|
|
1020
|
-
|
|
1021
|
-
4. Output actionable, step-by-step implementation instructions
|
|
1022
|
-
|
|
1023
|
-
Your output will be handed off to an implementation agent. Make sure your plan is:
|
|
1024
|
-
- Clear and unambiguous
|
|
1025
|
-
- Follows existing project patterns
|
|
1026
|
-
- Includes specific file paths and code locations
|
|
1027
|
-
- Prioritizes tasks in logical order
|
|
1028
|
-
|
|
1029
|
-
Focus on designing practical, implementable solutions.`,
|
|
1030
|
-
};
|
|
1031
|
-
/** Default trigger modes by role */
|
|
1032
|
-
const LEAD_TRIGGER_MODES = {
|
|
1033
|
-
security: 'watching',
|
|
1034
|
-
build: 'watching',
|
|
1035
|
-
optimization: 'manual',
|
|
1036
|
-
design: 'manual',
|
|
1037
|
-
product_designer: 'interactive',
|
|
1038
|
-
};
|
|
1039
1099
|
|
|
1040
1100
|
// ============================================================================
|
|
1041
|
-
//
|
|
1101
|
+
// Kanban Board - Task Management System
|
|
1042
1102
|
// ============================================================================
|
|
1043
1103
|
|
|
1044
|
-
|
|
1045
|
-
const
|
|
1046
|
-
const COMMIT_ORIGINS_FILE = resolve(PIPELINE_DATA_DIR, 'commit-origins.json');
|
|
1047
|
-
const PIPELINES_FILE = resolve(PIPELINE_DATA_DIR, 'pipelines.json');
|
|
1048
|
-
const PIPELINE_RUNS_FILE = resolve(PIPELINE_DATA_DIR, 'pipeline-runs.json');
|
|
1049
|
-
|
|
1050
|
-
/** Default stage prompts */
|
|
1051
|
-
const PIPELINE_STAGE_PROMPTS = {
|
|
1052
|
-
safety: `You are the Safety Check stage in an automated pipeline.
|
|
1053
|
-
|
|
1054
|
-
Analyze this commit for security vulnerabilities, bugs, and code quality issues.
|
|
1104
|
+
const KANBAN_FILE = resolve(expandHome('~/.vibeteam/data/kanban-tasks.json'));
|
|
1105
|
+
const VALID_COLUMNS = ['ideas', 'planning', 'in_progress', 'ai_review', 'human_review', 'done'];
|
|
1055
1106
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
If no issues found, report "No security issues found."
|
|
1062
|
-
|
|
1063
|
-
Always complete your analysis - don't stop until you've reviewed all changed files.`,
|
|
1107
|
+
class KanbanManager {
|
|
1108
|
+
constructor() {
|
|
1109
|
+
this.tasks = new Map();
|
|
1110
|
+
this.load();
|
|
1111
|
+
}
|
|
1064
1112
|
|
|
1065
|
-
|
|
1113
|
+
load() {
|
|
1114
|
+
try {
|
|
1115
|
+
if (existsSync(KANBAN_FILE)) {
|
|
1116
|
+
const data = JSON.parse(readFileSync(KANBAN_FILE, 'utf8'));
|
|
1117
|
+
if (Array.isArray(data)) {
|
|
1118
|
+
data.forEach(t => this.tasks.set(t.id, t));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
log(`Failed to load kanban tasks: ${e.message}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1066
1125
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1126
|
+
save() {
|
|
1127
|
+
try {
|
|
1128
|
+
const dir = resolve(expandHome('~/.vibeteam/data'));
|
|
1129
|
+
if (!existsSync(dir)) {
|
|
1130
|
+
mkdirSync(dir, { recursive: true });
|
|
1131
|
+
}
|
|
1132
|
+
writeFileSync(KANBAN_FILE, JSON.stringify(Array.from(this.tasks.values()), null, 2));
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
log(`Failed to save kanban tasks: ${e.message}`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1070
1137
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
4. Re-run build/tests to verify
|
|
1138
|
+
getTasks(projectId) {
|
|
1139
|
+
if (!projectId) return Array.from(this.tasks.values());
|
|
1140
|
+
return Array.from(this.tasks.values()).filter(t => t.projectId === projectId);
|
|
1141
|
+
}
|
|
1076
1142
|
|
|
1077
|
-
|
|
1143
|
+
getTask(id) {
|
|
1144
|
+
return this.tasks.get(id);
|
|
1145
|
+
}
|
|
1078
1146
|
|
|
1079
|
-
|
|
1147
|
+
createTask(data) {
|
|
1148
|
+
const task = {
|
|
1149
|
+
id: randomUUID(),
|
|
1150
|
+
projectId: data.projectId,
|
|
1151
|
+
columnId: data.columnId || 'ideas',
|
|
1152
|
+
title: data.title,
|
|
1153
|
+
description: data.description || '',
|
|
1154
|
+
priority: data.priority || 'medium',
|
|
1155
|
+
assignedAgentId: data.assignedAgentId || undefined,
|
|
1156
|
+
position: data.position ?? this.getNextPosition(data.projectId, data.columnId || 'ideas'),
|
|
1157
|
+
createdAt: Date.now(),
|
|
1158
|
+
updatedAt: Date.now(),
|
|
1159
|
+
tags: data.tags || [],
|
|
1160
|
+
attachments: Array.isArray(data.attachments) ? data.attachments : [],
|
|
1161
|
+
archived: false,
|
|
1162
|
+
};
|
|
1163
|
+
this.tasks.set(task.id, task);
|
|
1164
|
+
this.save();
|
|
1165
|
+
return task;
|
|
1166
|
+
}
|
|
1080
1167
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1168
|
+
updateTask(id, updates) {
|
|
1169
|
+
const task = this.tasks.get(id);
|
|
1170
|
+
if (!task) return null;
|
|
1171
|
+
const updated = { ...task, ...updates, updatedAt: Date.now() };
|
|
1172
|
+
// Don't let arbitrary fields override id or projectId
|
|
1173
|
+
updated.id = task.id;
|
|
1174
|
+
updated.projectId = task.projectId;
|
|
1175
|
+
this.tasks.set(id, updated);
|
|
1176
|
+
this.save();
|
|
1177
|
+
return updated;
|
|
1178
|
+
}
|
|
1084
1179
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1180
|
+
deleteTask(id) {
|
|
1181
|
+
const task = this.tasks.get(id);
|
|
1182
|
+
if (!task) return false;
|
|
1183
|
+
this.tasks.delete(id);
|
|
1184
|
+
this.normalizePositions(task.projectId, task.columnId);
|
|
1185
|
+
this.save();
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1087
1188
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
class CommitOriginRegistry {
|
|
1093
|
-
/** Map of commit SHA -> origin info */
|
|
1094
|
-
origins = new Map();
|
|
1189
|
+
moveTask(id, toColumn, position) {
|
|
1190
|
+
const task = this.tasks.get(id);
|
|
1191
|
+
if (!task) return null;
|
|
1192
|
+
if (!VALID_COLUMNS.includes(toColumn)) return null;
|
|
1095
1193
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1194
|
+
// Clamp position to valid range for target column
|
|
1195
|
+
const targetTasks = Array.from(this.tasks.values())
|
|
1196
|
+
.filter(t => t.projectId === task.projectId && t.columnId === toColumn && t.id !== id);
|
|
1197
|
+
const validPosition = Math.max(0, Math.min(position, targetTasks.length));
|
|
1098
1198
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1199
|
+
task.columnId = toColumn;
|
|
1200
|
+
task.position = validPosition;
|
|
1201
|
+
task.updatedAt = Date.now();
|
|
1202
|
+
this.tasks.set(id, task);
|
|
1203
|
+
this.normalizePositions(task.projectId, toColumn);
|
|
1204
|
+
this.save();
|
|
1205
|
+
return task;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
normalizePositions(projectId, columnId) {
|
|
1209
|
+
const columnTasks = Array.from(this.tasks.values())
|
|
1210
|
+
.filter(t => t.projectId === projectId && t.columnId === columnId)
|
|
1211
|
+
.sort((a, b) => a.position - b.position);
|
|
1212
|
+
columnTasks.forEach((task, index) => {
|
|
1213
|
+
if (task.position !== index) {
|
|
1214
|
+
task.position = index;
|
|
1215
|
+
task.updatedAt = Date.now();
|
|
1216
|
+
this.tasks.set(task.id, task);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1101
1219
|
}
|
|
1102
1220
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
*/
|
|
1110
|
-
register(sha, agentId, runId, stageId) {
|
|
1111
|
-
this.origins.set(sha, {
|
|
1112
|
-
origin: 'pipeline',
|
|
1113
|
-
agentId,
|
|
1114
|
-
pipelineRunId: runId,
|
|
1115
|
-
stageId,
|
|
1116
|
-
timestamp: Date.now(),
|
|
1221
|
+
getNextPosition(projectId, columnId) {
|
|
1222
|
+
let max = -1;
|
|
1223
|
+
this.tasks.forEach(t => {
|
|
1224
|
+
if (t.projectId === projectId && t.columnId === columnId && t.position > max) {
|
|
1225
|
+
max = t.position;
|
|
1226
|
+
}
|
|
1117
1227
|
});
|
|
1118
|
-
|
|
1228
|
+
return max + 1;
|
|
1119
1229
|
}
|
|
1230
|
+
}
|
|
1120
1231
|
|
|
1121
|
-
|
|
1122
|
-
* Check if a commit was made by an agent
|
|
1123
|
-
* @param {string} sha - Commit SHA
|
|
1124
|
-
* @returns {boolean}
|
|
1125
|
-
*/
|
|
1126
|
-
isAgentCommit(sha) {
|
|
1127
|
-
return this.origins.has(sha);
|
|
1128
|
-
}
|
|
1232
|
+
const kanbanManager = new KanbanManager();
|
|
1129
1233
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1234
|
+
function broadcastKanbanTasks(projectId) {
|
|
1235
|
+
broadcast({
|
|
1236
|
+
type: 'kanban_tasks',
|
|
1237
|
+
payload: { tasks: kanbanManager.getTasks(projectId) },
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ============================================================================
|
|
1242
|
+
// Planning Pipeline Manager
|
|
1243
|
+
// ============================================================================
|
|
1244
|
+
|
|
1245
|
+
const PIPELINES_DIR = resolve(expandHome('~/.vibeteam/data/pipelines'));
|
|
1246
|
+
const PIPELINE_PHASES = ['spec', 'plan', 'execute', 'review'];
|
|
1247
|
+
|
|
1248
|
+
class PlanningPipelineManager {
|
|
1249
|
+
constructor() {
|
|
1250
|
+
this.pipelines = new Map();
|
|
1251
|
+
this.agentToPipeline = new Map(); // agentId -> pipelineId
|
|
1252
|
+
this._shipTimeouts = new Map(); // pipelineId -> setTimeout handle (kept off pipeline object to avoid circular JSON)
|
|
1253
|
+
this.load();
|
|
1137
1254
|
}
|
|
1138
1255
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1256
|
+
load() {
|
|
1257
|
+
try {
|
|
1258
|
+
const indexFile = join(PIPELINES_DIR, 'index.json');
|
|
1259
|
+
if (existsSync(indexFile)) {
|
|
1260
|
+
const data = JSON.parse(readFileSync(indexFile, 'utf8'));
|
|
1261
|
+
if (Array.isArray(data)) {
|
|
1262
|
+
for (const entry of data) {
|
|
1263
|
+
// Load full state from per-pipeline file
|
|
1264
|
+
// Try worktreePath first (pipelines with worktrees save there), fall back to projectPath
|
|
1265
|
+
let pipelineFile = null;
|
|
1266
|
+
if (entry.worktreePath) {
|
|
1267
|
+
const worktreeFile = join(entry.worktreePath, '.vibeteam', 'plans', entry.taskId, 'pipeline.json');
|
|
1268
|
+
if (existsSync(worktreeFile)) pipelineFile = worktreeFile;
|
|
1269
|
+
}
|
|
1270
|
+
if (!pipelineFile) {
|
|
1271
|
+
const projectFile = join(entry.projectPath, '.vibeteam', 'plans', entry.taskId, 'pipeline.json');
|
|
1272
|
+
if (existsSync(projectFile)) pipelineFile = projectFile;
|
|
1273
|
+
}
|
|
1274
|
+
if (pipelineFile) {
|
|
1275
|
+
try {
|
|
1276
|
+
const pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
1277
|
+
// Backfill worktreeStatus for pipelines created before this field existed
|
|
1278
|
+
if (pipeline.worktreePath && !pipeline.worktreeStatus) {
|
|
1279
|
+
pipeline.worktreeStatus = existsSync(pipeline.worktreePath) ? 'active' : 'cleaned';
|
|
1280
|
+
}
|
|
1281
|
+
this.pipelines.set(pipeline.id, pipeline);
|
|
1282
|
+
// Rebuild agent->pipeline mapping
|
|
1283
|
+
for (const phase of pipeline.phases) {
|
|
1284
|
+
if (phase.agentId && (phase.status === 'running')) {
|
|
1285
|
+
this.agentToPipeline.set(phase.agentId, pipeline.id);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
} catch (e) {
|
|
1289
|
+
log(`Failed to load pipeline ${entry.taskId}: ${e.message}`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
log(`Loaded ${this.pipelines.size} pipeline(s) from index`);
|
|
1294
|
+
}
|
|
1149
1295
|
}
|
|
1296
|
+
} catch (e) {
|
|
1297
|
+
log(`Failed to load pipeline index: ${e.message}`);
|
|
1150
1298
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
this.save();
|
|
1154
|
-
}
|
|
1299
|
+
// Recovery: scan project worktree directories for pipeline files not in the index
|
|
1300
|
+
this.scanForOrphanedPipelines();
|
|
1155
1301
|
}
|
|
1156
1302
|
|
|
1157
|
-
|
|
1158
|
-
* Load from disk
|
|
1159
|
-
*/
|
|
1160
|
-
load() {
|
|
1303
|
+
scanForOrphanedPipelines() {
|
|
1161
1304
|
try {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1305
|
+
const projects = projectsManager.getProjects();
|
|
1306
|
+
for (const project of projects) {
|
|
1307
|
+
const repoName = project.path.split('/').filter(Boolean).pop() || 'repo';
|
|
1308
|
+
const worktreesDir = resolve(project.path, '..', `.${repoName}-worktrees`);
|
|
1309
|
+
if (!existsSync(worktreesDir)) continue;
|
|
1310
|
+
// Scan worktree directories for pipeline files
|
|
1311
|
+
const entries = readdirSync(worktreesDir, { withFileTypes: true });
|
|
1312
|
+
for (const entry of entries) {
|
|
1313
|
+
if (!entry.isDirectory() || !entry.name.startsWith('pipeline-')) continue;
|
|
1314
|
+
const worktreePath = join(worktreesDir, entry.name);
|
|
1315
|
+
const plansDir = join(worktreePath, '.vibeteam', 'plans');
|
|
1316
|
+
if (!existsSync(plansDir)) continue;
|
|
1317
|
+
const taskDirs = readdirSync(plansDir, { withFileTypes: true });
|
|
1318
|
+
for (const taskDir of taskDirs) {
|
|
1319
|
+
if (!taskDir.isDirectory()) continue;
|
|
1320
|
+
const pipelineFile = join(plansDir, taskDir.name, 'pipeline.json');
|
|
1321
|
+
if (!existsSync(pipelineFile)) continue;
|
|
1322
|
+
try {
|
|
1323
|
+
const pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
1324
|
+
if (this.pipelines.has(pipeline.id)) continue; // Already loaded
|
|
1325
|
+
// Backfill worktreeStatus
|
|
1326
|
+
if (pipeline.worktreePath && !pipeline.worktreeStatus) {
|
|
1327
|
+
pipeline.worktreeStatus = existsSync(pipeline.worktreePath) ? 'active' : 'cleaned';
|
|
1328
|
+
}
|
|
1329
|
+
this.pipelines.set(pipeline.id, pipeline);
|
|
1330
|
+
// Rebuild agent->pipeline mapping for running phases
|
|
1331
|
+
for (const phase of (pipeline.phases || [])) {
|
|
1332
|
+
if (phase.agentId && phase.status === 'running') {
|
|
1333
|
+
this.agentToPipeline.set(phase.agentId, pipeline.id);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (pipeline.shipAgentId) {
|
|
1337
|
+
this.agentToPipeline.set(pipeline.shipAgentId, pipeline.id);
|
|
1338
|
+
}
|
|
1339
|
+
log(`Recovered orphaned pipeline: ${pipeline.title?.slice(0, 40)} (${pipeline.id.slice(0, 8)})`);
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
// Ignore individual parse errors
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
if (this.pipelines.size > 0) {
|
|
1347
|
+
// Re-save index with recovered pipelines
|
|
1348
|
+
this.save();
|
|
1166
1349
|
}
|
|
1167
1350
|
} catch (e) {
|
|
1168
|
-
|
|
1351
|
+
log(`Pipeline recovery scan failed: ${e.message}`);
|
|
1169
1352
|
}
|
|
1170
1353
|
}
|
|
1171
1354
|
|
|
1172
|
-
/**
|
|
1173
|
-
* Save to disk
|
|
1174
|
-
*/
|
|
1175
1355
|
save() {
|
|
1176
1356
|
try {
|
|
1177
|
-
if (!existsSync(
|
|
1178
|
-
mkdirSync(
|
|
1357
|
+
if (!existsSync(PIPELINES_DIR)) {
|
|
1358
|
+
mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
1179
1359
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1360
|
+
// Save lightweight index (include worktreePath so load() can find pipeline files in worktrees)
|
|
1361
|
+
const index = Array.from(this.pipelines.values()).map(p => ({
|
|
1362
|
+
id: p.id,
|
|
1363
|
+
taskId: p.taskId,
|
|
1364
|
+
projectId: p.projectId,
|
|
1365
|
+
projectPath: p.projectPath,
|
|
1366
|
+
worktreePath: p.worktreePath || undefined,
|
|
1367
|
+
status: p.status,
|
|
1368
|
+
}));
|
|
1369
|
+
writeFileSync(join(PIPELINES_DIR, 'index.json'), JSON.stringify(index, null, 2));
|
|
1182
1370
|
} catch (e) {
|
|
1183
|
-
|
|
1371
|
+
log(`Failed to save pipeline index: ${e.message}`);
|
|
1184
1372
|
}
|
|
1185
1373
|
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/**
|
|
1189
|
-
* PipelineManager - Orchestrate pipeline execution
|
|
1190
|
-
*/
|
|
1191
|
-
class PipelineManager {
|
|
1192
|
-
/** Pipeline configs: pipelineId -> PipelineConfig */
|
|
1193
|
-
pipelines = new Map();
|
|
1194
|
-
|
|
1195
|
-
/** Active/recent runs: runId -> PipelineRun */
|
|
1196
|
-
runs = new Map();
|
|
1197
|
-
|
|
1198
|
-
/** Commit origin tracker */
|
|
1199
|
-
commitRegistry = new CommitOriginRegistry();
|
|
1200
|
-
|
|
1201
|
-
/** Processing queue: runId -> { timeoutId, stageIndex } */
|
|
1202
|
-
processing = new Map();
|
|
1203
1374
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1375
|
+
savePipeline(pipeline) {
|
|
1376
|
+
try {
|
|
1377
|
+
const planDir = this.getPlanDir(pipeline);
|
|
1378
|
+
if (!existsSync(planDir)) {
|
|
1379
|
+
mkdirSync(planDir, { recursive: true });
|
|
1380
|
+
}
|
|
1381
|
+
const pipelineJson = JSON.stringify(pipeline, null, 2);
|
|
1382
|
+
writeFileSync(join(planDir, 'pipeline.json'), pipelineJson);
|
|
1383
|
+
// Also save at projectPath so load() can always find it (even if worktree is deleted)
|
|
1384
|
+
if (pipeline.worktreePath && pipeline.projectPath) {
|
|
1385
|
+
const projectPlanDir = join(pipeline.projectPath, '.vibeteam', 'plans', pipeline.taskId);
|
|
1386
|
+
try {
|
|
1387
|
+
if (!existsSync(projectPlanDir)) {
|
|
1388
|
+
mkdirSync(projectPlanDir, { recursive: true });
|
|
1389
|
+
}
|
|
1390
|
+
writeFileSync(join(projectPlanDir, 'pipeline.json'), pipelineJson);
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
// Non-fatal: worktree copy is the primary
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
this.save(); // Also save the index
|
|
1396
|
+
} catch (e) {
|
|
1397
|
+
log(`Failed to save pipeline ${pipeline.id}: ${e.message}`);
|
|
1398
|
+
}
|
|
1208
1399
|
}
|
|
1209
1400
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
* 3. Co-Author check
|
|
1216
|
-
* @param {Object} commitInfo
|
|
1217
|
-
* @returns {boolean}
|
|
1218
|
-
*/
|
|
1219
|
-
isPipelineCommit(commitInfo) {
|
|
1220
|
-
// Layer 1: Message prefix
|
|
1221
|
-
if (commitInfo.message.startsWith('[pipeline]')) {
|
|
1222
|
-
debug(`Skipping pipeline commit (prefix): ${commitInfo.shortSha}`);
|
|
1223
|
-
return true;
|
|
1224
|
-
}
|
|
1401
|
+
getPlanDir(pipeline) {
|
|
1402
|
+
// Use worktree path if available (new pipelines), fall back to projectPath (legacy)
|
|
1403
|
+
const basePath = pipeline.worktreePath || pipeline.projectPath;
|
|
1404
|
+
return join(basePath, '.vibeteam', 'plans', pipeline.taskId);
|
|
1405
|
+
}
|
|
1225
1406
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1407
|
+
async createPipeline(taskId, projectId, projectPath, title, description, attachments) {
|
|
1408
|
+
// Check for existing pipeline for this task
|
|
1409
|
+
for (const p of this.pipelines.values()) {
|
|
1410
|
+
if (p.taskId === taskId && p.status === 'running') {
|
|
1411
|
+
throw new Error(`Task already has an active pipeline`);
|
|
1412
|
+
}
|
|
1230
1413
|
}
|
|
1231
1414
|
|
|
1232
|
-
//
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1415
|
+
// Create a shared worktree branch for the pipeline
|
|
1416
|
+
const safeName = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
1417
|
+
const shortId = randomBytes(3).toString('hex');
|
|
1418
|
+
const branch = `pipeline/${safeName}-${shortId}`;
|
|
1419
|
+
let worktreePath;
|
|
1420
|
+
try {
|
|
1421
|
+
worktreePath = await createWorktree(projectPath, branch);
|
|
1422
|
+
log(`Pipeline ${taskId.slice(0, 8)}: Created shared worktree at ${worktreePath} (branch: ${branch})`);
|
|
1423
|
+
} catch (e) {
|
|
1424
|
+
throw new Error(`Failed to create pipeline worktree: ${e.message}`);
|
|
1236
1425
|
}
|
|
1237
1426
|
|
|
1238
|
-
return false;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
/**
|
|
1242
|
-
* Create a new pipeline config for a project
|
|
1243
|
-
* @param {string} projectId
|
|
1244
|
-
* @param {Object} config
|
|
1245
|
-
* @returns {Object} Created pipeline
|
|
1246
|
-
*/
|
|
1247
|
-
createPipeline(projectId, config) {
|
|
1248
1427
|
const pipeline = {
|
|
1249
|
-
id:
|
|
1428
|
+
id: taskId, // 1:1 mapping
|
|
1429
|
+
taskId,
|
|
1250
1430
|
projectId,
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
],
|
|
1258
|
-
|
|
1431
|
+
projectPath,
|
|
1432
|
+
worktreePath, // Shared worktree for all pipeline agents
|
|
1433
|
+
branch, // Branch name for the worktree
|
|
1434
|
+
worktreeStatus: 'active', // Track worktree lifecycle: 'active' | 'cleaned'
|
|
1435
|
+
title,
|
|
1436
|
+
description,
|
|
1437
|
+
attachments: Array.isArray(attachments) ? attachments : [],
|
|
1438
|
+
status: 'running',
|
|
1439
|
+
currentPhase: null,
|
|
1440
|
+
phases: PIPELINE_PHASES.map(phase => ({
|
|
1441
|
+
phase,
|
|
1442
|
+
status: 'pending',
|
|
1443
|
+
})),
|
|
1259
1444
|
createdAt: Date.now(),
|
|
1445
|
+
updatedAt: Date.now(),
|
|
1260
1446
|
};
|
|
1261
1447
|
|
|
1262
|
-
|
|
1263
|
-
this.
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
/**
|
|
1269
|
-
* Get pipelines for a project
|
|
1270
|
-
* @param {string} projectId
|
|
1271
|
-
* @returns {Array}
|
|
1272
|
-
*/
|
|
1273
|
-
getProjectPipelines(projectId) {
|
|
1274
|
-
const result = [];
|
|
1275
|
-
for (const pipeline of this.pipelines.values()) {
|
|
1276
|
-
if (pipeline.projectId === projectId) {
|
|
1277
|
-
result.push(pipeline);
|
|
1278
|
-
}
|
|
1448
|
+
// Create plan directory inside the worktree
|
|
1449
|
+
const planDir = this.getPlanDir(pipeline);
|
|
1450
|
+
if (!existsSync(planDir)) {
|
|
1451
|
+
mkdirSync(planDir, { recursive: true });
|
|
1279
1452
|
}
|
|
1280
|
-
return result;
|
|
1281
|
-
}
|
|
1282
1453
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1454
|
+
this.pipelines.set(pipeline.id, pipeline);
|
|
1455
|
+
this.savePipeline(pipeline);
|
|
1456
|
+
|
|
1457
|
+
// Update kanban task
|
|
1458
|
+
kanbanManager.updateTask(taskId, {
|
|
1459
|
+
pipelineId: pipeline.id,
|
|
1460
|
+
pipelineStatus: 'running',
|
|
1461
|
+
pipelinePhase: 'spec',
|
|
1462
|
+
columnId: 'planning',
|
|
1463
|
+
});
|
|
1464
|
+
const updatedTask = kanbanManager.getTask(taskId);
|
|
1465
|
+
if (updatedTask) {
|
|
1466
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1467
|
+
}
|
|
1291
1468
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
* @param {string} pipelineId
|
|
1295
|
-
* @param {Object} updates
|
|
1296
|
-
* @returns {Object|null}
|
|
1297
|
-
*/
|
|
1298
|
-
updatePipeline(pipelineId, updates) {
|
|
1299
|
-
const pipeline = this.pipelines.get(pipelineId);
|
|
1300
|
-
if (!pipeline) return null;
|
|
1469
|
+
// Start first phase
|
|
1470
|
+
await this.startPhase(pipeline.id, 'spec');
|
|
1301
1471
|
|
|
1302
|
-
Object.assign(pipeline, updates);
|
|
1303
|
-
this.save();
|
|
1304
1472
|
return pipeline;
|
|
1305
1473
|
}
|
|
1306
1474
|
|
|
1307
|
-
|
|
1308
|
-
* Delete a pipeline
|
|
1309
|
-
* @param {string} pipelineId
|
|
1310
|
-
* @returns {boolean}
|
|
1311
|
-
*/
|
|
1312
|
-
deletePipeline(pipelineId) {
|
|
1313
|
-
const deleted = this.pipelines.delete(pipelineId);
|
|
1314
|
-
if (deleted) {
|
|
1315
|
-
this.save();
|
|
1316
|
-
}
|
|
1317
|
-
return deleted;
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
/**
|
|
1321
|
-
* Create a pipeline run for a commit
|
|
1322
|
-
* @param {string} pipelineId
|
|
1323
|
-
* @param {Object} commitInfo
|
|
1324
|
-
* @param {string} triggeredBy - 'commit' or 'manual'
|
|
1325
|
-
* @returns {Object} Created run
|
|
1326
|
-
*/
|
|
1327
|
-
createRun(pipelineId, commitInfo, triggeredBy = 'commit') {
|
|
1475
|
+
async startPhase(pipelineId, phase) {
|
|
1328
1476
|
const pipeline = this.pipelines.get(pipelineId);
|
|
1329
|
-
if (!pipeline)
|
|
1330
|
-
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
retryCount: 0,
|
|
1346
|
-
})),
|
|
1347
|
-
childCommits: [],
|
|
1348
|
-
startedAt: Date.now(),
|
|
1349
|
-
currentStageIndex: 0,
|
|
1477
|
+
if (!pipeline) return;
|
|
1478
|
+
|
|
1479
|
+
const phaseRecord = pipeline.phases.find(p => p.phase === phase);
|
|
1480
|
+
if (!phaseRecord) return;
|
|
1481
|
+
|
|
1482
|
+
pipeline.currentPhase = phase;
|
|
1483
|
+
phaseRecord.status = 'running';
|
|
1484
|
+
phaseRecord.startedAt = Date.now();
|
|
1485
|
+
pipeline.updatedAt = Date.now();
|
|
1486
|
+
|
|
1487
|
+
// Update kanban column based on phase
|
|
1488
|
+
const columnMap = {
|
|
1489
|
+
spec: 'planning',
|
|
1490
|
+
plan: 'planning',
|
|
1491
|
+
execute: 'in_progress',
|
|
1492
|
+
review: 'ai_review',
|
|
1350
1493
|
};
|
|
1494
|
+
const targetColumn = columnMap[phase];
|
|
1495
|
+
if (targetColumn) {
|
|
1496
|
+
kanbanManager.updateTask(pipeline.taskId, {
|
|
1497
|
+
columnId: targetColumn,
|
|
1498
|
+
pipelinePhase: phase,
|
|
1499
|
+
});
|
|
1500
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1501
|
+
if (updatedTask) {
|
|
1502
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1351
1505
|
|
|
1352
|
-
this
|
|
1353
|
-
this.
|
|
1354
|
-
|
|
1506
|
+
// Generate prompt for this phase
|
|
1507
|
+
const prompt = this.getPromptForPhase(pipeline, phase);
|
|
1508
|
+
const planDir = this.getPlanDir(pipeline);
|
|
1355
1509
|
|
|
1356
|
-
//
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
payload: { run },
|
|
1360
|
-
});
|
|
1510
|
+
// Agent naming
|
|
1511
|
+
const namePrefix = { spec: 'Spec', plan: 'Plan', execute: 'Exec', review: 'Review' };
|
|
1512
|
+
const agentName = `${namePrefix[phase]}: ${pipeline.title.slice(0, 30)}`;
|
|
1361
1513
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1514
|
+
try {
|
|
1515
|
+
// Spawn agent in the shared pipeline worktree (isolated from main)
|
|
1516
|
+
const session = await createSession({
|
|
1517
|
+
name: agentName,
|
|
1518
|
+
cwd: pipeline.worktreePath || pipeline.projectPath,
|
|
1519
|
+
worktree: { enabled: false }, // Worktree already created at pipeline level
|
|
1520
|
+
projectId: pipeline.projectId,
|
|
1521
|
+
});
|
|
1364
1522
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1523
|
+
phaseRecord.agentId = session.id;
|
|
1524
|
+
phaseRecord.agentName = agentName;
|
|
1525
|
+
this.agentToPipeline.set(session.id, pipeline.id);
|
|
1526
|
+
|
|
1527
|
+
this.savePipeline(pipeline);
|
|
1528
|
+
this.broadcastPipeline(pipeline);
|
|
1529
|
+
|
|
1530
|
+
// Wait for Claude CLI to be ready, then send prompt
|
|
1531
|
+
const label = `Pipeline ${pipeline.id.slice(0, 8)} [${phase}]`;
|
|
1532
|
+
sendPromptWhenReady(session, prompt, label).then(ok => {
|
|
1533
|
+
if (!ok) {
|
|
1534
|
+
log(`${label}: Failed to start ${phase} phase - prompt not sent`);
|
|
1535
|
+
phaseRecord.status = 'failed';
|
|
1536
|
+
phaseRecord.error = 'Failed to send prompt to agent';
|
|
1537
|
+
pipeline.status = 'failed';
|
|
1538
|
+
pipeline.error = `Failed to send prompt for ${phase} phase`;
|
|
1539
|
+
pipeline.updatedAt = Date.now();
|
|
1540
|
+
this.savePipeline(pipeline);
|
|
1541
|
+
this.broadcastPipeline(pipeline);
|
|
1542
|
+
}
|
|
1543
|
+
}).catch(err => {
|
|
1544
|
+
log(`${label}: UNHANDLED ERROR in sendPromptWhenReady: ${err.message}\n${err.stack}`);
|
|
1545
|
+
phaseRecord.status = 'failed';
|
|
1546
|
+
phaseRecord.error = `Unhandled error: ${err.message}`;
|
|
1547
|
+
pipeline.status = 'failed';
|
|
1548
|
+
pipeline.error = `Unhandled error in ${phase} phase: ${err.message}`;
|
|
1549
|
+
pipeline.updatedAt = Date.now();
|
|
1550
|
+
this.savePipeline(pipeline);
|
|
1551
|
+
this.broadcastPipeline(pipeline);
|
|
1552
|
+
});
|
|
1373
1553
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1554
|
+
} catch (e) {
|
|
1555
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to start ${phase}: ${e.message}`);
|
|
1556
|
+
phaseRecord.status = 'failed';
|
|
1557
|
+
phaseRecord.error = e.message;
|
|
1558
|
+
pipeline.status = 'failed';
|
|
1559
|
+
pipeline.error = `Failed to start ${phase} phase: ${e.message}`;
|
|
1560
|
+
pipeline.updatedAt = Date.now();
|
|
1561
|
+
this.savePipeline(pipeline);
|
|
1562
|
+
this.broadcastPipeline(pipeline);
|
|
1563
|
+
|
|
1564
|
+
// Update kanban
|
|
1565
|
+
kanbanManager.updateTask(pipeline.taskId, { pipelineStatus: 'failed' });
|
|
1566
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1567
|
+
if (updatedTask) {
|
|
1568
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1385
1569
|
}
|
|
1386
1570
|
}
|
|
1387
|
-
// Sort by startedAt descending
|
|
1388
|
-
result.sort((a, b) => b.startedAt - a.startedAt);
|
|
1389
|
-
return result.slice(0, limit);
|
|
1390
1571
|
}
|
|
1391
1572
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const stageIndex = run.currentStageIndex;
|
|
1401
|
-
if (stageIndex >= run.stages.length) {
|
|
1402
|
-
// All stages complete
|
|
1403
|
-
this.completeRun(runId, 'passed');
|
|
1404
|
-
return;
|
|
1573
|
+
getPromptForPhase(pipeline, phase) {
|
|
1574
|
+
const planDir = this.getPlanDir(pipeline);
|
|
1575
|
+
|
|
1576
|
+
// Build image attachment section if any images are attached
|
|
1577
|
+
let imageSection = '';
|
|
1578
|
+
if (Array.isArray(pipeline.attachments) && pipeline.attachments.length > 0) {
|
|
1579
|
+
imageSection = '\n\n**Attached Images (visual reference - use Read tool to view):**\n' +
|
|
1580
|
+
pipeline.attachments.map(a => `- ${a.path}`).join('\n');
|
|
1405
1581
|
}
|
|
1406
1582
|
|
|
1407
|
-
|
|
1583
|
+
switch (phase) {
|
|
1584
|
+
case 'spec':
|
|
1585
|
+
return `You are a Specification Agent for a multi-agent planning pipeline.
|
|
1408
1586
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
run.status = 'awaiting_approval';
|
|
1412
|
-
stage.status = 'awaiting_approval';
|
|
1413
|
-
this.saveRuns();
|
|
1587
|
+
**Task**: ${pipeline.title}
|
|
1588
|
+
**Description**: ${pipeline.description || 'No description provided.'}${imageSection}
|
|
1414
1589
|
|
|
1415
|
-
|
|
1416
|
-
type: 'pipeline_approval_required',
|
|
1417
|
-
payload: { runId, stageId: stage.id, stageName: stage.name },
|
|
1418
|
-
});
|
|
1419
|
-
return;
|
|
1420
|
-
}
|
|
1590
|
+
Your job is to explore the codebase and write a detailed specification document.
|
|
1421
1591
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1592
|
+
**Instructions:**
|
|
1593
|
+
1. Explore the project structure and understand the codebase architecture
|
|
1594
|
+
2. Identify all files relevant to this task
|
|
1595
|
+
3. Analyze the current state of the code related to this task
|
|
1596
|
+
4. Write your specification to: ${planDir}/spec.md
|
|
1425
1597
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
stage.startedAt = Date.now();
|
|
1434
|
-
this.saveRuns();
|
|
1435
|
-
|
|
1436
|
-
broadcast({
|
|
1437
|
-
type: 'pipeline_stage_started',
|
|
1438
|
-
payload: { runId: run.id, stageId: stage.id, stageName: stage.name },
|
|
1439
|
-
});
|
|
1598
|
+
**The spec.md must include:**
|
|
1599
|
+
- **Summary**: What needs to be done (1-2 paragraphs)
|
|
1600
|
+
- **Current State Analysis**: What exists today, how relevant code works
|
|
1601
|
+
- **Files Needing Changes**: List every file that will need modification
|
|
1602
|
+
- **Constraints**: Technical constraints, backwards compatibility concerns
|
|
1603
|
+
- **Acceptance Criteria**: Clear, testable criteria for completion
|
|
1604
|
+
- **Edge Cases**: Potential issues and how to handle them
|
|
1440
1605
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const project = projectsManager.getProject(run.projectId);
|
|
1444
|
-
if (!project) throw new Error('Project not found');
|
|
1606
|
+
**IMPORTANT**: Do NOT implement anything. Only analyze and write the specification.
|
|
1607
|
+
Write the file to: ${planDir}/spec.md`;
|
|
1445
1608
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
- Lines: +${run.triggerCommit.additions} -${run.triggerCommit.deletions}
|
|
1609
|
+
case 'plan': {
|
|
1610
|
+
// Check for human review feedback
|
|
1611
|
+
let humanFeedbackSection = '';
|
|
1612
|
+
const humanReviewPath = join(planDir, 'human_review.md');
|
|
1613
|
+
try {
|
|
1614
|
+
if (existsSync(humanReviewPath)) {
|
|
1615
|
+
const feedback = readFileSync(humanReviewPath, 'utf-8');
|
|
1616
|
+
humanFeedbackSection = `
|
|
1455
1617
|
|
|
1456
|
-
IMPORTANT:
|
|
1457
|
-
Example: git commit -m "[pipeline] Fix: <description>"
|
|
1618
|
+
## IMPORTANT: Human Review Feedback
|
|
1458
1619
|
|
|
1459
|
-
|
|
1620
|
+
The human reviewer has requested changes to the previous implementation. Address this feedback:
|
|
1460
1621
|
|
|
1461
|
-
|
|
1622
|
+
${feedback}
|
|
1462
1623
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1624
|
+
Revise the implementation plan to incorporate these changes. The specification (spec.md) is still valid, but the plan, execution, and review must be redone to address the feedback above.
|
|
1625
|
+
`;
|
|
1626
|
+
}
|
|
1627
|
+
} catch (e) {
|
|
1628
|
+
log(`Failed to read human_review.md for pipeline ${pipeline.id}: ${e.message}`);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return `You are a Planning Agent for a multi-agent planning pipeline.
|
|
1632
|
+
|
|
1633
|
+
**Task**: ${pipeline.title}
|
|
1634
|
+
**Description**: ${pipeline.description || 'No description provided.'}${imageSection}
|
|
1635
|
+
|
|
1636
|
+
Read the specification document at: ${planDir}/spec.md
|
|
1637
|
+
|
|
1638
|
+
Your job is to create a detailed implementation plan as a JSON file.
|
|
1639
|
+
|
|
1640
|
+
**Instructions:**
|
|
1641
|
+
1. Read and understand the specification at ${planDir}/spec.md
|
|
1642
|
+
2. Break the work into ordered subtasks with clear dependencies
|
|
1643
|
+
3. Write your plan to: ${planDir}/implementation_plan.json${humanFeedbackSection}
|
|
1644
|
+
|
|
1645
|
+
**The implementation_plan.json must follow this schema:**
|
|
1646
|
+
\`\`\`json
|
|
1647
|
+
{
|
|
1648
|
+
"version": 1,
|
|
1649
|
+
"taskTitle": "...",
|
|
1650
|
+
"summary": "Brief overall approach",
|
|
1651
|
+
"architecture": {
|
|
1652
|
+
"approach": "High-level approach description",
|
|
1653
|
+
"keyDecisions": ["Decision 1", "Decision 2"],
|
|
1654
|
+
"risksAndMitigations": [{"risk": "...", "mitigation": "..."}]
|
|
1655
|
+
},
|
|
1656
|
+
"subtasks": [
|
|
1657
|
+
{
|
|
1658
|
+
"id": 1,
|
|
1659
|
+
"title": "Short title",
|
|
1660
|
+
"description": "What to do in detail",
|
|
1661
|
+
"files": ["path/to/file.ts"],
|
|
1662
|
+
"dependencies": [],
|
|
1663
|
+
"verificationStrategy": "How to verify this subtask"
|
|
1664
|
+
}
|
|
1665
|
+
],
|
|
1666
|
+
"fileScope": ["all/files/that/will/be/touched.ts"],
|
|
1667
|
+
"testStrategy": "Overall testing approach",
|
|
1668
|
+
"estimatedComplexity": "low|medium|high"
|
|
1669
|
+
}
|
|
1670
|
+
\`\`\`
|
|
1473
1671
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1672
|
+
**IMPORTANT**: Do NOT implement anything. Only plan.
|
|
1673
|
+
Write the file to: ${planDir}/implementation_plan.json`;
|
|
1674
|
+
}
|
|
1476
1675
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1676
|
+
case 'execute':
|
|
1677
|
+
return `You are an Execution Agent for a multi-agent planning pipeline.
|
|
1479
1678
|
|
|
1480
|
-
|
|
1481
|
-
} catch (err) {
|
|
1482
|
-
this.stageFailed(run.id, stage.id, err.message);
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1679
|
+
**Task**: ${pipeline.title}
|
|
1485
1680
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
* @param {string} stageId
|
|
1490
|
-
* @param {string} output
|
|
1491
|
-
*/
|
|
1492
|
-
stageCompleted(runId, stageId, output = '') {
|
|
1493
|
-
const run = this.runs.get(runId);
|
|
1494
|
-
if (!run) return;
|
|
1681
|
+
Read these files before starting:
|
|
1682
|
+
- Specification: ${planDir}/spec.md
|
|
1683
|
+
- Implementation Plan: ${planDir}/implementation_plan.json
|
|
1495
1684
|
|
|
1496
|
-
|
|
1497
|
-
if (!stage) return;
|
|
1685
|
+
Your job is to implement ALL subtasks from the plan, in order.
|
|
1498
1686
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1687
|
+
**Instructions:**
|
|
1688
|
+
1. Read the spec and plan files above
|
|
1689
|
+
2. Implement each subtask in the order specified, respecting dependencies
|
|
1690
|
+
3. After completing each subtask, verify it works as described in its verificationStrategy
|
|
1691
|
+
4. Document what you did in: ${planDir}/execution_log.md
|
|
1504
1692
|
|
|
1505
|
-
|
|
1693
|
+
**The execution_log.md should include for each subtask:**
|
|
1694
|
+
- Subtask ID and title
|
|
1695
|
+
- What was actually done (files modified, approach taken)
|
|
1696
|
+
- Any deviations from the plan and why
|
|
1697
|
+
- Verification result
|
|
1506
1698
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1699
|
+
**IMPORTANT**: Implement everything described in the plan. Be thorough.
|
|
1700
|
+
Write your execution log to: ${planDir}/execution_log.md`;
|
|
1701
|
+
|
|
1702
|
+
case 'review':
|
|
1703
|
+
return `You are a Review Agent for a multi-agent planning pipeline.
|
|
1511
1704
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1705
|
+
**Task**: ${pipeline.title}
|
|
1706
|
+
|
|
1707
|
+
Read these files before starting:
|
|
1708
|
+
- Specification: ${planDir}/spec.md
|
|
1709
|
+
- Implementation Plan: ${planDir}/implementation_plan.json
|
|
1710
|
+
- Execution Log: ${planDir}/execution_log.md
|
|
1711
|
+
|
|
1712
|
+
Your job is to verify the implementation against the spec and plan.
|
|
1713
|
+
|
|
1714
|
+
**Instructions:**
|
|
1715
|
+
1. Read all plan files above
|
|
1716
|
+
2. For each subtask in the plan, verify it was implemented correctly
|
|
1717
|
+
3. Check that acceptance criteria from the spec are met
|
|
1718
|
+
4. Look for bugs, missing edge cases, or incomplete work
|
|
1719
|
+
5. Write your review to: ${planDir}/review.md
|
|
1720
|
+
|
|
1721
|
+
**The review.md must include:**
|
|
1722
|
+
- **Overall Assessment**: PASSED or NEEDS_CHANGES
|
|
1723
|
+
- **Subtask Reviews**: For each subtask, whether it was completed correctly
|
|
1724
|
+
- **Issues Found**: Any bugs, missing features, or concerns
|
|
1725
|
+
- **Suggestions**: Improvements for next iteration (if any)
|
|
1726
|
+
|
|
1727
|
+
If the overall assessment is PASSED, the task is complete.
|
|
1728
|
+
If NEEDS_CHANGES, list specific items that need to be fixed.
|
|
1729
|
+
|
|
1730
|
+
Write your review to: ${planDir}/review.md`;
|
|
1731
|
+
|
|
1732
|
+
default:
|
|
1733
|
+
return '';
|
|
1734
|
+
}
|
|
1514
1735
|
}
|
|
1515
1736
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
if (
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
stage.retryCount++;
|
|
1532
|
-
stage.status = 'retrying';
|
|
1533
|
-
log(`Pipeline stage retrying (${stage.retryCount}/${stage.maxRetries}): ${stage.name}`);
|
|
1534
|
-
this.saveRuns();
|
|
1535
|
-
|
|
1536
|
-
// Retry after a delay
|
|
1537
|
-
setTimeout(() => this.executeStage(run, stage), 2000);
|
|
1737
|
+
onAgentIdle(agentId) {
|
|
1738
|
+
const pipelineId = this.agentToPipeline.get(agentId);
|
|
1739
|
+
if (!pipelineId) return;
|
|
1740
|
+
|
|
1741
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
1742
|
+
if (!pipeline) return;
|
|
1743
|
+
|
|
1744
|
+
// Handle ship agent completion
|
|
1745
|
+
if (pipeline.shipAgentId === agentId) {
|
|
1746
|
+
if (pipeline.shipPromptPending) {
|
|
1747
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Ship agent idle event received but prompt still pending, ignoring`);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Ship agent completed (event-driven)`);
|
|
1751
|
+
this.forceCleanupShipAgent(pipeline);
|
|
1538
1752
|
return;
|
|
1539
1753
|
}
|
|
1540
1754
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
stage.error = error;
|
|
1544
|
-
run.status = 'failed';
|
|
1545
|
-
run.completedAt = Date.now();
|
|
1546
|
-
this.saveRuns();
|
|
1755
|
+
// Remove mapping FIRST to prevent double-advancement from duplicate stop/session_end events
|
|
1756
|
+
this.agentToPipeline.delete(agentId);
|
|
1547
1757
|
|
|
1548
|
-
|
|
1758
|
+
if (pipeline.status !== 'running') return;
|
|
1549
1759
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
payload: { runId, stageId, status: 'failed', error },
|
|
1553
|
-
});
|
|
1760
|
+
const currentPhaseRecord = pipeline.phases.find(p => p.agentId === agentId && p.status === 'running');
|
|
1761
|
+
if (!currentPhaseRecord) return;
|
|
1554
1762
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1763
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: ${currentPhaseRecord.phase} phase completed (agent idle)`);
|
|
1764
|
+
|
|
1765
|
+
// Mark phase as completed
|
|
1766
|
+
currentPhaseRecord.status = 'completed';
|
|
1767
|
+
currentPhaseRecord.completedAt = Date.now();
|
|
1768
|
+
|
|
1769
|
+
// Persist before advancing so crash between phases doesn't lose state
|
|
1770
|
+
this.savePipeline(pipeline);
|
|
1771
|
+
|
|
1772
|
+
// Update kanban task with phase output summary
|
|
1773
|
+
try {
|
|
1774
|
+
const planDir = this.getPlanDir(pipeline);
|
|
1775
|
+
const phase = currentPhaseRecord.phase;
|
|
1776
|
+
let summary = '';
|
|
1777
|
+
let taskUpdates = {};
|
|
1778
|
+
|
|
1779
|
+
if (phase === 'spec') {
|
|
1780
|
+
const specPath = join(planDir, 'spec.md');
|
|
1781
|
+
if (existsSync(specPath)) {
|
|
1782
|
+
const content = readFileSync(specPath, 'utf8');
|
|
1783
|
+
// Extract first 2 paragraphs as summary
|
|
1784
|
+
const paragraphs = content.split(/\n\n+/).filter(p => p.trim()).slice(0, 2);
|
|
1785
|
+
summary = paragraphs.join(' ').slice(0, 200);
|
|
1786
|
+
taskUpdates.description = pipeline.description + '\n\n**Spec Summary:** ' + summary;
|
|
1787
|
+
}
|
|
1788
|
+
} else if (phase === 'plan') {
|
|
1789
|
+
const planPath = join(planDir, 'implementation_plan.json');
|
|
1790
|
+
if (existsSync(planPath)) {
|
|
1791
|
+
try {
|
|
1792
|
+
const planData = JSON.parse(readFileSync(planPath, 'utf8'));
|
|
1793
|
+
const subtaskCount = planData.subtasks?.length || 0;
|
|
1794
|
+
summary = `${planData.summary || 'Plan created'} (${subtaskCount} subtasks)`;
|
|
1795
|
+
taskUpdates.description = pipeline.description + '\n\n**Plan:** ' + summary;
|
|
1796
|
+
} catch (e) {
|
|
1797
|
+
summary = 'Plan file created';
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
} else if (phase === 'execute') {
|
|
1801
|
+
const execPath = join(planDir, 'execution_log.md');
|
|
1802
|
+
if (existsSync(execPath)) {
|
|
1803
|
+
const content = readFileSync(execPath, 'utf8');
|
|
1804
|
+
// Extract first meaningful line as summary
|
|
1805
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
|
1806
|
+
summary = lines[0]?.slice(0, 200) || 'Execution completed';
|
|
1807
|
+
}
|
|
1808
|
+
} else if (phase === 'review') {
|
|
1809
|
+
const reviewPath = join(planDir, 'review.md');
|
|
1810
|
+
if (existsSync(reviewPath)) {
|
|
1811
|
+
const content = readFileSync(reviewPath, 'utf8');
|
|
1812
|
+
// Extract verdict
|
|
1813
|
+
const passMatch = content.match(/PASSED/i);
|
|
1814
|
+
const failMatch = content.match(/NEEDS_CHANGES/i);
|
|
1815
|
+
const verdict = passMatch ? 'PASSED' : failMatch ? 'NEEDS_CHANGES' : 'Unknown';
|
|
1816
|
+
summary = `Review verdict: ${verdict}`;
|
|
1817
|
+
taskUpdates.reviewFeedback = content.slice(0, 500);
|
|
1818
|
+
taskUpdates.reviewStatus = verdict === 'PASSED' ? 'passed' : 'failed';
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
if (summary) {
|
|
1823
|
+
// Add to pipelineLog
|
|
1824
|
+
const task = kanbanManager.getTask(pipeline.taskId);
|
|
1825
|
+
const existingLog = task?.pipelineLog || [];
|
|
1826
|
+
taskUpdates.pipelineLog = [...existingLog, { phase, summary, timestamp: Date.now() }];
|
|
1827
|
+
|
|
1828
|
+
kanbanManager.updateTask(pipeline.taskId, taskUpdates);
|
|
1829
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1830
|
+
if (updatedTask) {
|
|
1831
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
} catch (e) {
|
|
1835
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to update task after ${currentPhaseRecord.phase}: ${e.message}`);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Advance to next phase
|
|
1839
|
+
this.advancePhase(pipeline.id);
|
|
1559
1840
|
}
|
|
1560
1841
|
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
const run = this.runs.get(runId);
|
|
1568
|
-
if (!run) return;
|
|
1842
|
+
onAgentError(agentId) {
|
|
1843
|
+
const pipelineId = this.agentToPipeline.get(agentId);
|
|
1844
|
+
if (!pipelineId) return;
|
|
1845
|
+
|
|
1846
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
1847
|
+
if (!pipeline || pipeline.status !== 'running') return;
|
|
1569
1848
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
this.saveRuns();
|
|
1849
|
+
const currentPhaseRecord = pipeline.phases.find(p => p.agentId === agentId && p.status === 'running');
|
|
1850
|
+
if (!currentPhaseRecord) return;
|
|
1573
1851
|
|
|
1574
|
-
log(`Pipeline
|
|
1852
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: ${currentPhaseRecord.phase} phase failed (agent offline)`);
|
|
1575
1853
|
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1854
|
+
currentPhaseRecord.status = 'failed';
|
|
1855
|
+
currentPhaseRecord.error = 'Agent went offline';
|
|
1856
|
+
this.agentToPipeline.delete(agentId);
|
|
1857
|
+
|
|
1858
|
+
pipeline.status = 'failed';
|
|
1859
|
+
pipeline.error = `${currentPhaseRecord.phase} phase failed: agent went offline`;
|
|
1860
|
+
pipeline.updatedAt = Date.now();
|
|
1861
|
+
this.savePipeline(pipeline);
|
|
1862
|
+
this.broadcastPipeline(pipeline);
|
|
1863
|
+
|
|
1864
|
+
// Update kanban
|
|
1865
|
+
kanbanManager.updateTask(pipeline.taskId, { pipelineStatus: 'failed' });
|
|
1866
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1867
|
+
if (updatedTask) {
|
|
1868
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1869
|
+
}
|
|
1870
|
+
this.cleanupAgents(pipeline);
|
|
1871
|
+
this.cleanupWorktree(pipeline);
|
|
1872
|
+
this.scheduleCleanup(pipeline.id);
|
|
1580
1873
|
}
|
|
1581
1874
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1875
|
+
async advancePhase(pipelineId) {
|
|
1876
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
1877
|
+
if (!pipeline || pipeline.status !== 'running') return;
|
|
1878
|
+
|
|
1879
|
+
const currentIdx = PIPELINE_PHASES.indexOf(pipeline.currentPhase);
|
|
1880
|
+
const nextIdx = currentIdx + 1;
|
|
1881
|
+
|
|
1882
|
+
if (nextIdx >= PIPELINE_PHASES.length) {
|
|
1883
|
+
// All phases complete
|
|
1884
|
+
pipeline.status = 'completed';
|
|
1885
|
+
pipeline.currentPhase = null;
|
|
1886
|
+
pipeline.updatedAt = Date.now();
|
|
1887
|
+
this.savePipeline(pipeline);
|
|
1888
|
+
this.broadcastPipeline(pipeline);
|
|
1889
|
+
|
|
1890
|
+
// Move task to human_review
|
|
1891
|
+
kanbanManager.updateTask(pipeline.taskId, {
|
|
1892
|
+
columnId: 'human_review',
|
|
1893
|
+
pipelineStatus: 'completed',
|
|
1894
|
+
pipelinePhase: null,
|
|
1895
|
+
});
|
|
1896
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1897
|
+
if (updatedTask) {
|
|
1898
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: All phases completed!`);
|
|
1902
|
+
this.cleanupAgents(pipeline);
|
|
1903
|
+
this.scheduleCleanup(pipeline.id);
|
|
1904
|
+
return;
|
|
1602
1905
|
}
|
|
1603
1906
|
|
|
1604
|
-
|
|
1907
|
+
const nextPhase = PIPELINE_PHASES[nextIdx];
|
|
1908
|
+
this.savePipeline(pipeline);
|
|
1909
|
+
this.broadcastPipeline(pipeline);
|
|
1910
|
+
|
|
1911
|
+
// Small delay before starting next phase
|
|
1912
|
+
setTimeout(() => {
|
|
1913
|
+
this.startPhase(pipelineId, nextPhase);
|
|
1914
|
+
}, 3000);
|
|
1605
1915
|
}
|
|
1606
1916
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
*/
|
|
1611
|
-
cancelRun(runId) {
|
|
1612
|
-
const run = this.runs.get(runId);
|
|
1613
|
-
if (!run || run.status === 'passed' || run.status === 'failed') return false;
|
|
1917
|
+
async cancelPipeline(pipelineId) {
|
|
1918
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
1919
|
+
if (!pipeline) return null;
|
|
1614
1920
|
|
|
1615
|
-
|
|
1616
|
-
|
|
1921
|
+
// Mark running phase as failed/cancelled
|
|
1922
|
+
const runningPhase = pipeline.phases.find(p => p.status === 'running');
|
|
1923
|
+
if (runningPhase) {
|
|
1924
|
+
runningPhase.status = 'failed';
|
|
1925
|
+
runningPhase.error = 'Cancelled';
|
|
1926
|
+
}
|
|
1617
1927
|
|
|
1618
|
-
// Mark pending
|
|
1619
|
-
|
|
1620
|
-
if (
|
|
1621
|
-
|
|
1622
|
-
|
|
1928
|
+
// Mark remaining pending phases as skipped
|
|
1929
|
+
pipeline.phases.forEach(p => {
|
|
1930
|
+
if (p.status === 'pending') p.status = 'skipped';
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
pipeline.status = 'cancelled';
|
|
1934
|
+
pipeline.currentPhase = null;
|
|
1935
|
+
pipeline.updatedAt = Date.now();
|
|
1936
|
+
this.savePipeline(pipeline);
|
|
1937
|
+
this.broadcastPipeline(pipeline);
|
|
1938
|
+
|
|
1939
|
+
// Cleanup ALL agents from all phases
|
|
1940
|
+
this.cleanupAgents(pipeline);
|
|
1941
|
+
|
|
1942
|
+
// Update kanban
|
|
1943
|
+
kanbanManager.updateTask(pipeline.taskId, {
|
|
1944
|
+
pipelineStatus: 'cancelled',
|
|
1945
|
+
pipelinePhase: null,
|
|
1946
|
+
});
|
|
1947
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1948
|
+
if (updatedTask) {
|
|
1949
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1623
1950
|
}
|
|
1624
1951
|
|
|
1625
|
-
|
|
1952
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Cancelled`);
|
|
1953
|
+
this.cleanupWorktree(pipeline);
|
|
1954
|
+
this.scheduleCleanup(pipeline.id);
|
|
1955
|
+
return pipeline;
|
|
1956
|
+
}
|
|
1626
1957
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1958
|
+
async retryPipeline(pipelineId) {
|
|
1959
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
1960
|
+
if (!pipeline || pipeline.status !== 'failed') return null;
|
|
1961
|
+
|
|
1962
|
+
// Find the failed phase
|
|
1963
|
+
const failedPhase = pipeline.phases.find(p => p.status === 'failed');
|
|
1964
|
+
if (!failedPhase) return null;
|
|
1965
|
+
|
|
1966
|
+
// Reset failed phase
|
|
1967
|
+
failedPhase.status = 'pending';
|
|
1968
|
+
failedPhase.error = undefined;
|
|
1969
|
+
failedPhase.agentId = undefined;
|
|
1970
|
+
failedPhase.agentName = undefined;
|
|
1971
|
+
failedPhase.startedAt = undefined;
|
|
1972
|
+
failedPhase.completedAt = undefined;
|
|
1973
|
+
|
|
1974
|
+
pipeline.status = 'running';
|
|
1975
|
+
pipeline.error = undefined;
|
|
1976
|
+
pipeline.updatedAt = Date.now();
|
|
1977
|
+
|
|
1978
|
+
// Update kanban
|
|
1979
|
+
kanbanManager.updateTask(pipeline.taskId, {
|
|
1980
|
+
pipelineStatus: 'running',
|
|
1981
|
+
pipelinePhase: failedPhase.phase,
|
|
1630
1982
|
});
|
|
1983
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
1984
|
+
if (updatedTask) {
|
|
1985
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1986
|
+
}
|
|
1631
1987
|
|
|
1632
|
-
|
|
1988
|
+
// Restart the failed phase
|
|
1989
|
+
await this.startPhase(pipelineId, failedPhase.phase);
|
|
1990
|
+
|
|
1991
|
+
return pipeline;
|
|
1633
1992
|
}
|
|
1634
1993
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1994
|
+
async requestChanges(pipelineId, feedback) {
|
|
1995
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
1996
|
+
if (!pipeline || pipeline.status !== 'completed') return null;
|
|
1997
|
+
|
|
1998
|
+
// Write human feedback to plan directory
|
|
1999
|
+
const planDir = this.getPlanDir(pipeline);
|
|
2000
|
+
if (!existsSync(planDir)) {
|
|
2001
|
+
mkdirSync(planDir, { recursive: true });
|
|
2002
|
+
}
|
|
2003
|
+
writeFileSync(join(planDir, 'human_review.md'), feedback);
|
|
2004
|
+
|
|
2005
|
+
// Reset plan, execute, review phases to pending (keep spec)
|
|
2006
|
+
for (const phase of pipeline.phases) {
|
|
2007
|
+
if (phase.phase !== 'spec') {
|
|
2008
|
+
phase.status = 'pending';
|
|
2009
|
+
phase.error = undefined;
|
|
2010
|
+
phase.agentId = undefined;
|
|
2011
|
+
phase.agentName = undefined;
|
|
2012
|
+
phase.startedAt = undefined;
|
|
2013
|
+
phase.completedAt = undefined;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1644
2016
|
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
2017
|
+
pipeline.status = 'running';
|
|
2018
|
+
pipeline.currentPhase = 'plan';
|
|
2019
|
+
pipeline.error = undefined;
|
|
2020
|
+
pipeline.updatedAt = Date.now();
|
|
2021
|
+
|
|
2022
|
+
// Update kanban task
|
|
2023
|
+
kanbanManager.updateTask(pipeline.taskId, {
|
|
2024
|
+
columnId: 'planning',
|
|
2025
|
+
pipelineStatus: 'running',
|
|
2026
|
+
pipelinePhase: 'plan',
|
|
2027
|
+
humanNotes: feedback,
|
|
2028
|
+
});
|
|
2029
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
2030
|
+
if (updatedTask) {
|
|
2031
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
1650
2032
|
}
|
|
2033
|
+
|
|
2034
|
+
this.savePipeline(pipeline);
|
|
2035
|
+
this.broadcastPipeline(pipeline);
|
|
2036
|
+
|
|
2037
|
+
// Restart from plan phase
|
|
2038
|
+
await this.startPhase(pipelineId, 'plan');
|
|
2039
|
+
|
|
2040
|
+
return pipeline;
|
|
1651
2041
|
}
|
|
1652
2042
|
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2043
|
+
async shipToMain(pipelineId, targetBranch) {
|
|
2044
|
+
const pipeline = this.getPipeline(pipelineId);
|
|
2045
|
+
if (!pipeline) return null;
|
|
2046
|
+
if (pipeline.status !== 'completed') {
|
|
2047
|
+
throw new Error('Pipeline must be completed before shipping');
|
|
2048
|
+
}
|
|
2049
|
+
if (pipeline.shipAgentId) {
|
|
2050
|
+
throw new Error('Ship agent already running');
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const mergeTo = targetBranch || 'main';
|
|
2054
|
+
const pipelineBranch = pipeline.branch || 'main';
|
|
2055
|
+
const worktreeCwd = pipeline.worktreePath || pipeline.projectPath;
|
|
2056
|
+
|
|
2057
|
+
const prompt = `You are a Ship Agent. Your job is to commit the work done in the pipeline worktree for task "${pipeline.title}", then merge it into ${mergeTo}.
|
|
2058
|
+
|
|
2059
|
+
You are running in the pipeline worktree at: ${worktreeCwd}
|
|
2060
|
+
The pipeline branch is: ${pipelineBranch}
|
|
2061
|
+
The target branch to merge into is: ${mergeTo}
|
|
2062
|
+
|
|
2063
|
+
Steps:
|
|
2064
|
+
1. Run \`git status\` to see all changes in this worktree
|
|
2065
|
+
2. Stage all relevant changes (avoid .vibeteam/ directory)
|
|
2066
|
+
3. Create a descriptive commit message based on the task: "${pipeline.title}"
|
|
2067
|
+
4. Commit the changes to the pipeline branch
|
|
2068
|
+
5. Now switch to the main repo and merge into ${mergeTo}:
|
|
2069
|
+
- Run \`git -C ${pipeline.projectPath} checkout ${mergeTo}\` (if the branch doesn't exist, create it with \`git -C ${pipeline.projectPath} checkout -b ${mergeTo}\`)
|
|
2070
|
+
- Run \`git -C ${pipeline.projectPath} merge ${pipelineBranch}\`
|
|
2071
|
+
6. Push ${mergeTo}: \`git -C ${pipeline.projectPath} push origin ${mergeTo}\`
|
|
2072
|
+
7. If there are merge conflicts, resolve them
|
|
2073
|
+
|
|
2074
|
+
After pushing, your work is done.`;
|
|
2075
|
+
|
|
2076
|
+
try {
|
|
2077
|
+
const session = await createSession({
|
|
2078
|
+
name: `Ship: ${pipeline.title.slice(0, 30)}`,
|
|
2079
|
+
cwd: worktreeCwd, // Run in the pipeline worktree so it can see & commit the changes
|
|
2080
|
+
worktree: { enabled: false }, // Don't create another worktree
|
|
2081
|
+
projectId: pipeline.projectId,
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
pipeline.shipAgentId = session.id;
|
|
2085
|
+
this.agentToPipeline.set(session.id, pipeline.id);
|
|
2086
|
+
pipeline.updatedAt = Date.now();
|
|
2087
|
+
|
|
2088
|
+
// Watchdog: force-cleanup ship agent after 5 minutes if event-driven cleanup never fires
|
|
2089
|
+
// Store timeout handle in a separate Map (NOT on pipeline object) to avoid
|
|
2090
|
+
// circular reference errors when JSON.stringify serializes the pipeline.
|
|
2091
|
+
const shipTimeoutHandle = setTimeout(() => {
|
|
2092
|
+
if (pipeline.shipAgentId === session.id) {
|
|
2093
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Ship agent timed out after 5 minutes, forcing cleanup`);
|
|
2094
|
+
pipeline.shipPromptPending = false; // Clear stuck flag so cleanup can proceed
|
|
2095
|
+
this.forceCleanupShipAgent(pipeline);
|
|
1666
2096
|
}
|
|
1667
|
-
}
|
|
2097
|
+
}, 300_000); // 5 minutes
|
|
2098
|
+
this._shipTimeouts.set(pipeline.id, shipTimeoutHandle);
|
|
2099
|
+
|
|
2100
|
+
this.savePipeline(pipeline);
|
|
2101
|
+
this.broadcastPipeline(pipeline);
|
|
2102
|
+
|
|
2103
|
+
// Use sendPromptWhenReady — the SAME proven function that works for all phases
|
|
2104
|
+
// (spec, plan, execute, review). It polls for the ❯ prompt before sending.
|
|
2105
|
+
const label = `Pipeline ${pipeline.id.slice(0, 8)} [ship]`;
|
|
2106
|
+
pipeline.shipPromptPending = true;
|
|
2107
|
+
|
|
2108
|
+
sendPromptWhenReady(session, prompt, label).then(ok => {
|
|
2109
|
+
pipeline.shipPromptPending = false;
|
|
2110
|
+
if (!ok) {
|
|
2111
|
+
log(`${label}: Failed to deliver ship prompt after all attempts`);
|
|
2112
|
+
// Don't cleanup — leave the agent alive so user can manually send prompt
|
|
2113
|
+
}
|
|
2114
|
+
}).catch(err => {
|
|
2115
|
+
pipeline.shipPromptPending = false;
|
|
2116
|
+
log(`${label}: UNHANDLED ERROR in ship sendPromptWhenReady: ${err.message}\n${err.stack}`);
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
return pipeline;
|
|
2120
|
+
} catch (e) {
|
|
2121
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to start ship agent: ${e.message}`);
|
|
2122
|
+
throw e;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
getPipeline(id) {
|
|
2127
|
+
let pipeline = this.pipelines.get(id);
|
|
2128
|
+
if (!pipeline) {
|
|
2129
|
+
pipeline = this.loadPipelineFromDisk(id);
|
|
1668
2130
|
}
|
|
2131
|
+
return pipeline;
|
|
1669
2132
|
}
|
|
1670
2133
|
|
|
1671
2134
|
/**
|
|
1672
|
-
*
|
|
2135
|
+
* Try to reload a pipeline from disk that was cleaned up from memory.
|
|
2136
|
+
* Pipeline ID === task ID, so we look up the task to get the project path.
|
|
1673
2137
|
*/
|
|
1674
|
-
|
|
2138
|
+
loadPipelineFromDisk(pipelineId) {
|
|
1675
2139
|
try {
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
2140
|
+
const task = kanbanManager.getTask(pipelineId);
|
|
2141
|
+
if (!task?.projectId) return null;
|
|
2142
|
+
const project = projectsManager.getProject(task.projectId);
|
|
2143
|
+
if (!project?.path) return null;
|
|
2144
|
+
|
|
2145
|
+
// Check main project path first
|
|
2146
|
+
const pipelineFile = join(project.path, '.vibeteam', 'plans', pipelineId, 'pipeline.json');
|
|
2147
|
+
if (existsSync(pipelineFile)) {
|
|
2148
|
+
const pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
2149
|
+
this.pipelines.set(pipeline.id, pipeline);
|
|
2150
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Reloaded from disk`);
|
|
2151
|
+
return pipeline;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Check worktree directories (pipelines run in worktrees store files there)
|
|
2155
|
+
const repoName = getRepoName(project.path);
|
|
2156
|
+
const worktreesDir = resolve(project.path, '..', `.${repoName}-worktrees`);
|
|
2157
|
+
if (existsSync(worktreesDir)) {
|
|
2158
|
+
const entries = readdirSync(worktreesDir);
|
|
2159
|
+
for (const entry of entries) {
|
|
2160
|
+
const wtPipelineFile = join(worktreesDir, entry, '.vibeteam', 'plans', pipelineId, 'pipeline.json');
|
|
2161
|
+
if (existsSync(wtPipelineFile)) {
|
|
2162
|
+
const pipeline = JSON.parse(readFileSync(wtPipelineFile, 'utf8'));
|
|
2163
|
+
this.pipelines.set(pipeline.id, pipeline);
|
|
2164
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Reloaded from worktree disk`);
|
|
2165
|
+
return pipeline;
|
|
1681
2166
|
}
|
|
1682
2167
|
}
|
|
1683
|
-
debug(`Loaded ${this.pipelines.size} pipelines`);
|
|
1684
2168
|
}
|
|
2169
|
+
|
|
2170
|
+
return null;
|
|
1685
2171
|
} catch (e) {
|
|
1686
|
-
|
|
2172
|
+
log(`Failed to reload pipeline ${pipelineId.slice(0, 8)} from disk: ${e.message}`);
|
|
2173
|
+
return null;
|
|
1687
2174
|
}
|
|
2175
|
+
}
|
|
1688
2176
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
2177
|
+
getPipelines(projectId) {
|
|
2178
|
+
if (!projectId) return Array.from(this.pipelines.values());
|
|
2179
|
+
return Array.from(this.pipelines.values()).filter(p => p.projectId === projectId);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
getActivePipelines() {
|
|
2183
|
+
return Array.from(this.pipelines.values()).filter(p => p.status === 'running');
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Called when a session is manually deleted. If the session is a ship agent,
|
|
2188
|
+
* clear pipeline.shipAgentId and cancel the watchdog.
|
|
2189
|
+
*/
|
|
2190
|
+
onSessionDeleted(sessionId) {
|
|
2191
|
+
const pipelineId = this.agentToPipeline.get(sessionId);
|
|
2192
|
+
if (!pipelineId) return;
|
|
2193
|
+
|
|
2194
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
2195
|
+
if (!pipeline) return;
|
|
2196
|
+
|
|
2197
|
+
this.agentToPipeline.delete(sessionId);
|
|
2198
|
+
|
|
2199
|
+
if (pipeline.shipAgentId === sessionId) {
|
|
2200
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Ship agent manually deleted, clearing shipAgentId`);
|
|
2201
|
+
const th = this._shipTimeouts.get(pipeline.id);
|
|
2202
|
+
if (th) {
|
|
2203
|
+
clearTimeout(th);
|
|
2204
|
+
this._shipTimeouts.delete(pipeline.id);
|
|
1699
2205
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
2206
|
+
pipeline.shipAgentId = undefined;
|
|
2207
|
+
pipeline.updatedAt = Date.now();
|
|
2208
|
+
this.savePipeline(pipeline);
|
|
2209
|
+
this.broadcastPipeline(pipeline);
|
|
1702
2210
|
}
|
|
1703
2211
|
}
|
|
1704
2212
|
|
|
1705
2213
|
/**
|
|
1706
|
-
*
|
|
2214
|
+
* Force-cleanup a ship agent. Called by onAgentIdle (event-driven) or the timeout watchdog.
|
|
2215
|
+
* Idempotent: returns early if shipAgentId is already cleared.
|
|
1707
2216
|
*/
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2217
|
+
forceCleanupShipAgent(pipeline) {
|
|
2218
|
+
if (!pipeline.shipAgentId) return;
|
|
2219
|
+
|
|
2220
|
+
const agentId = pipeline.shipAgentId;
|
|
2221
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Cleaning up ship agent (${agentId.slice(0, 8)})`);
|
|
2222
|
+
|
|
2223
|
+
// Clear the watchdog timeout if it exists
|
|
2224
|
+
const timeoutHandle = this._shipTimeouts.get(pipeline.id);
|
|
2225
|
+
if (timeoutHandle) {
|
|
2226
|
+
clearTimeout(timeoutHandle);
|
|
2227
|
+
this._shipTimeouts.delete(pipeline.id);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// Remove agent-to-pipeline mapping
|
|
2231
|
+
this.agentToPipeline.delete(agentId);
|
|
2232
|
+
|
|
2233
|
+
// Move task to done
|
|
2234
|
+
const task = kanbanManager.getTask(pipeline.taskId);
|
|
2235
|
+
const existingLog = task?.pipelineLog || [];
|
|
2236
|
+
kanbanManager.updateTask(pipeline.taskId, {
|
|
2237
|
+
columnId: 'done',
|
|
2238
|
+
pipelineLog: [...existingLog, { phase: 'ship', summary: 'Changes committed and pushed', timestamp: Date.now() }],
|
|
2239
|
+
});
|
|
2240
|
+
const updatedTask = kanbanManager.getTask(pipeline.taskId);
|
|
2241
|
+
if (updatedTask) {
|
|
2242
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: updatedTask } });
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Delete the ship agent session
|
|
2246
|
+
deleteSession(agentId).catch(e => {
|
|
2247
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to cleanup ship agent: ${e.message}`);
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
// Cleanup all phase agents
|
|
2251
|
+
this.cleanupAgents(pipeline);
|
|
2252
|
+
|
|
2253
|
+
// NOTE: Do NOT call cleanupWorktree - worktree deletion is manual per spec
|
|
2254
|
+
|
|
2255
|
+
// Clear shipAgentId and broadcast - preserve worktree for user inspection
|
|
2256
|
+
pipeline.shipAgentId = undefined;
|
|
2257
|
+
pipeline.worktreeStatus = 'active';
|
|
2258
|
+
pipeline.updatedAt = Date.now();
|
|
2259
|
+
this.savePipeline(pipeline);
|
|
2260
|
+
this.broadcastPipeline(pipeline);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
broadcastPipeline(pipeline) {
|
|
2264
|
+
broadcast({ type: 'pipeline_updated', payload: { pipeline } });
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
async cleanupAgents(pipeline) {
|
|
2268
|
+
for (const phase of pipeline.phases) {
|
|
2269
|
+
if (phase.agentId) {
|
|
2270
|
+
this.agentToPipeline.delete(phase.agentId);
|
|
2271
|
+
deleteSession(phase.agentId).catch(e => {
|
|
2272
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to cleanup ${phase.phase} agent: ${e.message}`);
|
|
2273
|
+
});
|
|
1712
2274
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
2275
|
+
}
|
|
2276
|
+
// Also clean up the ship agent if present
|
|
2277
|
+
if (pipeline.shipAgentId) {
|
|
2278
|
+
this.agentToPipeline.delete(pipeline.shipAgentId);
|
|
2279
|
+
deleteSession(pipeline.shipAgentId).catch(e => {
|
|
2280
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to cleanup ship agent: ${e.message}`);
|
|
2281
|
+
});
|
|
1717
2282
|
}
|
|
1718
2283
|
}
|
|
1719
2284
|
|
|
1720
2285
|
/**
|
|
1721
|
-
*
|
|
2286
|
+
* Remove the pipeline's shared worktree (called after ship or cancel).
|
|
1722
2287
|
*/
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
}
|
|
1728
|
-
// Only save recent runs (last 50)
|
|
1729
|
-
const runs = Array.from(this.runs.values())
|
|
1730
|
-
.sort((a, b) => b.startedAt - a.startedAt)
|
|
1731
|
-
.slice(0, 50);
|
|
1732
|
-
writeFileSync(PIPELINE_RUNS_FILE, JSON.stringify(runs, null, 2));
|
|
1733
|
-
} catch (e) {
|
|
1734
|
-
console.error('Failed to save pipeline runs:', e);
|
|
2288
|
+
cleanupWorktree(pipeline) {
|
|
2289
|
+
if (pipeline.worktreePath && pipeline.projectPath) {
|
|
2290
|
+
removeWorktree(pipeline.projectPath, pipeline.worktreePath).catch(e => {
|
|
2291
|
+
log(`Pipeline ${pipeline.id.slice(0, 8)}: Failed to remove worktree: ${e.message}`);
|
|
2292
|
+
});
|
|
1735
2293
|
}
|
|
2294
|
+
pipeline.worktreeStatus = 'cleaned';
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/**
|
|
2298
|
+
* Schedule cleanup of a terminal-state pipeline from memory.
|
|
2299
|
+
* Keeps it in memory for 5 minutes for debugging/UI display, then removes.
|
|
2300
|
+
* The on-disk pipeline.json persists permanently for history.
|
|
2301
|
+
*/
|
|
2302
|
+
scheduleCleanup(pipelineId) {
|
|
2303
|
+
setTimeout(() => {
|
|
2304
|
+
const pipeline = this.pipelines.get(pipelineId);
|
|
2305
|
+
if (pipeline && pipeline.status !== 'running') {
|
|
2306
|
+
this.pipelines.delete(pipelineId);
|
|
2307
|
+
this.save(); // Update index.json
|
|
2308
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Cleaned up from memory`);
|
|
2309
|
+
}
|
|
2310
|
+
}, 300_000); // 5 minutes
|
|
1736
2311
|
}
|
|
1737
2312
|
}
|
|
1738
2313
|
|
|
1739
|
-
|
|
1740
|
-
const pipelineManager = new PipelineManager();
|
|
2314
|
+
const pipelineManager = new PlanningPipelineManager();
|
|
1741
2315
|
|
|
1742
2316
|
/** Active voice transcription sessions (WebSocket client → Deepgram connection) */
|
|
1743
2317
|
const voiceSessions = new Map();
|
|
@@ -1952,6 +2526,9 @@ function pollOutput(session) {
|
|
|
1952
2526
|
if (response && response !== tracker.lastResponse) {
|
|
1953
2527
|
tracker.lastResponse = response;
|
|
1954
2528
|
|
|
2529
|
+
// Update lastActivity when output is streaming - prevents false working timeouts
|
|
2530
|
+
session.lastActivity = Date.now();
|
|
2531
|
+
|
|
1955
2532
|
// Broadcast output to clients
|
|
1956
2533
|
broadcast({
|
|
1957
2534
|
type: 'output',
|
|
@@ -2328,7 +2905,7 @@ function pollPermissions(sessionId, tmuxSession) {
|
|
|
2328
2905
|
// Update session status
|
|
2329
2906
|
const session = managedSessions.get(sessionId);
|
|
2330
2907
|
if (session) {
|
|
2331
|
-
session.status = '
|
|
2908
|
+
session.status = 'needs_attention';
|
|
2332
2909
|
session.currentTool = prompt.tool;
|
|
2333
2910
|
broadcastSessions();
|
|
2334
2911
|
}
|
|
@@ -2344,7 +2921,7 @@ function pollPermissions(sessionId, tmuxSession) {
|
|
|
2344
2921
|
});
|
|
2345
2922
|
// Reset session status
|
|
2346
2923
|
const session = managedSessions.get(sessionId);
|
|
2347
|
-
if (session && session.status === '
|
|
2924
|
+
if (session && session.status === 'needs_attention') {
|
|
2348
2925
|
session.status = 'working';
|
|
2349
2926
|
session.currentTool = undefined;
|
|
2350
2927
|
broadcastSessions();
|
|
@@ -2450,7 +3027,9 @@ async function createSession(options = {}) {
|
|
|
2450
3027
|
// Use project's defaultWorktree setting if not explicitly specified
|
|
2451
3028
|
let worktreeInfo = null;
|
|
2452
3029
|
let worktreeOpts = options.worktree;
|
|
2453
|
-
|
|
3030
|
+
// Only apply project default if no explicit worktree choice was made
|
|
3031
|
+
// (worktree not in request at all, vs explicitly { enabled: false })
|
|
3032
|
+
if (worktreeOpts === undefined && project?.defaultWorktree) {
|
|
2454
3033
|
worktreeOpts = { enabled: true };
|
|
2455
3034
|
}
|
|
2456
3035
|
if (worktreeOpts?.enabled) {
|
|
@@ -2572,6 +3151,7 @@ async function createSession(options = {}) {
|
|
|
2572
3151
|
name,
|
|
2573
3152
|
tmuxSession,
|
|
2574
3153
|
status: 'idle',
|
|
3154
|
+
statusVersion: 0,
|
|
2575
3155
|
createdAt: Date.now(),
|
|
2576
3156
|
lastActivity: Date.now(),
|
|
2577
3157
|
cwd,
|
|
@@ -2603,11 +3183,6 @@ function getSessions() {
|
|
|
2603
3183
|
...session,
|
|
2604
3184
|
gitStatus: gitStatusManager.getStatus(session.id) ?? undefined,
|
|
2605
3185
|
projectId: session.projectId ?? undefined,
|
|
2606
|
-
// Lead-specific fields
|
|
2607
|
-
isLead: session.isLead ?? undefined,
|
|
2608
|
-
leadConfig: session.leadConfig ?? undefined,
|
|
2609
|
-
workQueue: session.workQueue ?? undefined,
|
|
2610
|
-
lastProcessedCommit: session.lastProcessedCommit ?? undefined,
|
|
2611
3186
|
}));
|
|
2612
3187
|
}
|
|
2613
3188
|
/**
|
|
@@ -2684,8 +3259,10 @@ async function deleteSession(id) {
|
|
|
2684
3259
|
async function sendPromptToSession(id, prompt) {
|
|
2685
3260
|
const session = managedSessions.get(id);
|
|
2686
3261
|
if (!session) {
|
|
3262
|
+
log(`[sendPromptToSession] Session ${id.slice(0, 8)} NOT FOUND in managedSessions`);
|
|
2687
3263
|
return { ok: false, error: 'Session not found' };
|
|
2688
3264
|
}
|
|
3265
|
+
log(`[sendPromptToSession] Found session ${session.name} (${id.slice(0, 8)}), tmux:${session.tmuxSession}`);
|
|
2689
3266
|
// Clear stale Claude link before sending prompt so next event can re-link
|
|
2690
3267
|
const timeSinceActivity = Date.now() - session.lastActivity;
|
|
2691
3268
|
if (session.claudeSessionId && timeSinceActivity > 60000) { // 1 minute stale
|
|
@@ -2703,6 +3280,7 @@ async function sendPromptToSession(id, prompt) {
|
|
|
2703
3280
|
session.status = 'working';
|
|
2704
3281
|
session.currentTool = undefined;
|
|
2705
3282
|
if (prevStatus !== 'working') {
|
|
3283
|
+
session.statusVersion = (session.statusVersion || 0) + 1;
|
|
2706
3284
|
broadcastSessions();
|
|
2707
3285
|
}
|
|
2708
3286
|
log(`Prompt sent to ${session.name}: ${prompt.slice(0, 50)}...`);
|
|
@@ -2718,7 +3296,8 @@ async function sendPromptToSession(id, prompt) {
|
|
|
2718
3296
|
* Check if tmux sessions are still alive and update status
|
|
2719
3297
|
*/
|
|
2720
3298
|
function checkSessionHealth() {
|
|
2721
|
-
|
|
3299
|
+
// Get both tmux session list and pane process info
|
|
3300
|
+
execFile('tmux', ['list-sessions', '-F', '#{session_name}'], EXEC_OPTIONS, (error, stdout) => {
|
|
2722
3301
|
if (error) {
|
|
2723
3302
|
// tmux might not be running
|
|
2724
3303
|
for (const session of managedSessions.values()) {
|
|
@@ -2732,15 +3311,71 @@ function checkSessionHealth() {
|
|
|
2732
3311
|
let changed = false;
|
|
2733
3312
|
for (const session of managedSessions.values()) {
|
|
2734
3313
|
const isAlive = activeSessions.has(session.tmuxSession);
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
3314
|
+
if (!isAlive) {
|
|
3315
|
+
if (session.status !== 'offline') {
|
|
3316
|
+
session.status = 'offline';
|
|
3317
|
+
session.statusVersion = (session.statusVersion || 0) + 1;
|
|
3318
|
+
changed = true;
|
|
3319
|
+
// Notify pipeline manager of agent going offline
|
|
3320
|
+
pipelineManager.onAgentError(session.id);
|
|
3321
|
+
}
|
|
3322
|
+
} else if (session.status === 'offline') {
|
|
3323
|
+
// Recover from offline: check if session was recently active
|
|
3324
|
+
// If lastActivity is recent (within timeout), preserve working state
|
|
3325
|
+
const timeSinceActivity = Date.now() - session.lastActivity;
|
|
3326
|
+
if (timeSinceActivity < WORKING_TIMEOUT_MS) {
|
|
3327
|
+
session.status = 'working';
|
|
3328
|
+
} else {
|
|
3329
|
+
session.status = 'idle';
|
|
3330
|
+
}
|
|
3331
|
+
session.statusVersion = (session.statusVersion || 0) + 1;
|
|
3332
|
+
changed = true;
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
if (changed) {
|
|
3336
|
+
broadcastSessions();
|
|
3337
|
+
saveSessions();
|
|
3338
|
+
}
|
|
3339
|
+
// Also check if the Claude process inside each live tmux pane is still running
|
|
3340
|
+
// This detects frozen sessions where tmux is alive but Claude has died
|
|
3341
|
+
checkTmuxPaneProcesses(activeSessions);
|
|
3342
|
+
});
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Check if the Claude process inside each tmux pane is still alive.
|
|
3346
|
+
* Detects frozen sessions where tmux is alive but Claude has died or is unresponsive.
|
|
3347
|
+
*/
|
|
3348
|
+
function checkTmuxPaneProcesses(activeSessions) {
|
|
3349
|
+
execFile('tmux', ['list-panes', '-a', '-F', '#{session_name} #{pane_pid} #{pane_dead}'], EXEC_OPTIONS, (error, stdout) => {
|
|
3350
|
+
if (error) return;
|
|
3351
|
+
const paneInfo = new Map();
|
|
3352
|
+
for (const line of stdout.trim().split('\n')) {
|
|
3353
|
+
const parts = line.split(' ');
|
|
3354
|
+
const sessionName = parts[0];
|
|
3355
|
+
const panePid = parts[1];
|
|
3356
|
+
const paneDead = parts[2];
|
|
3357
|
+
if (sessionName && panePid) {
|
|
3358
|
+
paneInfo.set(sessionName, { pid: parseInt(panePid, 10), dead: paneDead === '1' });
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
let changed = false;
|
|
3362
|
+
for (const session of managedSessions.values()) {
|
|
3363
|
+
if (!activeSessions.has(session.tmuxSession)) continue;
|
|
3364
|
+
if (session.status === 'offline' || session.status === 'stopped') continue;
|
|
3365
|
+
const pane = paneInfo.get(session.tmuxSession);
|
|
3366
|
+
if (!pane) continue;
|
|
3367
|
+
if (pane.dead) {
|
|
3368
|
+
// Pane process died but tmux session is alive — mark offline
|
|
3369
|
+
log(`Session "${session.name}" pane process is dead (pid ${pane.pid}), marking offline`);
|
|
3370
|
+
session.status = 'offline';
|
|
3371
|
+
session.statusVersion = (session.statusVersion || 0) + 1;
|
|
3372
|
+
session.currentTool = undefined;
|
|
2738
3373
|
changed = true;
|
|
2739
3374
|
}
|
|
2740
3375
|
}
|
|
2741
3376
|
if (changed) {
|
|
2742
3377
|
broadcastSessions();
|
|
2743
|
-
saveSessions();
|
|
3378
|
+
saveSessions();
|
|
2744
3379
|
}
|
|
2745
3380
|
});
|
|
2746
3381
|
}
|
|
@@ -2757,6 +3392,7 @@ function checkWorkingTimeout() {
|
|
|
2757
3392
|
if (timeSinceActivity > WORKING_TIMEOUT_MS) {
|
|
2758
3393
|
log(`Session "${session.name}" timed out after ${Math.round(timeSinceActivity / 1000)}s of no activity`);
|
|
2759
3394
|
session.status = 'idle';
|
|
3395
|
+
session.statusVersion = (session.statusVersion || 0) + 1;
|
|
2760
3396
|
session.currentTool = undefined;
|
|
2761
3397
|
changed = true;
|
|
2762
3398
|
}
|
|
@@ -2799,12 +3435,8 @@ function loadSessions() {
|
|
|
2799
3435
|
if (Array.isArray(data.sessions)) {
|
|
2800
3436
|
for (const session of data.sessions) {
|
|
2801
3437
|
// Mark all as offline initially - health check will update
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
session.status = 'watching';
|
|
2805
|
-
} else {
|
|
2806
|
-
session.status = 'offline';
|
|
2807
|
-
}
|
|
3438
|
+
session.status = 'offline';
|
|
3439
|
+
session.statusVersion = 0; // Reset version on load
|
|
2808
3440
|
session.currentTool = undefined;
|
|
2809
3441
|
managedSessions.set(session.id, session);
|
|
2810
3442
|
// Track git status if session has a cwd (pass worktree info for proper ahead/behind tracking)
|
|
@@ -2841,6 +3473,7 @@ function broadcastSessions() {
|
|
|
2841
3473
|
const stateHash = JSON.stringify(sessions.map(s => ({
|
|
2842
3474
|
id: s.id,
|
|
2843
3475
|
status: s.status,
|
|
3476
|
+
statusVersion: s.statusVersion,
|
|
2844
3477
|
currentTool: s.currentTool,
|
|
2845
3478
|
currentActivity: s.currentActivity,
|
|
2846
3479
|
lastActivity: s.lastActivity,
|
|
@@ -2874,26 +3507,6 @@ function broadcastProjects() {
|
|
|
2874
3507
|
});
|
|
2875
3508
|
}
|
|
2876
3509
|
|
|
2877
|
-
/**
|
|
2878
|
-
* Broadcast pipelines to all clients
|
|
2879
|
-
*/
|
|
2880
|
-
function broadcastPipelines() {
|
|
2881
|
-
broadcast({
|
|
2882
|
-
type: 'pipelines',
|
|
2883
|
-
payload: Array.from(pipelineManager.pipelines.values()),
|
|
2884
|
-
});
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
/**
|
|
2888
|
-
* Broadcast pipeline runs to all clients
|
|
2889
|
-
*/
|
|
2890
|
-
function broadcastPipelineRuns() {
|
|
2891
|
-
broadcast({
|
|
2892
|
-
type: 'pipeline_runs',
|
|
2893
|
-
payload: Array.from(pipelineManager.runs.values()),
|
|
2894
|
-
});
|
|
2895
|
-
}
|
|
2896
|
-
|
|
2897
3510
|
// ============================================================================
|
|
2898
3511
|
// Text Tiles (Grid Labels)
|
|
2899
3512
|
// ============================================================================
|
|
@@ -3041,635 +3654,24 @@ function linkClaudeSession(claudeSessionId, managedSessionId) {
|
|
|
3041
3654
|
claudeToManagedMap.set(claudeSessionId, managedSessionId);
|
|
3042
3655
|
}
|
|
3043
3656
|
|
|
3044
|
-
// ============================================================================
|
|
3045
|
-
// Lead Agent Management
|
|
3046
|
-
// ============================================================================
|
|
3047
|
-
|
|
3048
3657
|
/**
|
|
3049
|
-
*
|
|
3658
|
+
* Find managed session by Claude Code session ID
|
|
3050
3659
|
*/
|
|
3051
|
-
|
|
3052
|
-
const
|
|
3053
|
-
if (
|
|
3054
|
-
|
|
3055
|
-
}
|
|
3056
|
-
|
|
3057
|
-
// Determine trigger mode: options > role default
|
|
3058
|
-
const triggerMode = options.triggerMode || LEAD_TRIGGER_MODES[role] || 'manual';
|
|
3059
|
-
|
|
3060
|
-
// Create session with lead configuration
|
|
3061
|
-
const leadConfig = {
|
|
3062
|
-
role,
|
|
3063
|
-
enabled: true,
|
|
3064
|
-
priority: options.priority || 1,
|
|
3065
|
-
triggerMode,
|
|
3066
|
-
autoTrigger: triggerMode === 'watching', // Backward compatibility
|
|
3067
|
-
systemPrompt: options.systemPrompt || LEAD_ROLE_PROMPTS[role],
|
|
3068
|
-
};
|
|
3069
|
-
|
|
3070
|
-
const roleNames = {
|
|
3071
|
-
security: 'Security',
|
|
3072
|
-
build: 'Build',
|
|
3073
|
-
optimization: 'Optimization',
|
|
3074
|
-
design: 'Design',
|
|
3075
|
-
product_designer: 'Product Designer',
|
|
3076
|
-
};
|
|
3077
|
-
|
|
3078
|
-
// Create the session with project name included
|
|
3079
|
-
const roleName = roleNames[role] || role;
|
|
3080
|
-
const leadName = options.name || `${roleName} Lead · ${project.name}`;
|
|
3081
|
-
|
|
3082
|
-
const session = await createSession({
|
|
3083
|
-
name: leadName,
|
|
3084
|
-
cwd: project.path,
|
|
3085
|
-
projectId,
|
|
3086
|
-
flags: { skipPermissions: true },
|
|
3087
|
-
});
|
|
3088
|
-
|
|
3089
|
-
// Add lead configuration to session
|
|
3090
|
-
session.isLead = true;
|
|
3091
|
-
session.leadConfig = leadConfig;
|
|
3092
|
-
session.status = 'watching'; // Leads start in watching mode
|
|
3093
|
-
session.workQueue = [];
|
|
3094
|
-
session.lastProcessedCommit = null;
|
|
3095
|
-
|
|
3096
|
-
// Register lead with project
|
|
3097
|
-
if (!projectLeads.has(projectId)) {
|
|
3098
|
-
projectLeads.set(projectId, []);
|
|
3099
|
-
}
|
|
3100
|
-
projectLeads.get(projectId).push(session.id);
|
|
3101
|
-
|
|
3102
|
-
// Initialize work queue
|
|
3103
|
-
leadWorkQueues.set(session.id, []);
|
|
3104
|
-
|
|
3105
|
-
// Start watching commits for this project if not already
|
|
3106
|
-
if (!commitWatcher.isTracking(projectId)) {
|
|
3107
|
-
commitWatcher.track(projectId, project.path, 'main');
|
|
3660
|
+
function findManagedSession(claudeSessionId) {
|
|
3661
|
+
const managedId = claudeToManagedMap.get(claudeSessionId);
|
|
3662
|
+
if (managedId) {
|
|
3663
|
+
return managedSessions.get(managedId);
|
|
3108
3664
|
}
|
|
3109
|
-
|
|
3110
|
-
log(`Created lead agent: ${session.name} (${role}) for project ${project.name}`);
|
|
3111
|
-
broadcastSessions();
|
|
3112
|
-
saveSessions();
|
|
3113
|
-
saveLeadState();
|
|
3114
|
-
|
|
3115
|
-
return session;
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
/**
|
|
3119
|
-
* Get all leads for a project
|
|
3120
|
-
*/
|
|
3121
|
-
function getProjectLeads(projectId) {
|
|
3122
|
-
const leadIds = projectLeads.get(projectId) || [];
|
|
3123
|
-
return leadIds.map(id => managedSessions.get(id)).filter(Boolean);
|
|
3665
|
+
return undefined;
|
|
3124
3666
|
}
|
|
3125
|
-
|
|
3667
|
+
// ============================================================================
|
|
3668
|
+
// Event Processing
|
|
3669
|
+
// ============================================================================
|
|
3670
|
+
/** Maximum length for tool output before truncation */
|
|
3671
|
+
const MAX_TOOL_OUTPUT_LENGTH = 5000;
|
|
3126
3672
|
/**
|
|
3127
|
-
*
|
|
3128
|
-
|
|
3129
|
-
async function triggerLead(leadId, commitInfo) {
|
|
3130
|
-
const session = managedSessions.get(leadId);
|
|
3131
|
-
if (!session || !session.isLead) {
|
|
3132
|
-
log(`Cannot trigger: ${leadId} is not a lead`);
|
|
3133
|
-
return false;
|
|
3134
|
-
}
|
|
3135
|
-
|
|
3136
|
-
// Create work item
|
|
3137
|
-
const workItem = {
|
|
3138
|
-
id: randomUUID(),
|
|
3139
|
-
leadId,
|
|
3140
|
-
commit: commitInfo,
|
|
3141
|
-
status: 'pending',
|
|
3142
|
-
queuedAt: Date.now(),
|
|
3143
|
-
};
|
|
3144
|
-
|
|
3145
|
-
// Add to work queue
|
|
3146
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
3147
|
-
queue.push(workItem);
|
|
3148
|
-
leadWorkQueues.set(leadId, queue);
|
|
3149
|
-
|
|
3150
|
-
// Update session
|
|
3151
|
-
session.workQueue = queue;
|
|
3152
|
-
|
|
3153
|
-
log(`Queued work item for ${session.name}: commit ${commitInfo.shortSha}`);
|
|
3154
|
-
|
|
3155
|
-
// Broadcast updates
|
|
3156
|
-
broadcast({
|
|
3157
|
-
type: 'lead_queue_update',
|
|
3158
|
-
payload: { leadId, queue },
|
|
3159
|
-
});
|
|
3160
|
-
broadcastSessions();
|
|
3161
|
-
|
|
3162
|
-
// Process queue if not already processing
|
|
3163
|
-
processLeadQueue(leadId);
|
|
3164
|
-
|
|
3165
|
-
return true;
|
|
3166
|
-
}
|
|
3167
|
-
|
|
3168
|
-
/**
|
|
3169
|
-
* Trigger an interactive lead with user input (no commit)
|
|
3170
|
-
*/
|
|
3171
|
-
async function triggerInteractiveLead(leadId, input) {
|
|
3172
|
-
const session = managedSessions.get(leadId);
|
|
3173
|
-
if (!session || !session.isLead) {
|
|
3174
|
-
log(`Cannot trigger: ${leadId} is not a lead`);
|
|
3175
|
-
return { ok: false, error: 'Lead not found' };
|
|
3176
|
-
}
|
|
3177
|
-
|
|
3178
|
-
if (session.leadConfig?.triggerMode !== 'interactive') {
|
|
3179
|
-
log(`Cannot trigger interactively: ${session.name} is not an interactive lead`);
|
|
3180
|
-
return { ok: false, error: 'Lead is not in interactive mode' };
|
|
3181
|
-
}
|
|
3182
|
-
|
|
3183
|
-
// Create work item (no commit for interactive leads)
|
|
3184
|
-
const workItem = {
|
|
3185
|
-
id: randomUUID(),
|
|
3186
|
-
leadId,
|
|
3187
|
-
status: 'pending',
|
|
3188
|
-
queuedAt: Date.now(),
|
|
3189
|
-
input,
|
|
3190
|
-
};
|
|
3191
|
-
|
|
3192
|
-
// Add to work queue
|
|
3193
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
3194
|
-
queue.push(workItem);
|
|
3195
|
-
leadWorkQueues.set(leadId, queue);
|
|
3196
|
-
|
|
3197
|
-
// Update session
|
|
3198
|
-
session.workQueue = queue;
|
|
3199
|
-
|
|
3200
|
-
log(`Queued interactive work item for ${session.name}: "${input.slice(0, 50)}..."`);
|
|
3201
|
-
|
|
3202
|
-
// Broadcast updates
|
|
3203
|
-
broadcast({
|
|
3204
|
-
type: 'lead_queue_update',
|
|
3205
|
-
payload: { leadId, queue },
|
|
3206
|
-
});
|
|
3207
|
-
broadcastSessions();
|
|
3208
|
-
|
|
3209
|
-
// Process queue if not already processing
|
|
3210
|
-
processInteractiveLeadQueue(leadId);
|
|
3211
|
-
|
|
3212
|
-
return { ok: true, workItem };
|
|
3213
|
-
}
|
|
3214
|
-
|
|
3215
|
-
/**
|
|
3216
|
-
* Process the next item in an interactive lead's work queue
|
|
3217
|
-
*/
|
|
3218
|
-
async function processInteractiveLeadQueue(leadId) {
|
|
3219
|
-
const session = managedSessions.get(leadId);
|
|
3220
|
-
if (!session || !session.isLead) return;
|
|
3221
|
-
|
|
3222
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
3223
|
-
const pendingItem = queue.find(item => item.status === 'pending');
|
|
3224
|
-
|
|
3225
|
-
if (!pendingItem) {
|
|
3226
|
-
// No pending items
|
|
3227
|
-
if (session.status !== 'idle') {
|
|
3228
|
-
session.status = 'idle';
|
|
3229
|
-
broadcastSessions();
|
|
3230
|
-
}
|
|
3231
|
-
return;
|
|
3232
|
-
}
|
|
3233
|
-
|
|
3234
|
-
// Check if already processing or awaiting handoff
|
|
3235
|
-
const busyItem = queue.find(item =>
|
|
3236
|
-
item.status === 'processing' || item.status === 'awaiting_handoff'
|
|
3237
|
-
);
|
|
3238
|
-
if (busyItem) {
|
|
3239
|
-
return; // Already busy
|
|
3240
|
-
}
|
|
3241
|
-
|
|
3242
|
-
// Start processing
|
|
3243
|
-
pendingItem.status = 'processing';
|
|
3244
|
-
pendingItem.startedAt = Date.now();
|
|
3245
|
-
session.status = 'working';
|
|
3246
|
-
|
|
3247
|
-
// Build prompt for interactive lead (uses input instead of commit)
|
|
3248
|
-
const systemPrompt = session.leadConfig.systemPrompt || LEAD_ROLE_PROMPTS[session.leadConfig.role] || '';
|
|
3249
|
-
const prompt = `${systemPrompt}
|
|
3250
|
-
|
|
3251
|
-
---
|
|
3252
|
-
|
|
3253
|
-
**Feature Request:**
|
|
3254
|
-
${pendingItem.input}
|
|
3255
|
-
|
|
3256
|
-
Please analyze this request and provide a detailed implementation plan.`;
|
|
3257
|
-
|
|
3258
|
-
log(`Interactive lead ${session.name} processing: "${pendingItem.input.slice(0, 50)}..."`);
|
|
3259
|
-
|
|
3260
|
-
// Broadcast updates
|
|
3261
|
-
broadcast({
|
|
3262
|
-
type: 'lead_triggered',
|
|
3263
|
-
payload: { leadId, workItem: pendingItem },
|
|
3264
|
-
});
|
|
3265
|
-
broadcastSessions();
|
|
3266
|
-
|
|
3267
|
-
// Send the prompt to the session
|
|
3268
|
-
try {
|
|
3269
|
-
const result = await sendPromptToSession(leadId, prompt);
|
|
3270
|
-
if (!result.ok) {
|
|
3271
|
-
pendingItem.status = 'failed';
|
|
3272
|
-
pendingItem.completedAt = Date.now();
|
|
3273
|
-
log(`Interactive lead ${session.name} failed: ${result.error}`);
|
|
3274
|
-
session.status = 'idle';
|
|
3275
|
-
broadcastSessions();
|
|
3276
|
-
saveLeadState();
|
|
3277
|
-
}
|
|
3278
|
-
// Note: Actual completion is tracked via stop/session_end events
|
|
3279
|
-
} catch (err) {
|
|
3280
|
-
pendingItem.status = 'failed';
|
|
3281
|
-
pendingItem.completedAt = Date.now();
|
|
3282
|
-
log(`Interactive lead ${session.name} error: ${err.message}`);
|
|
3283
|
-
session.status = 'idle';
|
|
3284
|
-
broadcastSessions();
|
|
3285
|
-
saveLeadState();
|
|
3286
|
-
}
|
|
3287
|
-
}
|
|
3288
|
-
|
|
3289
|
-
/**
|
|
3290
|
-
* Process the next item in a lead's work queue
|
|
3291
|
-
*/
|
|
3292
|
-
async function processLeadQueue(leadId) {
|
|
3293
|
-
const session = managedSessions.get(leadId);
|
|
3294
|
-
if (!session || !session.isLead) return;
|
|
3295
|
-
|
|
3296
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
3297
|
-
const pendingItem = queue.find(item => item.status === 'pending');
|
|
3298
|
-
|
|
3299
|
-
if (!pendingItem) {
|
|
3300
|
-
// No pending items, return to watching
|
|
3301
|
-
if (session.status !== 'watching') {
|
|
3302
|
-
session.status = 'watching';
|
|
3303
|
-
broadcastSessions();
|
|
3304
|
-
}
|
|
3305
|
-
return;
|
|
3306
|
-
}
|
|
3307
|
-
|
|
3308
|
-
// Check if already processing
|
|
3309
|
-
const processingItem = queue.find(item => item.status === 'processing');
|
|
3310
|
-
if (processingItem) {
|
|
3311
|
-
return; // Already processing an item
|
|
3312
|
-
}
|
|
3313
|
-
|
|
3314
|
-
// Start processing
|
|
3315
|
-
pendingItem.status = 'processing';
|
|
3316
|
-
pendingItem.startedAt = Date.now();
|
|
3317
|
-
session.status = 'working';
|
|
3318
|
-
|
|
3319
|
-
// Build the prompt
|
|
3320
|
-
const prompt = buildLeadPrompt(session.leadConfig, pendingItem.commit);
|
|
3321
|
-
|
|
3322
|
-
log(`Lead ${session.name} processing commit ${pendingItem.commit.shortSha}`);
|
|
3323
|
-
|
|
3324
|
-
// Broadcast updates
|
|
3325
|
-
broadcast({
|
|
3326
|
-
type: 'lead_triggered',
|
|
3327
|
-
payload: { leadId, workItem: pendingItem },
|
|
3328
|
-
});
|
|
3329
|
-
broadcastSessions();
|
|
3330
|
-
|
|
3331
|
-
// Send the prompt to the session
|
|
3332
|
-
let failed = false;
|
|
3333
|
-
try {
|
|
3334
|
-
const result = await sendPromptToSession(leadId, prompt);
|
|
3335
|
-
if (!result.ok) {
|
|
3336
|
-
pendingItem.status = 'failed';
|
|
3337
|
-
pendingItem.completedAt = Date.now();
|
|
3338
|
-
failed = true;
|
|
3339
|
-
log(`Lead ${session.name} failed to process: ${result.error}`);
|
|
3340
|
-
}
|
|
3341
|
-
// Note: Actual completion is tracked via stop/session_end events
|
|
3342
|
-
} catch (err) {
|
|
3343
|
-
pendingItem.status = 'failed';
|
|
3344
|
-
pendingItem.completedAt = Date.now();
|
|
3345
|
-
failed = true;
|
|
3346
|
-
log(`Lead ${session.name} error: ${err.message}`);
|
|
3347
|
-
}
|
|
3348
|
-
|
|
3349
|
-
// Update queue
|
|
3350
|
-
session.workQueue = queue;
|
|
3351
|
-
broadcast({
|
|
3352
|
-
type: 'lead_queue_update',
|
|
3353
|
-
payload: { leadId, queue },
|
|
3354
|
-
});
|
|
3355
|
-
|
|
3356
|
-
// If failed, return to watching and try next item
|
|
3357
|
-
if (failed) {
|
|
3358
|
-
session.status = 'watching';
|
|
3359
|
-
broadcastSessions();
|
|
3360
|
-
saveLeadState();
|
|
3361
|
-
// Process next item if any (with delay)
|
|
3362
|
-
const timer = setTimeout(() => {
|
|
3363
|
-
leadTimers.delete(leadId);
|
|
3364
|
-
processLeadQueue(leadId);
|
|
3365
|
-
}, 1000);
|
|
3366
|
-
leadTimers.set(leadId, timer);
|
|
3367
|
-
}
|
|
3368
|
-
}
|
|
3369
|
-
|
|
3370
|
-
/**
|
|
3371
|
-
* Build the prompt for a lead to analyze a commit
|
|
3372
|
-
*/
|
|
3373
|
-
function buildLeadPrompt(leadConfig, commitInfo) {
|
|
3374
|
-
const systemPrompt = leadConfig.systemPrompt || LEAD_ROLE_PROMPTS[leadConfig.role] || '';
|
|
3375
|
-
|
|
3376
|
-
const commitDetails = `
|
|
3377
|
-
## New Commit Detected
|
|
3378
|
-
|
|
3379
|
-
**SHA:** ${commitInfo.sha}
|
|
3380
|
-
**Author:** ${commitInfo.author}
|
|
3381
|
-
**Message:** ${commitInfo.message}
|
|
3382
|
-
**Branch:** ${commitInfo.branch}
|
|
3383
|
-
**Files Changed:** ${commitInfo.filesChanged.length}
|
|
3384
|
-
- ${commitInfo.filesChanged.slice(0, 20).join('\n- ')}${commitInfo.filesChanged.length > 20 ? '\n- ... and more' : ''}
|
|
3385
|
-
**Changes:** +${commitInfo.additions} / -${commitInfo.deletions}
|
|
3386
|
-
|
|
3387
|
-
Please analyze this commit according to your role and provide your assessment.
|
|
3388
|
-
`;
|
|
3389
|
-
|
|
3390
|
-
return `${systemPrompt}\n\n${commitDetails}`;
|
|
3391
|
-
}
|
|
3392
|
-
|
|
3393
|
-
/**
|
|
3394
|
-
* Mark a lead's current work item as completed
|
|
3395
|
-
*/
|
|
3396
|
-
function completeLeadWorkItem(leadId, result = null) {
|
|
3397
|
-
const session = managedSessions.get(leadId);
|
|
3398
|
-
if (!session || !session.isLead) return;
|
|
3399
|
-
|
|
3400
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
3401
|
-
const processingItem = queue.find(item => item.status === 'processing');
|
|
3402
|
-
|
|
3403
|
-
if (processingItem) {
|
|
3404
|
-
// For interactive leads, set to awaiting_handoff; otherwise completed
|
|
3405
|
-
const isInteractive = session.leadConfig?.triggerMode === 'interactive';
|
|
3406
|
-
processingItem.status = isInteractive ? 'awaiting_handoff' : 'completed';
|
|
3407
|
-
processingItem.completedAt = Date.now();
|
|
3408
|
-
if (result) {
|
|
3409
|
-
processingItem.result = result;
|
|
3410
|
-
}
|
|
3411
|
-
if (processingItem.commit) {
|
|
3412
|
-
session.lastProcessedCommit = processingItem.commit.sha;
|
|
3413
|
-
}
|
|
3414
|
-
|
|
3415
|
-
const itemDesc = processingItem.commit
|
|
3416
|
-
? `commit ${processingItem.commit.shortSha}`
|
|
3417
|
-
: `work item ${processingItem.id.slice(0, 8)}`;
|
|
3418
|
-
log(`Lead ${session.name} ${isInteractive ? 'awaiting handoff for' : 'completed'} ${itemDesc}`);
|
|
3419
|
-
|
|
3420
|
-
// Broadcast completion or handoff ready
|
|
3421
|
-
if (isInteractive) {
|
|
3422
|
-
broadcast({
|
|
3423
|
-
type: 'lead_handoff_ready',
|
|
3424
|
-
payload: {
|
|
3425
|
-
leadId,
|
|
3426
|
-
leadName: session.name,
|
|
3427
|
-
workItemId: processingItem.id,
|
|
3428
|
-
result: processingItem.result || '',
|
|
3429
|
-
input: processingItem.input,
|
|
3430
|
-
},
|
|
3431
|
-
});
|
|
3432
|
-
} else {
|
|
3433
|
-
broadcast({
|
|
3434
|
-
type: 'lead_completed',
|
|
3435
|
-
payload: { leadId, workItem: processingItem },
|
|
3436
|
-
});
|
|
3437
|
-
}
|
|
3438
|
-
}
|
|
3439
|
-
|
|
3440
|
-
// Update session
|
|
3441
|
-
session.workQueue = queue;
|
|
3442
|
-
// Interactive leads stay idle until handoff is actioned; others return to watching
|
|
3443
|
-
const isInteractive = session.leadConfig?.triggerMode === 'interactive';
|
|
3444
|
-
session.status = isInteractive ? 'idle' : 'watching';
|
|
3445
|
-
|
|
3446
|
-
// Save state
|
|
3447
|
-
saveLeadState();
|
|
3448
|
-
broadcastSessions();
|
|
3449
|
-
|
|
3450
|
-
// For non-interactive leads, process next item if any
|
|
3451
|
-
if (!isInteractive) {
|
|
3452
|
-
const timer = setTimeout(() => {
|
|
3453
|
-
leadTimers.delete(leadId);
|
|
3454
|
-
processLeadQueue(leadId);
|
|
3455
|
-
}, 1000);
|
|
3456
|
-
leadTimers.set(leadId, timer);
|
|
3457
|
-
}
|
|
3458
|
-
}
|
|
3459
|
-
|
|
3460
|
-
/**
|
|
3461
|
-
* Pause a lead (stop watching)
|
|
3462
|
-
*/
|
|
3463
|
-
function pauseLead(leadId) {
|
|
3464
|
-
const session = managedSessions.get(leadId);
|
|
3465
|
-
if (!session || !session.isLead) return false;
|
|
3466
|
-
|
|
3467
|
-
// Clear any pending processing timer
|
|
3468
|
-
const timer = leadTimers.get(leadId);
|
|
3469
|
-
if (timer) {
|
|
3470
|
-
clearTimeout(timer);
|
|
3471
|
-
leadTimers.delete(leadId);
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
session.leadConfig.enabled = false;
|
|
3475
|
-
session.status = 'idle';
|
|
3476
|
-
|
|
3477
|
-
log(`Paused lead: ${session.name}`);
|
|
3478
|
-
broadcastSessions();
|
|
3479
|
-
saveLeadState();
|
|
3480
|
-
|
|
3481
|
-
return true;
|
|
3482
|
-
}
|
|
3483
|
-
|
|
3484
|
-
/**
|
|
3485
|
-
* Resume a lead (start watching)
|
|
3486
|
-
*/
|
|
3487
|
-
function resumeLead(leadId) {
|
|
3488
|
-
const session = managedSessions.get(leadId);
|
|
3489
|
-
if (!session || !session.isLead) return false;
|
|
3490
|
-
|
|
3491
|
-
session.leadConfig.enabled = true;
|
|
3492
|
-
session.status = 'watching';
|
|
3493
|
-
|
|
3494
|
-
log(`Resumed lead: ${session.name}`);
|
|
3495
|
-
broadcastSessions();
|
|
3496
|
-
saveLeadState();
|
|
3497
|
-
|
|
3498
|
-
return true;
|
|
3499
|
-
}
|
|
3500
|
-
|
|
3501
|
-
/**
|
|
3502
|
-
* Delete a lead agent
|
|
3503
|
-
*/
|
|
3504
|
-
async function deleteLead(leadId) {
|
|
3505
|
-
const session = managedSessions.get(leadId);
|
|
3506
|
-
if (!session || !session.isLead) return false;
|
|
3507
|
-
|
|
3508
|
-
// Remove from project leads
|
|
3509
|
-
if (session.projectId) {
|
|
3510
|
-
const leads = projectLeads.get(session.projectId) || [];
|
|
3511
|
-
const index = leads.indexOf(leadId);
|
|
3512
|
-
if (index !== -1) {
|
|
3513
|
-
leads.splice(index, 1);
|
|
3514
|
-
}
|
|
3515
|
-
|
|
3516
|
-
// Stop watching if no more leads for this project
|
|
3517
|
-
if (leads.length === 0) {
|
|
3518
|
-
commitWatcher.untrack(session.projectId);
|
|
3519
|
-
projectLeads.delete(session.projectId);
|
|
3520
|
-
}
|
|
3521
|
-
}
|
|
3522
|
-
|
|
3523
|
-
// Clean up pending timer
|
|
3524
|
-
const timer = leadTimers.get(leadId);
|
|
3525
|
-
if (timer) {
|
|
3526
|
-
clearTimeout(timer);
|
|
3527
|
-
leadTimers.delete(leadId);
|
|
3528
|
-
}
|
|
3529
|
-
|
|
3530
|
-
// Clean up work queue
|
|
3531
|
-
leadWorkQueues.delete(leadId);
|
|
3532
|
-
|
|
3533
|
-
// Delete the session
|
|
3534
|
-
await deleteSession(leadId);
|
|
3535
|
-
|
|
3536
|
-
saveLeadState();
|
|
3537
|
-
return true;
|
|
3538
|
-
}
|
|
3539
|
-
|
|
3540
|
-
/**
|
|
3541
|
-
* Handle new commit detected by CommitWatcher
|
|
3542
|
-
*/
|
|
3543
|
-
function handleNewCommit(projectId, commitInfo) {
|
|
3544
|
-
log(`New commit detected in project ${projectId.slice(0, 8)}: ${commitInfo.shortSha} - "${commitInfo.message}"`);
|
|
3545
|
-
|
|
3546
|
-
// LOOP PREVENTION: Check if this is a pipeline commit (should be skipped)
|
|
3547
|
-
if (pipelineManager.isPipelineCommit(commitInfo)) {
|
|
3548
|
-
log(`Skipping pipeline commit: ${commitInfo.shortSha}`);
|
|
3549
|
-
return;
|
|
3550
|
-
}
|
|
3551
|
-
|
|
3552
|
-
// Broadcast commit detection
|
|
3553
|
-
broadcast({
|
|
3554
|
-
type: 'commit_detected',
|
|
3555
|
-
payload: { projectId, commit: commitInfo },
|
|
3556
|
-
});
|
|
3557
|
-
|
|
3558
|
-
// Trigger pipelines for this project
|
|
3559
|
-
const pipelines = pipelineManager.getProjectPipelines(projectId);
|
|
3560
|
-
for (const pipeline of pipelines) {
|
|
3561
|
-
if (pipeline.enabled && pipeline.triggerBranches.includes(commitInfo.branch)) {
|
|
3562
|
-
log(`Triggering pipeline: ${pipeline.name} for commit ${commitInfo.shortSha}`);
|
|
3563
|
-
try {
|
|
3564
|
-
const run = pipelineManager.createRun(pipeline.id, commitInfo, 'commit');
|
|
3565
|
-
// Start processing the first stage
|
|
3566
|
-
pipelineManager.processNextStage(run.id);
|
|
3567
|
-
} catch (err) {
|
|
3568
|
-
log(`Failed to trigger pipeline ${pipeline.name}: ${err.message}`);
|
|
3569
|
-
}
|
|
3570
|
-
}
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
// Trigger all enabled leads for this project with 'watching' trigger mode
|
|
3574
|
-
// (kept for backward compatibility, but pipelines are preferred)
|
|
3575
|
-
const leads = projectLeads.get(projectId) || [];
|
|
3576
|
-
for (const leadId of leads) {
|
|
3577
|
-
const session = managedSessions.get(leadId);
|
|
3578
|
-
// Only trigger leads with watching mode (auto-trigger on commits)
|
|
3579
|
-
const shouldTrigger = session?.isLead &&
|
|
3580
|
-
session.leadConfig?.enabled &&
|
|
3581
|
-
session.leadConfig?.triggerMode === 'watching';
|
|
3582
|
-
if (shouldTrigger) {
|
|
3583
|
-
triggerLead(leadId, commitInfo);
|
|
3584
|
-
}
|
|
3585
|
-
}
|
|
3586
|
-
}
|
|
3587
|
-
|
|
3588
|
-
/**
|
|
3589
|
-
* Save lead state to disk
|
|
3590
|
-
*/
|
|
3591
|
-
function saveLeadState() {
|
|
3592
|
-
try {
|
|
3593
|
-
const leadsFile = resolve(expandHome('~/.vibeteam/data/leads.json'));
|
|
3594
|
-
const dir = dirname(leadsFile);
|
|
3595
|
-
if (!existsSync(dir)) {
|
|
3596
|
-
mkdirSync(dir, { recursive: true });
|
|
3597
|
-
}
|
|
3598
|
-
|
|
3599
|
-
const data = {
|
|
3600
|
-
projectLeads: Array.from(projectLeads.entries()),
|
|
3601
|
-
leadWorkQueues: Array.from(leadWorkQueues.entries()),
|
|
3602
|
-
};
|
|
3603
|
-
|
|
3604
|
-
writeFileSync(leadsFile, JSON.stringify(data, null, 2));
|
|
3605
|
-
debug(`Saved lead state to ${leadsFile}`);
|
|
3606
|
-
} catch (e) {
|
|
3607
|
-
console.error('Failed to save lead state:', e);
|
|
3608
|
-
}
|
|
3609
|
-
}
|
|
3610
|
-
|
|
3611
|
-
/**
|
|
3612
|
-
* Load lead state from disk
|
|
3613
|
-
*/
|
|
3614
|
-
function loadLeadState() {
|
|
3615
|
-
const leadsFile = resolve(expandHome('~/.vibeteam/data/leads.json'));
|
|
3616
|
-
if (!existsSync(leadsFile)) {
|
|
3617
|
-
debug('No saved leads file found');
|
|
3618
|
-
return;
|
|
3619
|
-
}
|
|
3620
|
-
|
|
3621
|
-
try {
|
|
3622
|
-
const content = readFileSync(leadsFile, 'utf-8');
|
|
3623
|
-
const data = JSON.parse(content);
|
|
3624
|
-
|
|
3625
|
-
// Restore project leads mapping
|
|
3626
|
-
if (Array.isArray(data.projectLeads)) {
|
|
3627
|
-
for (const [projectId, leadIds] of data.projectLeads) {
|
|
3628
|
-
projectLeads.set(projectId, leadIds);
|
|
3629
|
-
|
|
3630
|
-
// Start watching for commits
|
|
3631
|
-
const project = projectsManager.getProject(projectId);
|
|
3632
|
-
if (project && leadIds.length > 0) {
|
|
3633
|
-
commitWatcher.track(projectId, project.path, 'main');
|
|
3634
|
-
}
|
|
3635
|
-
}
|
|
3636
|
-
}
|
|
3637
|
-
|
|
3638
|
-
// Restore work queues
|
|
3639
|
-
if (Array.isArray(data.leadWorkQueues)) {
|
|
3640
|
-
for (const [leadId, queue] of data.leadWorkQueues) {
|
|
3641
|
-
leadWorkQueues.set(leadId, queue);
|
|
3642
|
-
// Update session with queue
|
|
3643
|
-
const session = managedSessions.get(leadId);
|
|
3644
|
-
if (session) {
|
|
3645
|
-
session.workQueue = queue;
|
|
3646
|
-
}
|
|
3647
|
-
}
|
|
3648
|
-
}
|
|
3649
|
-
|
|
3650
|
-
log(`Loaded lead state: ${projectLeads.size} projects with leads`);
|
|
3651
|
-
} catch (e) {
|
|
3652
|
-
console.error('Failed to load lead state:', e);
|
|
3653
|
-
}
|
|
3654
|
-
}
|
|
3655
|
-
/**
|
|
3656
|
-
* Find managed session by Claude Code session ID
|
|
3657
|
-
*/
|
|
3658
|
-
function findManagedSession(claudeSessionId) {
|
|
3659
|
-
const managedId = claudeToManagedMap.get(claudeSessionId);
|
|
3660
|
-
if (managedId) {
|
|
3661
|
-
return managedSessions.get(managedId);
|
|
3662
|
-
}
|
|
3663
|
-
return undefined;
|
|
3664
|
-
}
|
|
3665
|
-
// ============================================================================
|
|
3666
|
-
// Event Processing
|
|
3667
|
-
// ============================================================================
|
|
3668
|
-
/** Maximum length for tool output before truncation */
|
|
3669
|
-
const MAX_TOOL_OUTPUT_LENGTH = 5000;
|
|
3670
|
-
/**
|
|
3671
|
-
* Parse and format tool output for frontend display.
|
|
3672
|
-
* Truncates large outputs and determines success/error status.
|
|
3673
|
+
* Parse and format tool output for frontend display.
|
|
3674
|
+
* Truncates large outputs and determines success/error status.
|
|
3673
3675
|
*/
|
|
3674
3676
|
function formatToolOutput(result) {
|
|
3675
3677
|
if (!result) return undefined;
|
|
@@ -3794,20 +3796,20 @@ function addEvent(event) {
|
|
|
3794
3796
|
break;
|
|
3795
3797
|
case 'stop':
|
|
3796
3798
|
case 'session_end':
|
|
3797
|
-
|
|
3798
|
-
if (managedSession.isLead) {
|
|
3799
|
-
completeLeadWorkItem(managedSession.id);
|
|
3800
|
-
} else {
|
|
3801
|
-
managedSession.status = 'idle';
|
|
3802
|
-
}
|
|
3799
|
+
managedSession.status = 'idle';
|
|
3803
3800
|
managedSession.currentTool = undefined;
|
|
3801
|
+
// Check if this agent is part of a planning pipeline
|
|
3802
|
+
pipelineManager.onAgentIdle(managedSession.id);
|
|
3804
3803
|
break;
|
|
3805
3804
|
}
|
|
3806
|
-
//
|
|
3805
|
+
// Increment statusVersion on actual status changes for ordering resolution
|
|
3807
3806
|
if (managedSession.status !== prevStatus) {
|
|
3807
|
+
managedSession.statusVersion = (managedSession.statusVersion || 0) + 1;
|
|
3808
3808
|
broadcastSessions();
|
|
3809
3809
|
saveSessions();
|
|
3810
3810
|
}
|
|
3811
|
+
// Include statusVersion in event payload for frontend version tracking
|
|
3812
|
+
processed.statusVersion = managedSession.statusVersion || 0;
|
|
3811
3813
|
}
|
|
3812
3814
|
// Broadcast to all clients
|
|
3813
3815
|
broadcast({ type: 'event', payload: processed });
|
|
@@ -4481,6 +4483,34 @@ async function handleHttpRequest(req, res) {
|
|
|
4481
4483
|
}
|
|
4482
4484
|
}
|
|
4483
4485
|
|
|
4486
|
+
// GET /projects/:id/branches - List local branches for the project
|
|
4487
|
+
const branchesMatch = req.url?.match(/^\/projects\/([a-f0-9-]+)\/branches$/);
|
|
4488
|
+
if (req.method === 'GET' && branchesMatch) {
|
|
4489
|
+
const projectId = branchesMatch[1];
|
|
4490
|
+
const project = projectsManager.getProject(projectId);
|
|
4491
|
+
if (!project) {
|
|
4492
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4493
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4496
|
+
try {
|
|
4497
|
+
const branches = await new Promise((resolve, reject) => {
|
|
4498
|
+
execFile('git', ['branch', '--sort=-committerdate', '--format=%(refname:short)'], { cwd: project.path, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
4499
|
+
if (error) return reject(error);
|
|
4500
|
+
const list = stdout.split('\n').map(b => b.trim()).filter(Boolean).slice(0, 50);
|
|
4501
|
+
resolve(list);
|
|
4502
|
+
});
|
|
4503
|
+
});
|
|
4504
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4505
|
+
res.end(JSON.stringify({ ok: true, branches }));
|
|
4506
|
+
} catch (err) {
|
|
4507
|
+
log(`Error listing branches: ${err.message}`);
|
|
4508
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4509
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
4510
|
+
}
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4484
4514
|
// Project orphaned worktrees cleanup: /projects/:id/worktrees/orphaned
|
|
4485
4515
|
const orphanedMatch = req.url?.match(/^\/projects\/([a-f0-9-]+)\/worktrees\/orphaned$/);
|
|
4486
4516
|
if (orphanedMatch) {
|
|
@@ -4633,92 +4663,215 @@ async function handleHttpRequest(req, res) {
|
|
|
4633
4663
|
return;
|
|
4634
4664
|
}
|
|
4635
4665
|
|
|
4636
|
-
//
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
const projectId = projectPipelinesMatch[1];
|
|
4644
|
-
const project = projectsManager.getProject(projectId);
|
|
4645
|
-
if (!project) {
|
|
4646
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4647
|
-
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
4666
|
+
// GET /kanban-image?path=... - Serve a kanban task image from disk
|
|
4667
|
+
if (req.method === 'GET' && req.url?.startsWith('/kanban-image')) {
|
|
4668
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
4669
|
+
const imagePath = urlObj.searchParams.get('path');
|
|
4670
|
+
if (!imagePath) {
|
|
4671
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4672
|
+
res.end(JSON.stringify({ error: 'path parameter required' }));
|
|
4648
4673
|
return;
|
|
4649
4674
|
}
|
|
4675
|
+
// SECURITY: Only serve files from .vibeteam-images directories
|
|
4676
|
+
const canonicalPath = resolve(imagePath);
|
|
4677
|
+
if (!canonicalPath.includes('/.vibeteam-images/')) {
|
|
4678
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
4679
|
+
res.end(JSON.stringify({ error: 'Access denied' }));
|
|
4680
|
+
return;
|
|
4681
|
+
}
|
|
4682
|
+
try {
|
|
4683
|
+
if (!existsSync(canonicalPath)) {
|
|
4684
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4685
|
+
res.end(JSON.stringify({ error: 'Image not found' }));
|
|
4686
|
+
return;
|
|
4687
|
+
}
|
|
4688
|
+
const data = readFileSync(canonicalPath);
|
|
4689
|
+
const ext = canonicalPath.split('.').pop()?.toLowerCase();
|
|
4690
|
+
const mimeTypes = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
|
|
4691
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
4692
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' });
|
|
4693
|
+
res.end(data);
|
|
4694
|
+
} catch (e) {
|
|
4695
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4696
|
+
res.end(JSON.stringify({ error: 'Failed to read image' }));
|
|
4697
|
+
}
|
|
4698
|
+
return;
|
|
4699
|
+
}
|
|
4650
4700
|
|
|
4651
|
-
|
|
4701
|
+
// ============================================================================
|
|
4702
|
+
// Kanban Board API
|
|
4703
|
+
// ============================================================================
|
|
4704
|
+
|
|
4705
|
+
// GET /kanban/tasks?projectId=X - List tasks for project
|
|
4706
|
+
if (req.method === 'GET' && req.url?.startsWith('/kanban/tasks')) {
|
|
4707
|
+
const urlObj = new URL(req.url, `http://localhost`);
|
|
4708
|
+
const projectId = urlObj.searchParams.get('projectId');
|
|
4709
|
+
const tasks = kanbanManager.getTasks(projectId || undefined);
|
|
4710
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4711
|
+
res.end(JSON.stringify({ ok: true, tasks }));
|
|
4712
|
+
return;
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
// POST /kanban/images - Upload image for kanban task
|
|
4716
|
+
if (req.method === 'POST' && req.url === '/kanban/images') {
|
|
4717
|
+
collectRequestBody(req, 10 * 1024 * 1024).then(async (body) => {
|
|
4652
4718
|
try {
|
|
4653
|
-
const
|
|
4654
|
-
|
|
4719
|
+
const { image, projectId } = JSON.parse(body);
|
|
4720
|
+
if (!image || !image.startsWith('data:image/')) {
|
|
4721
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4722
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid image data. Only image files are supported.' }));
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
if (!projectId) {
|
|
4726
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4727
|
+
res.end(JSON.stringify({ ok: false, error: 'projectId is required' }));
|
|
4728
|
+
return;
|
|
4729
|
+
}
|
|
4730
|
+
// Parse base64 image
|
|
4731
|
+
const matches = image.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
4732
|
+
if (!matches) {
|
|
4733
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4734
|
+
res.end(JSON.stringify({ ok: false, error: 'Could not parse image data' }));
|
|
4735
|
+
return;
|
|
4736
|
+
}
|
|
4737
|
+
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
|
|
4738
|
+
const mimeType = `image/${matches[1]}`;
|
|
4739
|
+
const base64Data = matches[2];
|
|
4740
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
4655
4741
|
|
|
4656
|
-
//
|
|
4657
|
-
if (
|
|
4658
|
-
|
|
4742
|
+
// Check size (10MB limit)
|
|
4743
|
+
if (buffer.length > 10 * 1024 * 1024) {
|
|
4744
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4745
|
+
res.end(JSON.stringify({ ok: false, error: 'Image must be under 10MB' }));
|
|
4746
|
+
return;
|
|
4659
4747
|
}
|
|
4660
4748
|
|
|
4661
|
-
//
|
|
4662
|
-
|
|
4749
|
+
// Determine save directory from project path
|
|
4750
|
+
const project = projectsManager.getProject(projectId);
|
|
4751
|
+
if (!project) {
|
|
4752
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4753
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
4754
|
+
return;
|
|
4755
|
+
}
|
|
4663
4756
|
|
|
4664
|
-
|
|
4665
|
-
|
|
4757
|
+
// SECURITY: Validate and canonicalize paths to prevent path traversal
|
|
4758
|
+
const canonicalProjectPath = resolve(project.path);
|
|
4759
|
+
const imagesDir = join(canonicalProjectPath, '.vibeteam-images');
|
|
4760
|
+
const canonicalImagesDir = resolve(imagesDir);
|
|
4761
|
+
if (!canonicalImagesDir.startsWith(canonicalProjectPath + '/') && canonicalImagesDir !== canonicalProjectPath) {
|
|
4762
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4763
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid directory path' }));
|
|
4764
|
+
return;
|
|
4765
|
+
}
|
|
4766
|
+
if (!existsSync(canonicalImagesDir)) {
|
|
4767
|
+
mkdirSync(canonicalImagesDir, { recursive: true });
|
|
4768
|
+
}
|
|
4769
|
+
const timestamp = Date.now();
|
|
4770
|
+
const hex = randomBytes(4).toString('hex');
|
|
4771
|
+
const filename = `image-${timestamp}-${hex}.${ext}`;
|
|
4772
|
+
const filepath = join(canonicalImagesDir, filename);
|
|
4773
|
+
const canonicalFilepath = resolve(filepath);
|
|
4774
|
+
// Double-check the final filepath is still within the images directory
|
|
4775
|
+
if (!canonicalFilepath.startsWith(canonicalImagesDir + '/')) {
|
|
4776
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4777
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid file path' }));
|
|
4778
|
+
return;
|
|
4779
|
+
}
|
|
4780
|
+
writeFileSync(canonicalFilepath, buffer);
|
|
4781
|
+
log(`Kanban image saved: ${canonicalFilepath}`);
|
|
4782
|
+
|
|
4783
|
+
const attachment = {
|
|
4784
|
+
id: `img-${timestamp}-${hex}`,
|
|
4785
|
+
filename,
|
|
4786
|
+
path: canonicalFilepath,
|
|
4787
|
+
size: buffer.length,
|
|
4788
|
+
mimeType,
|
|
4789
|
+
createdAt: timestamp,
|
|
4790
|
+
};
|
|
4791
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4792
|
+
res.end(JSON.stringify({ ok: true, attachment }));
|
|
4666
4793
|
} catch (e) {
|
|
4667
4794
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4668
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4795
|
+
res.end(JSON.stringify({ ok: false, error: e.message || 'Failed to process image' }));
|
|
4669
4796
|
}
|
|
4670
4797
|
}).catch(() => {
|
|
4671
4798
|
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
4672
|
-
res.end(JSON.stringify({ error: '
|
|
4799
|
+
res.end(JSON.stringify({ error: 'Image too large (max 10MB)' }));
|
|
4673
4800
|
});
|
|
4674
4801
|
return;
|
|
4675
4802
|
}
|
|
4676
4803
|
|
|
4677
|
-
//
|
|
4678
|
-
if (
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4804
|
+
// POST /kanban/tasks - Create task
|
|
4805
|
+
if (req.method === 'POST' && req.url === '/kanban/tasks') {
|
|
4806
|
+
collectRequestBody(req).then((body) => {
|
|
4807
|
+
try {
|
|
4808
|
+
const data = JSON.parse(body);
|
|
4809
|
+
if (!data.title || !data.projectId) {
|
|
4810
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4811
|
+
res.end(JSON.stringify({ ok: false, error: 'title and projectId are required' }));
|
|
4812
|
+
return;
|
|
4813
|
+
}
|
|
4814
|
+
if (typeof data.title !== 'string' || data.title.length > 500) {
|
|
4815
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4816
|
+
res.end(JSON.stringify({ ok: false, error: 'Title must be a string under 500 characters' }));
|
|
4817
|
+
return;
|
|
4818
|
+
}
|
|
4819
|
+
if (data.description && (typeof data.description !== 'string' || data.description.length > 5000)) {
|
|
4820
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4821
|
+
res.end(JSON.stringify({ ok: false, error: 'Description must be under 5000 characters' }));
|
|
4822
|
+
return;
|
|
4823
|
+
}
|
|
4824
|
+
if (data.tags && (!Array.isArray(data.tags) || data.tags.length > 20)) {
|
|
4825
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4826
|
+
res.end(JSON.stringify({ ok: false, error: 'Tags must be an array with at most 20 items' }));
|
|
4827
|
+
return;
|
|
4828
|
+
}
|
|
4829
|
+
if (data.columnId && !VALID_COLUMNS.includes(data.columnId)) {
|
|
4830
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4831
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid columnId' }));
|
|
4832
|
+
return;
|
|
4833
|
+
}
|
|
4834
|
+
const task = kanbanManager.createTask(data);
|
|
4835
|
+
broadcast({ type: 'kanban_task_created', payload: { task } });
|
|
4836
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
4837
|
+
res.end(JSON.stringify({ ok: true, task }));
|
|
4838
|
+
} catch (e) {
|
|
4839
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4840
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4841
|
+
}
|
|
4842
|
+
}).catch(() => {
|
|
4843
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
4844
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
4845
|
+
});
|
|
4683
4846
|
return;
|
|
4684
4847
|
}
|
|
4685
4848
|
|
|
4686
|
-
//
|
|
4687
|
-
const
|
|
4688
|
-
if (
|
|
4689
|
-
const
|
|
4690
|
-
const action = pipelineMatch[2];
|
|
4849
|
+
// Kanban task-specific endpoints: /kanban/tasks/:id
|
|
4850
|
+
const kanbanTaskMatch = req.url?.match(/^\/kanban\/tasks\/([a-f0-9-]+)$/);
|
|
4851
|
+
if (kanbanTaskMatch) {
|
|
4852
|
+
const taskId = kanbanTaskMatch[1];
|
|
4691
4853
|
|
|
4692
|
-
//
|
|
4693
|
-
if (req.method === '
|
|
4694
|
-
const pipeline = pipelineManager.getPipeline(pipelineId);
|
|
4695
|
-
if (pipeline) {
|
|
4696
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4697
|
-
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
4698
|
-
} else {
|
|
4699
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4700
|
-
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
4701
|
-
}
|
|
4702
|
-
return;
|
|
4703
|
-
}
|
|
4704
|
-
|
|
4705
|
-
// PUT /pipelines/:id - Update pipeline
|
|
4706
|
-
if (req.method === 'PUT' && !action) {
|
|
4854
|
+
// PUT /kanban/tasks/:id - Update task
|
|
4855
|
+
if (req.method === 'PUT') {
|
|
4707
4856
|
collectRequestBody(req).then((body) => {
|
|
4708
4857
|
try {
|
|
4709
4858
|
const updates = JSON.parse(body);
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4859
|
+
if (updates.columnId && !VALID_COLUMNS.includes(updates.columnId)) {
|
|
4860
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4861
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid columnId' }));
|
|
4862
|
+
return;
|
|
4863
|
+
}
|
|
4864
|
+
const task = kanbanManager.updateTask(taskId, updates);
|
|
4865
|
+
if (!task) {
|
|
4717
4866
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4718
|
-
res.end(JSON.stringify({ ok: false, error: '
|
|
4867
|
+
res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
|
|
4868
|
+
return;
|
|
4719
4869
|
}
|
|
4870
|
+
broadcast({ type: 'kanban_task_updated', payload: { task } });
|
|
4871
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4872
|
+
res.end(JSON.stringify({ ok: true, task }));
|
|
4720
4873
|
} catch (e) {
|
|
4721
|
-
res.writeHead(
|
|
4874
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4722
4875
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4723
4876
|
}
|
|
4724
4877
|
}).catch(() => {
|
|
@@ -4728,157 +4881,129 @@ async function handleHttpRequest(req, res) {
|
|
|
4728
4881
|
return;
|
|
4729
4882
|
}
|
|
4730
4883
|
|
|
4731
|
-
// DELETE /
|
|
4732
|
-
if (req.method === 'DELETE'
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
broadcastPipelines();
|
|
4737
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4738
|
-
res.end(JSON.stringify({ ok: true }));
|
|
4739
|
-
} else {
|
|
4740
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4741
|
-
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
4742
|
-
}
|
|
4743
|
-
return;
|
|
4744
|
-
}
|
|
4745
|
-
|
|
4746
|
-
// POST /pipelines/:id/trigger - Manual trigger
|
|
4747
|
-
if (req.method === 'POST' && action === 'trigger') {
|
|
4748
|
-
const pipeline = pipelineManager.getPipeline(pipelineId);
|
|
4749
|
-
if (!pipeline) {
|
|
4750
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4751
|
-
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
4752
|
-
return;
|
|
4753
|
-
}
|
|
4754
|
-
|
|
4755
|
-
collectRequestBody(req).then(async (body) => {
|
|
4884
|
+
// DELETE /kanban/tasks/:id - Delete task
|
|
4885
|
+
if (req.method === 'DELETE') {
|
|
4886
|
+
// Cancel active pipeline before deleting task
|
|
4887
|
+
const pipeline = pipelineManager.getPipeline(taskId);
|
|
4888
|
+
if (pipeline && (pipeline.status === 'running' || pipeline.status === 'pending')) {
|
|
4756
4889
|
try {
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
// Get commit info (either specified or latest)
|
|
4760
|
-
const project = projectsManager.getProject(pipeline.projectId);
|
|
4761
|
-
if (!project) {
|
|
4762
|
-
throw new Error('Project not found');
|
|
4763
|
-
}
|
|
4764
|
-
|
|
4765
|
-
let commitInfo;
|
|
4766
|
-
if (commitSha) {
|
|
4767
|
-
commitInfo = await commitWatcher.getCommitInfoForPath(project.path, commitSha);
|
|
4768
|
-
} else {
|
|
4769
|
-
commitInfo = await commitWatcher.forceCheck(pipeline.projectId);
|
|
4770
|
-
}
|
|
4771
|
-
|
|
4772
|
-
if (!commitInfo) {
|
|
4773
|
-
throw new Error('Could not get commit info');
|
|
4774
|
-
}
|
|
4775
|
-
|
|
4776
|
-
const run = pipelineManager.createRun(pipelineId, commitInfo, 'manual');
|
|
4777
|
-
pipelineManager.processNextStage(run.id);
|
|
4778
|
-
|
|
4779
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4780
|
-
res.end(JSON.stringify({ ok: true, run }));
|
|
4890
|
+
await pipelineManager.cancelPipeline(taskId);
|
|
4781
4891
|
} catch (e) {
|
|
4782
|
-
|
|
4783
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4892
|
+
log(`Failed to cancel pipeline on task delete: ${e.message}`);
|
|
4784
4893
|
}
|
|
4785
|
-
}
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
return;
|
|
4790
|
-
}
|
|
4791
|
-
|
|
4792
|
-
// GET /pipelines/:id/runs - List pipeline runs
|
|
4793
|
-
if (req.method === 'GET' && action === 'runs') {
|
|
4794
|
-
const runs = pipelineManager.getPipelineRuns(pipelineId);
|
|
4795
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4796
|
-
res.end(JSON.stringify({ ok: true, runs }));
|
|
4797
|
-
return;
|
|
4798
|
-
}
|
|
4799
|
-
|
|
4800
|
-
// GET /pipelines/:id/runs/:runId - Get run details
|
|
4801
|
-
const runMatch = action?.match(/^runs\/([a-f0-9-]+)$/);
|
|
4802
|
-
if (req.method === 'GET' && runMatch) {
|
|
4803
|
-
const runId = runMatch[1];
|
|
4804
|
-
const run = pipelineManager.getRun(runId);
|
|
4805
|
-
if (run) {
|
|
4894
|
+
}
|
|
4895
|
+
const existed = kanbanManager.deleteTask(taskId);
|
|
4896
|
+
if (existed) {
|
|
4897
|
+
broadcast({ type: 'kanban_task_deleted', payload: { taskId } });
|
|
4806
4898
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4807
|
-
res.end(JSON.stringify({ ok: true
|
|
4899
|
+
res.end(JSON.stringify({ ok: true }));
|
|
4808
4900
|
} else {
|
|
4809
4901
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4810
|
-
res.end(JSON.stringify({ ok: false, error: '
|
|
4902
|
+
res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
|
|
4811
4903
|
}
|
|
4812
4904
|
return;
|
|
4813
4905
|
}
|
|
4906
|
+
}
|
|
4814
4907
|
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4824
|
-
res.end(JSON.stringify({ ok: false, error: 'stageId and approved required' }));
|
|
4825
|
-
return;
|
|
4826
|
-
}
|
|
4827
|
-
|
|
4828
|
-
const success = pipelineManager.approveStage(runId, stageId, approved);
|
|
4829
|
-
if (success) {
|
|
4830
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4831
|
-
res.end(JSON.stringify({ ok: true }));
|
|
4832
|
-
} else {
|
|
4833
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4834
|
-
res.end(JSON.stringify({ ok: false, error: 'Stage not awaiting approval' }));
|
|
4835
|
-
}
|
|
4836
|
-
} catch (e) {
|
|
4908
|
+
// POST /kanban/tasks/:id/move - Move task to column
|
|
4909
|
+
const kanbanMoveMatch = req.url?.match(/^\/kanban\/tasks\/([a-f0-9-]+)\/move$/);
|
|
4910
|
+
if (req.method === 'POST' && kanbanMoveMatch) {
|
|
4911
|
+
const taskId = kanbanMoveMatch[1];
|
|
4912
|
+
collectRequestBody(req).then((body) => {
|
|
4913
|
+
try {
|
|
4914
|
+
const { toColumn, position } = JSON.parse(body);
|
|
4915
|
+
if (!toColumn || !VALID_COLUMNS.includes(toColumn)) {
|
|
4837
4916
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4838
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
4917
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid toColumn' }));
|
|
4918
|
+
return;
|
|
4839
4919
|
}
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
// POST /pipelines/:id/runs/:runId/cancel - Cancel run
|
|
4848
|
-
const cancelMatch = action?.match(/^runs\/([a-f0-9-]+)\/cancel$/);
|
|
4849
|
-
if (req.method === 'POST' && cancelMatch) {
|
|
4850
|
-
const runId = cancelMatch[1];
|
|
4851
|
-
const success = pipelineManager.cancelRun(runId);
|
|
4852
|
-
if (success) {
|
|
4920
|
+
const task = kanbanManager.moveTask(taskId, toColumn, position ?? 0);
|
|
4921
|
+
if (!task) {
|
|
4922
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4923
|
+
res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
|
|
4924
|
+
return;
|
|
4925
|
+
}
|
|
4926
|
+
broadcast({ type: 'kanban_task_moved', payload: { taskId, toColumn, position: task.position, task } });
|
|
4853
4927
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4854
|
-
res.end(JSON.stringify({ ok: true }));
|
|
4855
|
-
}
|
|
4856
|
-
res.writeHead(
|
|
4857
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
4928
|
+
res.end(JSON.stringify({ ok: true, task }));
|
|
4929
|
+
} catch (e) {
|
|
4930
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4931
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4858
4932
|
}
|
|
4933
|
+
}).catch(() => {
|
|
4934
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
4935
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
4936
|
+
});
|
|
4937
|
+
return;
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
// POST /kanban/tasks/:id/ai-review - Trigger AI review for task
|
|
4941
|
+
const kanbanReviewMatch = req.url?.match(/^\/kanban\/tasks\/([a-f0-9-]+)\/ai-review$/);
|
|
4942
|
+
if (req.method === 'POST' && kanbanReviewMatch) {
|
|
4943
|
+
const taskId = kanbanReviewMatch[1];
|
|
4944
|
+
const task = kanbanManager.getTask(taskId);
|
|
4945
|
+
if (!task) {
|
|
4946
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4947
|
+
res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
|
|
4859
4948
|
return;
|
|
4860
4949
|
}
|
|
4950
|
+
// Mark as pending review
|
|
4951
|
+
kanbanManager.updateTask(taskId, { reviewStatus: 'pending' });
|
|
4952
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: kanbanManager.getTask(taskId) } });
|
|
4953
|
+
|
|
4954
|
+
// Spawn a review agent
|
|
4955
|
+
try {
|
|
4956
|
+
const reviewPrompt = `You are reviewing the following task:\n\nTitle: ${task.title}\nDescription: ${task.description}\n\nReview the work and provide feedback. If the implementation looks good, say "REVIEW: PASSED" at the end. If there are issues, say "REVIEW: FAILED" followed by the issues.`;
|
|
4957
|
+
const reviewSession = await createSession({
|
|
4958
|
+
name: `review-${task.title.slice(0, 20)}`,
|
|
4959
|
+
cwd: undefined,
|
|
4960
|
+
projectId: task.projectId,
|
|
4961
|
+
});
|
|
4962
|
+
// Send review prompt
|
|
4963
|
+
await sendPromptToSession(reviewSession.id, reviewPrompt);
|
|
4964
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4965
|
+
res.end(JSON.stringify({ ok: true, reviewSessionId: reviewSession.id }));
|
|
4966
|
+
} catch (e) {
|
|
4967
|
+
// If spawning fails, just mark as needing manual review
|
|
4968
|
+
kanbanManager.updateTask(taskId, { reviewStatus: 'failed', reviewFeedback: 'Failed to spawn review agent: ' + e.message });
|
|
4969
|
+
broadcast({ type: 'kanban_task_updated', payload: { task: kanbanManager.getTask(taskId) } });
|
|
4970
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4971
|
+
res.end(JSON.stringify({ ok: true, manualReview: true }));
|
|
4972
|
+
}
|
|
4973
|
+
return;
|
|
4861
4974
|
}
|
|
4862
4975
|
|
|
4863
4976
|
// ============================================================================
|
|
4864
|
-
//
|
|
4977
|
+
// Planning Pipeline API
|
|
4865
4978
|
// ============================================================================
|
|
4866
4979
|
|
|
4867
|
-
// POST /
|
|
4868
|
-
|
|
4869
|
-
if (projectLeadsMatch && req.method === 'POST') {
|
|
4870
|
-
const projectId = projectLeadsMatch[1];
|
|
4980
|
+
// POST /pipelines - Start a new pipeline
|
|
4981
|
+
if (req.method === 'POST' && req.url === '/pipelines') {
|
|
4871
4982
|
collectRequestBody(req).then(async (body) => {
|
|
4872
4983
|
try {
|
|
4873
|
-
const {
|
|
4874
|
-
if (!
|
|
4984
|
+
const { taskId, projectId } = JSON.parse(body);
|
|
4985
|
+
if (!taskId || !projectId) {
|
|
4875
4986
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4876
|
-
res.end(JSON.stringify({ ok: false, error: '
|
|
4987
|
+
res.end(JSON.stringify({ ok: false, error: 'taskId and projectId are required' }));
|
|
4988
|
+
return;
|
|
4989
|
+
}
|
|
4990
|
+
const task = kanbanManager.getTask(taskId);
|
|
4991
|
+
if (!task) {
|
|
4992
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4993
|
+
res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
|
|
4877
4994
|
return;
|
|
4878
4995
|
}
|
|
4879
|
-
const
|
|
4996
|
+
const project = projectsManager.getProject(projectId);
|
|
4997
|
+
if (!project) {
|
|
4998
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4999
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
5000
|
+
return;
|
|
5001
|
+
}
|
|
5002
|
+
const pipeline = await pipelineManager.createPipeline(
|
|
5003
|
+
taskId, projectId, project.path, task.title, task.description || '', task.attachments || []
|
|
5004
|
+
);
|
|
4880
5005
|
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
4881
|
-
res.end(JSON.stringify({ ok: true,
|
|
5006
|
+
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
4882
5007
|
} catch (e) {
|
|
4883
5008
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4884
5009
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
@@ -4890,315 +5015,285 @@ async function handleHttpRequest(req, res) {
|
|
|
4890
5015
|
return;
|
|
4891
5016
|
}
|
|
4892
5017
|
|
|
4893
|
-
// GET /
|
|
4894
|
-
if (
|
|
4895
|
-
const
|
|
4896
|
-
const
|
|
5018
|
+
// GET /pipelines?projectId=X - List pipelines
|
|
5019
|
+
if (req.method === 'GET' && req.url?.startsWith('/pipelines') && !req.url.includes('/pipelines/')) {
|
|
5020
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
5021
|
+
const projectId = urlObj.searchParams.get('projectId');
|
|
5022
|
+
const pipelines = pipelineManager.getPipelines(projectId || undefined);
|
|
4897
5023
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4898
|
-
res.end(JSON.stringify({ ok: true,
|
|
5024
|
+
res.end(JSON.stringify({ ok: true, pipelines }));
|
|
4899
5025
|
return;
|
|
4900
5026
|
}
|
|
4901
5027
|
|
|
4902
|
-
//
|
|
4903
|
-
const
|
|
4904
|
-
if (
|
|
4905
|
-
const
|
|
4906
|
-
const action = leadMatch[2];
|
|
5028
|
+
// Pipeline-specific endpoints: /pipelines/:id
|
|
5029
|
+
const pipelineMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)$/);
|
|
5030
|
+
if (pipelineMatch) {
|
|
5031
|
+
const pipelineId = pipelineMatch[1];
|
|
4907
5032
|
|
|
4908
|
-
//
|
|
4909
|
-
if (req.method === '
|
|
4910
|
-
const
|
|
4911
|
-
if (
|
|
4912
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4913
|
-
res.end(JSON.stringify({ ok: true }));
|
|
4914
|
-
} else {
|
|
5033
|
+
// GET /pipelines/:id - Get pipeline
|
|
5034
|
+
if (req.method === 'GET') {
|
|
5035
|
+
const pipeline = pipelineManager.getPipeline(pipelineId);
|
|
5036
|
+
if (!pipeline) {
|
|
4915
5037
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4916
|
-
res.end(JSON.stringify({ ok: false, error: '
|
|
5038
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
5039
|
+
return;
|
|
4917
5040
|
}
|
|
4918
|
-
return;
|
|
4919
|
-
}
|
|
4920
|
-
|
|
4921
|
-
// GET /leads/:id/queue - Get work queue
|
|
4922
|
-
if (req.method === 'GET' && action === 'queue') {
|
|
4923
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
4924
5041
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4925
|
-
res.end(JSON.stringify({ ok: true,
|
|
5042
|
+
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
4926
5043
|
return;
|
|
4927
5044
|
}
|
|
5045
|
+
}
|
|
4928
5046
|
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
5047
|
+
// POST /pipelines/:id/cancel - Cancel pipeline
|
|
5048
|
+
const pipelineCancelMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/cancel$/);
|
|
5049
|
+
if (req.method === 'POST' && pipelineCancelMatch) {
|
|
5050
|
+
const pipelineId = pipelineCancelMatch[1];
|
|
5051
|
+
try {
|
|
5052
|
+
const pipeline = await pipelineManager.cancelPipeline(pipelineId);
|
|
5053
|
+
if (!pipeline) {
|
|
4933
5054
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4934
|
-
res.end(JSON.stringify({ ok: false, error: '
|
|
5055
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
4935
5056
|
return;
|
|
4936
5057
|
}
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
// Get commit info - either specific commit or latest
|
|
4943
|
-
let commitInfo;
|
|
4944
|
-
if (commitSha) {
|
|
4945
|
-
// Get specific commit info
|
|
4946
|
-
const project = projectsManager.getProject(session.projectId);
|
|
4947
|
-
if (project) {
|
|
4948
|
-
commitInfo = await commitWatcher.getCommitInfo?.(project.path, commitSha);
|
|
4949
|
-
}
|
|
4950
|
-
} else {
|
|
4951
|
-
// Get latest commit
|
|
4952
|
-
commitInfo = await commitWatcher.forceCheck(session.projectId);
|
|
4953
|
-
}
|
|
4954
|
-
|
|
4955
|
-
if (!commitInfo) {
|
|
4956
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
4957
|
-
res.end(JSON.stringify({ ok: false, error: 'Could not get commit info' }));
|
|
4958
|
-
return;
|
|
4959
|
-
}
|
|
4960
|
-
|
|
4961
|
-
await triggerLead(leadId, commitInfo);
|
|
4962
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4963
|
-
res.end(JSON.stringify({ ok: true, commit: commitInfo }));
|
|
4964
|
-
} catch (e) {
|
|
4965
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
4966
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4967
|
-
}
|
|
4968
|
-
}).catch(() => {
|
|
4969
|
-
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
4970
|
-
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
4971
|
-
});
|
|
4972
|
-
return;
|
|
4973
|
-
}
|
|
4974
|
-
|
|
4975
|
-
// POST /leads/:id/pause - Pause lead
|
|
4976
|
-
if (req.method === 'POST' && action === 'pause') {
|
|
4977
|
-
const paused = pauseLead(leadId);
|
|
4978
|
-
if (paused) {
|
|
4979
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4980
|
-
res.end(JSON.stringify({ ok: true }));
|
|
4981
|
-
} else {
|
|
4982
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4983
|
-
res.end(JSON.stringify({ ok: false, error: 'Lead not found' }));
|
|
4984
|
-
}
|
|
4985
|
-
return;
|
|
4986
|
-
}
|
|
4987
|
-
|
|
4988
|
-
// POST /leads/:id/resume - Resume lead
|
|
4989
|
-
if (req.method === 'POST' && action === 'resume') {
|
|
4990
|
-
const resumed = resumeLead(leadId);
|
|
4991
|
-
if (resumed) {
|
|
4992
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4993
|
-
res.end(JSON.stringify({ ok: true }));
|
|
4994
|
-
} else {
|
|
4995
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
4996
|
-
res.end(JSON.stringify({ ok: false, error: 'Lead not found' }));
|
|
4997
|
-
}
|
|
4998
|
-
return;
|
|
5058
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5059
|
+
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
5060
|
+
} catch (e) {
|
|
5061
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5062
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
4999
5063
|
}
|
|
5064
|
+
return;
|
|
5065
|
+
}
|
|
5000
5066
|
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5067
|
+
// POST /pipelines/:id/retry - Retry failed pipeline
|
|
5068
|
+
const pipelineRetryMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/retry$/);
|
|
5069
|
+
if (req.method === 'POST' && pipelineRetryMatch) {
|
|
5070
|
+
const pipelineId = pipelineRetryMatch[1];
|
|
5071
|
+
try {
|
|
5072
|
+
const pipeline = await pipelineManager.retryPipeline(pipelineId);
|
|
5073
|
+
if (!pipeline) {
|
|
5005
5074
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5006
|
-
res.end(JSON.stringify({ ok: false, error: '
|
|
5007
|
-
return;
|
|
5008
|
-
}
|
|
5009
|
-
|
|
5010
|
-
if (session.leadConfig?.triggerMode !== 'interactive') {
|
|
5011
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5012
|
-
res.end(JSON.stringify({ ok: false, error: 'Lead is not in interactive mode' }));
|
|
5075
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found or not failed' }));
|
|
5013
5076
|
return;
|
|
5014
5077
|
}
|
|
5078
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5079
|
+
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
5080
|
+
} catch (e) {
|
|
5081
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5082
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5083
|
+
}
|
|
5084
|
+
return;
|
|
5085
|
+
}
|
|
5015
5086
|
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
const result = await triggerInteractiveLead(leadId, input);
|
|
5026
|
-
if (result.ok) {
|
|
5027
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5028
|
-
res.end(JSON.stringify({ ok: true, workItem: result.workItem }));
|
|
5029
|
-
} else {
|
|
5030
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5031
|
-
res.end(JSON.stringify({ ok: false, error: result.error }));
|
|
5032
|
-
}
|
|
5033
|
-
} catch (e) {
|
|
5034
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5035
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5036
|
-
}
|
|
5037
|
-
}).catch(() => {
|
|
5038
|
-
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
5039
|
-
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
5040
|
-
});
|
|
5087
|
+
// POST /pipelines/:id/advance - Force-advance a stuck pipeline to the next phase
|
|
5088
|
+
const pipelineAdvanceMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/advance$/);
|
|
5089
|
+
if (req.method === 'POST' && pipelineAdvanceMatch) {
|
|
5090
|
+
const pipelineId = pipelineAdvanceMatch[1];
|
|
5091
|
+
const pipeline = pipelineManager.getPipeline(pipelineId);
|
|
5092
|
+
if (!pipeline) {
|
|
5093
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5094
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
5041
5095
|
return;
|
|
5042
5096
|
}
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5097
|
+
if (pipeline.status !== 'running') {
|
|
5098
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5099
|
+
res.end(JSON.stringify({ ok: false, error: `Pipeline is ${pipeline.status}, not running` }));
|
|
5100
|
+
return;
|
|
5101
|
+
}
|
|
5102
|
+
// Mark current phase as completed if still running
|
|
5103
|
+
const currentPhaseRecord = pipeline.phases.find(p => p.phase === pipeline.currentPhase && p.status === 'running');
|
|
5104
|
+
if (currentPhaseRecord) {
|
|
5105
|
+
currentPhaseRecord.status = 'completed';
|
|
5106
|
+
currentPhaseRecord.completedAt = Date.now();
|
|
5107
|
+
// Cleanup agent mapping
|
|
5108
|
+
if (currentPhaseRecord.agentId) {
|
|
5109
|
+
pipelineManager.agentToPipeline.delete(currentPhaseRecord.agentId);
|
|
5051
5110
|
}
|
|
5111
|
+
pipelineManager.savePipeline(pipeline);
|
|
5112
|
+
}
|
|
5113
|
+
try {
|
|
5114
|
+
await pipelineManager.advancePhase(pipelineId);
|
|
5115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5116
|
+
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
5117
|
+
} catch (e) {
|
|
5118
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5119
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5120
|
+
}
|
|
5121
|
+
return;
|
|
5122
|
+
}
|
|
5052
5123
|
|
|
5053
|
-
collectRequestBody(req).then(async (body) => {
|
|
5054
|
-
try {
|
|
5055
|
-
const { workItemId, type, agentId, agentName, prompt } = JSON.parse(body);
|
|
5056
|
-
|
|
5057
|
-
// Find the work item
|
|
5058
|
-
const queue = leadWorkQueues.get(leadId) || [];
|
|
5059
|
-
const workItem = queue.find(w => w.id === workItemId);
|
|
5060
|
-
|
|
5061
|
-
if (!workItem) {
|
|
5062
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5063
|
-
res.end(JSON.stringify({ ok: false, error: 'Work item not found' }));
|
|
5064
|
-
return;
|
|
5065
|
-
}
|
|
5066
5124
|
|
|
5067
|
-
if (workItem.status !== 'awaiting_handoff') {
|
|
5068
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5069
|
-
res.end(JSON.stringify({ ok: false, error: 'Work item is not awaiting handoff' }));
|
|
5070
|
-
return;
|
|
5071
|
-
}
|
|
5072
5125
|
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
return;
|
|
5106
|
-
}
|
|
5126
|
+
// POST /pipelines/:id/ship - Ship to target branch (commit + push)
|
|
5127
|
+
const pipelineShipMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/ship$/);
|
|
5128
|
+
if (req.method === 'POST' && pipelineShipMatch) {
|
|
5129
|
+
const pipelineId = pipelineShipMatch[1];
|
|
5130
|
+
let body = '';
|
|
5131
|
+
req.on('data', chunk => body += chunk);
|
|
5132
|
+
req.on('end', async () => {
|
|
5133
|
+
try {
|
|
5134
|
+
let targetBranch;
|
|
5135
|
+
if (body) {
|
|
5136
|
+
try {
|
|
5137
|
+
const parsed = JSON.parse(body);
|
|
5138
|
+
targetBranch = parsed.targetBranch;
|
|
5139
|
+
} catch (_) { /* ignore parse errors, use default */ }
|
|
5140
|
+
}
|
|
5141
|
+
const pipeline = await pipelineManager.shipToMain(pipelineId, targetBranch);
|
|
5142
|
+
if (!pipeline) {
|
|
5143
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5144
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
5145
|
+
return;
|
|
5146
|
+
}
|
|
5147
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5148
|
+
// Exclude non-serializable fields (like timeout handles) from response
|
|
5149
|
+
const { _shipTimeout, ...safePipeline } = pipeline;
|
|
5150
|
+
res.end(JSON.stringify({ ok: true, pipeline: safePipeline }));
|
|
5151
|
+
} catch (e) {
|
|
5152
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5153
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5154
|
+
}
|
|
5155
|
+
});
|
|
5156
|
+
return;
|
|
5157
|
+
}
|
|
5107
5158
|
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5159
|
+
// POST /pipelines/:id/request-changes - Request changes with human feedback
|
|
5160
|
+
const pipelineRequestChangesMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/request-changes$/);
|
|
5161
|
+
if (req.method === 'POST' && pipelineRequestChangesMatch) {
|
|
5162
|
+
const pipelineId = pipelineRequestChangesMatch[1];
|
|
5163
|
+
let body = '';
|
|
5164
|
+
req.on('data', chunk => body += chunk);
|
|
5165
|
+
req.on('end', async () => {
|
|
5166
|
+
try {
|
|
5167
|
+
const { feedback } = JSON.parse(body);
|
|
5168
|
+
if (!feedback || typeof feedback !== 'string') {
|
|
5169
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5170
|
+
res.end(JSON.stringify({ ok: false, error: 'Feedback is required' }));
|
|
5171
|
+
return;
|
|
5172
|
+
}
|
|
5173
|
+
const pipeline = await pipelineManager.requestChanges(pipelineId, feedback);
|
|
5174
|
+
if (!pipeline) {
|
|
5175
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5176
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found or not completed' }));
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
5179
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5180
|
+
res.end(JSON.stringify({ ok: true, pipeline }));
|
|
5181
|
+
} catch (e) {
|
|
5182
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5183
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5184
|
+
}
|
|
5185
|
+
});
|
|
5186
|
+
return;
|
|
5187
|
+
}
|
|
5120
5188
|
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5189
|
+
// POST /pipelines/:id/cleanup-worktree - Manually remove worktree and optionally delete branch
|
|
5190
|
+
const pipelineCleanupMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/cleanup-worktree$/);
|
|
5191
|
+
if (req.method === 'POST' && pipelineCleanupMatch) {
|
|
5192
|
+
const pipelineId = pipelineCleanupMatch[1];
|
|
5193
|
+
let body = '';
|
|
5194
|
+
req.on('data', chunk => body += chunk);
|
|
5195
|
+
req.on('end', async () => {
|
|
5196
|
+
try {
|
|
5197
|
+
const { deleteBranch } = body ? JSON.parse(body) : {};
|
|
5198
|
+
const pipeline = pipelineManager.getPipeline(pipelineId);
|
|
5199
|
+
if (!pipeline) {
|
|
5200
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5201
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
5202
|
+
return;
|
|
5203
|
+
}
|
|
5128
5204
|
|
|
5129
|
-
|
|
5205
|
+
// Idempotent: if already cleaned, return success
|
|
5206
|
+
if (pipeline.worktreeStatus === 'cleaned') {
|
|
5130
5207
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5131
|
-
res.end(JSON.stringify({
|
|
5132
|
-
|
|
5133
|
-
targetAgentId,
|
|
5134
|
-
targetAgentName: workItem.handoffTarget.agentName,
|
|
5135
|
-
}));
|
|
5136
|
-
|
|
5137
|
-
// Broadcast queue update
|
|
5138
|
-
broadcast({
|
|
5139
|
-
type: 'lead_queue_update',
|
|
5140
|
-
payload: { leadId, queue },
|
|
5141
|
-
});
|
|
5142
|
-
|
|
5143
|
-
} catch (e) {
|
|
5144
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5145
|
-
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5208
|
+
res.end(JSON.stringify({ ok: true, alreadyCleaned: true }));
|
|
5209
|
+
return;
|
|
5146
5210
|
}
|
|
5147
|
-
}).catch(() => {
|
|
5148
|
-
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
5149
|
-
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
5150
|
-
});
|
|
5151
|
-
return;
|
|
5152
|
-
}
|
|
5153
|
-
|
|
5154
|
-
// POST /leads/:id/dismiss-handoff - Dismiss a pending handoff
|
|
5155
|
-
if (req.method === 'POST' && action === 'dismiss-handoff') {
|
|
5156
|
-
collectRequestBody(req).then(async (body) => {
|
|
5157
|
-
try {
|
|
5158
|
-
const { workItemId } = JSON.parse(body);
|
|
5159
5211
|
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5212
|
+
// Remove worktree (handle case where directory was already manually deleted)
|
|
5213
|
+
if (pipeline.worktreePath && pipeline.projectPath) {
|
|
5214
|
+
try {
|
|
5215
|
+
await removeWorktree(pipeline.projectPath, pipeline.worktreePath);
|
|
5216
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Worktree manually cleaned up`);
|
|
5217
|
+
} catch (e) {
|
|
5218
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Worktree removal warning: ${e.message}`);
|
|
5219
|
+
// Continue anyway — directory may already be gone
|
|
5167
5220
|
}
|
|
5221
|
+
}
|
|
5168
5222
|
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5223
|
+
// Optionally delete the local branch
|
|
5224
|
+
if (deleteBranch && pipeline.branch && pipeline.projectPath) {
|
|
5225
|
+
try {
|
|
5226
|
+
await new Promise((resolve, reject) => {
|
|
5227
|
+
execFile('git', ['-C', pipeline.projectPath, 'branch', '-D', pipeline.branch], EXEC_OPTIONS, (error) => {
|
|
5228
|
+
if (error) reject(error);
|
|
5229
|
+
else resolve();
|
|
5230
|
+
});
|
|
5231
|
+
});
|
|
5232
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Branch ${pipeline.branch} deleted`);
|
|
5233
|
+
} catch (e) {
|
|
5234
|
+
log(`Pipeline ${pipelineId.slice(0, 8)}: Branch deletion warning: ${e.message}`);
|
|
5235
|
+
// Continue — branch may already be gone
|
|
5173
5236
|
}
|
|
5237
|
+
}
|
|
5174
5238
|
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
}
|
|
5181
|
-
saveLeadState();
|
|
5182
|
-
broadcastSessions();
|
|
5239
|
+
// Update pipeline state
|
|
5240
|
+
pipeline.worktreeStatus = 'cleaned';
|
|
5241
|
+
pipeline.updatedAt = Date.now();
|
|
5242
|
+
pipelineManager.savePipeline(pipeline);
|
|
5243
|
+
pipelineManager.broadcastPipeline(pipeline);
|
|
5183
5244
|
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5245
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5246
|
+
res.end(JSON.stringify({ ok: true }));
|
|
5247
|
+
} catch (e) {
|
|
5248
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5249
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5250
|
+
}
|
|
5251
|
+
});
|
|
5252
|
+
return;
|
|
5253
|
+
}
|
|
5188
5254
|
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
});
|
|
5255
|
+
// GET /pipelines/:id/files/:filename - Read plan file
|
|
5256
|
+
// GET /pipelines/:id/files/:filename - Read plan file (strict whitelist)
|
|
5257
|
+
const pipelineFileMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)\/files\/([a-z_]+(?:\.[a-z]+)?)$/);
|
|
5258
|
+
if (req.method === 'GET' && pipelineFileMatch) {
|
|
5259
|
+
const pipelineId = pipelineFileMatch[1];
|
|
5260
|
+
const filename = pipelineFileMatch[2];
|
|
5261
|
+
const pipeline = pipelineManager.getPipeline(pipelineId);
|
|
5262
|
+
if (!pipeline) {
|
|
5263
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5264
|
+
res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
|
|
5265
|
+
return;
|
|
5266
|
+
}
|
|
5267
|
+
// Strict whitelist — only exact plan file names allowed
|
|
5268
|
+
const allowedFiles = ['spec.md', 'implementation_plan.json', 'execution_log.md', 'review.md', 'human_review.md', 'pipeline.json'];
|
|
5269
|
+
if (!allowedFiles.includes(filename)) {
|
|
5270
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5271
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid filename' }));
|
|
5272
|
+
return;
|
|
5273
|
+
}
|
|
5274
|
+
// Double-check: reject any path traversal chars (defense in depth)
|
|
5275
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
5276
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5277
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid filename' }));
|
|
5199
5278
|
return;
|
|
5200
5279
|
}
|
|
5280
|
+
const filePath = join(pipelineManager.getPlanDir(pipeline), filename);
|
|
5281
|
+
if (!existsSync(filePath)) {
|
|
5282
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5283
|
+
res.end(JSON.stringify({ ok: false, error: 'File not found' }));
|
|
5284
|
+
return;
|
|
5285
|
+
}
|
|
5286
|
+
try {
|
|
5287
|
+
const content = readFileSync(filePath, 'utf8');
|
|
5288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5289
|
+
res.end(JSON.stringify({ ok: true, content, filename }));
|
|
5290
|
+
} catch (e) {
|
|
5291
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5292
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5293
|
+
}
|
|
5294
|
+
return;
|
|
5201
5295
|
}
|
|
5296
|
+
|
|
5202
5297
|
// ============================================================================
|
|
5203
5298
|
// Claude Usage API
|
|
5204
5299
|
// ============================================================================
|
|
@@ -5330,6 +5425,8 @@ async function handleHttpRequest(req, res) {
|
|
|
5330
5425
|
if (req.method === 'DELETE' && !action) {
|
|
5331
5426
|
deleteSession(sessionId).then((deleted) => {
|
|
5332
5427
|
if (deleted) {
|
|
5428
|
+
// If the deleted session was a ship agent, clear pipeline state
|
|
5429
|
+
pipelineManager.onSessionDeleted(sessionId);
|
|
5333
5430
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5334
5431
|
res.end(JSON.stringify({ ok: true }));
|
|
5335
5432
|
}
|
|
@@ -5443,15 +5540,33 @@ async function handleHttpRequest(req, res) {
|
|
|
5443
5540
|
res.end(JSON.stringify({ ok: false, error: 'Invalid tmux session name' }));
|
|
5444
5541
|
return;
|
|
5445
5542
|
}
|
|
5543
|
+
// Send Ctrl+C, then verify the process responded after a short delay
|
|
5446
5544
|
execFile('tmux', ['send-keys', '-t', session.tmuxSession, 'C-c'], EXEC_OPTIONS, (error) => {
|
|
5447
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5448
5545
|
if (error) {
|
|
5546
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5449
5547
|
res.end(JSON.stringify({ ok: false, error: error.message }));
|
|
5548
|
+
return;
|
|
5450
5549
|
}
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5550
|
+
log(`Sent Ctrl+C to ${session.name}`);
|
|
5551
|
+
// Check if process is responsive after 2 seconds
|
|
5552
|
+
// If the pane process died, the session is frozen — force kill it
|
|
5553
|
+
setTimeout(() => {
|
|
5554
|
+
execFile('tmux', ['list-panes', '-t', session.tmuxSession, '-F', '#{pane_pid} #{pane_dead}'], EXEC_OPTIONS, (err2, stdout2) => {
|
|
5555
|
+
if (err2 || !stdout2) return;
|
|
5556
|
+
const parts = stdout2.trim().split(' ');
|
|
5557
|
+
const panePid = parseInt(parts[0], 10);
|
|
5558
|
+
const paneDead = parts[1] === '1';
|
|
5559
|
+
if (paneDead && panePid) {
|
|
5560
|
+
log(`${session.name} pane is dead after Ctrl+C, marking offline`);
|
|
5561
|
+
session.status = 'offline';
|
|
5562
|
+
session.currentTool = undefined;
|
|
5563
|
+
broadcastSessions();
|
|
5564
|
+
saveSessions();
|
|
5565
|
+
}
|
|
5566
|
+
});
|
|
5567
|
+
}, 2000);
|
|
5568
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5569
|
+
res.end(JSON.stringify({ ok: true }));
|
|
5455
5570
|
});
|
|
5456
5571
|
return;
|
|
5457
5572
|
}
|
|
@@ -5637,6 +5752,45 @@ async function handleHttpRequest(req, res) {
|
|
|
5637
5752
|
});
|
|
5638
5753
|
return;
|
|
5639
5754
|
}
|
|
5755
|
+
// GET /sessions/:id/output — Get recent agent output via REST (no WebSocket needed)
|
|
5756
|
+
if (req.method === 'GET' && action === 'output') {
|
|
5757
|
+
const session = getSession(sessionId);
|
|
5758
|
+
if (!session) {
|
|
5759
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5760
|
+
res.end(JSON.stringify({ ok: false, error: 'Session not found' }));
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
5763
|
+
try {
|
|
5764
|
+
validateTmuxSession(session.tmuxSession);
|
|
5765
|
+
} catch {
|
|
5766
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5767
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid tmux session' }));
|
|
5768
|
+
return;
|
|
5769
|
+
}
|
|
5770
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
5771
|
+
const lines = Math.min(parseInt(urlObj.searchParams.get('lines') || '100', 10), 500);
|
|
5772
|
+
execFile('tmux', ['capture-pane', '-t', session.tmuxSession, '-p', '-S', `-${lines}`],
|
|
5773
|
+
{ ...EXEC_OPTIONS, maxBuffer: 1024 * 1024 },
|
|
5774
|
+
(error, stdout) => {
|
|
5775
|
+
if (error) {
|
|
5776
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5777
|
+
res.end(JSON.stringify({ ok: false, error: 'Failed to capture output' }));
|
|
5778
|
+
return;
|
|
5779
|
+
}
|
|
5780
|
+
const tracker = sessionOutputs.get(session.id);
|
|
5781
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5782
|
+
res.end(JSON.stringify({
|
|
5783
|
+
ok: true,
|
|
5784
|
+
sessionId: session.id,
|
|
5785
|
+
name: session.name,
|
|
5786
|
+
status: session.status,
|
|
5787
|
+
raw: stdout,
|
|
5788
|
+
lastResponse: tracker?.lastResponse || null,
|
|
5789
|
+
}));
|
|
5790
|
+
}
|
|
5791
|
+
);
|
|
5792
|
+
return;
|
|
5793
|
+
}
|
|
5640
5794
|
}
|
|
5641
5795
|
// -------------------------------------------------------------------------
|
|
5642
5796
|
// Text Tiles API
|
|
@@ -5734,6 +5888,114 @@ async function handleHttpRequest(req, res) {
|
|
|
5734
5888
|
return;
|
|
5735
5889
|
}
|
|
5736
5890
|
}
|
|
5891
|
+
// ============================================================================
|
|
5892
|
+
// Remote Control API (convenience endpoints for external tools / MCP)
|
|
5893
|
+
// ============================================================================
|
|
5894
|
+
|
|
5895
|
+
// GET /api/overview — Full system status in a single call
|
|
5896
|
+
if (req.method === 'GET' && req.url === '/api/overview') {
|
|
5897
|
+
const sessions = getSessions();
|
|
5898
|
+
const projects = projectsManager.getProjects();
|
|
5899
|
+
const pipelines = pipelineManager.getPipelines();
|
|
5900
|
+
const allTasks = kanbanManager.getTasks();
|
|
5901
|
+
const taskCounts = {};
|
|
5902
|
+
for (const col of VALID_COLUMNS) { taskCounts[col] = 0; }
|
|
5903
|
+
for (const t of allTasks) {
|
|
5904
|
+
if (taskCounts[t.columnId] !== undefined) taskCounts[t.columnId]++;
|
|
5905
|
+
}
|
|
5906
|
+
const activePipelines = pipelines.filter(p => p.status === 'running' || p.status === 'pending');
|
|
5907
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5908
|
+
res.end(JSON.stringify({
|
|
5909
|
+
ok: true,
|
|
5910
|
+
agents: sessions.map(s => ({ id: s.id, name: s.name, status: s.status, currentTool: s.currentTool, projectId: s.projectId })),
|
|
5911
|
+
projects: projects.map(p => ({ id: p.id, name: p.name, path: p.path })),
|
|
5912
|
+
activePipelines: activePipelines.map(p => ({ id: p.id, taskId: p.taskId, status: p.status, phase: p.phase })),
|
|
5913
|
+
taskCounts,
|
|
5914
|
+
totalAgents: sessions.length,
|
|
5915
|
+
totalProjects: projects.length,
|
|
5916
|
+
totalTasks: allTasks.length,
|
|
5917
|
+
}));
|
|
5918
|
+
return;
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5921
|
+
// POST /api/restart — Gracefully restart the backend server
|
|
5922
|
+
// Sessions, pipelines, and tasks persist to disk. tmux sessions keep running.
|
|
5923
|
+
// WebSocket clients will auto-reconnect when the server comes back.
|
|
5924
|
+
if (req.method === 'POST' && req.url === '/api/restart') {
|
|
5925
|
+
log('Restart requested via API');
|
|
5926
|
+
// Save all state before exiting
|
|
5927
|
+
saveSessions();
|
|
5928
|
+
saveTiles();
|
|
5929
|
+
// Notify all WebSocket clients
|
|
5930
|
+
broadcast({ type: 'server_restarting', payload: { message: 'Server is restarting...' } });
|
|
5931
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5932
|
+
res.end(JSON.stringify({ ok: true, message: 'Server restarting...' }));
|
|
5933
|
+
// Give response time to flush, then self-restart
|
|
5934
|
+
setTimeout(() => {
|
|
5935
|
+
log('Restarting server...');
|
|
5936
|
+
// Spawn a replacement server process, detached from this one.
|
|
5937
|
+
// Uses spawn (not exec) with static args — no shell injection risk.
|
|
5938
|
+
const serverPath = resolve(__dirname, 'index.js');
|
|
5939
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
5940
|
+
env: process.env,
|
|
5941
|
+
detached: true,
|
|
5942
|
+
stdio: 'ignore',
|
|
5943
|
+
});
|
|
5944
|
+
child.unref();
|
|
5945
|
+
log('Spawned replacement server, exiting...');
|
|
5946
|
+
process.exit(75);
|
|
5947
|
+
}, 500);
|
|
5948
|
+
return;
|
|
5949
|
+
}
|
|
5950
|
+
|
|
5951
|
+
// POST /api/run-task — Create a kanban task and start a pipeline in one call
|
|
5952
|
+
if (req.method === 'POST' && req.url === '/api/run-task') {
|
|
5953
|
+
collectRequestBody(req).then(async (body) => {
|
|
5954
|
+
try {
|
|
5955
|
+
const { projectId, title, description, priority, tags } = JSON.parse(body);
|
|
5956
|
+
if (!projectId || !title) {
|
|
5957
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5958
|
+
res.end(JSON.stringify({ ok: false, error: 'projectId and title are required' }));
|
|
5959
|
+
return;
|
|
5960
|
+
}
|
|
5961
|
+
if (typeof title !== 'string' || title.length > 500) {
|
|
5962
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
5963
|
+
res.end(JSON.stringify({ ok: false, error: 'Title must be a string under 500 characters' }));
|
|
5964
|
+
return;
|
|
5965
|
+
}
|
|
5966
|
+
const project = projectsManager.getProject(projectId);
|
|
5967
|
+
if (!project) {
|
|
5968
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
5969
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
5970
|
+
return;
|
|
5971
|
+
}
|
|
5972
|
+
// Create task in "planning" column
|
|
5973
|
+
const task = kanbanManager.createTask({
|
|
5974
|
+
title,
|
|
5975
|
+
description: description || '',
|
|
5976
|
+
projectId,
|
|
5977
|
+
columnId: 'planning',
|
|
5978
|
+
priority: priority || 'medium',
|
|
5979
|
+
tags: tags || [],
|
|
5980
|
+
});
|
|
5981
|
+
broadcast({ type: 'kanban_task_created', payload: { task } });
|
|
5982
|
+
// Start pipeline
|
|
5983
|
+
const pipeline = await pipelineManager.createPipeline(
|
|
5984
|
+
task.id, projectId, project.path, task.title, task.description || '', task.attachments || []
|
|
5985
|
+
);
|
|
5986
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
5987
|
+
res.end(JSON.stringify({ ok: true, task, pipeline }));
|
|
5988
|
+
} catch (e) {
|
|
5989
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
5990
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
5991
|
+
}
|
|
5992
|
+
}).catch(() => {
|
|
5993
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
5994
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
5995
|
+
});
|
|
5996
|
+
return;
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5737
5999
|
// Static file serving for frontend (production mode)
|
|
5738
6000
|
serveStaticFile(req, res);
|
|
5739
6001
|
}
|
|
@@ -5866,13 +6128,6 @@ function main() {
|
|
|
5866
6128
|
gitStatusManager.start();
|
|
5867
6129
|
log(`Git status polling started (adaptive: active=5s, idle=30s)`);
|
|
5868
6130
|
|
|
5869
|
-
// Load lead agent state (after sessions are loaded)
|
|
5870
|
-
loadLeadState();
|
|
5871
|
-
|
|
5872
|
-
// Set up commit watcher for lead agents
|
|
5873
|
-
commitWatcher.setCommitHandler(handleNewCommit);
|
|
5874
|
-
commitWatcher.start();
|
|
5875
|
-
log(`Commit watcher started for ${projectLeads.size} projects`);
|
|
5876
6131
|
// Watch for new events
|
|
5877
6132
|
watchEventsFile();
|
|
5878
6133
|
// Create HTTP server
|
|
@@ -5907,24 +6162,25 @@ function main() {
|
|
|
5907
6162
|
payload: projectsManager.getProjects(),
|
|
5908
6163
|
};
|
|
5909
6164
|
ws.send(JSON.stringify(projectsMsg));
|
|
5910
|
-
// Send pipelines
|
|
5911
|
-
const pipelinesMsg = {
|
|
5912
|
-
type: 'pipelines',
|
|
5913
|
-
payload: Array.from(pipelineManager.pipelines.values()),
|
|
5914
|
-
};
|
|
5915
|
-
ws.send(JSON.stringify(pipelinesMsg));
|
|
5916
|
-
// Send pipeline runs
|
|
5917
|
-
const pipelineRunsMsg = {
|
|
5918
|
-
type: 'pipeline_runs',
|
|
5919
|
-
payload: Array.from(pipelineManager.runs.values()),
|
|
5920
|
-
};
|
|
5921
|
-
ws.send(JSON.stringify(pipelineRunsMsg));
|
|
5922
6165
|
// Send text tiles
|
|
5923
6166
|
const tilesMsg = {
|
|
5924
6167
|
type: 'text_tiles',
|
|
5925
6168
|
payload: getTiles(),
|
|
5926
6169
|
};
|
|
5927
6170
|
ws.send(JSON.stringify(tilesMsg));
|
|
6171
|
+
// Send kanban tasks
|
|
6172
|
+
const kanbanMsg = {
|
|
6173
|
+
type: 'kanban_tasks',
|
|
6174
|
+
payload: { tasks: kanbanManager.getTasks() },
|
|
6175
|
+
};
|
|
6176
|
+
ws.send(JSON.stringify(kanbanMsg));
|
|
6177
|
+
// Send all pipelines (not just active) so frontend has worktree/branch info for completed tasks
|
|
6178
|
+
const allPipelines = pipelineManager.getPipelines();
|
|
6179
|
+
if (allPipelines.length > 0) {
|
|
6180
|
+
for (const pipeline of allPipelines) {
|
|
6181
|
+
ws.send(JSON.stringify({ type: 'pipeline_updated', payload: { pipeline } }));
|
|
6182
|
+
}
|
|
6183
|
+
}
|
|
5928
6184
|
// Send recent history - filtered to only include events from current managed sessions
|
|
5929
6185
|
const activeClaudeSessionIds = new Set(Array.from(managedSessions.values())
|
|
5930
6186
|
.map(s => s.claudeSessionId)
|