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.
Files changed (121) hide show
  1. package/.gitattributes +2 -0
  2. package/.jettypod-backup/work.db +0 -0
  3. package/apps/dashboard/app/access-code/page.tsx +110 -0
  4. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +283 -60
  5. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +24 -0
  6. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +30 -5
  7. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +52 -0
  8. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +299 -61
  9. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +24 -0
  10. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +34 -0
  11. package/apps/dashboard/app/api/claude/sessions/route.ts +116 -15
  12. package/apps/dashboard/app/api/internal/set-project/route.ts +17 -0
  13. package/apps/dashboard/app/api/settings/env-vars/route.ts +125 -0
  14. package/apps/dashboard/app/api/settings/general/route.ts +21 -0
  15. package/apps/dashboard/app/api/tests/route.ts +9 -0
  16. package/apps/dashboard/app/api/tests/run/route.ts +82 -0
  17. package/apps/dashboard/app/api/tests/run/stream/route.ts +59 -0
  18. package/apps/dashboard/app/api/tests/undefined/route.ts +9 -0
  19. package/apps/dashboard/app/api/work/[id]/description/route.ts +21 -0
  20. package/apps/dashboard/app/api/work/[id]/status/route.ts +2 -2
  21. package/apps/dashboard/app/demo/gates/page.tsx +653 -0
  22. package/apps/dashboard/app/install-claude/page.tsx +56 -0
  23. package/apps/dashboard/app/layout.tsx +7 -2
  24. package/apps/dashboard/app/page.tsx +48 -37
  25. package/apps/dashboard/app/settings/page.tsx +27 -0
  26. package/apps/dashboard/app/tests/page.tsx +5 -68
  27. package/apps/dashboard/app/welcome/page.tsx +84 -0
  28. package/apps/dashboard/app/work/[id]/page.tsx +10 -14
  29. package/apps/dashboard/build-resources/entitlements.mac.plist +27 -0
  30. package/apps/dashboard/build-resources/icon.png +0 -0
  31. package/apps/dashboard/components/AppShell.tsx +91 -0
  32. package/apps/dashboard/components/CardMenu.tsx +91 -63
  33. package/apps/dashboard/components/ClaudePanel.tsx +759 -101
  34. package/apps/dashboard/components/ClaudePanelInput.tsx +90 -13
  35. package/apps/dashboard/components/DragContext.tsx +228 -180
  36. package/apps/dashboard/components/DraggableCard.tsx +48 -45
  37. package/apps/dashboard/components/DropZone.tsx +15 -2
  38. package/apps/dashboard/components/EditableDetailDescription.tsx +102 -0
  39. package/apps/dashboard/components/EditableDetailTitle.tsx +25 -0
  40. package/apps/dashboard/components/EditableTitle.tsx +15 -3
  41. package/apps/dashboard/components/GateCard.tsx +270 -0
  42. package/apps/dashboard/components/GateChoiceCard.tsx +104 -0
  43. package/apps/dashboard/components/InstallClaudeScreen.tsx +91 -0
  44. package/apps/dashboard/components/KanbanBoard.tsx +359 -58
  45. package/apps/dashboard/components/MainNav.tsx +133 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +246 -0
  47. package/apps/dashboard/components/ProjectSwitcher.tsx +165 -0
  48. package/apps/dashboard/components/PrototypeTimeline.tsx +262 -0
  49. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +270 -438
  50. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +697 -0
  51. package/apps/dashboard/components/WaveCompletionAnimation.tsx +142 -0
  52. package/apps/dashboard/components/WelcomeScreen.tsx +92 -0
  53. package/apps/dashboard/components/settings/EnvVarsSection.tsx +413 -0
  54. package/apps/dashboard/components/settings/GeneralSection.tsx +146 -0
  55. package/apps/dashboard/components/settings/SettingsLayout.tsx +39 -0
  56. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +1051 -0
  57. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +31 -0
  58. package/apps/dashboard/electron/ipc-handlers.js +832 -0
  59. package/apps/dashboard/electron/main.js +1746 -0
  60. package/apps/dashboard/electron/preload.js +104 -0
  61. package/apps/dashboard/electron-builder.config.js +359 -0
  62. package/apps/dashboard/hooks/useClaudeSessions.ts +2 -2
  63. package/apps/dashboard/hooks/useWebSocket.ts +8 -2
  64. package/apps/dashboard/lib/claude-process-manager.ts +490 -0
  65. package/apps/dashboard/lib/db-bridge.ts +250 -0
  66. package/apps/dashboard/lib/db.ts +765 -299
  67. package/apps/dashboard/lib/message-buffer.ts +264 -0
  68. package/apps/dashboard/lib/prototypes.ts +202 -0
  69. package/apps/dashboard/lib/run-migrations.js +138 -0
  70. package/apps/dashboard/lib/session-state-machine.ts +297 -0
  71. package/apps/dashboard/lib/session-state-utils.ts +274 -0
  72. package/apps/dashboard/lib/session-stream-manager.ts +760 -0
  73. package/apps/dashboard/lib/stream-manager-registry.ts +424 -0
  74. package/apps/dashboard/lib/test-results-db.ts +307 -0
  75. package/apps/dashboard/lib/tests.ts +100 -21
  76. package/apps/dashboard/{next.config.ts → next.config.js} +11 -5
  77. package/apps/dashboard/package.json +21 -2
  78. package/apps/dashboard/public/assets/wave-completion.mp4 +0 -0
  79. package/apps/dashboard/public/jettypod_wordmark.png +0 -0
  80. package/apps/dashboard/public/jettypod_wordmark.svg +3 -0
  81. package/apps/dashboard/scripts/download-node.js +104 -0
  82. package/bin/jettypod +22 -0
  83. package/claude-hooks/enforce-skill-activation.js +132 -40
  84. package/cucumber.js +1 -1
  85. package/jettypod.js +432 -377
  86. package/lib/config.js +28 -0
  87. package/lib/database.js +14 -112
  88. package/lib/discovery-checkpoint.js +41 -1
  89. package/lib/footer.js +5 -0
  90. package/lib/hello.js +8 -0
  91. package/lib/merge-lock.js +175 -11
  92. package/lib/migrations/023-session-content-column.js +93 -0
  93. package/lib/migrations/024-session-orphaned-status.js +135 -0
  94. package/lib/migrations/025-test-results-tables.js +99 -0
  95. package/lib/migrations/026-rejection-reason-columns.js +49 -0
  96. package/lib/schema.js +105 -0
  97. package/lib/seed-onboarding.js +165 -0
  98. package/lib/skills/feature-planning/dry-run-validator.js +17 -22
  99. package/lib/work-commands/index.js +469 -106
  100. package/lib/work-tracking/index.js +75 -1
  101. package/lib/worktree-manager.js +2 -24
  102. package/package.json +1 -1
  103. package/scripts/rebuild-app.sh +51 -0
  104. package/skills-templates/bug-mode/SKILL.md +27 -3
  105. package/skills-templates/bug-planning/SKILL.md +6 -0
  106. package/skills-templates/chore-mode/SKILL.md +24 -0
  107. package/skills-templates/chore-planning/SKILL.md +6 -0
  108. package/skills-templates/epic-planning/SKILL.md +12 -2
  109. package/skills-templates/feature-planning/SKILL.md +6 -0
  110. package/skills-templates/production-mode/SKILL.md +20 -2
  111. package/skills-templates/project-discovery/SKILL.md +372 -0
  112. package/skills-templates/request-routing/SKILL.md +7 -1
  113. package/skills-templates/simple-improvement/SKILL.md +37 -26
  114. package/skills-templates/speed-mode/SKILL.md +26 -2
  115. package/skills-templates/stable-mode/SKILL.md +31 -1
  116. package/.jettypod-backup/work.db-shm +0 -0
  117. package/.jettypod-backup/work.db-wal +0 -0
  118. package/apps/dashboard/app/api/decisions/route.ts +0 -9
  119. package/apps/dashboard/components/RecentDecisionsWidget.tsx +0 -63
  120. package/apps/dashboard/hooks/useClaudeStream.ts +0 -386
  121. package/cucumber-results.json +0 -12970
package/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto-resolve generated files during merge by keeping the current branch version
2
+ cucumber-results.json merge=ours
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 { spawn, spawnSync } from 'child_process';
1
+ import { spawnSync } from 'child_process';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
3
  import path from 'path';
4
+ 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
- interface ConversationMessage {
12
- type: string;
13
- content?: string;
14
- tool_name?: string;
15
- tool_input?: Record<string, unknown>;
16
- result?: string;
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 and conversation history
90
+ // Get the message (conversationHistory no longer needed - persistent process maintains context)
65
91
  const body = await request.json().catch(() => ({}));
66
- const { message, conversationHistory = [] } = body;
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 = path.resolve(process.cwd());
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
- // Build prompt with conversation context
86
- const context = buildConversationContext(conversationHistory);
87
- const prompt = context
88
- ? `Previous conversation:\n${context}\n\nUser: ${message}\n\nPlease respond to the user's message. Continue working on the task if needed.`
89
- : message;
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
- const claude = spawn('claude', [
97
- '-p', prompt,
98
- '--output-format', 'stream-json',
99
- '--verbose',
100
- '--dangerously-skip-permissions'
101
- ], {
102
- cwd: claudeCwd,
103
- env: { ...process.env },
104
- stdio: ['ignore', 'pipe', 'pipe'],
105
- });
106
-
107
- claude.stdout.on('data', (data: Buffer) => {
108
- const lines = data.toString().split('\n').filter(line => line.trim());
109
- for (const line of lines) {
110
- try {
111
- const parsed = JSON.parse(line);
112
- const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
113
- controller.enqueue(encoder.encode(sseData));
114
- } catch {
115
- const sseData = `data: ${JSON.stringify({ type: 'text', content: line })}\n\n`;
116
- controller.enqueue(encoder.encode(sseData));
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
- claude.stderr.on('data', (data: Buffer) => {
122
- const sseData = `data: ${JSON.stringify({ type: 'error', content: data.toString() })}\n\n`;
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
- claude.on('close', (code) => {
127
- const sseData = `data: ${JSON.stringify({ type: 'done', exitCode: code })}\n\n`;
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
- controller.close();
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
- claude.on('error', (err) => {
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 (go up from the API route to project root)
58
- const repoPath = path.resolve(process.cwd());
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
- const claude = spawn('claude', [
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
- '--dangerously-skip-permissions'
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
+ }