jettypod 4.4.116 → 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 (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
package/.env ADDED
@@ -0,0 +1,7 @@
1
+ CLOUDFLARE_API_TOKEN=qaygaUeCLS8RQA5FVsJxnsA02c8b8qWyiLgNSM1X
2
+ STRIPE_SECRET_KEY=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
3
+ STRIPE_WEBHOOK_SECRET=whsec_pkup32kvrcf3g5h8VmtjLfYU9WIWdAb6
4
+ GOOGLE_CLIENT_ID=816982147727-7ceu0dge0ton30q1d09dgcos9h62orjo.apps.googleusercontent.com
5
+ GOOGLE_CLIENT_SECRET=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
6
+ JWT_SECRET=bKYWqg7IR45K9CrA5BRmzXPQoWK6Vv4okZKpGmYD6o4=
7
+ RESEND_API_KEY=re_Tg7gAVGE_LVKhjnyLkpDgYmkzQvYXmCwc
@@ -2,17 +2,13 @@ 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 { appendSessionContentByWorkItem, getSessionContentByWorkItem, ConversationTurn } from '@/lib/db';
5
+ import { appendSessionContentByWorkItem, getSessionContentByWorkItem, getWorkItem, ConversationTurn } from '@/lib/db';
6
6
  import {
7
7
  getOrCreateProcess,
8
8
  sendMessage as sendProcessMessage,
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,
@@ -51,9 +47,14 @@ function buildContextPrefix(history: ConversationTurn[]): string {
51
47
  return `[Session context restored — your process was restarted. Previous conversation:]\n\n${lines.join('\n\n')}\n\n[End of restored context. New message from user:]\n\n`;
52
48
  }
53
49
 
50
+ // Cache the CLI availability check so we only pay the spawnSync cost once per process lifetime
51
+ let claudeCliAvailable: boolean | null = null;
52
+
54
53
  function isClaudeCliAvailable(): boolean {
54
+ if (claudeCliAvailable !== null) return claudeCliAvailable;
55
55
  const result = spawnSync('which', ['claude'], { encoding: 'utf-8' });
56
- return result.status === 0 && result.stdout.trim().length > 0;
56
+ claudeCliAvailable = result.status === 0 && result.stdout.trim().length > 0;
57
+ return claudeCliAvailable;
57
58
  }
58
59
 
59
60
  function isValidWorkItemId(id: string): boolean {
@@ -87,9 +88,9 @@ export async function POST(
87
88
  );
88
89
  }
89
90
 
90
- // Get the message (conversationHistory no longer needed - persistent process maintains context)
91
+ // Get the message and optional images
91
92
  const body = await request.json().catch(() => ({}));
92
- const { message } = body;
93
+ const { message, images } = body;
93
94
 
94
95
  if (!message || typeof message !== 'string') {
95
96
  return NextResponse.json(
@@ -151,15 +152,35 @@ export async function POST(
151
152
  timestamp: new Date().toISOString()
152
153
  });
153
154
 
154
- // Get or create worktree for this work item
155
- const workItem = {
156
- id: parseInt(workItemId, 10),
157
- title: `Work item ${workItemId}`
158
- };
155
+ // Check if this is a conversational work item (skip worktree)
156
+ const workItemData = getWorkItem(workItemIdNum);
157
+ const isConversational = workItemData?.conversational === 1;
159
158
 
160
159
  const repoPath = getProjectRoot();
161
- const workResult = await worktreeFacade.startWork(workItem, { repoPath });
162
- const claudeCwd = workResult.path;
160
+ let claudeCwd: string;
161
+
162
+ if (isConversational) {
163
+ // Conversational chores run from main repo — no worktree needed
164
+ claudeCwd = repoPath;
165
+ } else {
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
+ }
182
+ }
183
+
163
184
  const settingsPath = getSettingsPath(repoPath);
164
185
 
165
186
  // Use workItemId prefixed with 'wi-' to avoid collision with standalone session IDs
@@ -193,6 +214,9 @@ export async function POST(
193
214
  // Create a readable stream for SSE
194
215
  const encoder = new TextEncoder();
195
216
 
217
+ // Cleanup function hoisted so cancel() can access it when client disconnects
218
+ let cleanupStreamListeners: (() => void) | null = null;
219
+
196
220
  const stream = new ReadableStream({
197
221
  start(controller) {
198
222
  // Collect assistant response text for saving to session content
@@ -202,6 +226,43 @@ export async function POST(
202
226
  // Track if we've saved the response (to avoid duplicate saves)
203
227
  let responseSaved = false;
204
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
+
205
266
  // Helper to save assistant response if not already saved
206
267
  const saveAssistantResponse = () => {
207
268
  if (!responseSaved && assistantResponse.trim()) {
@@ -256,26 +317,23 @@ export async function POST(
256
317
  // Check for result message - this indicates Claude is done with this turn
257
318
  if (parsed.type === 'result') {
258
319
  responseComplete = true;
320
+ clearInterval(heartbeatInterval);
259
321
 
260
322
  // Save assistant response to session content (if not already saved)
261
323
  saveAssistantResponse();
262
324
 
263
- const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
264
- controller.enqueue(encoder.encode(sseData));
265
-
266
- // Send done event and close stream
267
- 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`));
268
327
 
269
328
  // Remove listeners and close
270
- emitter.off('data', onData);
271
- emitter.off('error', onError);
272
- emitter.off('close', onClose);
273
- controller.close();
329
+ currentEmitter.off('data', onData);
330
+ currentEmitter.off('error', onError);
331
+ currentEmitter.off('close', onClose);
332
+ try { controller.close(); } catch { /* already closed */ }
274
333
  return;
275
334
  }
276
335
 
277
- const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
278
- controller.enqueue(encoder.encode(sseData));
336
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(parsed)}\n\n`));
279
337
  };
280
338
 
281
339
  const onError = (err: { type: string; content: string }) => {
@@ -286,29 +344,39 @@ export async function POST(
286
344
  content: err.content,
287
345
  timestamp: new Date().toISOString()
288
346
  });
289
- const sseData = `data: ${JSON.stringify({ type: 'error', content: err.content })}\n\n`;
290
- controller.enqueue(encoder.encode(sseData));
347
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', content: err.content })}\n\n`));
291
348
  };
292
349
 
293
350
  const onClose = (info: { exitCode: number }) => {
294
351
  if (responseComplete) return;
295
352
  responseComplete = true;
353
+ clearInterval(heartbeatInterval);
296
354
 
297
355
  // Save any collected response (if not already saved)
298
356
  saveAssistantResponse();
299
357
 
300
- const sseData = `data: ${JSON.stringify({ type: 'done', exitCode: info.exitCode })}\n\n`;
301
- controller.enqueue(encoder.encode(sseData));
302
- 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);
303
371
  };
304
372
 
305
373
  // Attach listeners
306
- emitter.on('data', onData);
307
- emitter.on('error', onError);
308
- emitter.on('close', onClose);
374
+ currentEmitter.on('data', onData);
375
+ currentEmitter.on('error', onError);
376
+ currentEmitter.on('close', onClose);
309
377
 
310
378
  // Send the message to the persistent process
311
- let sent = sendProcessMessage(processSessionId, messageToSend);
379
+ let sent = sendProcessMessage(processSessionId, messageToSend, images);
312
380
 
313
381
  // If send failed, the process may have died after getOrCreateProcess
314
382
  // Try to create a fresh process and retry once
@@ -319,30 +387,33 @@ export async function POST(
319
387
  const retryResult = getOrCreateProcess(processSessionId, claudeCwd, settingsPath);
320
388
  if ('error' in retryResult) {
321
389
  // Hit process limit on retry - return error
322
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({
390
+ clearInterval(heartbeatInterval);
391
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({
323
392
  type: 'error',
324
393
  content: retryResult.error
325
394
  })}\n\n`));
326
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
327
- controller.close();
395
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
396
+ try { controller.close(); } catch { /* already closed */ }
328
397
  return;
329
398
  }
330
399
  const { emitter: newEmitter } = retryResult;
331
400
 
332
401
  // Re-attach listeners to new emitter
333
- emitter.off('data', onData);
334
- emitter.off('error', onError);
335
- emitter.off('close', onClose);
336
- newEmitter.on('data', onData);
337
- newEmitter.on('error', onError);
338
- 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);
339
409
 
340
410
  // Retry send
341
- sent = sendProcessMessage(processSessionId, messageToSend);
411
+ sent = sendProcessMessage(processSessionId, messageToSend, images);
342
412
  }
343
413
 
344
414
  if (!sent) {
345
415
  // Still failed after retry - give clear error
416
+ clearInterval(heartbeatInterval);
346
417
  const errorContent = 'Claude process is unavailable. The process may have crashed or failed to start. Please try again.';
347
418
  // Persist error to database (#1000098)
348
419
  appendSessionContentByWorkItem(workItemIdNum, {
@@ -350,14 +421,19 @@ export async function POST(
350
421
  content: errorContent,
351
422
  timestamp: new Date().toISOString()
352
423
  });
353
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({
424
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({
354
425
  type: 'error',
355
426
  content: errorContent
356
427
  })}\n\n`));
357
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
358
- controller.close();
428
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
429
+ try { controller.close(); } catch { /* already closed */ }
359
430
  }
360
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
+ },
361
437
  });
362
438
 
363
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