jettypod 4.4.120 → 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 (208) hide show
  1. package/.env +2 -1
  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 +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  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 +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  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 +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,529 +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
- options?: { model?: string }
207
- ): { emitter: EventEmitter; isNew: boolean } | { error: string } {
208
- const existing = processes.get(sessionId);
209
-
210
- // Check if existing process is actually healthy (not just "not killed")
211
- if (existing && isProcessHealthy(existing)) {
212
- existing.lastActivityAt = Date.now();
213
- return { emitter: existing.emitter, isNew: false };
214
- }
215
-
216
- // Clean up dead/unhealthy process if exists
217
- if (existing) {
218
- killProcess(sessionId);
219
- }
220
-
221
- // Check if we've hit the max process limit
222
- const activeCount = getActiveProcessCount();
223
- if (activeCount >= MAX_PROCESSES) {
224
- return { error: `Maximum concurrent processes (${MAX_PROCESSES}) reached. Please close an existing session first.` };
225
- }
226
-
227
- const emitter = new EventEmitter();
228
-
229
- // Build args for persistent mode
230
- const args = [
231
- '-p', // Print mode (required for input-format)
232
- '--input-format', 'stream-json',
233
- '--output-format', 'stream-json',
234
- '--verbose',
235
- '--permission-mode', 'bypassPermissions',
236
- ];
237
-
238
- if (settingsPath) {
239
- args.push('--settings', settingsPath);
240
- }
241
-
242
- if (options?.model) {
243
- args.push('--model', options.model);
244
- }
245
-
246
- const claudeProcess = spawn('claude', args, {
247
- cwd,
248
- env: { ...process.env, JETTYPOD_SESSION_ID: sessionId },
249
- stdio: ['pipe', 'pipe', 'pipe'],
250
- });
251
-
252
- // Handle stdout - stream JSON responses.
253
- // Buffer incomplete lines across data events. Node.js delivers arbitrary
254
- // chunks on pipes, so a single JSON line can be split across two events.
255
- // Without buffering, split lines fail JSON.parse and critical events like
256
- // `result` are lost — causing the chat panel to hang forever.
257
- let stdoutBuffer = '';
258
- claudeProcess.stdout.on('data', (data: Buffer) => {
259
- stdoutBuffer += data.toString();
260
- const lines = stdoutBuffer.split('\n');
261
- // Last element is either '' (chunk ended with \n) or an incomplete line
262
- stdoutBuffer = lines.pop() || '';
263
-
264
- for (const line of lines) {
265
- const trimmed = line.trim();
266
- if (!trimmed) continue;
267
- try {
268
- const parsed = JSON.parse(trimmed);
269
-
270
- // Auto-detect workflow events and emit synthetic gate events
271
- const syntheticGates = detectGates(sessionId, parsed);
272
- for (const gate of syntheticGates) {
273
- emitter.emit('data', gate);
274
- }
275
-
276
- emitter.emit('data', parsed);
277
- } catch {
278
- // Non-JSON line, emit as raw text
279
- emitter.emit('data', { type: 'text', content: trimmed });
280
- }
281
- }
282
- });
283
-
284
- // Handle stderr
285
- claudeProcess.stderr.on('data', (data: Buffer) => {
286
- emitter.emit('error', { type: 'stderr', content: data.toString() });
287
- });
288
-
289
- // Handle process close
290
- claudeProcess.on('close', (code) => {
291
- emitter.emit('close', { exitCode: code });
292
- cleanupGateState(sessionId);
293
- processes.delete(sessionId);
294
- });
295
-
296
- // Handle process error
297
- claudeProcess.on('error', (err) => {
298
- emitter.emit('error', { type: 'process_error', content: err.message });
299
- cleanupGateState(sessionId);
300
- processes.delete(sessionId);
301
- });
302
-
303
- const claudeProc: ClaudeProcess = {
304
- process: claudeProcess,
305
- emitter,
306
- sessionId,
307
- createdAt: Date.now(),
308
- lastActivityAt: Date.now(),
309
- };
310
-
311
- processes.set(sessionId, claudeProc);
312
-
313
- return { emitter, isNew: true };
314
- }
315
-
316
- /**
317
- * Send a user message to a session's Claude process via stdin.
318
- * The message is formatted as stream-json.
319
- */
320
- export function sendMessage(sessionId: string, message: string, images?: Array<{ type: string; data: string }>): boolean {
321
- const proc = processes.get(sessionId);
322
-
323
- if (!proc || !isProcessHealthy(proc)) {
324
- // Clean up dead process if it exists
325
- if (proc) {
326
- killProcess(sessionId);
327
- }
328
- return false;
329
- }
330
-
331
- proc.lastActivityAt = Date.now();
332
-
333
- // Build content: plain string when no images, content blocks array when images present
334
- let content: string | Array<Record<string, unknown>> = message;
335
- if (images && images.length > 0) {
336
- const contentBlocks: Array<Record<string, unknown>> = [];
337
- for (const img of images) {
338
- // Frontend sends dataUrl like "data:image/png;base64,iVBOR..."
339
- // Strip the data URL prefix to get raw base64
340
- const base64Data = img.data.replace(/^data:[^;]+;base64,/, '');
341
- contentBlocks.push({
342
- type: 'image',
343
- source: {
344
- type: 'base64',
345
- media_type: img.type,
346
- data: base64Data,
347
- },
348
- });
349
- }
350
- contentBlocks.push({ type: 'text', text: message });
351
- content = contentBlocks;
352
- }
353
-
354
- // Format as stream-json message
355
- const jsonMessage = JSON.stringify({
356
- type: 'user',
357
- message: {
358
- role: 'user',
359
- content,
360
- },
361
- });
362
-
363
- // Write to stdin with newline delimiter
364
- // Safety check: stdin should exist since isProcessHealthy verified it
365
- proc.process.stdin!.write(jsonMessage + '\n');
366
-
367
- return true;
368
- }
369
-
370
- /**
371
- * Kill a session's Claude process.
372
- */
373
- export function killProcess(sessionId: string): boolean {
374
- const proc = processes.get(sessionId);
375
-
376
- if (!proc) {
377
- return false;
378
- }
379
-
380
- if (!proc.process.killed) {
381
- proc.process.kill('SIGTERM');
382
- }
383
-
384
- cleanupGateState(sessionId);
385
- processes.delete(sessionId);
386
- return true;
387
- }
388
-
389
- /**
390
- * Check if a process is truly alive and responsive.
391
- * Returns true if the process is healthy, false if dead/unresponsive.
392
- *
393
- * Checks:
394
- * 1. Process hasn't been killed
395
- * 2. Process hasn't exited (exitCode is null for running processes)
396
- * 3. stdin is still writable
397
- */
398
- function isProcessHealthy(proc: ClaudeProcess): boolean {
399
- const childProcess = proc.process;
400
-
401
- // Check if process was explicitly killed
402
- if (childProcess.killed) {
403
- return false;
404
- }
405
-
406
- // Check if process has exited (exitCode is non-null when exited)
407
- if (childProcess.exitCode !== null) {
408
- return false;
409
- }
410
-
411
- // Check if stdin is still writable
412
- if (!childProcess.stdin || childProcess.stdin.destroyed || !childProcess.stdin.writable) {
413
- return false;
414
- }
415
-
416
- return true;
417
- }
418
-
419
- /**
420
- * Check process health and clean up if dead.
421
- * Returns true if process is healthy, false if dead (and cleaned up).
422
- */
423
- export function checkProcessHealth(sessionId: string): boolean {
424
- const proc = processes.get(sessionId);
425
-
426
- if (!proc) {
427
- return false;
428
- }
429
-
430
- if (!isProcessHealthy(proc)) {
431
- // Process is dead - clean it up
432
- killProcess(sessionId);
433
- return false;
434
- }
435
-
436
- return true;
437
- }
438
-
439
- /**
440
- * Check if a session has an active process.
441
- * Now includes health check to detect dead/zombie processes.
442
- */
443
- export function hasActiveProcess(sessionId: string): boolean {
444
- return checkProcessHealth(sessionId);
445
- }
446
-
447
- /**
448
- * Get count of currently active (healthy) processes.
449
- */
450
- export function getActiveProcessCount(): number {
451
- let count = 0;
452
- for (const proc of processes.values()) {
453
- if (isProcessHealthy(proc)) {
454
- count++;
455
- }
456
- }
457
- return count;
458
- }
459
-
460
- /**
461
- * Get stats about active processes (for debugging).
462
- * Now uses health check to accurately report active processes.
463
- */
464
- export function getProcessStats(): {
465
- activeCount: number;
466
- sessions: Array<{ sessionId: string; ageMs: number; idleMs: number; healthy: boolean }>;
467
- } {
468
- const now = Date.now();
469
- const sessions = Array.from(processes.entries())
470
- .map(([sessionId, proc]) => ({
471
- sessionId,
472
- ageMs: now - proc.createdAt,
473
- idleMs: now - proc.lastActivityAt,
474
- healthy: isProcessHealthy(proc),
475
- }));
476
-
477
- return {
478
- activeCount: sessions.filter(s => s.healthy).length,
479
- sessions,
480
- };
481
- }
482
-
483
- /**
484
- * Pin a session to prevent idle cleanup (session is active in UI).
485
- */
486
- export function pinSession(sessionId: string): void {
487
- pinnedSessions.add(sessionId);
488
- }
489
-
490
- /**
491
- * Unpin a session to allow idle cleanup (session closed/switched away).
492
- */
493
- export function unpinSession(sessionId: string): void {
494
- pinnedSessions.delete(sessionId);
495
- }
496
-
497
- /**
498
- * Check if a session is pinned.
499
- */
500
- export function isSessionPinned(sessionId: string): boolean {
501
- return pinnedSessions.has(sessionId);
502
- }
503
-
504
- /**
505
- * Cleanup idle processes. Call this periodically.
506
- * Skips pinned sessions (active in UI).
507
- */
508
- export function cleanupIdleProcesses(): number {
509
- const now = Date.now();
510
- let cleaned = 0;
511
-
512
- for (const [sessionId, proc] of processes.entries()) {
513
- // Never kill pinned sessions — they're active in the UI
514
- if (pinnedSessions.has(sessionId)) {
515
- continue;
516
- }
517
- if (now - proc.lastActivityAt > IDLE_TIMEOUT_MS) {
518
- killProcess(sessionId);
519
- cleaned++;
520
- }
521
- }
522
-
523
- return cleaned;
524
- }
525
-
526
- // Start periodic cleanup (every 15 minutes)
527
- setInterval(() => {
528
- cleanupIdleProcesses();
529
- }, 15 * 60 * 1000);