jettypod 4.4.118 → 4.4.120

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 (141) hide show
  1. package/.env +2 -2
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +106 -46
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +155 -8
  5. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  6. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  7. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  8. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +6 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +1 -1
  12. package/apps/dashboard/app/layout.tsx +17 -5
  13. package/apps/dashboard/app/login/page.tsx +62 -41
  14. package/apps/dashboard/app/page.tsx +9 -9
  15. package/apps/dashboard/app/settings/page.tsx +2 -2
  16. package/apps/dashboard/app/signup/page.tsx +245 -0
  17. package/apps/dashboard/app/welcome/page.tsx +1 -1
  18. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  19. package/apps/dashboard/components/AppShell.tsx +63 -65
  20. package/apps/dashboard/components/CardMenu.tsx +45 -11
  21. package/apps/dashboard/components/ClaudePanel.tsx +282 -619
  22. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  23. package/apps/dashboard/components/ConnectClaudeScreen.tsx +16 -29
  24. package/apps/dashboard/components/CopyableId.tsx +3 -3
  25. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  26. package/apps/dashboard/components/DragContext.tsx +2 -1
  27. package/apps/dashboard/components/DropZone.tsx +2 -2
  28. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  29. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  30. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  31. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  32. package/apps/dashboard/components/GateCard.tsx +79 -16
  33. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  34. package/apps/dashboard/components/InstallClaudeScreen.tsx +14 -27
  35. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  36. package/apps/dashboard/components/KanbanBoard.tsx +99 -835
  37. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  38. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  39. package/apps/dashboard/components/MainNav.tsx +20 -54
  40. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  41. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  42. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  43. package/apps/dashboard/components/PlaceholderCard.tsx +3 -3
  44. package/apps/dashboard/components/ProjectSwitcher.tsx +10 -10
  45. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  46. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +258 -325
  47. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +73 -78
  48. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  49. package/apps/dashboard/components/SessionList.tsx +19 -18
  50. package/apps/dashboard/components/SubscribeContent.tsx +53 -38
  51. package/apps/dashboard/components/TestTree.tsx +15 -14
  52. package/apps/dashboard/components/TipCard.tsx +16 -15
  53. package/apps/dashboard/components/Toast.tsx +5 -5
  54. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  55. package/apps/dashboard/components/UpgradeBanner.tsx +5 -4
  56. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  57. package/apps/dashboard/components/WelcomeScreen.tsx +17 -29
  58. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  59. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  60. package/apps/dashboard/components/settings/AccountSection.tsx +28 -22
  61. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  62. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  63. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  64. package/apps/dashboard/components/ui/Button.tsx +104 -0
  65. package/apps/dashboard/components/ui/Input.tsx +78 -0
  66. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +128 -88
  67. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  68. package/apps/dashboard/contexts/UsageContext.tsx +30 -6
  69. package/apps/dashboard/electron/ipc-handlers.js +66 -68
  70. package/apps/dashboard/electron/main.js +329 -147
  71. package/apps/dashboard/electron/preload.js +2 -0
  72. package/apps/dashboard/electron/session-manager.js +48 -26
  73. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  74. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  75. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  76. package/apps/dashboard/lib/claude-process-manager.ts +44 -7
  77. package/apps/dashboard/lib/constants.ts +43 -0
  78. package/apps/dashboard/lib/db-bridge.ts +1 -0
  79. package/apps/dashboard/lib/db.ts +43 -6
  80. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  81. package/apps/dashboard/lib/run-migrations.js +27 -2
  82. package/apps/dashboard/lib/session-stream-manager.ts +68 -25
  83. package/apps/dashboard/lib/shadows.ts +7 -0
  84. package/apps/dashboard/lib/utils.ts +6 -0
  85. package/apps/dashboard/next.config.js +16 -0
  86. package/apps/dashboard/package.json +3 -2
  87. package/apps/dashboard/public/bug-icon.svg +9 -0
  88. package/apps/dashboard/public/buoy-icon.svg +9 -0
  89. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  90. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  91. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  92. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  93. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  94. package/apps/dashboard/public/jettypod_logo.png +0 -0
  95. package/apps/dashboard/public/pier-icon.svg +14 -0
  96. package/apps/dashboard/public/star-icon.svg +9 -0
  97. package/apps/dashboard/public/wrench-icon.svg +9 -0
  98. package/apps/dashboard/scripts/ws-server.js +191 -0
  99. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  100. package/apps/update-server/src/index.ts +61 -50
  101. package/cucumber.js +9 -3
  102. package/docs/COMMAND_REFERENCE.md +34 -0
  103. package/hooks/post-checkout +32 -75
  104. package/hooks/post-merge +111 -10
  105. package/jest.setup.js +1 -0
  106. package/jettypod.js +49 -112
  107. package/lib/chore-taxonomy.js +33 -10
  108. package/lib/database.js +36 -16
  109. package/lib/db-watcher.js +1 -1
  110. package/lib/git-hooks/pre-commit +1 -1
  111. package/lib/jettypod-backup.js +27 -4
  112. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  113. package/lib/migrations/029-remove-autoincrement.js +307 -0
  114. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  115. package/lib/migrations/index.js +47 -4
  116. package/lib/schema.js +10 -5
  117. package/lib/seed-onboarding.js +1 -1
  118. package/lib/update-command/index.js +9 -175
  119. package/lib/work-commands/index.js +86 -3
  120. package/lib/work-tracking/index.js +40 -19
  121. package/lib/worktree-diagnostics.js +16 -16
  122. package/lib/worktree-facade.js +1 -1
  123. package/lib/worktree-manager.js +8 -8
  124. package/lib/worktree-reconciler.js +5 -5
  125. package/package.json +9 -2
  126. package/scripts/ndjson-to-cucumber-json.js +152 -0
  127. package/scripts/postinstall.js +25 -0
  128. package/skills-templates/bug-mode/SKILL.md +37 -20
  129. package/skills-templates/bug-planning/SKILL.md +25 -29
  130. package/skills-templates/chore-mode/SKILL.md +131 -68
  131. package/skills-templates/chore-mode/verification.js +51 -10
  132. package/skills-templates/chore-planning/SKILL.md +47 -18
  133. package/skills-templates/epic-planning/SKILL.md +68 -48
  134. package/skills-templates/external-transition/SKILL.md +47 -47
  135. package/skills-templates/feature-planning/SKILL.md +83 -73
  136. package/skills-templates/production-mode/SKILL.md +49 -49
  137. package/skills-templates/request-routing/SKILL.md +4 -4
  138. package/skills-templates/simple-improvement/SKILL.md +35 -27
  139. package/skills-templates/speed-mode/SKILL.md +209 -128
  140. package/skills-templates/stable-mode/SKILL.md +101 -89
  141. package/docs/bdd-guidance.md +0 -390
package/.env CHANGED
@@ -1,7 +1,7 @@
1
1
  CLOUDFLARE_API_TOKEN=qaygaUeCLS8RQA5FVsJxnsA02c8b8qWyiLgNSM1X
2
2
  STRIPE_SECRET_KEY=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
3
3
  STRIPE_WEBHOOK_SECRET=whsec_pkup32kvrcf3g5h8VmtjLfYU9WIWdAb6
4
- GOOGLE_CLIENT_ID=172847259733-r87e20pjv97290kuusm7ov7uams515mm.apps.googleusercontent.com
5
- GOOGLE_CLIENT_SECRET=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
4
+ GOOGLE_CLIENT_ID=816982147727-7ceu0dge0ton30q1d09dgcos9h62orjo.apps.googleusercontent.com
5
+ GOOGLE_CLIENT_SECRET=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
6
6
  JWT_SECRET=bKYWqg7IR45K9CrA5BRmzXPQoWK6Vv4okZKpGmYD6o4=
7
7
  RESEND_API_KEY=re_Tg7gAVGE_LVKhjnyLkpDgYmkzQvYXmCwc
@@ -9,10 +9,6 @@ import {
9
9
  killProcess,
10
10
  } from '@/lib/claude-process-manager';
11
11
 
12
- // Import worktree facade for worktree management
13
- // eslint-disable-next-line @typescript-eslint/no-require-imports
14
- const worktreeFacade = require('../../../../../../../lib/worktree-facade');
15
-
16
12
  /**
17
13
  * Get the project root path for Claude CLI operations.
18
14
  * In packaged Electron apps, process.cwd() returns the app bundle's Resources directory,
@@ -92,9 +88,9 @@ export async function POST(
92
88
  );
93
89
  }
94
90
 
95
- // Get the message (conversationHistory no longer needed - persistent process maintains context)
91
+ // Get the message and optional images
96
92
  const body = await request.json().catch(() => ({}));
97
- const { message } = body;
93
+ const { message, images } = body;
98
94
 
99
95
  if (!message || typeof message !== 'string') {
100
96
  return NextResponse.json(
@@ -167,13 +163,22 @@ export async function POST(
167
163
  // Conversational chores run from main repo — no worktree needed
168
164
  claudeCwd = repoPath;
169
165
  } else {
170
- // Get or create worktree for this work item
171
- const workItem = {
172
- id: parseInt(workItemId, 10),
173
- title: `Work item ${workItemId}`
174
- };
175
- const workResult = await worktreeFacade.startWork(workItem, { repoPath });
176
- claudeCwd = workResult.path;
166
+ // Non-conversational: attempt to create worktree via lazy-loaded facade.
167
+ // worktree-facade depends on the CLI's sqlite3 module which is not a dashboard
168
+ // dependency — lazy-load to avoid crashing the entire route module at import time.
169
+ try {
170
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
171
+ const worktreeFacade = require('../../../../../../../lib/worktree-facade');
172
+ const workItem = {
173
+ id: parseInt(workItemId, 10),
174
+ title: `Work item ${workItemId}`
175
+ };
176
+ const workResult = await worktreeFacade.startWork(workItem, { repoPath });
177
+ claudeCwd = workResult.path;
178
+ } catch {
179
+ // Worktree creation unavailable or failed — fall back to main repo
180
+ claudeCwd = repoPath;
181
+ }
177
182
  }
178
183
 
179
184
  const settingsPath = getSettingsPath(repoPath);
@@ -209,6 +214,9 @@ export async function POST(
209
214
  // Create a readable stream for SSE
210
215
  const encoder = new TextEncoder();
211
216
 
217
+ // Cleanup function hoisted so cancel() can access it when client disconnects
218
+ let cleanupStreamListeners: (() => void) | null = null;
219
+
212
220
  const stream = new ReadableStream({
213
221
  start(controller) {
214
222
  // Collect assistant response text for saving to session content
@@ -218,6 +226,43 @@ export async function POST(
218
226
  // Track if we've saved the response (to avoid duplicate saves)
219
227
  let responseSaved = false;
220
228
 
229
+ // Track active emitter (may change during retry)
230
+ let currentEmitter = emitter;
231
+
232
+ // Safe enqueue — if the stream has been closed (client disconnect, error),
233
+ // enqueue() throws. Without this wrapper, the error propagates through
234
+ // the EventEmitter chain and crashes the process manager's stdout handler,
235
+ // silently killing all subsequent events for this session.
236
+ const safeEnqueue = (data: Uint8Array): boolean => {
237
+ try {
238
+ controller.enqueue(data);
239
+ return true;
240
+ } catch {
241
+ // Stream is dead — clean up
242
+ if (!responseComplete) {
243
+ responseComplete = true;
244
+ saveAssistantResponse();
245
+ currentEmitter.off('data', onData);
246
+ currentEmitter.off('error', onError);
247
+ currentEmitter.off('close', onClose);
248
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
249
+ }
250
+ return false;
251
+ }
252
+ };
253
+
254
+ // SSE heartbeat — send a comment every 15s to keep the connection alive.
255
+ // During long tool executions (jettypod work start, npm test, etc.) the
256
+ // SSE stream can be idle for 30+ seconds. Proxies and HTTP middleware
257
+ // may close idle connections, killing the stream mid-session.
258
+ const heartbeatInterval = setInterval(() => {
259
+ if (responseComplete) {
260
+ clearInterval(heartbeatInterval);
261
+ return;
262
+ }
263
+ safeEnqueue(encoder.encode(': heartbeat\n\n'));
264
+ }, 15_000);
265
+
221
266
  // Helper to save assistant response if not already saved
222
267
  const saveAssistantResponse = () => {
223
268
  if (!responseSaved && assistantResponse.trim()) {
@@ -272,26 +317,23 @@ export async function POST(
272
317
  // Check for result message - this indicates Claude is done with this turn
273
318
  if (parsed.type === 'result') {
274
319
  responseComplete = true;
320
+ clearInterval(heartbeatInterval);
275
321
 
276
322
  // Save assistant response to session content (if not already saved)
277
323
  saveAssistantResponse();
278
324
 
279
- const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
280
- controller.enqueue(encoder.encode(sseData));
281
-
282
- // Send done event and close stream
283
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 0 })}\n\n`));
325
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
326
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 0 })}\n\n`));
284
327
 
285
328
  // Remove listeners and close
286
- emitter.off('data', onData);
287
- emitter.off('error', onError);
288
- emitter.off('close', onClose);
289
- controller.close();
329
+ currentEmitter.off('data', onData);
330
+ currentEmitter.off('error', onError);
331
+ currentEmitter.off('close', onClose);
332
+ try { controller.close(); } catch { /* already closed */ }
290
333
  return;
291
334
  }
292
335
 
293
- const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
294
- controller.enqueue(encoder.encode(sseData));
336
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
295
337
  };
296
338
 
297
339
  const onError = (err: { type: string; content: string }) => {
@@ -302,29 +344,39 @@ export async function POST(
302
344
  content: err.content,
303
345
  timestamp: new Date().toISOString()
304
346
  });
305
- const sseData = `data: ${JSON.stringify({ type: 'error', content: err.content })}\n\n`;
306
- controller.enqueue(encoder.encode(sseData));
347
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', content: err.content })}\n\n`));
307
348
  };
308
349
 
309
350
  const onClose = (info: { exitCode: number }) => {
310
351
  if (responseComplete) return;
311
352
  responseComplete = true;
353
+ clearInterval(heartbeatInterval);
312
354
 
313
355
  // Save any collected response (if not already saved)
314
356
  saveAssistantResponse();
315
357
 
316
- const sseData = `data: ${JSON.stringify({ type: 'done', exitCode: info.exitCode })}\n\n`;
317
- controller.enqueue(encoder.encode(sseData));
318
- controller.close();
358
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: info.exitCode })}\n\n`));
359
+ try { controller.close(); } catch { /* already closed */ }
360
+ };
361
+
362
+ // Expose cleanup so cancel() can call it when client disconnects
363
+ cleanupStreamListeners = () => {
364
+ if (responseComplete) return;
365
+ responseComplete = true;
366
+ clearInterval(heartbeatInterval);
367
+ saveAssistantResponse();
368
+ currentEmitter.off('data', onData);
369
+ currentEmitter.off('error', onError);
370
+ currentEmitter.off('close', onClose);
319
371
  };
320
372
 
321
373
  // Attach listeners
322
- emitter.on('data', onData);
323
- emitter.on('error', onError);
324
- emitter.on('close', onClose);
374
+ currentEmitter.on('data', onData);
375
+ currentEmitter.on('error', onError);
376
+ currentEmitter.on('close', onClose);
325
377
 
326
378
  // Send the message to the persistent process
327
- let sent = sendProcessMessage(processSessionId, messageToSend);
379
+ let sent = sendProcessMessage(processSessionId, messageToSend, images);
328
380
 
329
381
  // If send failed, the process may have died after getOrCreateProcess
330
382
  // Try to create a fresh process and retry once
@@ -335,30 +387,33 @@ export async function POST(
335
387
  const retryResult = getOrCreateProcess(processSessionId, claudeCwd, settingsPath);
336
388
  if ('error' in retryResult) {
337
389
  // Hit process limit on retry - return error
338
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({
390
+ clearInterval(heartbeatInterval);
391
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({
339
392
  type: 'error',
340
393
  content: retryResult.error
341
394
  })}\n\n`));
342
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
343
- controller.close();
395
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
396
+ try { controller.close(); } catch { /* already closed */ }
344
397
  return;
345
398
  }
346
399
  const { emitter: newEmitter } = retryResult;
347
400
 
348
401
  // Re-attach listeners to new emitter
349
- emitter.off('data', onData);
350
- emitter.off('error', onError);
351
- emitter.off('close', onClose);
352
- newEmitter.on('data', onData);
353
- newEmitter.on('error', onError);
354
- newEmitter.on('close', onClose);
402
+ currentEmitter.off('data', onData);
403
+ currentEmitter.off('error', onError);
404
+ currentEmitter.off('close', onClose);
405
+ currentEmitter = newEmitter;
406
+ currentEmitter.on('data', onData);
407
+ currentEmitter.on('error', onError);
408
+ currentEmitter.on('close', onClose);
355
409
 
356
410
  // Retry send
357
- sent = sendProcessMessage(processSessionId, messageToSend);
411
+ sent = sendProcessMessage(processSessionId, messageToSend, images);
358
412
  }
359
413
 
360
414
  if (!sent) {
361
415
  // Still failed after retry - give clear error
416
+ clearInterval(heartbeatInterval);
362
417
  const errorContent = 'Claude process is unavailable. The process may have crashed or failed to start. Please try again.';
363
418
  // Persist error to database (#1000098)
364
419
  appendSessionContentByWorkItem(workItemIdNum, {
@@ -366,14 +421,19 @@ export async function POST(
366
421
  content: errorContent,
367
422
  timestamp: new Date().toISOString()
368
423
  });
369
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({
424
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({
370
425
  type: 'error',
371
426
  content: errorContent
372
427
  })}\n\n`));
373
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
374
- controller.close();
428
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
429
+ try { controller.close(); } catch { /* already closed */ }
375
430
  }
376
431
  },
432
+ cancel() {
433
+ // Called when client disconnects — clean up listeners to prevent
434
+ // "Controller is already closed" errors from orphaned event handlers
435
+ cleanupStreamListeners?.();
436
+ },
377
437
  });
378
438
 
379
439
  return new Response(stream, {
@@ -1,8 +1,13 @@
1
- import { spawn, spawnSync } from 'child_process';
1
+ import { spawnSync } from 'child_process';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
- import { registerSession, updateSessionStatus } from '@/lib/db';
5
+ import { registerSession, appendSessionContentByWorkItem } from '@/lib/db';
6
+ import {
7
+ getOrCreateProcess,
8
+ sendMessage as sendProcessMessage,
9
+ killProcess,
10
+ } from '@/lib/claude-process-manager';
6
11
 
7
12
  // Import worktree facade for worktree management
8
13
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -92,68 +97,176 @@ Please start working on this ${workItemType}. Use the appropriate tools to imple
92
97
  const sessionTitle = title || `Work item ${workItemId}`;
93
98
  registerSession(parseInt(workItemId, 10), sessionTitle);
94
99
 
95
- // Create a readable stream that we'll pipe Claude's output to
100
+ // Use claude-process-manager for proper stdout buffering and gate detection.
101
+ // The inline spawn previously used here had no buffering — split JSON messages
102
+ // were silently lost, and no synthetic gate events (cards) were emitted.
103
+ const settingsPath = getSettingsPath(repoPath);
104
+ const processSessionId = `wi-${workItemId}`;
105
+ const workItemIdNum = parseInt(workItemId, 10);
106
+
107
+ const processResult = getOrCreateProcess(processSessionId, claudeCwd, settingsPath);
108
+
109
+ if ('error' in processResult) {
110
+ return NextResponse.json(
111
+ { type: 'error', message: processResult.error },
112
+ { status: 503 }
113
+ );
114
+ }
115
+
116
+ const { emitter } = processResult;
117
+
118
+ // Create SSE stream from the process emitter
96
119
  const encoder = new TextEncoder();
120
+ let cleanupStreamListeners: (() => void) | null = null;
97
121
 
98
122
  const stream = new ReadableStream({
99
123
  start(controller) {
100
- // Spawn Claude CLI with streaming JSON output in the worktree directory
101
- // Use bypassPermissions mode + explicit settings path (if exists) to enable hooks while avoiding prompts
102
- const settingsPath = getSettingsPath(repoPath);
103
- const claudeArgs = [
104
- '-p', prompt,
105
- '--output-format', 'stream-json',
106
- '--verbose',
107
- '--permission-mode', 'bypassPermissions',
108
- ];
109
- if (settingsPath) {
110
- claudeArgs.push('--settings', settingsPath);
111
- }
112
- const claude = spawn('claude', claudeArgs, {
113
- cwd: claudeCwd,
114
- env: { ...process.env },
115
- stdio: ['ignore', 'pipe', 'pipe'],
116
- });
117
-
118
- claude.stdout.on('data', (data: Buffer) => {
119
- // Parse each line of streaming JSON and send as SSE
120
- const lines = data.toString().split('\n').filter(line => line.trim());
121
- for (const line of lines) {
122
- try {
123
- const parsed = JSON.parse(line);
124
- const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
125
- controller.enqueue(encoder.encode(sseData));
126
- } catch {
127
- // If not valid JSON, send as raw text
128
- const sseData = `data: ${JSON.stringify({ type: 'text', content: line })}\n\n`;
129
- controller.enqueue(encoder.encode(sseData));
124
+ let assistantResponse = '';
125
+ let responseComplete = false;
126
+ let responseSaved = false;
127
+
128
+ // Safe enqueue — prevents crashes when stream is already closed
129
+ const safeEnqueue = (data: Uint8Array): boolean => {
130
+ try {
131
+ controller.enqueue(data);
132
+ return true;
133
+ } catch {
134
+ if (!responseComplete) {
135
+ responseComplete = true;
136
+ saveAssistantResponse();
137
+ emitter.off('data', onData);
138
+ emitter.off('error', onError);
139
+ emitter.off('close', onClose);
140
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
141
+ }
142
+ return false;
143
+ }
144
+ };
145
+
146
+ // SSE heartbeat to prevent connection timeouts during long tool executions
147
+ const heartbeatInterval = setInterval(() => {
148
+ if (responseComplete) {
149
+ clearInterval(heartbeatInterval);
150
+ return;
151
+ }
152
+ safeEnqueue(encoder.encode(': heartbeat\n\n'));
153
+ }, 15_000);
154
+
155
+ const saveAssistantResponse = () => {
156
+ if (!responseSaved && assistantResponse.trim()) {
157
+ appendSessionContentByWorkItem(workItemIdNum, {
158
+ role: 'assistant',
159
+ content: assistantResponse.trim(),
160
+ timestamp: new Date().toISOString()
161
+ });
162
+ responseSaved = true;
163
+ }
164
+ };
165
+
166
+ const onData = (parsed: Record<string, unknown>) => {
167
+ if (responseComplete) return;
168
+
169
+ // Collect text content for session storage
170
+ if (parsed.type === 'assistant' && parsed.message) {
171
+ const msg = parsed.message as { content?: Array<{ type: string; text?: string; name?: string }> };
172
+ if (msg.content) {
173
+ for (const block of msg.content) {
174
+ if (block.type === 'text' && block.text) {
175
+ if (assistantResponse && !assistantResponse.endsWith('\n')) {
176
+ assistantResponse += '\n\n';
177
+ }
178
+ assistantResponse += block.text;
179
+ }
180
+ }
130
181
  }
182
+ } else if (parsed.type === 'content_block_delta') {
183
+ const delta = parsed.delta as { text?: string } | undefined;
184
+ if (delta?.text) {
185
+ assistantResponse += delta.text;
186
+ }
187
+ }
188
+
189
+ // Check for result message - Claude is done with this turn
190
+ if (parsed.type === 'result') {
191
+ responseComplete = true;
192
+ clearInterval(heartbeatInterval);
193
+ saveAssistantResponse();
194
+
195
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
196
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 0 })}\n\n`));
197
+
198
+ emitter.off('data', onData);
199
+ emitter.off('error', onError);
200
+ emitter.off('close', onClose);
201
+ try { controller.close(); } catch { /* already closed */ }
202
+ return;
203
+ }
204
+
205
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
206
+ };
207
+
208
+ const onError = (err: { type: string; content: string }) => {
209
+ if (responseComplete) return;
210
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', content: err.content })}\n\n`));
211
+ };
212
+
213
+ const onClose = (info: { exitCode: number }) => {
214
+ if (responseComplete) return;
215
+ responseComplete = true;
216
+ clearInterval(heartbeatInterval);
217
+ saveAssistantResponse();
218
+
219
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: info.exitCode })}\n\n`));
220
+ try { controller.close(); } catch { /* already closed */ }
221
+ };
222
+
223
+ cleanupStreamListeners = () => {
224
+ if (responseComplete) return;
225
+ responseComplete = true;
226
+ clearInterval(heartbeatInterval);
227
+ saveAssistantResponse();
228
+ emitter.off('data', onData);
229
+ emitter.off('error', onError);
230
+ emitter.off('close', onClose);
231
+ };
232
+
233
+ emitter.on('data', onData);
234
+ emitter.on('error', onError);
235
+ emitter.on('close', onClose);
236
+
237
+ // Send the initial prompt as first message
238
+ let sent = sendProcessMessage(processSessionId, prompt);
239
+
240
+ // If send failed, retry with fresh process
241
+ if (!sent) {
242
+ killProcess(processSessionId);
243
+ const retryResult = getOrCreateProcess(processSessionId, claudeCwd, settingsPath);
244
+ if ('error' in retryResult) {
245
+ clearInterval(heartbeatInterval);
246
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', content: retryResult.error })}\n\n`));
247
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
248
+ try { controller.close(); } catch { /* already closed */ }
249
+ return;
131
250
  }
132
- });
133
-
134
- claude.stderr.on('data', (data: Buffer) => {
135
- const sseData = `data: ${JSON.stringify({ type: 'error', content: data.toString() })}\n\n`;
136
- controller.enqueue(encoder.encode(sseData));
137
- });
138
-
139
- claude.on('close', (code) => {
140
- // Update session status based on exit code
141
- const status = code === 0 ? 'completed' : 'error';
142
- updateSessionStatus(parseInt(workItemId, 10), status);
143
-
144
- const sseData = `data: ${JSON.stringify({ type: 'done', exitCode: code })}\n\n`;
145
- controller.enqueue(encoder.encode(sseData));
146
- controller.close();
147
- });
148
-
149
- claude.on('error', (err) => {
150
- // Mark session as error on spawn failure
151
- updateSessionStatus(parseInt(workItemId, 10), 'error');
152
-
153
- const sseData = `data: ${JSON.stringify({ type: 'error', content: err.message })}\n\n`;
154
- controller.enqueue(encoder.encode(sseData));
155
- controller.close();
156
- });
251
+ const { emitter: newEmitter } = retryResult;
252
+ emitter.off('data', onData);
253
+ emitter.off('error', onError);
254
+ emitter.off('close', onClose);
255
+ newEmitter.on('data', onData);
256
+ newEmitter.on('error', onError);
257
+ newEmitter.on('close', onClose);
258
+ sent = sendProcessMessage(processSessionId, prompt);
259
+ }
260
+
261
+ if (!sent) {
262
+ clearInterval(heartbeatInterval);
263
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', content: 'Claude process unavailable' })}\n\n`));
264
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
265
+ try { controller.close(); } catch { /* already closed */ }
266
+ }
267
+ },
268
+ cancel() {
269
+ cleanupStreamListeners?.();
157
270
  },
158
271
  });
159
272