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.
@@ -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 = 120_000; // 2 minutes
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
- await execFileAsync('tmux', ['load-buffer', tempFile]);
202
- // Paste buffer into session
203
- await execFileAsync('tmux', ['paste-buffer', '-t', tmuxSession]);
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 && !s.isLead);
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 && !s.isLead);
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
- // Pipeline System - Loop Prevention & Stage Management
1101
+ // Kanban Board - Task Management System
1042
1102
  // ============================================================================
1043
1103
 
1044
- /** Pipeline data file paths */
1045
- const PIPELINE_DATA_DIR = resolve(expandHome('~/.vibeteam/data'));
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
- If you find ANY issues:
1057
- 1. Fix them directly in the code
1058
- 2. Commit your fixes with: git commit -m "[pipeline] Fix: <description>"
1059
- 3. Push the changes
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
- build: `You are the Build Check stage in an automated pipeline.
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
- Run the build and tests:
1068
- 1. npm run build (or appropriate build command)
1069
- 2. npm test (or appropriate test command)
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
- If build/tests fail:
1072
- 1. Analyze the error
1073
- 2. Fix the code causing the failure
1074
- 3. Commit with: git commit -m "[pipeline] Fix: <description>"
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
- Report: Build status, test results, what was fixed (if anything).`,
1143
+ getTask(id) {
1144
+ return this.tasks.get(id);
1145
+ }
1078
1146
 
1079
- deploy: `You are the Deploy stage in an automated pipeline.
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
- Deploy the application:
1082
- 1. Run: npx vercel --project vibeteam --yes
1083
- 2. Capture the preview URL
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
- Report: Deployment status, preview URL.`,
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
- * CommitOriginRegistry - Track agent-created commits to prevent loops
1090
- * Persists to ~/.vibeteam/data/commit-origins.json
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
- /** How long to keep entries (7 days) */
1097
- MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
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
- constructor() {
1100
- this.load();
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
- * Register an agent commit
1105
- * @param {string} sha - Commit SHA
1106
- * @param {string} agentId - Agent that made the commit
1107
- * @param {string} runId - Pipeline run ID
1108
- * @param {string} stageId - Stage ID (safety, build, deploy)
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
- this.save();
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
- * Get origin info for a commit
1132
- * @param {string} sha - Commit SHA
1133
- * @returns {Object|null}
1134
- */
1135
- getOrigin(sha) {
1136
- return this.origins.get(sha) || null;
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
- * Prune entries older than MAX_AGE_MS
1141
- */
1142
- prune() {
1143
- const now = Date.now();
1144
- let pruned = 0;
1145
- for (const [sha, info] of this.origins) {
1146
- if (now - info.timestamp > this.MAX_AGE_MS) {
1147
- this.origins.delete(sha);
1148
- pruned++;
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
- if (pruned > 0) {
1152
- debug(`Pruned ${pruned} old commit origins`);
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
- if (existsSync(COMMIT_ORIGINS_FILE)) {
1163
- const data = JSON.parse(readFileSync(COMMIT_ORIGINS_FILE, 'utf-8'));
1164
- this.origins = new Map(Object.entries(data));
1165
- debug(`Loaded ${this.origins.size} commit origins`);
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
- console.error('Failed to load commit origins:', e);
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(PIPELINE_DATA_DIR)) {
1178
- mkdirSync(PIPELINE_DATA_DIR, { recursive: true });
1357
+ if (!existsSync(PIPELINES_DIR)) {
1358
+ mkdirSync(PIPELINES_DIR, { recursive: true });
1179
1359
  }
1180
- const data = Object.fromEntries(this.origins);
1181
- writeFileSync(COMMIT_ORIGINS_FILE, JSON.stringify(data, null, 2));
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
- console.error('Failed to save commit origins:', e);
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
- constructor() {
1205
- this.load();
1206
- // Prune old commit origins every hour
1207
- setInterval(() => this.commitRegistry.prune(), 60 * 60 * 1000);
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
- * Check if a commit is a pipeline commit (should be skipped)
1212
- * Three-layer protection:
1213
- * 1. Commit message prefix [pipeline]
1214
- * 2. Origin registry check
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
- // Layer 2: Origin registry
1227
- if (this.commitRegistry.isAgentCommit(commitInfo.sha)) {
1228
- debug(`Skipping pipeline commit (registry): ${commitInfo.shortSha}`);
1229
- return true;
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
- // Layer 3: Claude co-author
1233
- if (commitInfo.message.includes('Co-Authored-By: Claude')) {
1234
- debug(`Skipping pipeline commit (co-author): ${commitInfo.shortSha}`);
1235
- return true;
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: randomUUID(),
1428
+ id: taskId, // 1:1 mapping
1429
+ taskId,
1250
1430
  projectId,
1251
- name: config.name || 'Default Pipeline',
1252
- enabled: config.enabled !== false,
1253
- stages: config.stages || [
1254
- { id: 'safety', name: 'Safety Check', enabled: true, requiresApproval: false, maxRetries: 2 },
1255
- { id: 'build', name: 'Build Check', enabled: true, requiresApproval: false, maxRetries: 2 },
1256
- { id: 'deploy', name: 'Deploy', enabled: true, requiresApproval: true, maxRetries: 1 },
1257
- ],
1258
- triggerBranches: config.triggerBranches || ['main'],
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
- this.pipelines.set(pipeline.id, pipeline);
1263
- this.save();
1264
- log(`Created pipeline: ${pipeline.name} for project ${projectId.slice(0, 8)}`);
1265
- return pipeline;
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
- * Get a pipeline by ID
1285
- * @param {string} pipelineId
1286
- * @returns {Object|null}
1287
- */
1288
- getPipeline(pipelineId) {
1289
- return this.pipelines.get(pipelineId) || null;
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
- * Update a pipeline
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) throw new Error('Pipeline not found');
1330
-
1331
- const run = {
1332
- id: randomUUID(),
1333
- pipelineId,
1334
- projectId: pipeline.projectId,
1335
- pipelineName: pipeline.name,
1336
- triggerCommit: commitInfo,
1337
- triggeredBy,
1338
- status: 'running',
1339
- stages: pipeline.stages.filter(s => s.enabled).map(s => ({
1340
- id: s.id,
1341
- name: s.name,
1342
- status: 'pending',
1343
- requiresApproval: s.requiresApproval,
1344
- maxRetries: s.maxRetries,
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.runs.set(run.id, run);
1353
- this.saveRuns();
1354
- log(`Created pipeline run: ${run.id.slice(0, 8)} for commit ${commitInfo.shortSha}`);
1506
+ // Generate prompt for this phase
1507
+ const prompt = this.getPromptForPhase(pipeline, phase);
1508
+ const planDir = this.getPlanDir(pipeline);
1355
1509
 
1356
- // Broadcast run created event
1357
- broadcast({
1358
- type: 'pipeline_run_created',
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
- return run;
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
- * Get a pipeline run by ID
1367
- * @param {string} runId
1368
- * @returns {Object|null}
1369
- */
1370
- getRun(runId) {
1371
- return this.runs.get(runId) || null;
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
- * Get runs for a pipeline
1376
- * @param {string} pipelineId
1377
- * @param {number} limit
1378
- * @returns {Array}
1379
- */
1380
- getPipelineRuns(pipelineId, limit = 10) {
1381
- const result = [];
1382
- for (const run of this.runs.values()) {
1383
- if (run.pipelineId === pipelineId) {
1384
- result.push(run);
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
- * Start processing the next stage in a run
1394
- * @param {string} runId
1395
- */
1396
- async processNextStage(runId) {
1397
- const run = this.runs.get(runId);
1398
- if (!run || run.status !== 'running') return;
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
- const stage = run.stages[stageIndex];
1583
+ switch (phase) {
1584
+ case 'spec':
1585
+ return `You are a Specification Agent for a multi-agent planning pipeline.
1408
1586
 
1409
- // Check if approval is required
1410
- if (stage.requiresApproval && stage.status === 'pending') {
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
- broadcast({
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
- // Execute the stage
1423
- await this.executeStage(run, stage);
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
- * Execute a pipeline stage
1428
- * @param {Object} run
1429
- * @param {Object} stage
1430
- */
1431
- async executeStage(run, stage) {
1432
- stage.status = 'running';
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
- try {
1442
- // Get the project
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
- // Build the prompt for the stage agent
1447
- const basePrompt = PIPELINE_STAGE_PROMPTS[stage.id] || 'Execute pipeline stage.';
1448
- const commitContext = `
1449
- Commit being analyzed:
1450
- - SHA: ${run.triggerCommit.sha}
1451
- - Message: ${run.triggerCommit.message}
1452
- - Author: ${run.triggerCommit.author}
1453
- - Files changed: ${run.triggerCommit.filesChanged.join(', ')}
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: If you make any commits, prefix with [pipeline].
1457
- Example: git commit -m "[pipeline] Fix: <description>"
1618
+ ## IMPORTANT: Human Review Feedback
1458
1619
 
1459
- When done, provide a clear summary of what was checked/done.`;
1620
+ The human reviewer has requested changes to the previous implementation. Address this feedback:
1460
1621
 
1461
- const fullPrompt = `${basePrompt}\n\n${commitContext}`;
1622
+ ${feedback}
1462
1623
 
1463
- // Create a session for this stage
1464
- const sessionName = `Pipeline: ${stage.name} (${run.triggerCommit.shortSha})`;
1465
- const session = await createSession({
1466
- name: sessionName,
1467
- cwd: project.path,
1468
- projectId: run.projectId,
1469
- isPipelineAgent: true,
1470
- pipelineRunId: run.id,
1471
- pipelineStageId: stage.id,
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
- stage.agentId = session.id;
1475
- this.saveRuns();
1672
+ **IMPORTANT**: Do NOT implement anything. Only plan.
1673
+ Write the file to: ${planDir}/implementation_plan.json`;
1674
+ }
1476
1675
 
1477
- // Send the prompt to the agent
1478
- await sendPromptToSession(session.id, fullPrompt);
1676
+ case 'execute':
1677
+ return `You are an Execution Agent for a multi-agent planning pipeline.
1479
1678
 
1480
- log(`Started pipeline stage: ${stage.name} for run ${run.id.slice(0, 8)}`);
1481
- } catch (err) {
1482
- this.stageFailed(run.id, stage.id, err.message);
1483
- }
1484
- }
1679
+ **Task**: ${pipeline.title}
1485
1680
 
1486
- /**
1487
- * Mark a stage as completed
1488
- * @param {string} runId
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
- const stage = run.stages.find(s => s.id === stageId);
1497
- if (!stage) return;
1685
+ Your job is to implement ALL subtasks from the plan, in order.
1498
1686
 
1499
- stage.status = 'passed';
1500
- stage.completedAt = Date.now();
1501
- stage.output = output;
1502
- run.currentStageIndex++;
1503
- this.saveRuns();
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
- log(`Pipeline stage completed: ${stage.name} for run ${runId.slice(0, 8)}`);
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
- broadcast({
1508
- type: 'pipeline_stage_completed',
1509
- payload: { runId, stageId, status: 'passed', output },
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
- // Process next stage
1513
- this.processNextStage(runId);
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
- * Mark a stage as failed
1518
- * @param {string} runId
1519
- * @param {string} stageId
1520
- * @param {string} error
1521
- */
1522
- stageFailed(runId, stageId, error = '') {
1523
- const run = this.runs.get(runId);
1524
- if (!run) return;
1525
-
1526
- const stage = run.stages.find(s => s.id === stageId);
1527
- if (!stage) return;
1528
-
1529
- // Check if we should retry
1530
- if (stage.retryCount < stage.maxRetries) {
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
- stage.status = 'failed';
1542
- stage.completedAt = Date.now();
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
- log(`Pipeline stage failed: ${stage.name} for run ${runId.slice(0, 8)}: ${error}`);
1758
+ if (pipeline.status !== 'running') return;
1549
1759
 
1550
- broadcast({
1551
- type: 'pipeline_stage_completed',
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
- broadcast({
1556
- type: 'pipeline_run_completed',
1557
- payload: { runId, status: 'failed' },
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
- * Complete a pipeline run
1563
- * @param {string} runId
1564
- * @param {string} status
1565
- */
1566
- completeRun(runId, status) {
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
- run.status = status;
1571
- run.completedAt = Date.now();
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 run completed: ${runId.slice(0, 8)} with status ${status}`);
1852
+ log(`Pipeline ${pipeline.id.slice(0, 8)}: ${currentPhaseRecord.phase} phase failed (agent offline)`);
1575
1853
 
1576
- broadcast({
1577
- type: 'pipeline_run_completed',
1578
- payload: { runId, status },
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
- * Approve a pending stage
1584
- * @param {string} runId
1585
- * @param {string} stageId
1586
- * @param {boolean} approved
1587
- */
1588
- approveStage(runId, stageId, approved) {
1589
- const run = this.runs.get(runId);
1590
- if (!run) return false;
1591
-
1592
- const stage = run.stages.find(s => s.id === stageId);
1593
- if (!stage || stage.status !== 'awaiting_approval') return false;
1594
-
1595
- if (approved) {
1596
- run.status = 'running';
1597
- stage.status = 'pending';
1598
- this.saveRuns();
1599
- this.executeStage(run, stage);
1600
- } else {
1601
- this.stageFailed(runId, stageId, 'Approval denied');
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
- return true;
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
- * Cancel a pipeline run
1609
- * @param {string} runId
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
- run.status = 'cancelled';
1616
- run.completedAt = Date.now();
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 stages as cancelled
1619
- for (const stage of run.stages) {
1620
- if (stage.status === 'pending' || stage.status === 'awaiting_approval') {
1621
- stage.status = 'cancelled';
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
- this.saveRuns();
1952
+ log(`Pipeline ${pipelineId.slice(0, 8)}: Cancelled`);
1953
+ this.cleanupWorktree(pipeline);
1954
+ this.scheduleCleanup(pipeline.id);
1955
+ return pipeline;
1956
+ }
1626
1957
 
1627
- broadcast({
1628
- type: 'pipeline_run_completed',
1629
- payload: { runId, status: 'cancelled' },
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
- return true;
1988
+ // Restart the failed phase
1989
+ await this.startPhase(pipelineId, failedPhase.phase);
1990
+
1991
+ return pipeline;
1633
1992
  }
1634
1993
 
1635
- /**
1636
- * Register a commit made by a pipeline agent
1637
- * @param {string} sha
1638
- * @param {string} agentId
1639
- * @param {string} runId
1640
- * @param {string} stageId
1641
- */
1642
- registerAgentCommit(sha, agentId, runId, stageId) {
1643
- this.commitRegistry.register(sha, agentId, runId, stageId);
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
- // Also track in the run
1646
- const run = this.runs.get(runId);
1647
- if (run && !run.childCommits.includes(sha)) {
1648
- run.childCommits.push(sha);
1649
- this.saveRuns();
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
- * Handle agent session ending (stage completion detection)
1655
- * @param {string} sessionId
1656
- */
1657
- handleAgentSessionEnd(sessionId) {
1658
- // Find the run/stage for this agent
1659
- for (const run of this.runs.values()) {
1660
- for (const stage of run.stages) {
1661
- if (stage.agentId === sessionId && stage.status === 'running') {
1662
- // Agent finished - mark stage as complete
1663
- // In a real implementation, we'd check the actual outcome
1664
- this.stageCompleted(run.id, stage.id, 'Stage completed');
1665
- return;
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
- * Load pipelines from disk
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
- load() {
2138
+ loadPipelineFromDisk(pipelineId) {
1675
2139
  try {
1676
- if (existsSync(PIPELINES_FILE)) {
1677
- const data = JSON.parse(readFileSync(PIPELINES_FILE, 'utf-8'));
1678
- if (Array.isArray(data)) {
1679
- for (const p of data) {
1680
- this.pipelines.set(p.id, p);
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
- console.error('Failed to load pipelines:', e);
2172
+ log(`Failed to reload pipeline ${pipelineId.slice(0, 8)} from disk: ${e.message}`);
2173
+ return null;
1687
2174
  }
2175
+ }
1688
2176
 
1689
- // Load runs
1690
- try {
1691
- if (existsSync(PIPELINE_RUNS_FILE)) {
1692
- const data = JSON.parse(readFileSync(PIPELINE_RUNS_FILE, 'utf-8'));
1693
- if (Array.isArray(data)) {
1694
- for (const r of data) {
1695
- this.runs.set(r.id, r);
1696
- }
1697
- }
1698
- debug(`Loaded ${this.runs.size} pipeline runs`);
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
- } catch (e) {
1701
- console.error('Failed to load pipeline runs:', e);
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
- * Save pipelines to disk
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
- save() {
1709
- try {
1710
- if (!existsSync(PIPELINE_DATA_DIR)) {
1711
- mkdirSync(PIPELINE_DATA_DIR, { recursive: true });
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
- const data = Array.from(this.pipelines.values());
1714
- writeFileSync(PIPELINES_FILE, JSON.stringify(data, null, 2));
1715
- } catch (e) {
1716
- console.error('Failed to save pipelines:', e);
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
- * Save pipeline runs to disk
2286
+ * Remove the pipeline's shared worktree (called after ship or cancel).
1722
2287
  */
1723
- saveRuns() {
1724
- try {
1725
- if (!existsSync(PIPELINE_DATA_DIR)) {
1726
- mkdirSync(PIPELINE_DATA_DIR, { recursive: true });
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
- /** Pipeline manager instance */
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 = 'waiting';
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 === 'waiting') {
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
- if (!worktreeOpts && project?.defaultWorktree) {
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
- exec('tmux list-sessions -F "#{session_name}"', EXEC_OPTIONS, (error, stdout) => {
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
- const newStatus = isAlive ? (session.status === 'offline' ? 'idle' : session.status) : 'offline';
2736
- if (session.status !== newStatus) {
2737
- session.status = newStatus;
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(); // Persist state changes
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
- // For leads, set to watching; for regular sessions, set to offline
2803
- if (session.isLead) {
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
- * Create a new lead agent for a project
3658
+ * Find managed session by Claude Code session ID
3050
3659
  */
3051
- async function createLeadAgent(projectId, role, options = {}) {
3052
- const project = projectsManager.getProject(projectId);
3053
- if (!project) {
3054
- throw new Error('Project not found');
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
- * Trigger a lead to process a commit
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
- // For lead agents, complete work item and return to watching
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
- // Broadcast and persist if status changed
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
- // Pipeline API - Safety & Deploy Pipeline System
4638
- // ============================================================================
4639
-
4640
- // POST /projects/:id/pipelines - Create pipeline for project
4641
- const projectPipelinesMatch = req.url?.match(/^\/projects\/([a-f0-9-]+)\/pipelines$/);
4642
- if (projectPipelinesMatch && req.method === 'POST') {
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
- collectRequestBody(req).then((body) => {
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 config = body ? JSON.parse(body) : {};
4654
- const pipeline = pipelineManager.createPipeline(projectId, config);
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
- // Start watching for commits if not already
4657
- if (!commitWatcher.isTracking(projectId)) {
4658
- commitWatcher.track(projectId, project.path, 'main');
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
- // Broadcast updated pipelines to all clients
4662
- broadcastPipelines();
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
- res.writeHead(201, { 'Content-Type': 'application/json' });
4665
- res.end(JSON.stringify({ ok: true, pipeline }));
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: 'Request body too large' }));
4799
+ res.end(JSON.stringify({ error: 'Image too large (max 10MB)' }));
4673
4800
  });
4674
4801
  return;
4675
4802
  }
4676
4803
 
4677
- // GET /projects/:id/pipelines - List pipelines for project
4678
- if (projectPipelinesMatch && req.method === 'GET') {
4679
- const projectId = projectPipelinesMatch[1];
4680
- const pipelines = pipelineManager.getProjectPipelines(projectId);
4681
- res.writeHead(200, { 'Content-Type': 'application/json' });
4682
- res.end(JSON.stringify({ ok: true, pipelines }));
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
- // Pipeline-specific endpoints: /pipelines/:id
4687
- const pipelineMatch = req.url?.match(/^\/pipelines\/([a-f0-9-]+)(?:\/(.+))?$/);
4688
- if (pipelineMatch) {
4689
- const pipelineId = pipelineMatch[1];
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
- // GET /pipelines/:id - Get pipeline details
4693
- if (req.method === 'GET' && !action) {
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
- const pipeline = pipelineManager.updatePipeline(pipelineId, updates);
4711
- if (pipeline) {
4712
- // Broadcast updated pipelines to all clients
4713
- broadcastPipelines();
4714
- res.writeHead(200, { 'Content-Type': 'application/json' });
4715
- res.end(JSON.stringify({ ok: true, pipeline }));
4716
- } else {
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: 'Pipeline not found' }));
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(400, { 'Content-Type': 'application/json' });
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 /pipelines/:id - Delete pipeline
4732
- if (req.method === 'DELETE' && !action) {
4733
- const deleted = pipelineManager.deletePipeline(pipelineId);
4734
- if (deleted) {
4735
- // Broadcast updated pipelines to all clients
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
- const { commitSha } = body ? JSON.parse(body) : {};
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
- res.writeHead(500, { 'Content-Type': 'application/json' });
4783
- res.end(JSON.stringify({ ok: false, error: e.message }));
4892
+ log(`Failed to cancel pipeline on task delete: ${e.message}`);
4784
4893
  }
4785
- }).catch(() => {
4786
- res.writeHead(413, { 'Content-Type': 'application/json' });
4787
- res.end(JSON.stringify({ error: 'Request body too large' }));
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, run }));
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: 'Run not found' }));
4902
+ res.end(JSON.stringify({ ok: false, error: 'Task not found' }));
4811
4903
  }
4812
4904
  return;
4813
4905
  }
4906
+ }
4814
4907
 
4815
- // POST /pipelines/:id/runs/:runId/approve - Approve stage
4816
- const approveMatch = action?.match(/^runs\/([a-f0-9-]+)\/approve$/);
4817
- if (req.method === 'POST' && approveMatch) {
4818
- const runId = approveMatch[1];
4819
- collectRequestBody(req).then((body) => {
4820
- try {
4821
- const { stageId, approved } = JSON.parse(body);
4822
- if (!stageId || typeof approved !== 'boolean') {
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: e.message }));
4917
+ res.end(JSON.stringify({ ok: false, error: 'Invalid toColumn' }));
4918
+ return;
4839
4919
  }
4840
- }).catch(() => {
4841
- res.writeHead(413, { 'Content-Type': 'application/json' });
4842
- res.end(JSON.stringify({ error: 'Request body too large' }));
4843
- });
4844
- return;
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
- } else {
4856
- res.writeHead(400, { 'Content-Type': 'application/json' });
4857
- res.end(JSON.stringify({ ok: false, error: 'Cannot cancel run' }));
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
- // Lead Agents API
4977
+ // Planning Pipeline API
4865
4978
  // ============================================================================
4866
4979
 
4867
- // POST /projects/:id/leads - Create lead for project
4868
- const projectLeadsMatch = req.url?.match(/^\/projects\/([a-f0-9-]+)\/leads$/);
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 { role, name, priority, autoTrigger, triggerMode, systemPrompt } = JSON.parse(body);
4874
- if (!role || !['security', 'build', 'optimization', 'design', 'product_designer'].includes(role)) {
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: 'Invalid or missing role. Must be: security, build, optimization, design, or product_designer' }));
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 lead = await createLeadAgent(projectId, role, { name, priority, autoTrigger, triggerMode, systemPrompt });
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, lead }));
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 /projects/:id/leads - List leads for project
4894
- if (projectLeadsMatch && req.method === 'GET') {
4895
- const projectId = projectLeadsMatch[1];
4896
- const leads = getProjectLeads(projectId);
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, leads }));
5024
+ res.end(JSON.stringify({ ok: true, pipelines }));
4899
5025
  return;
4900
5026
  }
4901
5027
 
4902
- // Lead-specific endpoints: /leads/:id
4903
- const leadMatch = req.url?.match(/^\/leads\/([a-f0-9-]+)(?:\/(.+))?$/);
4904
- if (leadMatch) {
4905
- const leadId = leadMatch[1];
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
- // DELETE /leads/:id - Remove lead
4909
- if (req.method === 'DELETE' && !action) {
4910
- const deleted = await deleteLead(leadId);
4911
- if (deleted) {
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: 'Lead not found' }));
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, queue }));
5042
+ res.end(JSON.stringify({ ok: true, pipeline }));
4926
5043
  return;
4927
5044
  }
5045
+ }
4928
5046
 
4929
- // POST /leads/:id/trigger - Manual trigger
4930
- if (req.method === 'POST' && action === 'trigger') {
4931
- const session = managedSessions.get(leadId);
4932
- if (!session || !session.isLead) {
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: 'Lead not found' }));
5055
+ res.end(JSON.stringify({ ok: false, error: 'Pipeline not found' }));
4935
5056
  return;
4936
5057
  }
4937
-
4938
- collectRequestBody(req).then(async (body) => {
4939
- try {
4940
- const { commitSha } = body ? JSON.parse(body) : {};
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
- // POST /leads/:id/trigger-interactive - Trigger interactive lead with user input
5002
- if (req.method === 'POST' && action === 'trigger-interactive') {
5003
- const session = managedSessions.get(leadId);
5004
- if (!session || !session.isLead) {
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: 'Lead not found' }));
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
- collectRequestBody(req).then(async (body) => {
5017
- try {
5018
- const { input } = JSON.parse(body);
5019
- if (!input || typeof input !== 'string') {
5020
- res.writeHead(400, { 'Content-Type': 'application/json' });
5021
- res.end(JSON.stringify({ ok: false, error: 'Input is required' }));
5022
- return;
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
- // POST /leads/:id/handoff - Execute handoff to target agent
5045
- if (req.method === 'POST' && action === 'handoff') {
5046
- const session = managedSessions.get(leadId);
5047
- if (!session || !session.isLead) {
5048
- res.writeHead(404, { 'Content-Type': 'application/json' });
5049
- res.end(JSON.stringify({ ok: false, error: 'Lead not found' }));
5050
- return;
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
- // Build the handoff prompt
5074
- const handoffPrompt = prompt || workItem.result || '';
5075
- let targetAgentId;
5076
-
5077
- if (type === 'spawn') {
5078
- // Create a new agent
5079
- const project = projectsManager.getProject(session.projectId);
5080
- const newName = agentName || `Implementation Agent`;
5081
- const newSession = await createSession({
5082
- name: newName,
5083
- cwd: project?.path || session.cwd,
5084
- projectId: session.projectId,
5085
- });
5086
- targetAgentId = newSession.id;
5087
- log(`Handoff: Spawned new agent ${newName} (${targetAgentId.slice(0, 8)})`);
5088
- } else if (type === 'existing') {
5089
- if (!agentId) {
5090
- res.writeHead(400, { 'Content-Type': 'application/json' });
5091
- res.end(JSON.stringify({ ok: false, error: 'agentId required for existing type' }));
5092
- return;
5093
- }
5094
- const targetSession = managedSessions.get(agentId);
5095
- if (!targetSession) {
5096
- res.writeHead(404, { 'Content-Type': 'application/json' });
5097
- res.end(JSON.stringify({ ok: false, error: 'Target agent not found' }));
5098
- return;
5099
- }
5100
- targetAgentId = agentId;
5101
- log(`Handoff: Using existing agent ${targetSession.name} (${agentId.slice(0, 8)})`);
5102
- } else {
5103
- res.writeHead(400, { 'Content-Type': 'application/json' });
5104
- res.end(JSON.stringify({ ok: false, error: 'Invalid type. Must be: spawn or existing' }));
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
- // Update work item status
5109
- workItem.status = 'handed_off';
5110
- workItem.handoffTarget = {
5111
- type,
5112
- agentId: targetAgentId,
5113
- agentName: agentName || managedSessions.get(targetAgentId)?.name,
5114
- };
5115
-
5116
- // Save state
5117
- session.workQueue = queue;
5118
- saveLeadState();
5119
- broadcastSessions();
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
- // Send prompt to target agent
5122
- const result = await sendPromptToSession(targetAgentId, handoffPrompt);
5123
- if (!result.ok) {
5124
- res.writeHead(500, { 'Content-Type': 'application/json' });
5125
- res.end(JSON.stringify({ ok: false, error: `Failed to send prompt: ${result.error}` }));
5126
- return;
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
- log(`Handoff complete: ${session.name} -> ${workItem.handoffTarget.agentName}`);
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
- ok: true,
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
- const queue = leadWorkQueues.get(leadId) || [];
5161
- const workItem = queue.find(w => w.id === workItemId);
5162
-
5163
- if (!workItem) {
5164
- res.writeHead(404, { 'Content-Type': 'application/json' });
5165
- res.end(JSON.stringify({ ok: false, error: 'Work item not found' }));
5166
- return;
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
- if (workItem.status !== 'awaiting_handoff') {
5170
- res.writeHead(400, { 'Content-Type': 'application/json' });
5171
- res.end(JSON.stringify({ ok: false, error: 'Work item is not awaiting handoff' }));
5172
- return;
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
- // Mark as completed (dismissed)
5176
- workItem.status = 'completed';
5177
- const session = managedSessions.get(leadId);
5178
- if (session) {
5179
- session.workQueue = queue;
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
- broadcast({
5185
- type: 'lead_queue_update',
5186
- payload: { leadId, queue },
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
- res.writeHead(200, { 'Content-Type': 'application/json' });
5190
- res.end(JSON.stringify({ ok: true }));
5191
- } catch (e) {
5192
- res.writeHead(500, { 'Content-Type': 'application/json' });
5193
- res.end(JSON.stringify({ ok: false, error: e.message }));
5194
- }
5195
- }).catch(() => {
5196
- res.writeHead(413, { 'Content-Type': 'application/json' });
5197
- res.end(JSON.stringify({ error: 'Request body too large' }));
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
- else {
5452
- log(`Sent Ctrl+C to ${session.name}`);
5453
- res.end(JSON.stringify({ ok: true }));
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)