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
@@ -3,9 +3,31 @@
3
3
  *
4
4
  * Extracted from useClaudeStream hook to enable each session to have its own
5
5
  * independent stream state. Multiple instances can run concurrently.
6
+ *
7
+ * Uses Tauri IPC (invoke) to spawn Claude processes and Tauri events to receive
8
+ * streaming output, replacing the previous fetch-based SSE approach.
6
9
  */
7
10
 
8
11
  import type { AttachedImage } from '../components/ClaudePanelInput';
12
+ import { invoke, listen } from './tauri';
13
+
14
+ // Tauri event listener type
15
+ type UnlistenFn = () => void;
16
+
17
+ interface ProcessOutputBatchEvent {
18
+ pid: number;
19
+ stream: string; // "stdout" | "stderr"
20
+ lines: string[];
21
+ label: string;
22
+ kind: string;
23
+ }
24
+
25
+ interface ProcessExitEvent {
26
+ pid: number;
27
+ code: number | null;
28
+ label: string;
29
+ kind: string;
30
+ }
9
31
 
10
32
  // ============================================================================
11
33
  // Types
@@ -46,6 +68,8 @@ export interface StreamState {
46
68
  isReconnecting: boolean;
47
69
  reconnectAttempt: number;
48
70
  narratedMode: boolean;
71
+ fullReadoutMode: boolean;
72
+ rawEvents: unknown[];
49
73
  queuedMessage: QueuedMessage | null;
50
74
  }
51
75
 
@@ -80,10 +104,12 @@ const GATE_PATTERN = /\[GATE:([\w-]+)\](.*?)\[\/GATE\]/;
80
104
  // ============================================================================
81
105
 
82
106
  /**
83
- * Transform Claude's stream-json format into our ClaudeMessage format
107
+ * Transform Claude's stream-json format into our ClaudeMessage format.
108
+ * Returns an array when a single event contains multiple content blocks
109
+ * (e.g., assistant message with both text and tool_use).
84
110
  */
85
111
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
- function transformClaudeMessage(parsed: any): ClaudeMessage | null {
112
+ function transformClaudeMessage(parsed: any): ClaudeMessage | ClaudeMessage[] | null {
87
113
  const timestamp = Date.now();
88
114
 
89
115
  switch (parsed.type) {
@@ -94,24 +120,32 @@ function transformClaudeMessage(parsed: any): ClaudeMessage | null {
94
120
  case 'assistant': {
95
121
  const messageContent = parsed.message?.content;
96
122
  if (Array.isArray(messageContent)) {
123
+ const messages: ClaudeMessage[] = [];
124
+
97
125
  const textParts = messageContent
98
126
  .filter((part: { type: string }) => part.type === 'text')
99
127
  .map((part: { text: string }) => part.text)
100
128
  .join('');
101
129
 
102
130
  if (textParts) {
103
- return { type: 'assistant', content: textParts, timestamp };
131
+ messages.push({ type: 'assistant', content: textParts, timestamp });
104
132
  }
105
133
 
106
- const toolUse = messageContent.find((part: { type: string }) => part.type === 'tool_use');
107
- if (toolUse) {
108
- return {
109
- type: 'tool_use',
110
- tool_name: toolUse.name,
111
- tool_input: toolUse.input,
112
- timestamp,
113
- };
134
+ // Emit ALL tool_use blocks (Claude can call multiple tools in parallel)
135
+ for (const part of messageContent) {
136
+ if (part.type === 'tool_use') {
137
+ messages.push({
138
+ type: 'tool_use',
139
+ tool_name: part.name,
140
+ tool_input: part.input,
141
+ timestamp,
142
+ });
143
+ }
114
144
  }
145
+
146
+ if (messages.length === 0) return null;
147
+ if (messages.length === 1) return messages[0];
148
+ return messages;
115
149
  }
116
150
  return null;
117
151
  }
@@ -230,14 +264,13 @@ function extractCreatedWorkItem(content: string | undefined): { id: number; titl
230
264
  * Check any message for work item creation
231
265
  */
232
266
  function checkMessageForWorkItemCreation(message: ClaudeMessage): { id: number; title: string } | null {
233
- if (message.result) {
267
+ // Only check tool_result messages, not assistant content.
268
+ // Checking message.content would false-positive when Claude mentions
269
+ // patterns like "Created feature #123: My Feature" in its text output.
270
+ if (message.type === 'tool_result' && message.result) {
234
271
  const created = extractCreatedWorkItem(message.result);
235
272
  if (created) return created;
236
273
  }
237
- if (message.content) {
238
- const created = extractCreatedWorkItem(message.content);
239
- if (created) return created;
240
- }
241
274
  return null;
242
275
  }
243
276
 
@@ -291,6 +324,8 @@ export class SessionStreamManager {
291
324
  private _reconnectAttempt: number = 0;
292
325
  private _narratedMode: boolean = true;
293
326
  private _userToggledNarratedMode: boolean = false;
327
+ private _fullReadoutMode: boolean = false;
328
+ private _rawEvents: unknown[] = [];
294
329
  private _pendingQuestion: ClaudeMessage | null = null;
295
330
  private _queuedMessage: QueuedMessage | null = null;
296
331
  private _isFirstMessage: boolean = true;
@@ -298,16 +333,29 @@ export class SessionStreamManager {
298
333
  // Notification batching — coalesces rapid state changes into one callback per frame
299
334
  private _notifyPending: boolean = false;
300
335
 
336
+ // Tauri process tracking
337
+ private activePid: number | null = null;
338
+ private unlistenOutput: UnlistenFn | null = null;
339
+ private unlistenExit: UnlistenFn | null = null;
340
+ private _isContinue: boolean = false; // tracks if this is a follow-up message
341
+
301
342
  // Control
302
- private abortController: AbortController | null = null;
303
343
  private creatingStatusTimeout: ReturnType<typeof setTimeout> | null = null;
304
344
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
305
345
  private stopped: boolean = false;
306
346
 
347
+ // Last tool_use tracking (to correlate tool_result with the tool that produced it)
348
+ private _lastToolUse: { name: string; command: string } | null = null;
349
+
350
+ // stderr buffer — collected per-stream to surface in error messages
351
+ private _stderrLines: string[] = [];
352
+
307
353
  // Last request (for retry/reconnect)
308
354
  private lastMessage: string | null = null;
309
355
  private lastImages: AttachedImage[] | undefined = undefined;
310
356
  private _lastWasHidden: boolean = false;
357
+ // Index of the user message that triggered the current request (for precise retry removal)
358
+ private _lastUserMessageIndex: number = -1;
311
359
 
312
360
  // Callbacks
313
361
  private callbacks: StreamManagerCallbacks;
@@ -353,6 +401,14 @@ export class SessionStreamManager {
353
401
  return this._narratedMode;
354
402
  }
355
403
 
404
+ get fullReadoutMode(): boolean {
405
+ return this._fullReadoutMode;
406
+ }
407
+
408
+ get rawEvents(): unknown[] {
409
+ return this._rawEvents;
410
+ }
411
+
356
412
  get pendingQuestion(): ClaudeMessage | null {
357
413
  return this._pendingQuestion;
358
414
  }
@@ -394,6 +450,15 @@ export class SessionStreamManager {
394
450
  this._userToggledNarratedMode = true;
395
451
  }
396
452
 
453
+ setFullReadoutMode(enabled: boolean): void {
454
+ this._fullReadoutMode = enabled;
455
+ this.notifyStateChange();
456
+ }
457
+
458
+ setFullReadoutModeQuiet(enabled: boolean): void {
459
+ this._fullReadoutMode = enabled;
460
+ }
461
+
397
462
  /**
398
463
  * Answer a pending question gate by clearing it and sending the selection as a message
399
464
  */
@@ -413,6 +478,8 @@ export class SessionStreamManager {
413
478
  isReconnecting: this._isReconnecting,
414
479
  reconnectAttempt: this._reconnectAttempt,
415
480
  narratedMode: this._narratedMode,
481
+ fullReadoutMode: this._fullReadoutMode,
482
+ rawEvents: this._rawEvents,
416
483
  queuedMessage: this._queuedMessage,
417
484
  };
418
485
  }
@@ -525,11 +592,29 @@ export class SessionStreamManager {
525
592
  return false;
526
593
  }
527
594
 
528
- private getEndpoint(): string {
529
- const { workItemId, standalone } = this.sessionContext;
530
- return standalone
531
- ? `/api/claude/sessions/${workItemId}/message`
532
- : `/api/claude/${workItemId}/message`;
595
+ /**
596
+ * Clean up Tauri event listeners
597
+ */
598
+ private cleanupEventListeners(): void {
599
+ if (this.unlistenOutput) {
600
+ this.unlistenOutput();
601
+ this.unlistenOutput = null;
602
+ }
603
+ if (this.unlistenExit) {
604
+ this.unlistenExit();
605
+ this.unlistenExit = null;
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Process any queued message after the current stream completes
611
+ */
612
+ private processQueuedMessage(): void {
613
+ if (this._queuedMessage && !this.stopped) {
614
+ const { message, images } = this._queuedMessage;
615
+ this._queuedMessage = null;
616
+ this.sendMessage(message, images);
617
+ }
533
618
  }
534
619
 
535
620
  /**
@@ -554,12 +639,9 @@ export class SessionStreamManager {
554
639
  this.reconnectTimeout = setTimeout(() => {
555
640
  if (this.stopped || !this.lastMessage) return;
556
641
 
557
- // Remove the user message we added (will be re-added on retry) — skip if hidden
558
- if (!this._lastWasHidden) {
559
- const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
560
- if (lastUserIndex >= 0) {
561
- this._messages = this._messages.slice(0, lastUserIndex);
562
- }
642
+ // Remove messages from the failed request (will be re-added on retry)
643
+ if (this._lastUserMessageIndex >= 0) {
644
+ this._messages = this._messages.slice(0, this._lastUserMessageIndex);
563
645
  }
564
646
 
565
647
  this._isReconnecting = false;
@@ -582,6 +664,7 @@ export class SessionStreamManager {
582
664
  async sendMessage(message: string, images?: AttachedImage[], options?: { hidden?: boolean }): Promise<void> {
583
665
  const hidden = options?.hidden ?? false;
584
666
  this.stopped = false;
667
+ this._stderrLines = [];
585
668
 
586
669
  // Store for potential retry/reconnect
587
670
  this.lastMessage = message;
@@ -595,7 +678,10 @@ export class SessionStreamManager {
595
678
  content: message,
596
679
  timestamp: Date.now(),
597
680
  };
681
+ this._lastUserMessageIndex = this._messages.length;
598
682
  this.addMessage(userMessage);
683
+ } else {
684
+ this._lastUserMessageIndex = this._messages.length;
599
685
  }
600
686
 
601
687
  // For first message in a new session, show "creating" status
@@ -623,119 +709,152 @@ export class SessionStreamManager {
623
709
  this.notifyStateChange();
624
710
  }
625
711
 
626
- // Create new abort controller for this request
627
- if (this.abortController) {
628
- this.abortController.abort();
629
- }
630
- this.abortController = new AbortController();
712
+ // Clean up any previous event listeners
713
+ this.cleanupEventListeners();
714
+
715
+ // Set up event listeners BEFORE spawning so we never miss events.
716
+ // Use a mutable ref for PID filtering — events arriving before the PID is set
717
+ // are from other processes and get filtered out. Once the spawn returns and
718
+ // the PID is assigned, all subsequent events from our process are captured.
719
+ const pidRef = { current: 0 };
631
720
 
632
721
  try {
633
- const response = await fetch(this.getEndpoint(), {
634
- method: 'POST',
635
- headers: {
636
- 'Content-Type': 'application/json',
637
- },
638
- body: JSON.stringify({
722
+ await this.listenForProcessEvents(pidRef);
723
+
724
+ // Spawn Claude process via Tauri IPC
725
+ const pid = await invoke<number>('claude_start_stream', {
726
+ args: {
727
+ session_id: this.sessionContext.workItemId,
639
728
  message,
729
+ work_item_id: this.sessionContext.standalone ? null : Number(this.sessionContext.workItemId) || null,
640
730
  images: images?.map(img => ({
641
731
  type: img.type,
642
732
  data: img.dataUrl,
643
- })),
644
- }),
645
- signal: this.abortController.signal,
733
+ })) || null,
734
+ is_continue: this._isContinue,
735
+ narrated_mode: this._narratedMode,
736
+ full_readout: this._fullReadoutMode,
737
+ },
646
738
  });
647
739
 
648
- if (!response.ok) {
649
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
650
- }
651
-
652
- if (!response.body) {
653
- throw new Error('Response body is null');
654
- }
655
-
656
- await this.processStream(response.body);
740
+ // Set PID — events from our process will now be accepted by the listeners
741
+ pidRef.current = pid;
742
+ this.activePid = pid;
743
+ this._isContinue = true; // subsequent messages use --continue
657
744
 
658
- // Success - reset reconnect state
745
+ // Reset reconnect state on successful spawn
659
746
  this._reconnectAttempt = 0;
660
747
  this._isReconnecting = false;
661
748
  } catch (err) {
662
- if (err instanceof Error && err.name !== 'AbortError') {
663
- // Check if this is a network error that we should auto-retry
664
- if (this.isNetworkError(err) && this._reconnectAttempt < RECONNECT_CONFIG.maxAttempts) {
665
- this._error = `Connection lost. Reconnecting... (attempt ${this._reconnectAttempt + 1}/${RECONNECT_CONFIG.maxAttempts})`;
666
- this.notifyStateChange();
667
- this.scheduleReconnect();
668
- } else {
669
- // Non-network error or max attempts exhausted
670
- this._status = 'error';
671
- this._error = err.message;
672
- this._canRetry = true; // Allow manual retry
673
- this.notifyStateChange();
674
- }
675
- }
749
+ // Clean up listeners on failure
750
+ this.cleanupEventListeners();
751
+ // Tauri invoke rejects with strings (not Error objects), so handle both.
752
+ this._status = 'error';
753
+ this._error = err instanceof Error ? err.message : String(err);
754
+ this._canRetry = true;
755
+ this.notifyStateChange();
676
756
  }
677
757
  }
678
758
 
679
759
  /**
680
- * Process SSE stream from response body
760
+ * Listen for Tauri process events and process Claude output.
761
+ * Replaces the old processStream() method that consumed SSE from fetch.
681
762
  */
682
- private async processStream(body: ReadableStream<Uint8Array>): Promise<void> {
683
- const reader = body.getReader();
684
- const decoder = new TextDecoder();
685
- let buffer = '';
686
-
687
- try {
688
- while (true) {
689
- const { done, value } = await reader.read();
690
-
691
- if (done) {
692
- this._status = 'done';
693
- this.notifyStateChange();
694
- break;
695
- }
696
-
697
- buffer += decoder.decode(value, { stream: true });
698
-
699
- const lines = buffer.split('\n');
700
- buffer = lines.pop() || '';
701
-
702
- for (const line of lines) {
703
- if (line.startsWith('data: ')) {
704
- const data = line.slice(6);
705
- try {
706
- const parsed = JSON.parse(data);
707
- const claudeMessage = transformClaudeMessage(parsed);
708
-
709
- if (claudeMessage && !this.stopped) {
763
+ private async listenForProcessEvents(pidRef: { current: number }): Promise<void> {
764
+ // Listen for stdout/stderr line batches (backend sends batches of lines every ~50ms)
765
+ this.unlistenOutput = await listen<ProcessOutputBatchEvent>('process-output-batch', (event) => {
766
+ const { payload } = event;
767
+ if (pidRef.current === 0 || payload.pid !== pidRef.current) return; // Filter to our process
768
+
769
+ if (payload.stream === 'stdout') {
770
+ for (const line of payload.lines) {
771
+ // Each stdout line from `claude --output-format stream-json` is a JSON object
772
+ try {
773
+ const parsed = JSON.parse(line);
774
+ this._rawEvents.push(parsed);
775
+ const result = transformClaudeMessage(parsed);
776
+ const claudeMessages = result === null ? [] : Array.isArray(result) ? result : [result];
777
+
778
+ for (const claudeMessage of claudeMessages) {
779
+ if (!this.stopped) {
710
780
  this.addMessage(claudeMessage);
711
781
 
782
+ // Track last tool_use to correlate with tool_result
783
+ if (claudeMessage.type === 'tool_use') {
784
+ this._lastToolUse = {
785
+ name: claudeMessage.tool_name || '',
786
+ command: (claudeMessage.tool_input as Record<string, string>)?.command || '',
787
+ };
788
+ }
789
+
712
790
  // Check for work item creation
713
791
  if (this.sessionContext.standalone && this.callbacks.onWorkItemCreated) {
714
- const created = checkMessageForWorkItemCreation(claudeMessage);
715
- if (created) {
716
- this.callbacks.onWorkItemCreated(created.id, created.title, this.sessionContext.workItemId);
792
+ const wasBashJettypod = this._lastToolUse?.name === 'Bash'
793
+ && this._lastToolUse.command.includes('jettypod');
794
+ if (wasBashJettypod) {
795
+ const created = checkMessageForWorkItemCreation(claudeMessage);
796
+ if (created) {
797
+ this.callbacks.onWorkItemCreated(created.id, created.title, this.sessionContext.workItemId);
798
+ }
717
799
  }
718
800
  }
719
801
  }
802
+ }
720
803
 
721
- // Handle completion states
722
- if (parsed.type === 'result' || parsed.type === 'done') {
723
- this._status = 'done';
724
- this.notifyStateChange();
725
- } else if (parsed.type === 'error') {
726
- this._status = 'error';
727
- this._error = parsed.content || 'Unknown error';
728
- this.notifyStateChange();
729
- }
730
- } catch {
731
- // Non-JSON line, skip
804
+ // Handle completion states from the JSON
805
+ if (parsed.type === 'result' || parsed.type === 'done') {
806
+ this._status = 'done';
807
+ this.notifyStateChange();
808
+ this.cleanupEventListeners();
809
+ // Defer queued message to next microtask to avoid setting up
810
+ // new listeners while old ones are being cleaned up
811
+ queueMicrotask(() => this.processQueuedMessage());
812
+ return; // Stop processing batch on completion
813
+ } else if (parsed.type === 'error') {
814
+ this._status = 'error';
815
+ this._error = parsed.content || 'Unknown error';
816
+ this.notifyStateChange();
817
+ this.cleanupEventListeners();
818
+ return; // Stop processing batch on error
732
819
  }
820
+ } catch {
821
+ // Non-JSON line from stdout, skip (e.g., progress indicators)
733
822
  }
734
823
  }
735
824
  }
736
- } finally {
737
- reader.releaseLock();
738
- }
825
+ // Collect stderr lines so we can surface them if the process fails
826
+ if (payload.stream === 'stderr') {
827
+ this._stderrLines.push(...payload.lines);
828
+ }
829
+ });
830
+
831
+ // Listen for process exit
832
+ this.unlistenExit = await listen<ProcessExitEvent>('process-exit', (event) => {
833
+ const { payload } = event;
834
+ if (pidRef.current === 0 || payload.pid !== pidRef.current) return;
835
+
836
+ this.activePid = null;
837
+
838
+ // If status wasn't already set to 'done' by a result/done JSON message,
839
+ // set it based on exit code
840
+ if (this._status === 'streaming' || this._status === 'creating' || this._status === 'connecting') {
841
+ if (payload.code === 0) {
842
+ this._status = 'done';
843
+ } else {
844
+ this._status = 'error';
845
+ // Include stderr output so users can see why the process failed
846
+ const stderrSummary = this._stderrLines.join('\n').trim();
847
+ this._error = stderrSummary
848
+ ? `${stderrSummary}`
849
+ : `Process exited with code ${payload.code}`;
850
+ this._canRetry = true;
851
+ }
852
+ this.notifyStateChange();
853
+ }
854
+
855
+ this.cleanupEventListeners();
856
+ queueMicrotask(() => this.processQueuedMessage());
857
+ });
739
858
  }
740
859
 
741
860
  /**
@@ -744,11 +863,17 @@ export class SessionStreamManager {
744
863
  stop(): void {
745
864
  this.stopped = true;
746
865
 
747
- if (this.abortController) {
748
- this.abortController.abort();
749
- this.abortController = null;
866
+ // Kill the active Claude process
867
+ if (this.activePid !== null) {
868
+ invoke('claude_stop_stream', { sessionId: this.sessionContext.workItemId }).catch(err => {
869
+ console.error('Failed to stop stream:', this.sessionContext.workItemId, err);
870
+ });
871
+ this.activePid = null;
750
872
  }
751
873
 
874
+ // Clean up event listeners
875
+ this.cleanupEventListeners();
876
+
752
877
  if (this.reconnectTimeout) {
753
878
  clearTimeout(this.reconnectTimeout);
754
879
  this.reconnectTimeout = null;
@@ -762,7 +887,7 @@ export class SessionStreamManager {
762
887
  this._status = 'idle';
763
888
  this._isReconnecting = false;
764
889
  this._reconnectAttempt = 0;
765
- this._queuedMessage = null; // Clear queue on stop
890
+ this._queuedMessage = null;
766
891
  this.notifyStateChange();
767
892
  }
768
893
 
@@ -770,6 +895,8 @@ export class SessionStreamManager {
770
895
  * Clear all messages and reset state
771
896
  */
772
897
  clear(): void {
898
+ this.cleanupEventListeners();
899
+ this._isContinue = false; // Reset continue flag
773
900
  this._messages = [];
774
901
  this._status = 'idle';
775
902
  this._error = null;
@@ -778,6 +905,8 @@ export class SessionStreamManager {
778
905
  this._isReconnecting = false;
779
906
  this._reconnectAttempt = 0;
780
907
  this._queuedMessage = null;
908
+ this._rawEvents = [];
909
+ this._stderrLines = [];
781
910
  this.lastMessage = null;
782
911
  this.lastImages = undefined;
783
912
  this.notifyStateChange();
@@ -797,12 +926,9 @@ export class SessionStreamManager {
797
926
  this._error = null;
798
927
  this._canRetry = false;
799
928
 
800
- // Remove the failed user message (will be re-added on retry) — skip if hidden
801
- if (!this._lastWasHidden) {
802
- const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
803
- if (lastUserIndex >= 0) {
804
- this._messages = this._messages.slice(0, lastUserIndex);
805
- }
929
+ // Remove messages from the failed request (will be re-added on retry)
930
+ if (this._lastUserMessageIndex >= 0) {
931
+ this._messages = this._messages.slice(0, this._lastUserMessageIndex);
806
932
  }
807
933
 
808
934
  this.notifyStateChange();
@@ -847,7 +973,12 @@ export class SessionStreamManager {
847
973
  */
848
974
  destroy(): void {
849
975
  this.stop();
976
+ // Remove session from Rust tracker
977
+ invoke('claude_remove_stream_session', { sessionId: this.sessionContext.workItemId }).catch(err => {
978
+ console.error('Failed to remove stream session:', this.sessionContext.workItemId, err);
979
+ });
850
980
  this._messages = [];
981
+ this._rawEvents = [];
851
982
  }
852
983
  }
853
984
 
@@ -9,7 +9,7 @@
9
9
  * Part of Epic #1000129: Claude Session Architecture Refactor
10
10
  */
11
11
 
12
- import { EventEmitter } from 'events';
12
+ import { EventEmitter } from 'eventemitter3';
13
13
  import {
14
14
  SessionStreamManager,
15
15
  createStreamManager,
@@ -46,6 +46,8 @@ interface ManagerMetadata {
46
46
  createdAt: number;
47
47
  /** Timestamp of last activity (message sent/received) */
48
48
  lastActivityAt: number;
49
+ /** Mutable ref for the session ID — allows rekey() to update the closure */
50
+ sessionIdRef: { current: string };
49
51
  }
50
52
 
51
53
  /** Configuration for stale manager cleanup */
@@ -164,10 +166,11 @@ class StreamManagerRegistry extends EventEmitter {
164
166
  // Skip if recently active
165
167
  if (meta.lastActivityAt > staleThreshold) continue;
166
168
 
167
- // Skip streaming managers if configured
169
+ // Skip actively-running managers if configured
168
170
  if (this.cleanupConfig.onlyCleanupIdle) {
169
171
  const manager = this.managers.get(sessionId);
170
- if (manager && manager.status === 'streaming') continue;
172
+ const activeStatuses = ['streaming', 'creating', 'connecting'];
173
+ if (manager && activeStatuses.includes(manager.status)) continue;
171
174
  }
172
175
 
173
176
  // Clean up this manager
@@ -266,21 +269,26 @@ class StreamManagerRegistry extends EventEmitter {
266
269
 
267
270
  const now = Date.now();
268
271
 
272
+ // Mutable ref so the closure always emits with the current session ID,
273
+ // even after rekey() changes it (fixes stream freeze after session linking)
274
+ const sessionIdRef = { current: sessionId };
275
+
269
276
  // Create new manager with state change callback that emits registry events
270
277
  const manager = createStreamManager(options.context, {
271
278
  ...options.callbacks,
272
279
  onStateChange: (state) => {
273
280
  // Record activity on state changes
274
- this.recordActivity(sessionId);
275
- this.emit('stateChange', sessionId, state);
281
+ this.recordActivity(sessionIdRef.current);
282
+ this.emit('stateChange', sessionIdRef.current, state);
276
283
  },
277
284
  });
278
285
 
279
- // Initialize metadata
286
+ // Initialize metadata (includes sessionIdRef for rekey support)
280
287
  this.metadata.set(sessionId, {
281
288
  refCount: 0,
282
289
  createdAt: now,
283
290
  lastActivityAt: now,
291
+ sessionIdRef,
284
292
  });
285
293
 
286
294
  this.managers.set(sessionId, manager);
@@ -313,6 +321,38 @@ class StreamManagerRegistry extends EventEmitter {
313
321
  return true;
314
322
  }
315
323
 
324
+ /**
325
+ * Re-key a manager from one session ID to another.
326
+ * Used when a standalone session is linked to a work item — the session ID
327
+ * changes but the stream manager (with all its state) must continue working.
328
+ *
329
+ * Updates the mutable sessionIdRef so the onStateChange closure emits events
330
+ * with the new ID, allowing the React state handler to find the session.
331
+ *
332
+ * @param oldId - The current session ID
333
+ * @param newId - The new session ID to re-key to
334
+ * @returns true if re-keyed, false if old ID not found or new ID already exists
335
+ */
336
+ rekey(oldId: string, newId: string): boolean {
337
+ const manager = this.managers.get(oldId);
338
+ const meta = this.metadata.get(oldId);
339
+ if (!manager || !meta) return false;
340
+
341
+ // Don't overwrite an existing manager
342
+ if (this.managers.has(newId)) return false;
343
+
344
+ // Update the mutable ref so the closure emits with the new ID
345
+ meta.sessionIdRef.current = newId;
346
+
347
+ // Move manager and metadata to new key
348
+ this.managers.delete(oldId);
349
+ this.managers.set(newId, manager);
350
+ this.metadata.delete(oldId);
351
+ this.metadata.set(newId, meta);
352
+
353
+ return true;
354
+ }
355
+
316
356
  /**
317
357
  * Get all session IDs in the registry.
318
358
  */