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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- 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/connect-claude/page.tsx +24 -0
- 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 +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- 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 +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- 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 +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- 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 +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- 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 +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- 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/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- 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 +54 -116
- 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 +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- 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 +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- 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 +39 -28
- 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 +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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
|
-
|
|
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
|
|
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
|
-
//
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
controller.
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
390
|
+
clearInterval(heartbeatInterval);
|
|
391
|
+
safeEnqueue(encoder.encode(`data: ${JSON.stringify({
|
|
323
392
|
type: 'error',
|
|
324
393
|
content: retryResult.error
|
|
325
394
|
})}\n\n`));
|
|
326
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
newEmitter
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
424
|
+
safeEnqueue(encoder.encode(`data: ${JSON.stringify({
|
|
354
425
|
type: 'error',
|
|
355
426
|
content: errorContent
|
|
356
427
|
})}\n\n`));
|
|
357
|
-
|
|
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 {
|
|
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
|
|