jettypod 4.4.109 → 4.4.111
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/.gitattributes +2 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +110 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +283 -60
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +24 -0
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +30 -5
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +52 -0
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +299 -61
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +24 -0
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +34 -0
- package/apps/dashboard/app/api/claude/sessions/route.ts +116 -15
- package/apps/dashboard/app/api/internal/set-project/route.ts +17 -0
- package/apps/dashboard/app/api/settings/env-vars/route.ts +125 -0
- package/apps/dashboard/app/api/settings/general/route.ts +21 -0
- package/apps/dashboard/app/api/tests/route.ts +9 -0
- package/apps/dashboard/app/api/tests/run/route.ts +82 -0
- package/apps/dashboard/app/api/tests/run/stream/route.ts +59 -0
- package/apps/dashboard/app/api/tests/undefined/route.ts +9 -0
- package/apps/dashboard/app/api/work/[id]/description/route.ts +21 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +2 -2
- package/apps/dashboard/app/demo/gates/page.tsx +653 -0
- package/apps/dashboard/app/install-claude/page.tsx +56 -0
- package/apps/dashboard/app/layout.tsx +7 -2
- package/apps/dashboard/app/page.tsx +48 -37
- package/apps/dashboard/app/settings/page.tsx +27 -0
- package/apps/dashboard/app/tests/page.tsx +5 -68
- package/apps/dashboard/app/welcome/page.tsx +84 -0
- package/apps/dashboard/app/work/[id]/page.tsx +10 -14
- package/apps/dashboard/build-resources/entitlements.mac.plist +27 -0
- package/apps/dashboard/build-resources/icon.png +0 -0
- package/apps/dashboard/components/AppShell.tsx +91 -0
- package/apps/dashboard/components/CardMenu.tsx +91 -63
- package/apps/dashboard/components/ClaudePanel.tsx +759 -101
- package/apps/dashboard/components/ClaudePanelInput.tsx +90 -13
- package/apps/dashboard/components/DragContext.tsx +228 -180
- package/apps/dashboard/components/DraggableCard.tsx +48 -45
- package/apps/dashboard/components/DropZone.tsx +15 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +102 -0
- package/apps/dashboard/components/EditableDetailTitle.tsx +25 -0
- package/apps/dashboard/components/EditableTitle.tsx +15 -3
- package/apps/dashboard/components/GateCard.tsx +270 -0
- package/apps/dashboard/components/GateChoiceCard.tsx +104 -0
- package/apps/dashboard/components/InstallClaudeScreen.tsx +91 -0
- package/apps/dashboard/components/KanbanBoard.tsx +359 -58
- package/apps/dashboard/components/MainNav.tsx +133 -0
- package/apps/dashboard/components/ModeStartCard.tsx +246 -0
- package/apps/dashboard/components/ProjectSwitcher.tsx +165 -0
- package/apps/dashboard/components/PrototypeTimeline.tsx +262 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +270 -438
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +697 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +142 -0
- package/apps/dashboard/components/WelcomeScreen.tsx +92 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +413 -0
- package/apps/dashboard/components/settings/GeneralSection.tsx +146 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +39 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +1051 -0
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +31 -0
- package/apps/dashboard/electron/ipc-handlers.js +832 -0
- package/apps/dashboard/electron/main.js +1746 -0
- package/apps/dashboard/electron/preload.js +104 -0
- package/apps/dashboard/electron-builder.config.js +359 -0
- package/apps/dashboard/hooks/useClaudeSessions.ts +2 -2
- package/apps/dashboard/hooks/useWebSocket.ts +8 -2
- package/apps/dashboard/lib/claude-process-manager.ts +490 -0
- package/apps/dashboard/lib/db-bridge.ts +250 -0
- package/apps/dashboard/lib/db.ts +765 -299
- package/apps/dashboard/lib/message-buffer.ts +264 -0
- package/apps/dashboard/lib/prototypes.ts +202 -0
- package/apps/dashboard/lib/run-migrations.js +138 -0
- package/apps/dashboard/lib/session-state-machine.ts +297 -0
- package/apps/dashboard/lib/session-state-utils.ts +274 -0
- package/apps/dashboard/lib/session-stream-manager.ts +760 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +424 -0
- package/apps/dashboard/lib/test-results-db.ts +307 -0
- package/apps/dashboard/lib/tests.ts +100 -21
- package/apps/dashboard/{next.config.ts → next.config.js} +11 -5
- package/apps/dashboard/package.json +21 -2
- package/apps/dashboard/public/assets/wave-completion.mp4 +0 -0
- package/apps/dashboard/public/jettypod_wordmark.png +0 -0
- package/apps/dashboard/public/jettypod_wordmark.svg +3 -0
- package/apps/dashboard/scripts/download-node.js +104 -0
- package/bin/jettypod +22 -0
- package/claude-hooks/enforce-skill-activation.js +132 -40
- package/cucumber.js +1 -1
- package/jettypod.js +432 -377
- package/lib/config.js +28 -0
- package/lib/database.js +14 -112
- package/lib/discovery-checkpoint.js +41 -1
- package/lib/footer.js +5 -0
- package/lib/hello.js +8 -0
- package/lib/merge-lock.js +175 -11
- package/lib/migrations/023-session-content-column.js +93 -0
- package/lib/migrations/024-session-orphaned-status.js +135 -0
- package/lib/migrations/025-test-results-tables.js +99 -0
- package/lib/migrations/026-rejection-reason-columns.js +49 -0
- package/lib/schema.js +105 -0
- package/lib/seed-onboarding.js +165 -0
- package/lib/skills/feature-planning/dry-run-validator.js +17 -22
- package/lib/work-commands/index.js +469 -106
- package/lib/work-tracking/index.js +75 -1
- package/lib/worktree-manager.js +2 -24
- package/package.json +1 -1
- package/scripts/rebuild-app.sh +51 -0
- package/skills-templates/bug-mode/SKILL.md +27 -3
- package/skills-templates/bug-planning/SKILL.md +6 -0
- package/skills-templates/chore-mode/SKILL.md +24 -0
- package/skills-templates/chore-planning/SKILL.md +6 -0
- package/skills-templates/epic-planning/SKILL.md +12 -2
- package/skills-templates/feature-planning/SKILL.md +6 -0
- package/skills-templates/production-mode/SKILL.md +20 -2
- package/skills-templates/project-discovery/SKILL.md +372 -0
- package/skills-templates/request-routing/SKILL.md +7 -1
- package/skills-templates/simple-improvement/SKILL.md +37 -26
- package/skills-templates/speed-mode/SKILL.md +26 -2
- package/skills-templates/stable-mode/SKILL.md +31 -1
- package/.jettypod-backup/work.db-shm +0 -0
- package/.jettypod-backup/work.db-wal +0 -0
- package/apps/dashboard/app/api/decisions/route.ts +0 -9
- package/apps/dashboard/components/RecentDecisionsWidget.tsx +0 -63
- package/apps/dashboard/hooks/useClaudeStream.ts +0 -386
- package/cucumber-results.json +0 -12970
package/.gitattributes
ADDED
package/.jettypod-backup/work.db
CHANGED
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
|
|
6
|
+
export default function AccessCodePage() {
|
|
7
|
+
const [code, setCode] = useState('');
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
10
|
+
|
|
11
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
setError(null);
|
|
14
|
+
|
|
15
|
+
if (!code.trim()) {
|
|
16
|
+
setError('Please enter an access code.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!window.electronAPI?.isElectron) {
|
|
21
|
+
setError('Access code validation is only available in the desktop app.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setIsSubmitting(true);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = await window.electronAPI.access.validate(code.trim());
|
|
29
|
+
|
|
30
|
+
if (!result.success) {
|
|
31
|
+
setError(result.error || 'Invalid access code.');
|
|
32
|
+
setIsSubmitting(false);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Access granted - navigate to main app
|
|
37
|
+
window.location.href = '/';
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setError(err instanceof Error ? err.message : 'Failed to validate access code.');
|
|
40
|
+
setIsSubmitting(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900 p-8">
|
|
46
|
+
<div className="max-w-md w-full space-y-8">
|
|
47
|
+
{/* Logo */}
|
|
48
|
+
<div className="flex flex-col items-center space-y-4">
|
|
49
|
+
<Image
|
|
50
|
+
src="/jettypod_wordmark.png"
|
|
51
|
+
alt="JettyPod"
|
|
52
|
+
width={160}
|
|
53
|
+
height={40}
|
|
54
|
+
priority
|
|
55
|
+
/>
|
|
56
|
+
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
|
|
57
|
+
Enter Access Code
|
|
58
|
+
</h1>
|
|
59
|
+
<p className="text-zinc-500 dark:text-zinc-400 text-center">
|
|
60
|
+
Enter your access code to get started with JettyPod.
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Error */}
|
|
65
|
+
{error && (
|
|
66
|
+
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
|
67
|
+
{error}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{/* Form */}
|
|
72
|
+
<form onSubmit={handleSubmit} className="pt-4 space-y-4">
|
|
73
|
+
<input
|
|
74
|
+
type="text"
|
|
75
|
+
value={code}
|
|
76
|
+
onChange={(e) => setCode(e.target.value)}
|
|
77
|
+
placeholder="Access code"
|
|
78
|
+
autoFocus
|
|
79
|
+
disabled={isSubmitting}
|
|
80
|
+
className="w-full px-4 py-3 rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-500 disabled:opacity-50"
|
|
81
|
+
data-testid="access-code-input"
|
|
82
|
+
/>
|
|
83
|
+
<button
|
|
84
|
+
type="submit"
|
|
85
|
+
disabled={isSubmitting}
|
|
86
|
+
className="w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 hover:-translate-y-1 hover:scale-[1.01] active:translate-y-0 active:scale-100 disabled:opacity-50 disabled:pointer-events-none"
|
|
87
|
+
style={{
|
|
88
|
+
cursor: isSubmitting ? 'default' : 'pointer',
|
|
89
|
+
background: 'linear-gradient(145deg, #ffffff 0%, #faf9f7 10%, #f0f4f4 35%, #c8d9da 55%, #819D9F 90%)',
|
|
90
|
+
color: '#3d4d4e',
|
|
91
|
+
boxShadow: `
|
|
92
|
+
0 1px 1px rgba(0, 0, 0, 0.02),
|
|
93
|
+
0 2px 4px rgba(0, 0, 0, 0.03),
|
|
94
|
+
0 6px 12px rgba(0, 0, 0, 0.05),
|
|
95
|
+
0 12px 24px rgba(0, 0, 0, 0.06),
|
|
96
|
+
0 20px 40px rgba(129, 157, 159, 0.2),
|
|
97
|
+
0 32px 64px rgba(129, 157, 159, 0.18),
|
|
98
|
+
inset 0 2px 4px rgba(255, 255, 255, 1),
|
|
99
|
+
inset 0 -2px 4px rgba(129, 157, 159, 0.05)
|
|
100
|
+
`,
|
|
101
|
+
}}
|
|
102
|
+
data-testid="access-code-submit"
|
|
103
|
+
>
|
|
104
|
+
{isSubmitting ? 'Validating...' : 'Continue'}
|
|
105
|
+
</button>
|
|
106
|
+
</form>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -1,19 +1,54 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { appendSessionContentByWorkItem, getSessionContentByWorkItem, ConversationTurn } from '@/lib/db';
|
|
6
|
+
import {
|
|
7
|
+
getOrCreateProcess,
|
|
8
|
+
sendMessage as sendProcessMessage,
|
|
9
|
+
killProcess,
|
|
10
|
+
} from '@/lib/claude-process-manager';
|
|
4
11
|
|
|
5
12
|
// Import worktree facade for worktree management
|
|
6
13
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
7
14
|
const worktreeFacade = require('../../../../../../../lib/worktree-facade');
|
|
8
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Get the project root path for Claude CLI operations.
|
|
18
|
+
* In packaged Electron apps, process.cwd() returns the app bundle's Resources directory,
|
|
19
|
+
* so we use JETTYPOD_PROJECT_PATH env var which is set correctly by the Electron main process.
|
|
20
|
+
*/
|
|
21
|
+
function getProjectRoot(): string {
|
|
22
|
+
return process.env.JETTYPOD_PROJECT_PATH || path.resolve(process.cwd());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the settings path if it exists, otherwise return undefined.
|
|
27
|
+
* Claude CLI can run without explicit settings, so this is optional.
|
|
28
|
+
*/
|
|
29
|
+
function getSettingsPath(projectRoot: string): string | undefined {
|
|
30
|
+
const settingsPath = path.join(projectRoot, '.claude/settings.json');
|
|
31
|
+
return fs.existsSync(settingsPath) ? settingsPath : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
export const dynamic = 'force-dynamic';
|
|
10
35
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Build a context restoration prefix from stored conversation history.
|
|
38
|
+
* Prepended to the user's message when a Claude process was respawned.
|
|
39
|
+
*/
|
|
40
|
+
function buildContextPrefix(history: ConversationTurn[]): string {
|
|
41
|
+
if (history.length === 0) return '';
|
|
42
|
+
|
|
43
|
+
const relevant = history.filter(t => t.role === 'user' || t.role === 'assistant');
|
|
44
|
+
if (relevant.length === 0) return '';
|
|
45
|
+
|
|
46
|
+
const lines = relevant.map(t => {
|
|
47
|
+
const label = t.role === 'user' ? 'User' : 'Assistant';
|
|
48
|
+
return `${label}: ${t.content}`;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
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`;
|
|
17
52
|
}
|
|
18
53
|
|
|
19
54
|
function isClaudeCliAvailable(): boolean {
|
|
@@ -26,25 +61,16 @@ function isValidWorkItemId(id: string): boolean {
|
|
|
26
61
|
return !isNaN(parsed) && parsed > 0 && String(parsed) === id;
|
|
27
62
|
}
|
|
28
63
|
|
|
29
|
-
function buildConversationContext(history: ConversationMessage[]): string {
|
|
30
|
-
return history
|
|
31
|
-
.filter(msg => msg.type === 'user' || msg.type === 'assistant' || msg.type === 'text')
|
|
32
|
-
.map(msg => {
|
|
33
|
-
if (msg.type === 'user') {
|
|
34
|
-
return `User: ${msg.content}`;
|
|
35
|
-
} else {
|
|
36
|
-
return `Assistant: ${msg.content}`;
|
|
37
|
-
}
|
|
38
|
-
})
|
|
39
|
-
.join('\n\n');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
64
|
export async function POST(
|
|
43
65
|
request: NextRequest,
|
|
44
66
|
{ params }: { params: Promise<{ workItemId: string }> }
|
|
45
67
|
) {
|
|
46
68
|
const { workItemId } = await params;
|
|
47
69
|
|
|
70
|
+
// Check for debug mode - shows synthetic messages for troubleshooting (#1000104)
|
|
71
|
+
const { searchParams } = new URL(request.url);
|
|
72
|
+
const showSynthetic = searchParams.get('debug') === 'true';
|
|
73
|
+
|
|
48
74
|
// Validate work item ID
|
|
49
75
|
if (!isValidWorkItemId(workItemId)) {
|
|
50
76
|
return NextResponse.json(
|
|
@@ -61,9 +87,9 @@ export async function POST(
|
|
|
61
87
|
);
|
|
62
88
|
}
|
|
63
89
|
|
|
64
|
-
// Get the message
|
|
90
|
+
// Get the message (conversationHistory no longer needed - persistent process maintains context)
|
|
65
91
|
const body = await request.json().catch(() => ({}));
|
|
66
|
-
const { message
|
|
92
|
+
const { message } = body;
|
|
67
93
|
|
|
68
94
|
if (!message || typeof message !== 'string') {
|
|
69
95
|
return NextResponse.json(
|
|
@@ -72,68 +98,265 @@ export async function POST(
|
|
|
72
98
|
);
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
// Handle empty/whitespace-only input gracefully
|
|
102
|
+
const trimmedMessage = message.trim();
|
|
103
|
+
if (!trimmedMessage) {
|
|
104
|
+
// Save empty user message to session content
|
|
105
|
+
const workItemIdNum = parseInt(workItemId, 10);
|
|
106
|
+
appendSessionContentByWorkItem(workItemIdNum, {
|
|
107
|
+
role: 'user',
|
|
108
|
+
content: message,
|
|
109
|
+
timestamp: new Date().toISOString()
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Return a helpful response without invoking Claude
|
|
113
|
+
const clarificationMessage = 'I didn\'t catch that. What would you like me to help with?';
|
|
114
|
+
|
|
115
|
+
// Save assistant response to session content
|
|
116
|
+
appendSessionContentByWorkItem(workItemIdNum, {
|
|
117
|
+
role: 'assistant',
|
|
118
|
+
content: clarificationMessage,
|
|
119
|
+
timestamp: new Date().toISOString()
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const encoder = new TextEncoder();
|
|
123
|
+
const emptyInputResponse = new ReadableStream({
|
|
124
|
+
start(controller) {
|
|
125
|
+
const response = {
|
|
126
|
+
type: 'assistant',
|
|
127
|
+
message: {
|
|
128
|
+
content: [{ type: 'text', text: clarificationMessage }]
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(response)}\n\n`));
|
|
132
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 0 })}\n\n`));
|
|
133
|
+
controller.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return new Response(emptyInputResponse, {
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'text/event-stream',
|
|
140
|
+
'Cache-Control': 'no-cache',
|
|
141
|
+
'Connection': 'keep-alive',
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Save user message to session content
|
|
147
|
+
const workItemIdNum = parseInt(workItemId, 10);
|
|
148
|
+
appendSessionContentByWorkItem(workItemIdNum, {
|
|
149
|
+
role: 'user',
|
|
150
|
+
content: message,
|
|
151
|
+
timestamp: new Date().toISOString()
|
|
152
|
+
});
|
|
153
|
+
|
|
75
154
|
// Get or create worktree for this work item
|
|
76
155
|
const workItem = {
|
|
77
156
|
id: parseInt(workItemId, 10),
|
|
78
157
|
title: `Work item ${workItemId}`
|
|
79
158
|
};
|
|
80
159
|
|
|
81
|
-
const repoPath =
|
|
160
|
+
const repoPath = getProjectRoot();
|
|
82
161
|
const workResult = await worktreeFacade.startWork(workItem, { repoPath });
|
|
83
162
|
const claudeCwd = workResult.path;
|
|
163
|
+
const settingsPath = getSettingsPath(repoPath);
|
|
164
|
+
|
|
165
|
+
// Use workItemId prefixed with 'wi-' to avoid collision with standalone session IDs
|
|
166
|
+
const processSessionId = `wi-${workItemId}`;
|
|
167
|
+
|
|
168
|
+
// Get or create persistent Claude process for this work item
|
|
169
|
+
const processResult = getOrCreateProcess(processSessionId, claudeCwd, settingsPath);
|
|
170
|
+
|
|
171
|
+
// Check if we hit the process limit
|
|
172
|
+
if ('error' in processResult) {
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
{ type: 'error', message: processResult.error },
|
|
175
|
+
{ status: 503 }
|
|
176
|
+
);
|
|
177
|
+
}
|
|
84
178
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
179
|
+
const { emitter, isNew } = processResult;
|
|
180
|
+
|
|
181
|
+
// If process was just spawned for an existing session, restore conversation context
|
|
182
|
+
let messageToSend = trimmedMessage;
|
|
183
|
+
if (isNew) {
|
|
184
|
+
const history = getSessionContentByWorkItem(workItemIdNum);
|
|
185
|
+
// Exclude the message we just appended (last user turn)
|
|
186
|
+
const priorHistory = history.slice(0, -1);
|
|
187
|
+
const prefix = buildContextPrefix(priorHistory);
|
|
188
|
+
if (prefix) {
|
|
189
|
+
messageToSend = prefix + trimmedMessage;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
90
192
|
|
|
91
193
|
// Create a readable stream for SSE
|
|
92
194
|
const encoder = new TextEncoder();
|
|
93
195
|
|
|
94
196
|
const stream = new ReadableStream({
|
|
95
197
|
start(controller) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
198
|
+
// Collect assistant response text for saving to session content
|
|
199
|
+
let assistantResponse = '';
|
|
200
|
+
let responseComplete = false;
|
|
201
|
+
|
|
202
|
+
// Track if we've saved the response (to avoid duplicate saves)
|
|
203
|
+
let responseSaved = false;
|
|
204
|
+
|
|
205
|
+
// Helper to save assistant response if not already saved
|
|
206
|
+
const saveAssistantResponse = () => {
|
|
207
|
+
if (!responseSaved && assistantResponse.trim()) {
|
|
208
|
+
appendSessionContentByWorkItem(workItemIdNum, {
|
|
209
|
+
role: 'assistant',
|
|
210
|
+
content: assistantResponse.trim(),
|
|
211
|
+
timestamp: new Date().toISOString()
|
|
212
|
+
});
|
|
213
|
+
responseSaved = true;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Handle data from the persistent process
|
|
218
|
+
const onData = (parsed: Record<string, unknown>) => {
|
|
219
|
+
if (responseComplete) return;
|
|
220
|
+
|
|
221
|
+
// Skip synthetic messages (skill prompt injections) unless in debug mode (#1000104)
|
|
222
|
+
// These are internal system prompts that shouldn't normally appear in the conversation
|
|
223
|
+
if (parsed.isSynthetic === true && !showSynthetic) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Collect text content for session storage
|
|
228
|
+
if (parsed.type === 'assistant' && parsed.message) {
|
|
229
|
+
const msg = parsed.message as { content?: Array<{ type: string; text?: string; name?: string }> };
|
|
230
|
+
if (msg.content) {
|
|
231
|
+
for (const block of msg.content) {
|
|
232
|
+
if (block.type === 'text' && block.text) {
|
|
233
|
+
if (assistantResponse && !assistantResponse.endsWith('\n')) {
|
|
234
|
+
assistantResponse += '\n\n';
|
|
235
|
+
}
|
|
236
|
+
assistantResponse += block.text;
|
|
237
|
+
}
|
|
238
|
+
// When Claude invokes the Skill tool, save accumulated response immediately
|
|
239
|
+
// This prevents losing content when skills are invoked and the turn ends differently
|
|
240
|
+
if (block.type === 'tool_use' && block.name === 'Skill') {
|
|
241
|
+
saveAssistantResponse();
|
|
242
|
+
// Reset for potential post-skill content (Bug #1000097)
|
|
243
|
+
// Without this, responseSaved=true blocks saving any content after the skill
|
|
244
|
+
assistantResponse = '';
|
|
245
|
+
responseSaved = false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else if (parsed.type === 'content_block_delta') {
|
|
250
|
+
const delta = parsed.delta as { text?: string } | undefined;
|
|
251
|
+
if (delta?.text) {
|
|
252
|
+
assistantResponse += delta.text;
|
|
117
253
|
}
|
|
118
254
|
}
|
|
119
|
-
});
|
|
120
255
|
|
|
121
|
-
|
|
122
|
-
|
|
256
|
+
// Check for result message - this indicates Claude is done with this turn
|
|
257
|
+
if (parsed.type === 'result') {
|
|
258
|
+
responseComplete = true;
|
|
259
|
+
|
|
260
|
+
// Save assistant response to session content (if not already saved)
|
|
261
|
+
saveAssistantResponse();
|
|
262
|
+
|
|
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`));
|
|
268
|
+
|
|
269
|
+
// Remove listeners and close
|
|
270
|
+
emitter.off('data', onData);
|
|
271
|
+
emitter.off('error', onError);
|
|
272
|
+
emitter.off('close', onClose);
|
|
273
|
+
controller.close();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
|
|
123
278
|
controller.enqueue(encoder.encode(sseData));
|
|
124
|
-
}
|
|
279
|
+
};
|
|
125
280
|
|
|
126
|
-
|
|
127
|
-
|
|
281
|
+
const onError = (err: { type: string; content: string }) => {
|
|
282
|
+
if (responseComplete) return;
|
|
283
|
+
// Persist error to database (#1000098)
|
|
284
|
+
appendSessionContentByWorkItem(workItemIdNum, {
|
|
285
|
+
role: 'error',
|
|
286
|
+
content: err.content,
|
|
287
|
+
timestamp: new Date().toISOString()
|
|
288
|
+
});
|
|
289
|
+
const sseData = `data: ${JSON.stringify({ type: 'error', content: err.content })}\n\n`;
|
|
128
290
|
controller.enqueue(encoder.encode(sseData));
|
|
129
|
-
|
|
130
|
-
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const onClose = (info: { exitCode: number }) => {
|
|
294
|
+
if (responseComplete) return;
|
|
295
|
+
responseComplete = true;
|
|
296
|
+
|
|
297
|
+
// Save any collected response (if not already saved)
|
|
298
|
+
saveAssistantResponse();
|
|
131
299
|
|
|
132
|
-
|
|
133
|
-
const sseData = `data: ${JSON.stringify({ type: 'error', content: err.message })}\n\n`;
|
|
300
|
+
const sseData = `data: ${JSON.stringify({ type: 'done', exitCode: info.exitCode })}\n\n`;
|
|
134
301
|
controller.enqueue(encoder.encode(sseData));
|
|
135
302
|
controller.close();
|
|
136
|
-
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Attach listeners
|
|
306
|
+
emitter.on('data', onData);
|
|
307
|
+
emitter.on('error', onError);
|
|
308
|
+
emitter.on('close', onClose);
|
|
309
|
+
|
|
310
|
+
// Send the message to the persistent process
|
|
311
|
+
let sent = sendProcessMessage(processSessionId, messageToSend);
|
|
312
|
+
|
|
313
|
+
// If send failed, the process may have died after getOrCreateProcess
|
|
314
|
+
// Try to create a fresh process and retry once
|
|
315
|
+
if (!sent) {
|
|
316
|
+
// Kill any zombie process and create fresh one
|
|
317
|
+
killProcess(processSessionId);
|
|
318
|
+
|
|
319
|
+
const retryResult = getOrCreateProcess(processSessionId, claudeCwd, settingsPath);
|
|
320
|
+
if ('error' in retryResult) {
|
|
321
|
+
// Hit process limit on retry - return error
|
|
322
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
323
|
+
type: 'error',
|
|
324
|
+
content: retryResult.error
|
|
325
|
+
})}\n\n`));
|
|
326
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
|
|
327
|
+
controller.close();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const { emitter: newEmitter } = retryResult;
|
|
331
|
+
|
|
332
|
+
// 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);
|
|
339
|
+
|
|
340
|
+
// Retry send
|
|
341
|
+
sent = sendProcessMessage(processSessionId, messageToSend);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!sent) {
|
|
345
|
+
// Still failed after retry - give clear error
|
|
346
|
+
const errorContent = 'Claude process is unavailable. The process may have crashed or failed to start. Please try again.';
|
|
347
|
+
// Persist error to database (#1000098)
|
|
348
|
+
appendSessionContentByWorkItem(workItemIdNum, {
|
|
349
|
+
role: 'error',
|
|
350
|
+
content: errorContent,
|
|
351
|
+
timestamp: new Date().toISOString()
|
|
352
|
+
});
|
|
353
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
354
|
+
type: 'error',
|
|
355
|
+
content: errorContent
|
|
356
|
+
})}\n\n`));
|
|
357
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', exitCode: 1 })}\n\n`));
|
|
358
|
+
controller.close();
|
|
359
|
+
}
|
|
137
360
|
},
|
|
138
361
|
});
|
|
139
362
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { pinSession, unpinSession } from '@/lib/claude-process-manager';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
5
|
+
|
|
6
|
+
// POST /api/claude/[workItemId]/pin - Pin work item session to prevent idle cleanup
|
|
7
|
+
export async function POST(
|
|
8
|
+
_request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ workItemId: string }> }
|
|
10
|
+
) {
|
|
11
|
+
const { workItemId } = await params;
|
|
12
|
+
pinSession(`wi-${workItemId}`);
|
|
13
|
+
return NextResponse.json({ pinned: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// DELETE /api/claude/[workItemId]/pin - Unpin work item session to allow idle cleanup
|
|
17
|
+
export async function DELETE(
|
|
18
|
+
_request: NextRequest,
|
|
19
|
+
{ params }: { params: Promise<{ workItemId: string }> }
|
|
20
|
+
) {
|
|
21
|
+
const { workItemId } = await params;
|
|
22
|
+
unpinSession(`wi-${workItemId}`);
|
|
23
|
+
return NextResponse.json({ pinned: false });
|
|
24
|
+
}
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'child_process';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
4
5
|
import { registerSession, updateSessionStatus } from '@/lib/db';
|
|
5
6
|
|
|
6
7
|
// Import worktree facade for worktree management
|
|
7
8
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
8
9
|
const worktreeFacade = require('../../../../../../lib/worktree-facade');
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Get the project root path for Claude CLI operations.
|
|
13
|
+
* In packaged Electron apps, process.cwd() returns the app bundle's Resources directory,
|
|
14
|
+
* so we use JETTYPOD_PROJECT_PATH env var which is set correctly by the Electron main process.
|
|
15
|
+
*/
|
|
16
|
+
function getProjectRoot(): string {
|
|
17
|
+
return process.env.JETTYPOD_PROJECT_PATH || path.resolve(process.cwd());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the settings path if it exists, otherwise return undefined.
|
|
22
|
+
* Claude CLI can run without explicit settings, so this is optional.
|
|
23
|
+
*/
|
|
24
|
+
function getSettingsPath(projectRoot: string): string | undefined {
|
|
25
|
+
const settingsPath = path.join(projectRoot, '.claude/settings.json');
|
|
26
|
+
return fs.existsSync(settingsPath) ? settingsPath : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
export const dynamic = 'force-dynamic';
|
|
11
30
|
|
|
12
31
|
function isClaudeCliAvailable(): boolean {
|
|
@@ -54,8 +73,8 @@ export async function POST(
|
|
|
54
73
|
title: title || `Work item ${workItemId}`
|
|
55
74
|
};
|
|
56
75
|
|
|
57
|
-
// Determine the repo path
|
|
58
|
-
const repoPath =
|
|
76
|
+
// Determine the repo path - use env var in packaged apps, fallback to cwd
|
|
77
|
+
const repoPath = getProjectRoot();
|
|
59
78
|
|
|
60
79
|
// Check for existing worktree or create one
|
|
61
80
|
const workResult = await worktreeFacade.startWork(workItem, { repoPath });
|
|
@@ -79,12 +98,18 @@ Please start working on this ${workItemType}. Use the appropriate tools to imple
|
|
|
79
98
|
const stream = new ReadableStream({
|
|
80
99
|
start(controller) {
|
|
81
100
|
// Spawn Claude CLI with streaming JSON output in the worktree directory
|
|
82
|
-
|
|
101
|
+
// Use bypassPermissions mode + explicit settings path (if exists) to enable hooks while avoiding prompts
|
|
102
|
+
const settingsPath = getSettingsPath(repoPath);
|
|
103
|
+
const claudeArgs = [
|
|
83
104
|
'-p', prompt,
|
|
84
105
|
'--output-format', 'stream-json',
|
|
85
106
|
'--verbose',
|
|
86
|
-
'--
|
|
87
|
-
]
|
|
107
|
+
'--permission-mode', 'bypassPermissions',
|
|
108
|
+
];
|
|
109
|
+
if (settingsPath) {
|
|
110
|
+
claudeArgs.push('--settings', settingsPath);
|
|
111
|
+
}
|
|
112
|
+
const claude = spawn('claude', claudeArgs, {
|
|
88
113
|
cwd: claudeCwd,
|
|
89
114
|
env: { ...process.env },
|
|
90
115
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSessionContent, getSessionContentByWorkItem } from '@/lib/db';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
5
|
+
|
|
6
|
+
function isValidSessionId(id: string): boolean {
|
|
7
|
+
const parsed = parseInt(id, 10);
|
|
8
|
+
return !isNaN(parsed) && parsed > 0 && String(parsed) === id;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// GET /api/claude/sessions/[sessionId]/content - Get session conversation history
|
|
12
|
+
// Use ?by=workitem to look up by work_item_id instead of session id
|
|
13
|
+
export async function GET(
|
|
14
|
+
request: NextRequest,
|
|
15
|
+
{ params }: { params: Promise<{ sessionId: string }> }
|
|
16
|
+
) {
|
|
17
|
+
const { sessionId } = await params;
|
|
18
|
+
const { searchParams } = new URL(request.url);
|
|
19
|
+
const lookupBy = searchParams.get('by');
|
|
20
|
+
|
|
21
|
+
if (!isValidSessionId(sessionId)) {
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: 'Invalid session ID' },
|
|
24
|
+
{ status: 400 }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const id = parseInt(sessionId, 10);
|
|
30
|
+
const dbContent = lookupBy === 'workitem'
|
|
31
|
+
? getSessionContentByWorkItem(id)
|
|
32
|
+
: getSessionContent(id);
|
|
33
|
+
|
|
34
|
+
// Transform DB format (role) to frontend format (type)
|
|
35
|
+
// DB stores: { role: 'user'|'assistant'|'error', content, timestamp }
|
|
36
|
+
// Frontend expects: { type: 'user'|'assistant'|'error', content, timestamp }
|
|
37
|
+
// Includes error messages persisted during conversation (#1000098, #1000099)
|
|
38
|
+
const content = dbContent.map(msg => ({
|
|
39
|
+
type: msg.role,
|
|
40
|
+
content: msg.content,
|
|
41
|
+
timestamp: msg.timestamp,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
return NextResponse.json({ content });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error('Failed to get session content:', error);
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: 'Failed to get session content' },
|
|
49
|
+
{ status: 500 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|