jettypod 4.4.116 → 4.4.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -1,5 +1,8 @@
1
- const { app, BrowserWindow, Menu, dialog } = require('electron');
2
- const { spawn, execSync } = require('child_process');
1
+ const { app, BrowserWindow, Menu, dialog, shell, ipcMain } = require('electron');
2
+ const { spawn, exec, execFile } = require('child_process');
3
+ const { promisify } = require('util');
4
+ const execAsync = promisify(exec);
5
+ const execFileAsync = promisify(execFile);
3
6
  const path = require('path');
4
7
  const fs = require('fs');
5
8
  const http = require('http');
@@ -7,6 +10,7 @@ const { WebSocketServer } = require('ws');
7
10
  const { registerIpcHandlers, closeDb } = require('./ipc-handlers');
8
11
  const { ingestCucumberResults, closeIngesterDb } = require('./test-results-ingester');
9
12
  const { autoUpdater } = require('electron-updater');
13
+ const sessionManager = require('./session-manager');
10
14
 
11
15
  // Track child processes and servers for cleanup
12
16
  let nextProcess = null;
@@ -16,8 +20,11 @@ const windows = new Set(); // Track all open windows
16
20
  let wss = null;
17
21
  let dbPollInterval = null;
18
22
  let testResultsPollInterval = null;
23
+ let worktreeCleanupInterval = null;
19
24
  let lastDbMtimes = { db: null, wal: null };
20
25
  let lastTestResultsMtime = null;
26
+ // Map of worktree path → last mtime for cucumber-results.json
27
+ const worktreeTestResultsMtimes = new Map();
21
28
 
22
29
  // Track Claude processes by session ID
23
30
  const claudeProcesses = new Map();
@@ -32,7 +39,9 @@ const isPackaged = app.isPackaged;
32
39
  // Suppress noisy EGL GPU driver error logs (eglQueryDeviceAttribEXT: Bad attribute)
33
40
  app.commandLine.appendSwitch('enable-logging', 'stderr');
34
41
  app.commandLine.appendSwitch('log-level', '2');
35
- app.commandLine.appendSwitch('use-angle', 'metal');
42
+ // Disable hardware acceleration to prevent GPU process crashes (exit_code=512)
43
+ // that cause blank white screens. Software rendering is sufficient for this app.
44
+ app.disableHardwareAcceleration();
36
45
 
37
46
  // Last-selected project persistence (for dev mode)
38
47
  const LAST_SELECTED_PROJECT_PATH = path.join(
@@ -90,6 +99,26 @@ function hasValidProject(projectPath) {
90
99
  return fs.existsSync(dbPath);
91
100
  }
92
101
 
102
+ // Determine the correct start URL based on auth state, Claude Code, and project status.
103
+ // Used both at initial window creation and after OAuth callback.
104
+ function getStartUrl() {
105
+ if (isPackaged && !isAuthenticated()) {
106
+ const authPage = hasLoggedInBefore() ? '/login' : '/signup';
107
+ log(`No auth token, showing ${authPage} screen`);
108
+ return `http://localhost:3000${authPage}`;
109
+ } else if (!claudeCodeInstalled) {
110
+ log('Claude Code not installed, showing install screen');
111
+ return 'http://localhost:3000/install-claude';
112
+ } else if (!claudeCodeAuthenticated) {
113
+ log('Claude Code not authenticated, showing connect screen');
114
+ return 'http://localhost:3000/connect-claude';
115
+ } else {
116
+ const hasProject = hasValidProject(projectRoot);
117
+ log(`Project configured: ${hasProject}, loading: ${hasProject ? '/' : '/welcome'}`);
118
+ return hasProject ? 'http://localhost:3000' : 'http://localhost:3000/welcome';
119
+ }
120
+ }
121
+
93
122
  // Set the project root (called when user selects a project)
94
123
  async function setProjectRoot(newPath) {
95
124
  projectRoot = newPath;
@@ -145,8 +174,39 @@ function getMainWindow() {
145
174
  // WebSocket configuration
146
175
  const WS_PORT = 47808;
147
176
 
177
+ // Persistent log file for diagnostics
178
+ const LOG_DIR = path.join(app.getPath('home'), 'Library', 'Logs', 'jettypod');
179
+ const LOG_FILE = path.join(LOG_DIR, 'main.log');
180
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
181
+
182
+ let logStream = null;
183
+
184
+ function initLogFile() {
185
+ try {
186
+ fs.mkdirSync(LOG_DIR, { recursive: true });
187
+ // Rotate if too large
188
+ if (fs.existsSync(LOG_FILE)) {
189
+ const stats = fs.statSync(LOG_FILE);
190
+ if (stats.size > MAX_LOG_SIZE) {
191
+ const rotated = `${LOG_FILE}.1`;
192
+ if (fs.existsSync(rotated)) fs.unlinkSync(rotated);
193
+ fs.renameSync(LOG_FILE, rotated);
194
+ }
195
+ }
196
+ logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
197
+ } catch {
198
+ // Fall back to console-only logging
199
+ }
200
+ }
201
+
202
+ initLogFile();
203
+
148
204
  function log(msg) {
149
- console.log(`[Electron] ${msg}`);
205
+ const line = `[${new Date().toISOString()}] [Electron] ${msg}`;
206
+ console.log(line);
207
+ if (logStream) {
208
+ logStream.write(line + '\n');
209
+ }
150
210
  }
151
211
 
152
212
  /**
@@ -198,6 +258,7 @@ function getShellEnv() {
198
258
 
199
259
  // Common paths where npm/node might be installed (fallback for users who have it)
200
260
  const additionalPaths = [
261
+ `${home}/.claude/local`, // Official Claude Code install script
201
262
  '/usr/local/bin', // Homebrew (Intel Mac) / common location
202
263
  '/opt/homebrew/bin', // Homebrew (Apple Silicon)
203
264
  '/opt/homebrew/sbin',
@@ -252,7 +313,7 @@ function getShellEnv() {
252
313
  * - Orphaned processes on port 3000
253
314
  * - Orphaned next-router-worker processes
254
315
  */
255
- function cleanupStaleResources() {
316
+ async function cleanupStaleResources() {
256
317
  log('Cleaning up stale resources...');
257
318
 
258
319
  // 1. Remove Next.js lock file if it exists
@@ -266,63 +327,33 @@ function cleanupStaleResources() {
266
327
  }
267
328
  }
268
329
 
269
- // 2. Kill any process using port 3000 (Next.js dev server port)
270
- try {
271
- const pidsOnPort = execSync('lsof -ti:3000 2>/dev/null || true', { encoding: 'utf-8' }).trim();
272
- if (pidsOnPort) {
273
- const pids = pidsOnPort.split('\n').filter(Boolean);
330
+ // Helper to kill PIDs from a shell command
331
+ async function killPidsFromCommand(cmd, label) {
332
+ try {
333
+ const { stdout } = await execAsync(cmd, { encoding: 'utf-8' });
334
+ const pids = stdout.trim().split('\n').filter(Boolean);
274
335
  for (const pid of pids) {
336
+ if (parseInt(pid, 10) === process.pid) continue;
275
337
  try {
276
338
  process.kill(parseInt(pid, 10), 'SIGKILL');
277
- log(`Killed stale process on port 3000: PID ${pid}`);
339
+ log(`Killed ${label}: PID ${pid}`);
278
340
  } catch (killErr) {
279
- // Process may have already exited
280
341
  log(`Could not kill PID ${pid}: ${killErr.message}`);
281
342
  }
282
343
  }
344
+ } catch (err) {
345
+ // Command failed or no processes found - that's fine
283
346
  }
284
- } catch (err) {
285
- // lsof command failed or no processes found - that's fine
286
- log(`Port 3000 check: ${err.message || 'no stale processes'}`);
287
347
  }
288
348
 
349
+ // 2. Kill any process using port 3000 (Next.js dev server port)
350
+ await killPidsFromCommand('lsof -ti:3000 2>/dev/null || true', 'stale process on port 3000');
351
+
289
352
  // 3. Kill orphaned next-router-worker processes
290
- try {
291
- const routerWorkers = execSync('pgrep -f "next-router-worker" 2>/dev/null || true', { encoding: 'utf-8' }).trim();
292
- if (routerWorkers) {
293
- const pids = routerWorkers.split('\n').filter(Boolean);
294
- for (const pid of pids) {
295
- try {
296
- process.kill(parseInt(pid, 10), 'SIGKILL');
297
- log(`Killed orphaned next-router-worker: PID ${pid}`);
298
- } catch (killErr) {
299
- log(`Could not kill router worker PID ${pid}: ${killErr.message}`);
300
- }
301
- }
302
- }
303
- } catch (err) {
304
- // No orphaned workers - that's fine
305
- }
353
+ await killPidsFromCommand('pgrep -f "next-router-worker" 2>/dev/null || true', 'orphaned next-router-worker');
306
354
 
307
355
  // 4. Kill any orphaned node processes running our dev script
308
- try {
309
- const devProcesses = execSync(`pgrep -f "node.*next.*dev" 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
310
- if (devProcesses) {
311
- const pids = devProcesses.split('\n').filter(Boolean);
312
- for (const pid of pids) {
313
- // Don't kill ourselves
314
- if (parseInt(pid, 10) === process.pid) continue;
315
- try {
316
- process.kill(parseInt(pid, 10), 'SIGKILL');
317
- log(`Killed orphaned Next.js dev process: PID ${pid}`);
318
- } catch (killErr) {
319
- log(`Could not kill dev process PID ${pid}: ${killErr.message}`);
320
- }
321
- }
322
- }
323
- } catch (err) {
324
- // No orphaned processes - that's fine
325
- }
356
+ await killPidsFromCommand('pgrep -f "node.*next.*dev" 2>/dev/null || true', 'orphaned Next.js dev process');
326
357
 
327
358
  log('Stale resource cleanup complete');
328
359
  }
@@ -446,7 +477,7 @@ function startNextServer() {
446
477
  // (Moved from apps/ws-server/server.js)
447
478
 
448
479
  const wsClients = new Set();
449
- const DB_POLL_MS = 50; // Poll database every 50ms (same as lib/db-watcher.js)
480
+ const DB_POLL_MS = 500; // Poll database every 500ms (same as lib/db-watcher.js)
450
481
 
451
482
  function broadcastToClients(message) {
452
483
  const payload = JSON.stringify(message);
@@ -526,7 +557,102 @@ function startDatabasePolling() {
526
557
  }, DB_POLL_MS);
527
558
  }
528
559
 
560
+ // Get active worktree paths from SQLite
561
+ function getActiveWorktreePaths() {
562
+ if (!projectRoot) return [];
563
+ const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
564
+ if (!fs.existsSync(dbPath)) return [];
565
+ try {
566
+ const Database = require('better-sqlite3');
567
+ const db = new Database(dbPath, { readonly: true });
568
+ const rows = db.prepare("SELECT worktree_path FROM worktrees WHERE status = 'active'").all();
569
+ db.close();
570
+ return rows.map(r => r.worktree_path).filter(p => fs.existsSync(p));
571
+ } catch {
572
+ return [];
573
+ }
574
+ }
575
+
576
+ // Periodic worktree cleanup — catches orphaned worktrees from done/cancelled items
577
+ const WORKTREE_CLEANUP_MS = 60000; // 60 seconds
578
+
579
+ function startWorktreeCleanup() {
580
+ if (!projectRoot) {
581
+ log('[cleanup] No project selected, skipping worktree cleanup');
582
+ return;
583
+ }
584
+
585
+ log('[cleanup] Starting periodic worktree cleanup (every 60s)');
586
+
587
+ worktreeCleanupInterval = setInterval(() => {
588
+ if (!projectRoot) return;
589
+
590
+ const dbPath = path.join(projectRoot, '.jettypod', 'work.db');
591
+ if (!fs.existsSync(dbPath)) return;
592
+
593
+ try {
594
+ const Database = require('better-sqlite3');
595
+ const db = new Database(dbPath, { readonly: true });
596
+ const orphans = db.prepare(`
597
+ SELECT w.id as worktree_id, w.work_item_id, w.worktree_path, wi.status as work_status
598
+ FROM worktrees w
599
+ JOIN work_items wi ON w.work_item_id = wi.id
600
+ WHERE w.status IN ('active', 'merged')
601
+ AND wi.status IN ('done', 'cancelled')
602
+ `).all();
603
+ db.close();
604
+
605
+ if (orphans.length === 0) return;
606
+
607
+ log(`[cleanup] Found ${orphans.length} orphaned worktree(s), running cleanup...`);
608
+
609
+ // Shell out to jettypod CLI for the actual cleanup (non-blocking)
610
+ const cliPath = getBundledCliPath();
611
+ execAsync(`"${cliPath}" work cleanup`, { cwd: projectRoot, timeout: 30000 })
612
+ .then(({ stdout }) => {
613
+ if (stdout && stdout.trim()) {
614
+ log(`[cleanup] ${stdout.trim()}`);
615
+ }
616
+ })
617
+ .catch((err) => {
618
+ log(`[cleanup] Cleanup failed: ${err.message}`);
619
+ });
620
+ } catch {
621
+ // Silently ignore — will retry on next interval
622
+ }
623
+ }, WORKTREE_CLEANUP_MS);
624
+ }
625
+
626
+ // Check a single cucumber-results.json path for changes, ingest + broadcast if changed
627
+ function checkTestResultsFile(resultsPath, mtimeKey, getMtime, setMtime) {
628
+ if (!fs.existsSync(resultsPath)) {
629
+ if (getMtime() !== null) setMtime(null);
630
+ return;
631
+ }
632
+
633
+ const stats = fs.statSync(resultsPath);
634
+ if (stats.mtimeMs !== getMtime()) {
635
+ setMtime(stats.mtimeMs);
636
+ log(`[WS] Test results changed: ${resultsPath}`);
637
+
638
+ try {
639
+ const count = ingestCucumberResults(projectRoot, resultsPath);
640
+ if (count > 0) {
641
+ log(`[WS] Ingested ${count} test results into database`);
642
+ }
643
+ } catch (err) {
644
+ log(`[WS] Failed to ingest test results: ${err.message}`);
645
+ }
646
+
647
+ broadcastToClients({
648
+ type: 'test_change',
649
+ timestamp: Date.now()
650
+ });
651
+ }
652
+ }
653
+
529
654
  // Watch for test results file changes (cucumber-results.json)
655
+ // Monitors both projectRoot and all active worktrees
530
656
  function startTestResultsPolling() {
531
657
  if (!projectRoot) {
532
658
  log(`[WS] No project selected, skipping test results watcher`);
@@ -548,36 +674,55 @@ function startTestResultsPolling() {
548
674
  log(`[WS] Test results file not found at ${testResultsPath}, will watch for creation`);
549
675
  }
550
676
 
677
+ // Refresh worktree list periodically (every 10s via counter)
678
+ let worktreePaths = getActiveWorktreePaths();
679
+ let tickCount = 0;
680
+ const WORKTREE_REFRESH_TICKS = Math.round(10000 / DB_POLL_MS); // ~10 seconds
681
+
682
+ if (worktreePaths.length > 0) {
683
+ log(`[WS] Also watching ${worktreePaths.length} active worktree(s) for test results`);
684
+ }
685
+
551
686
  // Poll for test results changes
552
687
  testResultsPollInterval = setInterval(() => {
553
688
  try {
554
- if (!fs.existsSync(testResultsPath)) {
555
- // File doesn't exist yet - reset mtime so we detect when it's created
556
- if (lastTestResultsMtime !== null) {
557
- lastTestResultsMtime = null;
558
- }
559
- return;
689
+ // Check main project root
690
+ checkTestResultsFile(
691
+ testResultsPath,
692
+ 'root',
693
+ () => lastTestResultsMtime,
694
+ (v) => { lastTestResultsMtime = v; }
695
+ );
696
+
697
+ // Check active worktrees
698
+ for (const wtPath of worktreePaths) {
699
+ const wtResultsPath = path.join(wtPath, 'cucumber-results.json');
700
+ checkTestResultsFile(
701
+ wtResultsPath,
702
+ wtPath,
703
+ () => worktreeTestResultsMtimes.get(wtPath) ?? null,
704
+ (v) => {
705
+ if (v === null) {
706
+ worktreeTestResultsMtimes.delete(wtPath);
707
+ } else {
708
+ worktreeTestResultsMtimes.set(wtPath, v);
709
+ }
710
+ }
711
+ );
560
712
  }
561
713
 
562
- const stats = fs.statSync(testResultsPath);
563
- if (stats.mtimeMs !== lastTestResultsMtime) {
564
- lastTestResultsMtime = stats.mtimeMs;
565
- log(`[WS] Test results changed`);
566
-
567
- // Ingest results into SQLite before broadcasting
568
- try {
569
- const count = ingestCucumberResults(projectRoot);
570
- if (count > 0) {
571
- log(`[WS] Ingested ${count} test results into database`);
714
+ // Periodically refresh worktree list
715
+ tickCount++;
716
+ if (tickCount >= WORKTREE_REFRESH_TICKS) {
717
+ tickCount = 0;
718
+ const newPaths = getActiveWorktreePaths();
719
+ // Clean up mtimes for removed worktrees
720
+ for (const oldPath of worktreePaths) {
721
+ if (!newPaths.includes(oldPath)) {
722
+ worktreeTestResultsMtimes.delete(oldPath);
572
723
  }
573
- } catch (err) {
574
- log(`[WS] Failed to ingest test results: ${err.message}`);
575
724
  }
576
-
577
- broadcastToClients({
578
- type: 'test_change',
579
- timestamp: Date.now()
580
- });
725
+ worktreePaths = newPaths;
581
726
  }
582
727
  } catch {
583
728
  // File might be temporarily locked during writes - ignore
@@ -598,17 +743,45 @@ function restartPolling() {
598
743
  clearInterval(testResultsPollInterval);
599
744
  testResultsPollInterval = null;
600
745
  lastTestResultsMtime = null;
746
+ worktreeTestResultsMtimes.clear();
747
+ }
748
+ if (worktreeCleanupInterval) {
749
+ clearInterval(worktreeCleanupInterval);
750
+ worktreeCleanupInterval = null;
601
751
  }
602
752
 
603
753
  // Start polling for the new project
604
754
  startDatabasePolling();
605
755
  startTestResultsPolling();
756
+ startWorktreeCleanup();
757
+ }
758
+
759
+ function isPortInUse(port) {
760
+ return new Promise((resolve) => {
761
+ const net = require('net');
762
+ const server = net.createServer();
763
+ server.once('error', (err) => {
764
+ resolve(err.code === 'EADDRINUSE');
765
+ });
766
+ server.once('listening', () => {
767
+ server.close();
768
+ resolve(false);
769
+ });
770
+ server.listen(port);
771
+ });
606
772
  }
607
773
 
608
774
  function startEmbeddedWebSocketServer() {
609
- return new Promise((resolve, reject) => {
775
+ return new Promise(async (resolve, reject) => {
610
776
  log('Starting embedded WebSocket server...');
611
777
 
778
+ // Skip if standalone ws-server.js is already running (e.g. npm run dev)
779
+ if (await isPortInUse(WS_PORT)) {
780
+ log(`[WS] Port ${WS_PORT} already in use — standalone WS server detected, skipping`);
781
+ resolve();
782
+ return;
783
+ }
784
+
612
785
  try {
613
786
  wss = new WebSocketServer({ port: WS_PORT });
614
787
 
@@ -626,6 +799,7 @@ function startEmbeddedWebSocketServer() {
626
799
 
627
800
  ws.on('error', (error) => {
628
801
  log(`[WS] WebSocket error: ${error.message}`);
802
+ ws.close();
629
803
  wsClients.delete(ws);
630
804
  });
631
805
  });
@@ -637,6 +811,7 @@ function startEmbeddedWebSocketServer() {
637
811
 
638
812
  startDatabasePolling();
639
813
  startTestResultsPolling();
814
+ startWorktreeCleanup();
640
815
 
641
816
  log(`[WS] WebSocket server running on ws://localhost:${WS_PORT}`);
642
817
  resolve();
@@ -659,9 +834,16 @@ function stopWebSocketServer() {
659
834
  clearInterval(testResultsPollInterval);
660
835
  testResultsPollInterval = null;
661
836
  lastTestResultsMtime = null;
837
+ worktreeTestResultsMtimes.clear();
662
838
  log('[WS] Test results polling stopped');
663
839
  }
664
840
 
841
+ if (worktreeCleanupInterval) {
842
+ clearInterval(worktreeCleanupInterval);
843
+ worktreeCleanupInterval = null;
844
+ log('[cleanup] Worktree cleanup stopped');
845
+ }
846
+
665
847
  if (wss) {
666
848
  // Close all client connections
667
849
  for (const client of wsClients) {
@@ -768,6 +950,7 @@ function killClaude(sessionId) {
768
950
  }
769
951
 
770
952
  log(`[Claude:${sessionId}] Killing process`);
953
+ claudeProcess.stdin.destroy();
771
954
  claudeProcess.kill('SIGTERM');
772
955
  claudeProcesses.delete(sessionId);
773
956
  return { success: true };
@@ -777,6 +960,7 @@ function killAllClaudeProcesses() {
777
960
  log(`[Claude] Killing all ${claudeProcesses.size} processes`);
778
961
  for (const [sessionId, process] of claudeProcesses) {
779
962
  log(`[Claude] Killing session ${sessionId}`);
963
+ process.stdin.destroy();
780
964
  process.kill('SIGTERM');
781
965
  }
782
966
  claudeProcesses.clear();
@@ -948,7 +1132,10 @@ module.exports = {
948
1132
  writeLastSelectedProject,
949
1133
  installClaudeCode,
950
1134
  updateClaudeCode,
951
- getClaudeCodeInstalled
1135
+ getClaudeCodeInstalled,
1136
+ getClaudeCodeAuthenticated,
1137
+ checkClaudeCodeAuthenticated,
1138
+ loginClaudeCode
952
1139
  };
953
1140
 
954
1141
  // ==================== Application Menu ====================
@@ -1069,7 +1256,19 @@ function buildApplicationMenu() {
1069
1256
  {
1070
1257
  label: 'Check for Updates',
1071
1258
  click: async () => {
1259
+ const headers = sessionManager.getUpdaterHeaders();
1260
+ if (!headers.Authorization) {
1261
+ dialog.showMessageBox({
1262
+ type: 'warning',
1263
+ title: 'Update Check Unavailable',
1264
+ message: 'Not signed in.',
1265
+ detail: 'Please sign in to check for updates.',
1266
+ buttons: ['OK']
1267
+ });
1268
+ return;
1269
+ }
1072
1270
  log('[AutoUpdate] Manual check for updates...');
1271
+ autoUpdater.requestHeaders = headers;
1073
1272
  try {
1074
1273
  const result = await autoUpdater.checkForUpdates();
1075
1274
  if (result && result.updateInfo) {
@@ -1115,6 +1314,7 @@ function createWindow() {
1115
1314
  width: 1400,
1116
1315
  height: 900,
1117
1316
  title: 'JettyPod',
1317
+ backgroundColor: '#FAF9F7',
1118
1318
  webPreferences: {
1119
1319
  nodeIntegration: false,
1120
1320
  contextIsolation: true,
@@ -1124,24 +1324,7 @@ function createWindow() {
1124
1324
 
1125
1325
  windows.add(mainWindow);
1126
1326
 
1127
- // Determine start URL based on access code, Claude Code, and project status
1128
- let startUrl;
1129
- if (isPackaged && !isAccessGranted()) {
1130
- // Access code required - show access code screen (production only)
1131
- startUrl = 'http://localhost:3000/access-code';
1132
- log('Access not granted, showing access code screen');
1133
- } else if (!claudeCodeInstalled) {
1134
- // Claude Code is required - show install screen
1135
- startUrl = 'http://localhost:3000/install-claude';
1136
- log('Claude Code not installed, showing install screen');
1137
- } else {
1138
- // Check if we have a valid project configured
1139
- const hasProject = hasValidProject(projectRoot);
1140
- startUrl = hasProject ? 'http://localhost:3000' : 'http://localhost:3000/welcome';
1141
- log(`Project configured: ${hasProject}, loading: ${startUrl}`);
1142
- }
1143
-
1144
- mainWindow.loadURL(startUrl);
1327
+ mainWindow.loadURL(getStartUrl());
1145
1328
 
1146
1329
  // Open DevTools in development
1147
1330
  if (process.env.NODE_ENV === 'development') {
@@ -1201,6 +1384,7 @@ function createNewWindow() {
1201
1384
  width: 1400,
1202
1385
  height: 900,
1203
1386
  title: 'JettyPod',
1387
+ backgroundColor: '#FAF9F7',
1204
1388
  webPreferences: {
1205
1389
  nodeIntegration: false,
1206
1390
  contextIsolation: true,
@@ -1260,21 +1444,25 @@ function cleanup() {
1260
1444
 
1261
1445
  // ==================== Claude Code Detection ====================
1262
1446
 
1263
- // Track whether Claude Code is installed (checked on startup)
1447
+ // Track whether Claude Code is installed and authenticated (checked on startup)
1264
1448
  let claudeCodeInstalled = false;
1449
+ let claudeCodeAuthenticated = false;
1450
+ let claudeCodeNeedsUpdate = false;
1265
1451
 
1266
1452
  /**
1267
1453
  * Check if Claude Code CLI is installed
1268
1454
  * Uses 'which claude' to detect installation
1269
1455
  * @returns {boolean} true if Claude Code is installed
1270
1456
  */
1271
- function checkClaudeCodeInstalled() {
1457
+ async function checkClaudeCodeInstalled() {
1458
+ const shellEnv = getShellEnv();
1459
+ log(`[ClaudeCode] Checking with PATH: ${shellEnv.PATH}`);
1272
1460
  try {
1273
- execSync('which claude', { encoding: 'utf-8', stdio: 'pipe', env: getShellEnv() });
1274
- log('[ClaudeCode] Claude Code is installed');
1461
+ const { stdout } = await execFileAsync('which', ['claude'], { encoding: 'utf-8', env: shellEnv });
1462
+ log(`[ClaudeCode] Claude Code found at: ${stdout.trim()}`);
1275
1463
  return true;
1276
1464
  } catch {
1277
- log('[ClaudeCode] Claude Code is NOT installed');
1465
+ log('[ClaudeCode] Claude Code is NOT installed (not found in PATH)');
1278
1466
  return false;
1279
1467
  }
1280
1468
  }
@@ -1377,6 +1565,125 @@ function getClaudeCodeInstalled() {
1377
1565
  return claudeCodeInstalled;
1378
1566
  }
1379
1567
 
1568
+ function getClaudeCodeAuthenticated() {
1569
+ return claudeCodeAuthenticated;
1570
+ }
1571
+
1572
+ /**
1573
+ * Check if Claude Code CLI is authenticated using `claude auth status --json`.
1574
+ * This is a local check (no API call), so it returns instantly.
1575
+ * On any unexpected failure, defaults to false (shows connect screen)
1576
+ * so the user can authenticate rather than hitting a broken state.
1577
+ * @returns {boolean} true if Claude Code is authenticated
1578
+ */
1579
+ async function checkClaudeCodeAuthenticated() {
1580
+ if (!claudeCodeInstalled) return false;
1581
+ const shellEnv = getShellEnv();
1582
+ try {
1583
+ const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], {
1584
+ encoding: 'utf-8',
1585
+ env: shellEnv,
1586
+ timeout: 10000
1587
+ });
1588
+ const status = JSON.parse(stdout);
1589
+ if (status.loggedIn) {
1590
+ log('[ClaudeCode] Claude Code is authenticated');
1591
+ return true;
1592
+ }
1593
+ log('[ClaudeCode] Claude Code is NOT authenticated');
1594
+ return false;
1595
+ } catch (err) {
1596
+ const combined = (err.stderr || '') + (err.stdout || '') + (err.message || '');
1597
+
1598
+ if (combined.includes('needs an update') || combined.includes('needs_update')) {
1599
+ log('[ClaudeCode] CLI is outdated, flagging for auto-update');
1600
+ claudeCodeNeedsUpdate = true;
1601
+ return false;
1602
+ }
1603
+
1604
+ log(`[ClaudeCode] Auth check failed, defaulting to unauthenticated: ${combined}`);
1605
+ return false;
1606
+ }
1607
+ }
1608
+
1609
+ /**
1610
+ * Spawn `claude auth login` to trigger browser-based OAuth authentication.
1611
+ * The CLI prints an auth URL to stdout/stderr. We capture it and open it
1612
+ * with shell.openExternal() since the spawned process has no TTY.
1613
+ * @returns {Promise<{success: boolean, error?: string}>}
1614
+ */
1615
+ function loginClaudeCode() {
1616
+ return new Promise((resolve) => {
1617
+ const shellEnv = getShellEnv();
1618
+ log('[ClaudeCode] Starting claude auth login...');
1619
+
1620
+ const loginProcess = spawn(getUserShell(), ['-lc', 'claude auth login'], {
1621
+ env: shellEnv,
1622
+ stdio: 'pipe'
1623
+ });
1624
+
1625
+ let stdout = '';
1626
+ let stderr = '';
1627
+ let urlOpened = false;
1628
+
1629
+ const tryOpenUrl = (text) => {
1630
+ if (urlOpened) return;
1631
+ // Match OAuth/auth URLs the CLI prints for non-TTY environments
1632
+ const urlMatch = text.match(/https?:\/\/\S+/);
1633
+ if (urlMatch) {
1634
+ const url = urlMatch[0];
1635
+ log(`[ClaudeCode] Found auth URL, opening in browser: ${url}`);
1636
+ shell.openExternal(url);
1637
+ urlOpened = true;
1638
+ }
1639
+ };
1640
+
1641
+ loginProcess.stdout.on('data', (data) => {
1642
+ const chunk = data.toString();
1643
+ stdout += chunk;
1644
+ log(`[ClaudeCode] stdout: ${chunk.trim()}`);
1645
+ tryOpenUrl(chunk);
1646
+ });
1647
+
1648
+ loginProcess.stderr.on('data', (data) => {
1649
+ const chunk = data.toString();
1650
+ stderr += chunk;
1651
+ log(`[ClaudeCode] stderr: ${chunk.trim()}`);
1652
+ tryOpenUrl(chunk);
1653
+ });
1654
+
1655
+ loginProcess.on('close', async (code) => {
1656
+ if (code === 0) {
1657
+ claudeCodeAuthenticated = true;
1658
+ log('[ClaudeCode] Auth completed successfully');
1659
+ resolve({ success: true });
1660
+ } else if (stderr.includes('needs an update') || stderr.includes('needs_update') ||
1661
+ stdout.includes('needs an update') || stdout.includes('needs_update')) {
1662
+ log('[ClaudeCode] CLI outdated during login, running auto-update...');
1663
+ const updateResult = await updateClaudeCode();
1664
+ if (updateResult.success) {
1665
+ log('[ClaudeCode] Auto-update succeeded, retrying login');
1666
+ claudeCodeNeedsUpdate = false;
1667
+ // Retry login after update
1668
+ const retryResult = await loginClaudeCode();
1669
+ resolve(retryResult);
1670
+ } else {
1671
+ log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
1672
+ resolve({ success: false, error: 'Claude Code needs an update but the update failed. Please run "claude update" manually.' });
1673
+ }
1674
+ } else {
1675
+ log(`[ClaudeCode] Auth failed with code ${code}: ${stderr}`);
1676
+ resolve({ success: false, error: stderr || `Auth exited with code ${code}` });
1677
+ }
1678
+ });
1679
+
1680
+ loginProcess.on('error', (err) => {
1681
+ log(`[ClaudeCode] Auth spawn error: ${err.message}`);
1682
+ resolve({ success: false, error: err.message });
1683
+ });
1684
+ });
1685
+ }
1686
+
1380
1687
  // ==================== PATH Setup ====================
1381
1688
 
1382
1689
  const SYMLINK_PATH = '/usr/local/bin/jettypod';
@@ -1397,7 +1704,7 @@ function getBundledCliPath() {
1397
1704
  * Setup PATH by creating symlink to /usr/local/bin
1398
1705
  * Uses osascript to request admin privileges
1399
1706
  */
1400
- async function setupPathOnFirstLaunch() {
1707
+ async function setupCliSymlink() {
1401
1708
  // Only run on macOS
1402
1709
  if (process.platform !== 'darwin') {
1403
1710
  log('[PATH] Not macOS, skipping PATH setup');
@@ -1413,19 +1720,29 @@ async function setupPathOnFirstLaunch() {
1413
1720
  return;
1414
1721
  }
1415
1722
 
1416
- // Skip setup if symlink already exists (subsequent launches)
1417
- if (fs.existsSync(SYMLINK_PATH)) {
1418
- log('[PATH] Symlink already exists, skipping PATH setup');
1419
- return;
1723
+ // Check if symlink already points to the correct bundled CLI
1724
+ try {
1725
+ const currentTarget = fs.readlinkSync(SYMLINK_PATH);
1726
+ if (currentTarget === bundledPath) {
1727
+ log('[PATH] Symlink already points to bundled CLI, skipping');
1728
+ return;
1729
+ }
1730
+ log(`[PATH] Symlink points to ${currentTarget}, updating to bundled CLI`);
1731
+ } catch {
1732
+ // Symlink doesn't exist or isn't a symlink — create it
1733
+ log('[PATH] No existing symlink, creating');
1420
1734
  }
1421
1735
 
1422
1736
  log('[PATH] Setting up PATH with admin privileges...');
1423
1737
 
1424
- // Use osascript to run ln with admin privileges
1425
- const script = `do shell script "ln -sf '${bundledPath}' '${SYMLINK_PATH}'" with administrator privileges`;
1738
+ // Use osascript to run ln with admin privileges.
1739
+ // mkdir -p ensures the parent directory exists (e.g. /usr/local/bin may not
1740
+ // exist on fresh Apple Silicon Macs which use /opt/homebrew/bin by default).
1741
+ const symlinkDir = path.dirname(SYMLINK_PATH);
1742
+ const script = `do shell script "mkdir -p '${symlinkDir}' && ln -sf '${bundledPath}' '${SYMLINK_PATH}'" with administrator privileges`;
1426
1743
 
1427
1744
  try {
1428
- execSync(`osascript -e '${script}'`, { encoding: 'utf-8' });
1745
+ await execAsync(`osascript -e '${script}'`, { encoding: 'utf-8' });
1429
1746
  log('[PATH] Symlink created successfully');
1430
1747
  } catch (error) {
1431
1748
  // User cancelled or error occurred
@@ -1433,35 +1750,117 @@ async function setupPathOnFirstLaunch() {
1433
1750
  }
1434
1751
  }
1435
1752
 
1436
- // ==================== Access Code Gating ====================
1753
+ // ==================== Subscription & Access ====================
1437
1754
 
1438
1755
  /**
1439
- * Get path to access-granted.json file
1756
+ * Get path to subscription.json file
1440
1757
  * Stored in Electron's userData directory (~/.config/JettyPod/)
1441
1758
  */
1442
- function getAccessGrantedPath() {
1443
- return path.join(app.getPath('userData'), 'access-granted.json');
1759
+ function getSubscriptionPath() {
1760
+ return path.join(app.getPath('userData'), 'subscription.json');
1761
+ }
1762
+
1763
+ /**
1764
+ * Get the path to the auth state file.
1765
+ * Stored alongside subscription.json in Electron's userData directory.
1766
+ */
1767
+ function getAuthPath() {
1768
+ return path.join(app.getPath('userData'), 'auth.json');
1769
+ }
1770
+
1771
+ /**
1772
+ * Check if there's a stored auth token (JWT).
1773
+ * Does NOT validate the token — just checks if auth.json exists with a token.
1774
+ */
1775
+ function isAuthenticated() {
1776
+ const authPath = getAuthPath();
1777
+ if (!fs.existsSync(authPath)) {
1778
+ log('[Auth] No auth.json found');
1779
+ return false;
1780
+ }
1781
+
1782
+ try {
1783
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
1784
+ if (data.token) {
1785
+ log('[Auth] Auth token found');
1786
+ return true;
1787
+ }
1788
+ } catch (error) {
1789
+ log(`[Auth] Error reading auth file: ${error.message}`);
1790
+ }
1791
+
1792
+ return false;
1793
+ }
1794
+
1795
+ /**
1796
+ * Check if the user has ever logged in before.
1797
+ * Uses a persistent marker file that survives logout (auth.json deletion).
1798
+ */
1799
+ function hasLoggedInBefore() {
1800
+ try {
1801
+ const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
1802
+ return fs.existsSync(markerPath);
1803
+ } catch {
1804
+ return false;
1805
+ }
1806
+ }
1807
+
1808
+ /**
1809
+ * Mark that the user has logged in at least once.
1810
+ * Called after successful authentication (both OAuth and OTP).
1811
+ */
1812
+ function markHasLoggedIn() {
1813
+ try {
1814
+ const markerPath = path.join(app.getPath('userData'), 'has-logged-in');
1815
+ const dir = path.dirname(markerPath);
1816
+ if (!fs.existsSync(dir)) {
1817
+ fs.mkdirSync(dir, { recursive: true });
1818
+ }
1819
+ if (!fs.existsSync(markerPath)) {
1820
+ fs.writeFileSync(markerPath, new Date().toISOString());
1821
+ log('[Auth] Marked user as having logged in before');
1822
+ }
1823
+ } catch (error) {
1824
+ log(`[Auth] Failed to write login marker: ${error.message}`);
1825
+ }
1826
+ }
1827
+
1828
+ /**
1829
+ * Read the Stripe customer ID from subscription config.
1830
+ * Returns null if no subscription is stored.
1831
+ * Written by the Stripe checkout flow (chore #1000535).
1832
+ */
1833
+ function getSubscriptionCustomerId() {
1834
+ const subPath = getSubscriptionPath();
1835
+ if (!fs.existsSync(subPath)) return null;
1836
+ try {
1837
+ const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
1838
+ return data.customerId || null;
1839
+ } catch (error) {
1840
+ log(`[Subscription] Error reading subscription file: ${error.message}`);
1841
+ return null;
1842
+ }
1444
1843
  }
1445
1844
 
1446
1845
  /**
1447
- * Check if app access has been granted via access code
1448
- * @returns {boolean} True if access has been granted
1846
+ * Check if there's an active subscription stored locally.
1847
+ * Full validation against Stripe happens on update check / app refresh.
1449
1848
  */
1450
- function isAccessGranted() {
1451
- const accessPath = getAccessGrantedPath();
1452
- if (!fs.existsSync(accessPath)) {
1453
- log('[Access] No access-granted.json found');
1849
+ function isSubscriptionActive() {
1850
+ const subPath = getSubscriptionPath();
1851
+ if (!fs.existsSync(subPath)) {
1852
+ log('[Subscription] No subscription.json found');
1454
1853
  return false;
1455
1854
  }
1456
1855
 
1457
1856
  try {
1458
- const data = JSON.parse(fs.readFileSync(accessPath, 'utf-8'));
1459
- if (data.activated) {
1460
- log('[Access] Access granted');
1857
+ const data = JSON.parse(fs.readFileSync(subPath, 'utf-8'));
1858
+ if (data.customerId) {
1859
+ log('[Subscription] Active subscription found');
1461
1860
  return true;
1462
1861
  }
1463
1862
  } catch (error) {
1464
- log(`[Access] Error reading access file: ${error.message}`);
1863
+ log(`[Subscription] Error reading subscription file: ${error.message}`);
1465
1864
  }
1466
1865
 
1467
1866
  return false;
@@ -1510,6 +1909,16 @@ function copyDirSync(src, dest) {
1510
1909
  if (entry.isDirectory()) {
1511
1910
  copyDirSync(srcPath, destPath);
1512
1911
  } else {
1912
+ // Skip copy if dest exists and has same or newer mtime + same size
1913
+ try {
1914
+ const srcStat = fs.statSync(srcPath);
1915
+ const destStat = fs.statSync(destPath);
1916
+ if (destStat.mtimeMs >= srcStat.mtimeMs && destStat.size === srcStat.size) {
1917
+ continue;
1918
+ }
1919
+ } catch {
1920
+ // dest doesn't exist — copy it
1921
+ }
1513
1922
  fs.copyFileSync(srcPath, destPath);
1514
1923
  }
1515
1924
  }
@@ -1652,28 +2061,168 @@ autoUpdater.on('update-downloaded', (info) => {
1652
2061
 
1653
2062
  // ==================== App Lifecycle ====================
1654
2063
 
2064
+ // Register jettypod:// protocol for OAuth callback
2065
+ if (!isPackaged) {
2066
+ // Dev mode: pass app path so macOS can route the URL to this Electron instance
2067
+ app.setAsDefaultProtocolClient('jettypod', process.execPath, [path.resolve(process.argv[1])]);
2068
+ } else {
2069
+ app.setAsDefaultProtocolClient('jettypod');
2070
+ }
2071
+
2072
+ /**
2073
+ * Decode a JWT payload without verifying the signature.
2074
+ * Used to extract user info (email, plan) from the token before saving.
2075
+ */
2076
+ function decodeJWTPayload(token) {
2077
+ try {
2078
+ const parts = token.split('.');
2079
+ if (parts.length !== 3) return null;
2080
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
2081
+ return JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
2082
+ } catch {
2083
+ return null;
2084
+ }
2085
+ }
2086
+
2087
+ /**
2088
+ * Handle a jettypod:// protocol URL (OAuth callback).
2089
+ * Saves the token + decoded user info to auth.json and navigates to the dashboard.
2090
+ */
2091
+ function handleProtocolUrl(url) {
2092
+ log(`[Auth] Protocol handler received: ${url}`);
2093
+
2094
+ try {
2095
+ const parsed = new URL(url);
2096
+ // jettypod://auth/callback parses as hostname='auth', pathname='/callback'
2097
+ const isAuthCallback =
2098
+ (parsed.hostname === 'auth' && parsed.pathname === '/callback') ||
2099
+ parsed.pathname === '//auth/callback' ||
2100
+ parsed.pathname === '/auth/callback';
2101
+ if (isAuthCallback) {
2102
+ const token = parsed.searchParams.get('token');
2103
+ if (token && mainWindow) {
2104
+ // Decode JWT to extract user info (sub, email, plan)
2105
+ const payload = decodeJWTPayload(token);
2106
+ const user = payload ? { id: payload.sub, email: payload.email, plan: payload.plan } : undefined;
2107
+
2108
+ // Save auth.json with token AND user data
2109
+ const authPath = path.join(app.getPath('userData'), 'auth.json');
2110
+ const dir = path.dirname(authPath);
2111
+ if (!fs.existsSync(dir)) {
2112
+ fs.mkdirSync(dir, { recursive: true });
2113
+ }
2114
+ fs.writeFileSync(authPath, JSON.stringify({ token, user, savedAt: new Date().toISOString() }, null, 2));
2115
+ markHasLoggedIn();
2116
+ log('[Auth] Token + user data saved from OAuth callback');
2117
+
2118
+ // Navigate to the appropriate onboarding/dashboard page
2119
+ mainWindow.loadURL(getStartUrl());
2120
+ mainWindow.focus();
2121
+ }
2122
+ } else if (parsed.hostname === 'subscription' && parsed.pathname === '/activated') {
2123
+ log('[Auth] Subscription activated deep link received');
2124
+ if (mainWindow) {
2125
+ mainWindow.loadURL(getStartUrl());
2126
+ mainWindow.focus();
2127
+ }
2128
+ }
2129
+ } catch (error) {
2130
+ log(`[Auth] Error handling protocol URL: ${error.message}`);
2131
+ }
2132
+ }
2133
+
2134
+ // Single-instance lock: ensure deep links are routed to the existing instance
2135
+ const gotTheLock = app.requestSingleInstanceLock();
2136
+
2137
+ if (!gotTheLock) {
2138
+ // Another instance is already running — quit this one.
2139
+ // The URL (if any) will be forwarded to the existing instance via 'second-instance'.
2140
+ app.quit();
2141
+ } else {
2142
+ // On macOS, protocol URLs for a running app arrive via 'open-url'.
2143
+ app.on('open-url', (event, url) => {
2144
+ event.preventDefault();
2145
+ handleProtocolUrl(url);
2146
+ });
2147
+
2148
+ // On Windows/Linux (and macOS second-instance fallback), the URL arrives here.
2149
+ app.on('second-instance', (_event, commandLine) => {
2150
+ // The protocol URL is typically the last argument
2151
+ const url = commandLine.find(arg => arg.startsWith('jettypod://'));
2152
+ if (url) {
2153
+ handleProtocolUrl(url);
2154
+ }
2155
+ // Focus the main window
2156
+ if (mainWindow) {
2157
+ if (mainWindow.isMinimized()) mainWindow.restore();
2158
+ mainWindow.focus();
2159
+ }
2160
+ });
2161
+ }
2162
+
1655
2163
  app.whenReady().then(async () => {
1656
2164
  log('Electron app ready');
1657
2165
 
2166
+ // Start session manager (handles auth heartbeat + auto-updater token)
2167
+ sessionManager.setLogger(log);
2168
+ if (isAuthenticated()) {
2169
+ sessionManager.start();
2170
+ }
2171
+
1658
2172
  // Check for updates in the background (only in packaged app)
1659
- if (isPackaged) {
1660
- log('[AutoUpdate] Checking for updates...');
1661
- try {
1662
- await autoUpdater.checkForUpdatesAndNotify();
1663
- } catch (error) {
1664
- // Log error but don't crash - auto-update failures shouldn't prevent app launch
1665
- console.error('[AutoUpdate] Failed to check for updates:', error.message);
1666
- log(`[AutoUpdate] Update check failed: ${error.message}`);
2173
+ if (isPackaged && isAuthenticated()) {
2174
+ const headers = sessionManager.getUpdaterHeaders();
2175
+ if (headers.Authorization) {
2176
+ autoUpdater.requestHeaders = headers;
2177
+ log('[AutoUpdate] Checking for updates with JWT...');
2178
+ try {
2179
+ await autoUpdater.checkForUpdatesAndNotify();
2180
+ } catch (error) {
2181
+ log(`[AutoUpdate] Update check failed: ${error.message}`);
2182
+ }
2183
+ } else {
2184
+ log('[AutoUpdate] Skipping update check - no auth token');
1667
2185
  }
1668
2186
  }
1669
2187
 
1670
2188
  // Check if Claude Code is installed (for dependency flow)
1671
2189
  // Updates the global claudeCodeInstalled variable
1672
- claudeCodeInstalled = checkClaudeCodeInstalled();
2190
+ claudeCodeInstalled = await checkClaudeCodeInstalled();
1673
2191
  log(`Claude Code installed: ${claudeCodeInstalled}`);
1674
2192
 
2193
+ // Check if Claude Code is authenticated (only if installed)
2194
+ // Wrapped in try/catch so a catastrophic failure here doesn't crash startup
2195
+ if (claudeCodeInstalled) {
2196
+ try {
2197
+ claudeCodeAuthenticated = await checkClaudeCodeAuthenticated();
2198
+ } catch (err) {
2199
+ log(`[ClaudeCode] Auth check crashed unexpectedly: ${err.message}`);
2200
+ claudeCodeAuthenticated = false;
2201
+ }
2202
+
2203
+ // Auto-update if CLI is outdated, then retry auth check
2204
+ if (!claudeCodeAuthenticated && claudeCodeNeedsUpdate) {
2205
+ log('[ClaudeCode] CLI outdated, running auto-update...');
2206
+ const updateResult = await updateClaudeCode();
2207
+ if (updateResult.success) {
2208
+ log('[ClaudeCode] Auto-update succeeded, retrying auth check');
2209
+ claudeCodeNeedsUpdate = false;
2210
+ try {
2211
+ claudeCodeAuthenticated = await checkClaudeCodeAuthenticated();
2212
+ } catch (err) {
2213
+ log(`[ClaudeCode] Auth re-check crashed: ${err.message}`);
2214
+ claudeCodeAuthenticated = false;
2215
+ }
2216
+ } else {
2217
+ log(`[ClaudeCode] Auto-update failed: ${updateResult.error}`);
2218
+ }
2219
+ }
2220
+
2221
+ log(`Claude Code authenticated: ${claudeCodeAuthenticated}`);
2222
+ }
2223
+
1675
2224
  // Setup PATH on first launch (creates symlink to /usr/local/bin)
1676
- await setupPathOnFirstLaunch();
2225
+ await setupCliSymlink();
1677
2226
 
1678
2227
  // Sync bundled skills to ~/.claude/skills/
1679
2228
  syncSkills();
@@ -1686,8 +2235,19 @@ app.whenReady().then(async () => {
1686
2235
  // Register IPC handlers for database operations
1687
2236
  registerIpcHandlers();
1688
2237
 
2238
+ // Post-login navigation: returns the correct path based on Claude Code state
2239
+ ipcMain.handle('auth:getPostLoginPath', () => {
2240
+ const url = getStartUrl();
2241
+ // Extract just the path from the full URL
2242
+ try {
2243
+ return new URL(url).pathname;
2244
+ } catch {
2245
+ return '/';
2246
+ }
2247
+ });
2248
+
1689
2249
  // Clean up stale resources from previous runs (lock files, orphaned processes)
1690
- cleanupStaleResources();
2250
+ await cleanupStaleResources();
1691
2251
 
1692
2252
  try {
1693
2253
  // Start servers in parallel