jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -1,492 +0,0 @@
1
- import { spawn, ChildProcess } from 'child_process';
2
- import { EventEmitter } from 'events';
3
-
4
- /**
5
- * Manages persistent Claude CLI processes for dashboard sessions.
6
- *
7
- * Instead of spawning a new Claude process per message (-p mode),
8
- * we spawn a persistent process with --input-format stream-json
9
- * and pipe messages via stdin. This enables Claude's agentic loop
10
- * to work properly - Claude continues until it decides to stop.
11
- */
12
-
13
- interface ClaudeProcess {
14
- process: ChildProcess;
15
- emitter: EventEmitter;
16
- sessionId: string;
17
- createdAt: number;
18
- lastActivityAt: number;
19
- }
20
-
21
- // Singleton map of session ID -> Claude process
22
- const processes = new Map<string, ClaudeProcess>();
23
-
24
- // Sessions pinned by the UI (active in a tab) — exempt from idle cleanup
25
- const pinnedSessions = new Set<string>();
26
-
27
- // Maximum concurrent Claude processes allowed
28
- const MAX_PROCESSES = 8;
29
-
30
- // Timeout for idle processes (2 hours, only applies to unpinned sessions).
31
- // Longer timeout reduces process respawn frequency, avoiding the cost of
32
- // context restoration (dumping full conversation history into a single message).
33
- const IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
34
-
35
- // ============================================================================
36
- // Auto Gate Emission
37
- // ============================================================================
38
-
39
- // Track last tool_use per session to correlate with tool_result
40
- const lastToolUse = new Map<string, { name: string; input: Record<string, unknown>; id: string }>();
41
-
42
- // Track whether implementing gate has been emitted this session (debounce)
43
- const implementingEmitted = new Map<string, number>();
44
-
45
- // Debounce interval for implementing gate (don't re-emit within 30s)
46
- const IMPLEMENTING_DEBOUNCE_MS = 30_000;
47
-
48
- /**
49
- * Pattern to detect work item creation from tool result content.
50
- * Matches: "Created chore #12345: Title Here"
51
- */
52
- const WORK_CREATED_PATTERN = /Created (chore|feature|bug|epic) #(\d+): ([^\n]+)/;
53
-
54
- /**
55
- * Pattern to detect worktree creation from tool result content.
56
- * Matches: "✅ Created worktree: /path/to/worktree"
57
- */
58
- const WORKTREE_STARTED_PATTERN = /Created worktree: ([^\n]+)/;
59
-
60
- /**
61
- * Check a parsed stream event for workflow patterns and emit synthetic gate events.
62
- * Returns an array of gate events to inject into the stream (may be empty).
63
- */
64
- function detectGates(
65
- sessionId: string,
66
- parsed: Record<string, unknown>
67
- ): Array<Record<string, unknown>> {
68
- const gates: Array<Record<string, unknown>> = [];
69
-
70
- // Track tool_use from assistant messages
71
- if (parsed.type === 'assistant' && parsed.message) {
72
- const msg = parsed.message as { content?: Array<{ type: string; name?: string; input?: Record<string, unknown>; id?: string }> };
73
- if (Array.isArray(msg.content)) {
74
- for (const block of msg.content) {
75
- if (block.type === 'tool_use' && block.name && block.id) {
76
- lastToolUse.set(sessionId, {
77
- name: block.name,
78
- input: block.input || {},
79
- id: block.id,
80
- });
81
-
82
- // Emit implementing gate for Edit/Write tool_use (debounced)
83
- if (block.name === 'Edit' || block.name === 'Write') {
84
- const lastEmit = implementingEmitted.get(sessionId) || 0;
85
- if (Date.now() - lastEmit > IMPLEMENTING_DEBOUNCE_MS) {
86
- const filePath = (block.input as Record<string, string>)?.file_path || '';
87
- const fileName = filePath.split('/').pop() || filePath;
88
- gates.push(buildGateEvent('implementing', {
89
- description: `Editing ${fileName}`,
90
- files: [fileName],
91
- }));
92
- implementingEmitted.set(sessionId, Date.now());
93
- }
94
- }
95
- }
96
- }
97
- }
98
- }
99
-
100
- // Check tool_result from user messages for workflow command outputs
101
- if (parsed.type === 'user' && parsed.message) {
102
- const msg = parsed.message as { content?: Array<{ type: string; content?: string | Array<{ type: string; text?: string }> }> };
103
- if (Array.isArray(msg.content)) {
104
- for (const block of msg.content) {
105
- if (block.type === 'tool_result') {
106
- const resultText = typeof block.content === 'string'
107
- ? block.content
108
- : Array.isArray(block.content)
109
- ? block.content.filter(p => p.type === 'text').map(p => p.text || '').join('')
110
- : '';
111
-
112
- // Check for work item creation
113
- const workCreated = resultText.match(WORK_CREATED_PATTERN);
114
- if (workCreated) {
115
- gates.push(buildGateEvent('work-created', {
116
- id: parseInt(workCreated[2], 10),
117
- title: workCreated[3].trim(),
118
- type: workCreated[1],
119
- }));
120
- }
121
-
122
- // Check for worktree creation
123
- const worktreeStarted = resultText.match(WORKTREE_STARTED_PATTERN);
124
- if (worktreeStarted) {
125
- gates.push(buildGateEvent('worktree-started', {
126
- path: worktreeStarted[1].trim(),
127
- }));
128
- }
129
-
130
- // Check for test runs
131
- const lastTool = lastToolUse.get(sessionId);
132
- if (lastTool?.name === 'Bash') {
133
- const cmd = (lastTool.input as Record<string, string>)?.command || '';
134
-
135
- // Test running detection
136
- if (cmd.includes('cucumber-js') || cmd.includes('jest') || cmd.includes('npm test') || cmd.includes('npx test')) {
137
- gates.push(buildGateEvent('tests-running', {}));
138
-
139
- // If tests passed (exit code 0 implied by non-error result)
140
- if (!resultText.includes('failing') && !resultText.includes('FAIL') && !resultText.includes('Error:')) {
141
- gates.push(buildGateEvent('tests-passed', {}));
142
- }
143
- }
144
-
145
- // Merge detection
146
- if (cmd.includes('jettypod work merge')) {
147
- gates.push(buildGateEvent('merging', {}));
148
- }
149
-
150
- // Completion detection
151
- if (cmd.includes('jettypod work status') && cmd.includes('done')) {
152
- gates.push(buildGateEvent('complete', {
153
- summary: 'Work complete',
154
- }));
155
- }
156
- }
157
- }
158
- }
159
- }
160
- }
161
-
162
- return gates;
163
- }
164
-
165
- /**
166
- * Build a synthetic gate event in the format that session-stream-manager expects.
167
- * Wraps the gate marker in a tool_result-style user message.
168
- */
169
- function buildGateEvent(gateType: string, data: Record<string, unknown>): Record<string, unknown> {
170
- const gateMarker = `[GATE:${gateType}]${JSON.stringify(data)}[/GATE]`;
171
- return {
172
- type: 'user',
173
- message: {
174
- role: 'user',
175
- content: [{
176
- type: 'tool_result',
177
- tool_use_id: `synthetic-gate-${Date.now()}`,
178
- content: gateMarker,
179
- }],
180
- },
181
- isSyntheticGate: true,
182
- };
183
- }
184
-
185
- /**
186
- * Clean up gate tracking state for a session.
187
- */
188
- function cleanupGateState(sessionId: string): void {
189
- lastToolUse.delete(sessionId);
190
- implementingEmitted.delete(sessionId);
191
- }
192
-
193
- // ============================================================================
194
- // Process Management
195
- // ============================================================================
196
-
197
- /**
198
- * Get or create a persistent Claude process for a session.
199
- * Returns an EventEmitter that emits 'data', 'error', and 'close' events.
200
- * Returns error if max process limit is reached.
201
- */
202
- export function getOrCreateProcess(
203
- sessionId: string,
204
- cwd: string,
205
- settingsPath?: string
206
- ): { emitter: EventEmitter; isNew: boolean } | { error: string } {
207
- const existing = processes.get(sessionId);
208
-
209
- // Check if existing process is actually healthy (not just "not killed")
210
- if (existing && isProcessHealthy(existing)) {
211
- existing.lastActivityAt = Date.now();
212
- return { emitter: existing.emitter, isNew: false };
213
- }
214
-
215
- // Clean up dead/unhealthy process if exists
216
- if (existing) {
217
- killProcess(sessionId);
218
- }
219
-
220
- // Check if we've hit the max process limit
221
- const activeCount = getActiveProcessCount();
222
- if (activeCount >= MAX_PROCESSES) {
223
- return { error: `Maximum concurrent processes (${MAX_PROCESSES}) reached. Please close an existing session first.` };
224
- }
225
-
226
- const emitter = new EventEmitter();
227
-
228
- // Build args for persistent mode
229
- const args = [
230
- '-p', // Print mode (required for input-format)
231
- '--input-format', 'stream-json',
232
- '--output-format', 'stream-json',
233
- '--verbose',
234
- '--permission-mode', 'bypassPermissions',
235
- ];
236
-
237
- if (settingsPath) {
238
- args.push('--settings', settingsPath);
239
- }
240
-
241
- const claudeProcess = spawn('claude', args, {
242
- cwd,
243
- env: { ...process.env, JETTYPOD_SESSION_ID: sessionId },
244
- stdio: ['pipe', 'pipe', 'pipe'],
245
- });
246
-
247
- // Handle stdout - stream JSON responses
248
- claudeProcess.stdout.on('data', (data: Buffer) => {
249
- const lines = data.toString().split('\n').filter(line => line.trim());
250
- for (const line of lines) {
251
- try {
252
- const parsed = JSON.parse(line);
253
-
254
- // Auto-detect workflow events and emit synthetic gate events
255
- const syntheticGates = detectGates(sessionId, parsed);
256
- for (const gate of syntheticGates) {
257
- emitter.emit('data', gate);
258
- }
259
-
260
- emitter.emit('data', parsed);
261
- } catch {
262
- // Non-JSON line, emit as raw text
263
- emitter.emit('data', { type: 'text', content: line });
264
- }
265
- }
266
- });
267
-
268
- // Handle stderr
269
- claudeProcess.stderr.on('data', (data: Buffer) => {
270
- emitter.emit('error', { type: 'stderr', content: data.toString() });
271
- });
272
-
273
- // Handle process close
274
- claudeProcess.on('close', (code) => {
275
- emitter.emit('close', { exitCode: code });
276
- cleanupGateState(sessionId);
277
- processes.delete(sessionId);
278
- });
279
-
280
- // Handle process error
281
- claudeProcess.on('error', (err) => {
282
- emitter.emit('error', { type: 'process_error', content: err.message });
283
- cleanupGateState(sessionId);
284
- processes.delete(sessionId);
285
- });
286
-
287
- const claudeProc: ClaudeProcess = {
288
- process: claudeProcess,
289
- emitter,
290
- sessionId,
291
- createdAt: Date.now(),
292
- lastActivityAt: Date.now(),
293
- };
294
-
295
- processes.set(sessionId, claudeProc);
296
-
297
- return { emitter, isNew: true };
298
- }
299
-
300
- /**
301
- * Send a user message to a session's Claude process via stdin.
302
- * The message is formatted as stream-json.
303
- */
304
- export function sendMessage(sessionId: string, message: string): boolean {
305
- const proc = processes.get(sessionId);
306
-
307
- if (!proc || !isProcessHealthy(proc)) {
308
- // Clean up dead process if it exists
309
- if (proc) {
310
- killProcess(sessionId);
311
- }
312
- return false;
313
- }
314
-
315
- proc.lastActivityAt = Date.now();
316
-
317
- // Format as stream-json message
318
- const jsonMessage = JSON.stringify({
319
- type: 'user',
320
- message: {
321
- role: 'user',
322
- content: message,
323
- },
324
- });
325
-
326
- // Write to stdin with newline delimiter
327
- // Safety check: stdin should exist since isProcessHealthy verified it
328
- proc.process.stdin!.write(jsonMessage + '\n');
329
-
330
- return true;
331
- }
332
-
333
- /**
334
- * Kill a session's Claude process.
335
- */
336
- export function killProcess(sessionId: string): boolean {
337
- const proc = processes.get(sessionId);
338
-
339
- if (!proc) {
340
- return false;
341
- }
342
-
343
- if (!proc.process.killed) {
344
- proc.process.kill('SIGTERM');
345
- }
346
-
347
- cleanupGateState(sessionId);
348
- processes.delete(sessionId);
349
- return true;
350
- }
351
-
352
- /**
353
- * Check if a process is truly alive and responsive.
354
- * Returns true if the process is healthy, false if dead/unresponsive.
355
- *
356
- * Checks:
357
- * 1. Process hasn't been killed
358
- * 2. Process hasn't exited (exitCode is null for running processes)
359
- * 3. stdin is still writable
360
- */
361
- function isProcessHealthy(proc: ClaudeProcess): boolean {
362
- const childProcess = proc.process;
363
-
364
- // Check if process was explicitly killed
365
- if (childProcess.killed) {
366
- return false;
367
- }
368
-
369
- // Check if process has exited (exitCode is non-null when exited)
370
- if (childProcess.exitCode !== null) {
371
- return false;
372
- }
373
-
374
- // Check if stdin is still writable
375
- if (!childProcess.stdin || childProcess.stdin.destroyed || !childProcess.stdin.writable) {
376
- return false;
377
- }
378
-
379
- return true;
380
- }
381
-
382
- /**
383
- * Check process health and clean up if dead.
384
- * Returns true if process is healthy, false if dead (and cleaned up).
385
- */
386
- export function checkProcessHealth(sessionId: string): boolean {
387
- const proc = processes.get(sessionId);
388
-
389
- if (!proc) {
390
- return false;
391
- }
392
-
393
- if (!isProcessHealthy(proc)) {
394
- // Process is dead - clean it up
395
- killProcess(sessionId);
396
- return false;
397
- }
398
-
399
- return true;
400
- }
401
-
402
- /**
403
- * Check if a session has an active process.
404
- * Now includes health check to detect dead/zombie processes.
405
- */
406
- export function hasActiveProcess(sessionId: string): boolean {
407
- return checkProcessHealth(sessionId);
408
- }
409
-
410
- /**
411
- * Get count of currently active (healthy) processes.
412
- */
413
- export function getActiveProcessCount(): number {
414
- let count = 0;
415
- for (const proc of processes.values()) {
416
- if (isProcessHealthy(proc)) {
417
- count++;
418
- }
419
- }
420
- return count;
421
- }
422
-
423
- /**
424
- * Get stats about active processes (for debugging).
425
- * Now uses health check to accurately report active processes.
426
- */
427
- export function getProcessStats(): {
428
- activeCount: number;
429
- sessions: Array<{ sessionId: string; ageMs: number; idleMs: number; healthy: boolean }>;
430
- } {
431
- const now = Date.now();
432
- const sessions = Array.from(processes.entries())
433
- .map(([sessionId, proc]) => ({
434
- sessionId,
435
- ageMs: now - proc.createdAt,
436
- idleMs: now - proc.lastActivityAt,
437
- healthy: isProcessHealthy(proc),
438
- }));
439
-
440
- return {
441
- activeCount: sessions.filter(s => s.healthy).length,
442
- sessions,
443
- };
444
- }
445
-
446
- /**
447
- * Pin a session to prevent idle cleanup (session is active in UI).
448
- */
449
- export function pinSession(sessionId: string): void {
450
- pinnedSessions.add(sessionId);
451
- }
452
-
453
- /**
454
- * Unpin a session to allow idle cleanup (session closed/switched away).
455
- */
456
- export function unpinSession(sessionId: string): void {
457
- pinnedSessions.delete(sessionId);
458
- }
459
-
460
- /**
461
- * Check if a session is pinned.
462
- */
463
- export function isSessionPinned(sessionId: string): boolean {
464
- return pinnedSessions.has(sessionId);
465
- }
466
-
467
- /**
468
- * Cleanup idle processes. Call this periodically.
469
- * Skips pinned sessions (active in UI).
470
- */
471
- export function cleanupIdleProcesses(): number {
472
- const now = Date.now();
473
- let cleaned = 0;
474
-
475
- for (const [sessionId, proc] of processes.entries()) {
476
- // Never kill pinned sessions — they're active in the UI
477
- if (pinnedSessions.has(sessionId)) {
478
- continue;
479
- }
480
- if (now - proc.lastActivityAt > IDLE_TIMEOUT_MS) {
481
- killProcess(sessionId);
482
- cleaned++;
483
- }
484
- }
485
-
486
- return cleaned;
487
- }
488
-
489
- // Start periodic cleanup (every 15 minutes)
490
- setInterval(() => {
491
- cleanupIdleProcesses();
492
- }, 15 * 60 * 1000);