@evolve.labs/devflow 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude/commands/agents/architect.md +1162 -0
  2. package/.claude/commands/agents/architect.meta.yaml +124 -0
  3. package/.claude/commands/agents/builder.md +1432 -0
  4. package/.claude/commands/agents/builder.meta.yaml +117 -0
  5. package/.claude/commands/agents/chronicler.md +633 -0
  6. package/.claude/commands/agents/chronicler.meta.yaml +217 -0
  7. package/.claude/commands/agents/guardian.md +456 -0
  8. package/.claude/commands/agents/guardian.meta.yaml +127 -0
  9. package/.claude/commands/agents/strategist.md +483 -0
  10. package/.claude/commands/agents/strategist.meta.yaml +158 -0
  11. package/.claude/commands/agents/system-designer.md +1137 -0
  12. package/.claude/commands/agents/system-designer.meta.yaml +156 -0
  13. package/.claude/commands/devflow-help.md +93 -0
  14. package/.claude/commands/devflow-status.md +60 -0
  15. package/.claude/commands/quick/create-adr.md +82 -0
  16. package/.claude/commands/quick/new-feature.md +57 -0
  17. package/.claude/commands/quick/security-check.md +54 -0
  18. package/.claude/commands/quick/system-design.md +58 -0
  19. package/.claude_project +52 -0
  20. package/.devflow/agents/architect.meta.yaml +122 -0
  21. package/.devflow/agents/builder.meta.yaml +116 -0
  22. package/.devflow/agents/chronicler.meta.yaml +222 -0
  23. package/.devflow/agents/guardian.meta.yaml +127 -0
  24. package/.devflow/agents/strategist.meta.yaml +158 -0
  25. package/.devflow/agents/system-designer.meta.yaml +265 -0
  26. package/.devflow/project.yaml +242 -0
  27. package/.gitignore-template +84 -0
  28. package/LICENSE +21 -0
  29. package/README.md +249 -0
  30. package/bin/devflow.js +54 -0
  31. package/lib/autopilot.js +235 -0
  32. package/lib/autopilotConstants.js +213 -0
  33. package/lib/constants.js +95 -0
  34. package/lib/init.js +200 -0
  35. package/lib/update.js +181 -0
  36. package/lib/utils.js +157 -0
  37. package/lib/web.js +119 -0
  38. package/package.json +57 -0
  39. package/web/CHANGELOG.md +192 -0
  40. package/web/README.md +156 -0
  41. package/web/app/api/autopilot/execute/route.ts +102 -0
  42. package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
  43. package/web/app/api/files/route.ts +280 -0
  44. package/web/app/api/files/tree/route.ts +160 -0
  45. package/web/app/api/git/route.ts +201 -0
  46. package/web/app/api/health/route.ts +94 -0
  47. package/web/app/api/project/open/route.ts +134 -0
  48. package/web/app/api/search/route.ts +247 -0
  49. package/web/app/api/specs/route.ts +405 -0
  50. package/web/app/api/terminal/route.ts +222 -0
  51. package/web/app/globals.css +160 -0
  52. package/web/app/ide/layout.tsx +43 -0
  53. package/web/app/ide/page.tsx +216 -0
  54. package/web/app/layout.tsx +34 -0
  55. package/web/app/page.tsx +303 -0
  56. package/web/components/agents/AgentIcons.tsx +281 -0
  57. package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
  58. package/web/components/autopilot/AutopilotPanel.tsx +299 -0
  59. package/web/components/dashboard/DashboardPanel.tsx +393 -0
  60. package/web/components/editor/Breadcrumbs.tsx +134 -0
  61. package/web/components/editor/EditorPanel.tsx +120 -0
  62. package/web/components/editor/EditorTabs.tsx +229 -0
  63. package/web/components/editor/MarkdownPreview.tsx +154 -0
  64. package/web/components/editor/MermaidDiagram.tsx +113 -0
  65. package/web/components/editor/MonacoEditor.tsx +177 -0
  66. package/web/components/editor/TabContextMenu.tsx +207 -0
  67. package/web/components/git/GitPanel.tsx +534 -0
  68. package/web/components/layout/Shell.tsx +15 -0
  69. package/web/components/layout/StatusBar.tsx +100 -0
  70. package/web/components/modals/CommandPalette.tsx +393 -0
  71. package/web/components/modals/GlobalSearch.tsx +348 -0
  72. package/web/components/modals/QuickOpen.tsx +241 -0
  73. package/web/components/modals/RecentFiles.tsx +208 -0
  74. package/web/components/projects/ProjectSelector.tsx +147 -0
  75. package/web/components/settings/SettingItem.tsx +150 -0
  76. package/web/components/settings/SettingsPanel.tsx +323 -0
  77. package/web/components/specs/SpecsPanel.tsx +1091 -0
  78. package/web/components/terminal/TerminalPanel.tsx +683 -0
  79. package/web/components/ui/ContextMenu.tsx +182 -0
  80. package/web/components/ui/LoadingSpinner.tsx +66 -0
  81. package/web/components/ui/ResizeHandle.tsx +110 -0
  82. package/web/components/ui/Skeleton.tsx +108 -0
  83. package/web/components/ui/SkipLinks.tsx +37 -0
  84. package/web/components/ui/Toaster.tsx +57 -0
  85. package/web/hooks/useFocusTrap.ts +141 -0
  86. package/web/hooks/useKeyboardShortcuts.ts +169 -0
  87. package/web/hooks/useListNavigation.ts +237 -0
  88. package/web/lib/autopilotConstants.ts +213 -0
  89. package/web/lib/constants/agents.ts +67 -0
  90. package/web/lib/git.ts +339 -0
  91. package/web/lib/ptyManager.ts +191 -0
  92. package/web/lib/specsParser.ts +299 -0
  93. package/web/lib/stores/autopilotStore.ts +288 -0
  94. package/web/lib/stores/fileStore.ts +550 -0
  95. package/web/lib/stores/gitStore.ts +386 -0
  96. package/web/lib/stores/projectStore.ts +196 -0
  97. package/web/lib/stores/settingsStore.ts +126 -0
  98. package/web/lib/stores/specsStore.ts +297 -0
  99. package/web/lib/stores/uiStore.ts +175 -0
  100. package/web/lib/types/index.ts +177 -0
  101. package/web/lib/utils.ts +98 -0
  102. package/web/next.config.js +50 -0
  103. package/web/package.json +54 -0
  104. package/web/postcss.config.js +6 -0
  105. package/web/tailwind.config.ts +68 -0
  106. package/web/tsconfig.json +41 -0
@@ -0,0 +1,405 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import {
5
+ parseUserStory,
6
+ parseADR,
7
+ getSpecType,
8
+ parseFrontmatter,
9
+ extractTasks,
10
+ } from '@/lib/specsParser';
11
+ import type { Spec, Requirement, DesignDecision, Task } from '@/lib/types';
12
+
13
+ interface SpecsResponse {
14
+ specs: Spec[];
15
+ requirements: Requirement[];
16
+ decisions: DesignDecision[];
17
+ tasks: Task[];
18
+ }
19
+
20
+ /**
21
+ * GET /api/specs - List all specs from a project
22
+ */
23
+ export async function GET(req: NextRequest) {
24
+ try {
25
+ const searchParams = req.nextUrl.searchParams;
26
+ const projectPath = searchParams.get('projectPath');
27
+
28
+ if (!projectPath) {
29
+ return NextResponse.json(
30
+ { error: 'projectPath is required' },
31
+ { status: 400 }
32
+ );
33
+ }
34
+
35
+ // Security: Validate path
36
+ if (projectPath.includes('..')) {
37
+ return NextResponse.json(
38
+ { error: 'Invalid path' },
39
+ { status: 400 }
40
+ );
41
+ }
42
+
43
+ const response: SpecsResponse = {
44
+ specs: [],
45
+ requirements: [],
46
+ decisions: [],
47
+ tasks: [],
48
+ };
49
+
50
+ // Directories to scan for specs
51
+ const specDirs = [
52
+ 'docs/planning/stories',
53
+ 'docs/planning/specs',
54
+ 'docs/planning',
55
+ 'docs/decisions',
56
+ '.devflow/specs',
57
+ '.devflow/stories',
58
+ 'specs',
59
+ 'stories',
60
+ ];
61
+
62
+ for (const dir of specDirs) {
63
+ const fullPath = path.join(projectPath, dir);
64
+
65
+ try {
66
+ const stat = await fs.stat(fullPath);
67
+ if (!stat.isDirectory()) continue;
68
+
69
+ const files = await fs.readdir(fullPath);
70
+
71
+ for (const file of files) {
72
+ if (!file.endsWith('.md')) continue;
73
+
74
+ const filePath = path.join(fullPath, file);
75
+ const content = await fs.readFile(filePath, 'utf-8');
76
+ const specType = getSpecType(filePath);
77
+
78
+ try {
79
+ if (specType === 'story' || specType === 'spec') {
80
+ const { spec, requirement, tasks } = parseUserStory(content, filePath);
81
+ response.specs.push(spec);
82
+ response.requirements.push(requirement);
83
+ response.tasks.push(...tasks);
84
+ } else if (specType === 'adr') {
85
+ const { spec, decision } = parseADR(content, filePath);
86
+ response.specs.push(spec);
87
+ response.decisions.push(decision);
88
+ } else {
89
+ // Generic spec parsing
90
+ const { frontmatter, body } = parseFrontmatter(content);
91
+ const titleMatch = body.match(/^#\s+(.+)$/m);
92
+ const id = file.replace('.md', '');
93
+
94
+ const spec: Spec = {
95
+ id,
96
+ name: frontmatter.title || titleMatch?.[1] || id,
97
+ description: body.slice(0, 200).replace(/^#.+\n/, '').trim(),
98
+ phase: 'requirements',
99
+ status: 'draft',
100
+ createdAt: new Date(),
101
+ updatedAt: new Date(),
102
+ filePath,
103
+ };
104
+
105
+ response.specs.push(spec);
106
+ response.tasks.push(...extractTasks(body, id, filePath));
107
+ }
108
+ } catch (parseError) {
109
+ console.error(`Error parsing ${filePath}:`, parseError);
110
+ }
111
+ }
112
+ } catch {
113
+ // Directory doesn't exist, skip
114
+ }
115
+ }
116
+
117
+ // Deduplicate by ID
118
+ response.specs = [...new Map(response.specs.map(s => [s.id, s])).values()];
119
+ response.requirements = [...new Map(response.requirements.map(r => [r.id, r])).values()];
120
+ response.decisions = [...new Map(response.decisions.map(d => [d.id, d])).values()];
121
+ response.tasks = [...new Map(response.tasks.map(t => [t.id, t])).values()];
122
+
123
+ return NextResponse.json(response);
124
+ } catch (error) {
125
+ console.error('Error loading specs:', error);
126
+ return NextResponse.json(
127
+ { error: 'Failed to load specs' },
128
+ { status: 500 }
129
+ );
130
+ }
131
+ }
132
+
133
+ /**
134
+ * POST /api/specs - Create a new spec
135
+ */
136
+ export async function POST(req: NextRequest) {
137
+ try {
138
+ const body = await req.json();
139
+ const { projectPath, type, title, description, priority, phase } = body;
140
+
141
+ if (!projectPath || !type || !title) {
142
+ return NextResponse.json(
143
+ { error: 'projectPath, type, and title are required' },
144
+ { status: 400 }
145
+ );
146
+ }
147
+
148
+ // Security: Validate path
149
+ if (projectPath.includes('..')) {
150
+ return NextResponse.json(
151
+ { error: 'Invalid path' },
152
+ { status: 400 }
153
+ );
154
+ }
155
+
156
+ // Determine directory based on type
157
+ let dir: string;
158
+ let prefix: string;
159
+ let template: string;
160
+
161
+ switch (type) {
162
+ case 'story':
163
+ dir = 'docs/planning/stories';
164
+ prefix = 'US';
165
+ template = generateStoryTemplate(title, description, priority);
166
+ break;
167
+ case 'adr':
168
+ dir = 'docs/decisions';
169
+ prefix = 'ADR';
170
+ template = generateADRTemplate(title, description);
171
+ break;
172
+ case 'spec':
173
+ default:
174
+ dir = 'docs/planning/specs';
175
+ prefix = 'SPEC';
176
+ template = generateSpecTemplate(title, description, phase);
177
+ break;
178
+ }
179
+
180
+ // Create directory if it doesn't exist
181
+ const fullDir = path.join(projectPath, dir);
182
+ await fs.mkdir(fullDir, { recursive: true });
183
+
184
+ // Find next ID
185
+ const files = await fs.readdir(fullDir).catch(() => []);
186
+ const existingIds = files
187
+ .filter(f => f.endsWith('.md'))
188
+ .map(f => {
189
+ const match = f.match(new RegExp(`${prefix}-(\\d+)`));
190
+ return match ? parseInt(match[1], 10) : 0;
191
+ });
192
+ const nextId = Math.max(0, ...existingIds) + 1;
193
+
194
+ // Generate filename
195
+ const slug = title
196
+ .toLowerCase()
197
+ .replace(/[^a-z0-9]+/g, '-')
198
+ .replace(/^-|-$/g, '')
199
+ .slice(0, 50);
200
+ const filename = `${prefix}-${String(nextId).padStart(3, '0')}-${slug}.md`;
201
+ const filePath = path.join(fullDir, filename);
202
+
203
+ // Write file
204
+ await fs.writeFile(filePath, template, 'utf-8');
205
+
206
+ return NextResponse.json({
207
+ success: true,
208
+ filePath,
209
+ id: `${prefix}-${String(nextId).padStart(3, '0')}`,
210
+ });
211
+ } catch (error) {
212
+ console.error('Error creating spec:', error);
213
+ return NextResponse.json(
214
+ { error: 'Failed to create spec' },
215
+ { status: 500 }
216
+ );
217
+ }
218
+ }
219
+
220
+ // Template generators
221
+ function generateStoryTemplate(title: string, description?: string, priority?: string): string {
222
+ const date = new Date().toISOString().split('T')[0];
223
+ return `---
224
+ title: "${title}"
225
+ status: draft
226
+ priority: ${priority || 'should'}
227
+ created: ${date}
228
+ ---
229
+
230
+ # ${title}
231
+
232
+ ${description || 'Description of the user story.'}
233
+
234
+ ## User Story
235
+
236
+ As a [type of user],
237
+ I want [goal/desire],
238
+ So that [benefit].
239
+
240
+ ## Acceptance Criteria
241
+
242
+ - [ ] Criteria 1
243
+ - [ ] Criteria 2
244
+ - [ ] Criteria 3
245
+
246
+ ## Tasks
247
+
248
+ - [ ] Task 1
249
+ - [ ] Task 2
250
+ - [ ] Task 3
251
+
252
+ ## Notes
253
+
254
+ _Additional notes and context._
255
+ `;
256
+ }
257
+
258
+ function generateADRTemplate(title: string, description?: string): string {
259
+ const date = new Date().toISOString().split('T')[0];
260
+ return `---
261
+ title: "${title}"
262
+ status: proposed
263
+ created: ${date}
264
+ ---
265
+
266
+ # ${title}
267
+
268
+ ## Status
269
+
270
+ Proposed
271
+
272
+ ## Context
273
+
274
+ ${description || 'Describe the context and problem statement.'}
275
+
276
+ ## Decision
277
+
278
+ Describe the decision made.
279
+
280
+ ## Consequences
281
+
282
+ - Positive consequence 1
283
+ - Positive consequence 2
284
+ - Negative consequence 1
285
+
286
+ ## Alternatives
287
+
288
+ - Alternative 1: Description
289
+ - Alternative 2: Description
290
+ `;
291
+ }
292
+
293
+ function generateSpecTemplate(title: string, description?: string, phase?: string): string {
294
+ const date = new Date().toISOString().split('T')[0];
295
+ return `---
296
+ title: "${title}"
297
+ status: draft
298
+ phase: ${phase || 'requirements'}
299
+ created: ${date}
300
+ ---
301
+
302
+ # ${title}
303
+
304
+ ${description || 'Description of the specification.'}
305
+
306
+ ## Overview
307
+
308
+ Provide an overview of this specification.
309
+
310
+ ## Requirements
311
+
312
+ - [ ] Requirement 1
313
+ - [ ] Requirement 2
314
+
315
+ ## Implementation Notes
316
+
317
+ _Notes for implementation._
318
+ `;
319
+ }
320
+
321
+ /**
322
+ * PATCH /api/specs - Update task status in a spec file
323
+ */
324
+ export async function PATCH(req: NextRequest) {
325
+ try {
326
+ const body = await req.json();
327
+ const { filePath, taskTitle, completed } = body;
328
+
329
+ if (!filePath || taskTitle === undefined || completed === undefined) {
330
+ return NextResponse.json(
331
+ { error: 'filePath, taskTitle, and completed are required' },
332
+ { status: 400 }
333
+ );
334
+ }
335
+
336
+ // Security: Validate path
337
+ if (filePath.includes('..')) {
338
+ return NextResponse.json(
339
+ { error: 'Invalid path' },
340
+ { status: 400 }
341
+ );
342
+ }
343
+
344
+ // Read the file
345
+ let content: string;
346
+ try {
347
+ content = await fs.readFile(filePath, 'utf-8');
348
+ } catch {
349
+ return NextResponse.json(
350
+ { error: 'File not found' },
351
+ { status: 404 }
352
+ );
353
+ }
354
+
355
+ // Find and update the task checkbox
356
+ // Task format: - [ ] Task title or - [x] Task title
357
+ const escapedTitle = taskTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
358
+ const taskRegex = new RegExp(
359
+ `^(\\s*[-*]\\s*)\\[([ xX])\\](\\s*(?:\\[[^\\]]+\\]\\s*)?)${escapedTitle}`,
360
+ 'gm'
361
+ );
362
+
363
+ let found = false;
364
+ const newContent = content.replace(taskRegex, (match, prefix, _checkbox, priorityAndSpace) => {
365
+ found = true;
366
+ const newCheckbox = completed ? 'x' : ' ';
367
+ return `${prefix}[${newCheckbox}]${priorityAndSpace}${taskTitle}`;
368
+ });
369
+
370
+ if (!found) {
371
+ // Try a more lenient match (just the title anywhere in a checkbox line)
372
+ const lenientRegex = new RegExp(
373
+ `^(\\s*[-*]\\s*)\\[([ xX])\\](\\s*.*)${escapedTitle}(.*)$`,
374
+ 'gm'
375
+ );
376
+
377
+ const newContentLenient = content.replace(lenientRegex, (match, prefix, _checkbox, beforeTitle, afterTitle) => {
378
+ found = true;
379
+ const newCheckbox = completed ? 'x' : ' ';
380
+ return `${prefix}[${newCheckbox}]${beforeTitle}${taskTitle}${afterTitle}`;
381
+ });
382
+
383
+ if (found) {
384
+ await fs.writeFile(filePath, newContentLenient, 'utf-8');
385
+ return NextResponse.json({ success: true, updated: true });
386
+ }
387
+
388
+ return NextResponse.json(
389
+ { error: 'Task not found in file' },
390
+ { status: 404 }
391
+ );
392
+ }
393
+
394
+ // Write the updated content
395
+ await fs.writeFile(filePath, newContent, 'utf-8');
396
+
397
+ return NextResponse.json({ success: true, updated: true });
398
+ } catch (error) {
399
+ console.error('Error updating task:', error);
400
+ return NextResponse.json(
401
+ { error: 'Failed to update task' },
402
+ { status: 500 }
403
+ );
404
+ }
405
+ }
@@ -0,0 +1,222 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { ptyManager } from '@/lib/ptyManager';
3
+
4
+ // POST - Create session or write data
5
+ export async function POST(request: NextRequest) {
6
+ try {
7
+ const body = await request.json();
8
+ const { action, sessionId, data, cwd, cols, rows } = body;
9
+
10
+ switch (action) {
11
+ case 'create': {
12
+ if (!sessionId || !cwd) {
13
+ return NextResponse.json(
14
+ { error: 'sessionId and cwd are required' },
15
+ { status: 400 }
16
+ );
17
+ }
18
+
19
+ // Check if session already exists
20
+ const existing = ptyManager.getSession(sessionId);
21
+ if (existing) {
22
+ return NextResponse.json({
23
+ success: true,
24
+ sessionId,
25
+ message: 'Session already exists'
26
+ });
27
+ }
28
+
29
+ ptyManager.createSession(sessionId, cwd, cols || 80, rows || 24);
30
+ return NextResponse.json({
31
+ success: true,
32
+ sessionId,
33
+ message: 'Session created'
34
+ });
35
+ }
36
+
37
+ case 'write': {
38
+ if (!sessionId || data === undefined) {
39
+ return NextResponse.json(
40
+ { error: 'sessionId and data are required' },
41
+ { status: 400 }
42
+ );
43
+ }
44
+
45
+ const success = ptyManager.write(sessionId, data);
46
+ if (!success) {
47
+ return NextResponse.json(
48
+ { error: 'Session not found' },
49
+ { status: 404 }
50
+ );
51
+ }
52
+
53
+ return NextResponse.json({ success: true });
54
+ }
55
+
56
+ case 'resize': {
57
+ if (!sessionId || !cols || !rows) {
58
+ return NextResponse.json(
59
+ { error: 'sessionId, cols, and rows are required' },
60
+ { status: 400 }
61
+ );
62
+ }
63
+
64
+ const success = ptyManager.resize(sessionId, cols, rows);
65
+ if (!success) {
66
+ return NextResponse.json(
67
+ { error: 'Session not found' },
68
+ { status: 404 }
69
+ );
70
+ }
71
+
72
+ return NextResponse.json({ success: true });
73
+ }
74
+
75
+ case 'destroy': {
76
+ if (!sessionId) {
77
+ return NextResponse.json(
78
+ { error: 'sessionId is required' },
79
+ { status: 400 }
80
+ );
81
+ }
82
+
83
+ ptyManager.destroySession(sessionId);
84
+ return NextResponse.json({ success: true });
85
+ }
86
+
87
+ default:
88
+ return NextResponse.json(
89
+ { error: 'Invalid action' },
90
+ { status: 400 }
91
+ );
92
+ }
93
+ } catch (error) {
94
+ console.error('Terminal API error:', error);
95
+ return NextResponse.json(
96
+ { error: 'Internal server error' },
97
+ { status: 500 }
98
+ );
99
+ }
100
+ }
101
+
102
+ // GET - Stream output via SSE
103
+ export async function GET(request: NextRequest) {
104
+ const { searchParams } = new URL(request.url);
105
+ const sessionId = searchParams.get('sessionId');
106
+
107
+ if (!sessionId) {
108
+ return NextResponse.json(
109
+ { error: 'sessionId is required' },
110
+ { status: 400 }
111
+ );
112
+ }
113
+
114
+ const session = ptyManager.getSession(sessionId);
115
+ if (!session) {
116
+ return NextResponse.json(
117
+ { error: 'Session not found' },
118
+ { status: 404 }
119
+ );
120
+ }
121
+
122
+ // Create SSE stream
123
+ const encoder = new TextEncoder();
124
+ const stream = new ReadableStream({
125
+ start(controller) {
126
+ // Send any buffered output first
127
+ const buffer = ptyManager.getOutputBuffer(sessionId);
128
+ if (buffer.length > 0) {
129
+ const initialData = buffer.join('');
130
+ controller.enqueue(
131
+ encoder.encode(`data: ${JSON.stringify({ type: 'data', data: initialData })}\n\n`)
132
+ );
133
+ ptyManager.clearOutputBuffer(sessionId);
134
+ }
135
+
136
+ // Listen for new data
137
+ const dataHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
138
+ if (sid === sessionId) {
139
+ try {
140
+ controller.enqueue(
141
+ encoder.encode(`data: ${JSON.stringify({ type: 'data', data })}\n\n`)
142
+ );
143
+ } catch {
144
+ // Stream closed
145
+ }
146
+ }
147
+ };
148
+
149
+ // Listen for exit
150
+ const exitHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number }) => {
151
+ if (sid === sessionId) {
152
+ try {
153
+ controller.enqueue(
154
+ encoder.encode(`data: ${JSON.stringify({ type: 'exit', exitCode })}\n\n`)
155
+ );
156
+ controller.close();
157
+ } catch {
158
+ // Stream already closed
159
+ }
160
+ }
161
+ };
162
+
163
+ // Listen for autopilot phase completion
164
+ const autopilotDoneHandler = ({ sessionId: sid, exitCode }: { sessionId: string; output: string; exitCode: number }) => {
165
+ if (sid === sessionId) {
166
+ try {
167
+ controller.enqueue(
168
+ encoder.encode(`data: ${JSON.stringify({ type: 'autopilot-phase-done', exitCode })}\n\n`)
169
+ );
170
+ } catch {
171
+ // Stream closed
172
+ }
173
+ }
174
+ };
175
+
176
+ ptyManager.on('data', dataHandler);
177
+ ptyManager.on('exit', exitHandler);
178
+ ptyManager.on('autopilot-phase-done', autopilotDoneHandler);
179
+
180
+ // Heartbeat to keep connection alive
181
+ const heartbeat = setInterval(() => {
182
+ try {
183
+ controller.enqueue(encoder.encode(': heartbeat\n\n'));
184
+ } catch {
185
+ clearInterval(heartbeat);
186
+ }
187
+ }, 30000);
188
+
189
+ // Cleanup on close
190
+ request.signal.addEventListener('abort', () => {
191
+ ptyManager.off('data', dataHandler);
192
+ ptyManager.off('exit', exitHandler);
193
+ ptyManager.off('autopilot-phase-done', autopilotDoneHandler);
194
+ clearInterval(heartbeat);
195
+ });
196
+ },
197
+ });
198
+
199
+ return new Response(stream, {
200
+ headers: {
201
+ 'Content-Type': 'text/event-stream',
202
+ 'Cache-Control': 'no-cache',
203
+ 'Connection': 'keep-alive',
204
+ },
205
+ });
206
+ }
207
+
208
+ // DELETE - Destroy session
209
+ export async function DELETE(request: NextRequest) {
210
+ const { searchParams } = new URL(request.url);
211
+ const sessionId = searchParams.get('sessionId');
212
+
213
+ if (!sessionId) {
214
+ return NextResponse.json(
215
+ { error: 'sessionId is required' },
216
+ { status: 400 }
217
+ );
218
+ }
219
+
220
+ ptyManager.destroySession(sessionId);
221
+ return NextResponse.json({ success: true });
222
+ }