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.
- package/.env +2 -2
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +106 -46
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +155 -8
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +1 -1
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +62 -41
- package/apps/dashboard/app/page.tsx +9 -9
- package/apps/dashboard/app/settings/page.tsx +2 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/welcome/page.tsx +1 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +63 -65
- package/apps/dashboard/components/CardMenu.tsx +45 -11
- package/apps/dashboard/components/ClaudePanel.tsx +282 -619
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +16 -29
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +2 -1
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +79 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +14 -27
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +99 -835
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -3
- package/apps/dashboard/components/ProjectSwitcher.tsx +10 -10
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +258 -325
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +73 -78
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +53 -38
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +16 -15
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +5 -4
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +17 -29
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +28 -22
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +128 -88
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +30 -6
- package/apps/dashboard/electron/ipc-handlers.js +66 -68
- package/apps/dashboard/electron/main.js +329 -147
- package/apps/dashboard/electron/preload.js +2 -0
- package/apps/dashboard/electron/session-manager.js +48 -26
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +44 -7
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +1 -0
- package/apps/dashboard/lib/db.ts +43 -6
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-stream-manager.ts +68 -25
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +16 -0
- package/apps/dashboard/package.json +3 -2
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/update-server/src/index.ts +61 -50
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +49 -112
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +86 -3
- package/lib/work-tracking/index.js +40 -19
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +37 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +35 -27
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +101 -89
- 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=
|
|
5
|
-
GOOGLE_CLIENT_SECRET=GOCSPX-
|
|
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
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
controller.
|
|
318
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
390
|
+
clearInterval(heartbeatInterval);
|
|
391
|
+
safeEnqueue(encoder.encode(`data: ${JSON.stringify({
|
|
339
392
|
type: 'error',
|
|
340
393
|
content: retryResult.error
|
|
341
394
|
})}\n\n`));
|
|
342
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
newEmitter
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
424
|
+
safeEnqueue(encoder.encode(`data: ${JSON.stringify({
|
|
370
425
|
type: 'error',
|
|
371
426
|
content: errorContent
|
|
372
427
|
})}\n\n`));
|
|
373
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
controller.close();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|