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
@@ -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,12 +68,14 @@ 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
 
52
76
  export interface StreamManagerCallbacks {
53
77
  onStateChange?: (state: StreamState) => void;
54
- onWorkItemCreated?: (workItemId: number, title: string) => void;
78
+ onWorkItemCreated?: (workItemId: number, title: string, sourceSessionId: string) => void;
55
79
  onGate?: (gate: ClaudeMessage) => void;
56
80
  onQuestion?: (gate: ClaudeMessage) => void;
57
81
  }
@@ -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
  }
@@ -195,6 +229,23 @@ function transformClaudeMessage(parsed: any): ClaudeMessage | null {
195
229
  timestamp,
196
230
  };
197
231
 
232
+ // Claude CLI v2.1.49+ may emit streaming content block deltas
233
+ case 'content_block_delta': {
234
+ const delta = parsed.delta as { type?: string; text?: string } | undefined;
235
+ if (delta?.type === 'text_delta' && delta.text) {
236
+ return { type: 'assistant', content: delta.text, timestamp };
237
+ }
238
+ return null;
239
+ }
240
+
241
+ case 'content_block_start':
242
+ case 'content_block_stop':
243
+ case 'message_start':
244
+ case 'message_delta':
245
+ case 'message_stop':
246
+ // Streaming lifecycle events — no user-visible content
247
+ return null;
248
+
198
249
  default:
199
250
  return null;
200
251
  }
@@ -213,14 +264,13 @@ function extractCreatedWorkItem(content: string | undefined): { id: number; titl
213
264
  * Check any message for work item creation
214
265
  */
215
266
  function checkMessageForWorkItemCreation(message: ClaudeMessage): { id: number; title: string } | null {
216
- 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) {
217
271
  const created = extractCreatedWorkItem(message.result);
218
272
  if (created) return created;
219
273
  }
220
- if (message.content) {
221
- const created = extractCreatedWorkItem(message.content);
222
- if (created) return created;
223
- }
224
274
  return null;
225
275
  }
226
276
 
@@ -273,6 +323,9 @@ export class SessionStreamManager {
273
323
  private _isReconnecting: boolean = false;
274
324
  private _reconnectAttempt: number = 0;
275
325
  private _narratedMode: boolean = true;
326
+ private _userToggledNarratedMode: boolean = false;
327
+ private _fullReadoutMode: boolean = false;
328
+ private _rawEvents: unknown[] = [];
276
329
  private _pendingQuestion: ClaudeMessage | null = null;
277
330
  private _queuedMessage: QueuedMessage | null = null;
278
331
  private _isFirstMessage: boolean = true;
@@ -280,15 +333,29 @@ export class SessionStreamManager {
280
333
  // Notification batching — coalesces rapid state changes into one callback per frame
281
334
  private _notifyPending: boolean = false;
282
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
+
283
342
  // Control
284
- private abortController: AbortController | null = null;
285
343
  private creatingStatusTimeout: ReturnType<typeof setTimeout> | null = null;
286
344
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
287
345
  private stopped: boolean = false;
288
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
+
289
353
  // Last request (for retry/reconnect)
290
354
  private lastMessage: string | null = null;
291
355
  private lastImages: AttachedImage[] | undefined = undefined;
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;
292
359
 
293
360
  // Callbacks
294
361
  private callbacks: StreamManagerCallbacks;
@@ -334,6 +401,14 @@ export class SessionStreamManager {
334
401
  return this._narratedMode;
335
402
  }
336
403
 
404
+ get fullReadoutMode(): boolean {
405
+ return this._fullReadoutMode;
406
+ }
407
+
408
+ get rawEvents(): unknown[] {
409
+ return this._rawEvents;
410
+ }
411
+
337
412
  get pendingQuestion(): ClaudeMessage | null {
338
413
  return this._pendingQuestion;
339
414
  }
@@ -361,9 +436,29 @@ export class SessionStreamManager {
361
436
 
362
437
  setNarratedMode(enabled: boolean): void {
363
438
  this._narratedMode = enabled;
439
+ this._userToggledNarratedMode = true;
440
+ this.notifyStateChange();
441
+ }
442
+
443
+ /**
444
+ * Update narrated mode without firing notifyStateChange.
445
+ * Used when React state is the source of truth (e.g., user toggle)
446
+ * to keep the stream manager in sync without overwriting React state.
447
+ */
448
+ setNarratedModeQuiet(enabled: boolean): void {
449
+ this._narratedMode = enabled;
450
+ this._userToggledNarratedMode = true;
451
+ }
452
+
453
+ setFullReadoutMode(enabled: boolean): void {
454
+ this._fullReadoutMode = enabled;
364
455
  this.notifyStateChange();
365
456
  }
366
457
 
458
+ setFullReadoutModeQuiet(enabled: boolean): void {
459
+ this._fullReadoutMode = enabled;
460
+ }
461
+
367
462
  /**
368
463
  * Answer a pending question gate by clearing it and sending the selection as a message
369
464
  */
@@ -383,6 +478,8 @@ export class SessionStreamManager {
383
478
  isReconnecting: this._isReconnecting,
384
479
  reconnectAttempt: this._reconnectAttempt,
385
480
  narratedMode: this._narratedMode,
481
+ fullReadoutMode: this._fullReadoutMode,
482
+ rawEvents: this._rawEvents,
386
483
  queuedMessage: this._queuedMessage,
387
484
  };
388
485
  }
@@ -454,7 +551,9 @@ export class SessionStreamManager {
454
551
  }
455
552
  // Add the gate message instead of (or in addition to) the raw message
456
553
  this._messages = [...this._messages, gate];
457
- this._narratedMode = true; // Auto-enable narrated mode on first gate
554
+ if (!this._userToggledNarratedMode) {
555
+ this._narratedMode = true; // Auto-enable narrated mode on first gate
556
+ }
458
557
  this.callbacks.onGate?.(gate);
459
558
 
460
559
  // Question gates pause the workflow for user input
@@ -493,11 +592,29 @@ export class SessionStreamManager {
493
592
  return false;
494
593
  }
495
594
 
496
- private getEndpoint(): string {
497
- const { workItemId, standalone } = this.sessionContext;
498
- return standalone
499
- ? `/api/claude/sessions/${workItemId}/message`
500
- : `/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
+ }
501
618
  }
502
619
 
503
620
  /**
@@ -522,18 +639,16 @@ export class SessionStreamManager {
522
639
  this.reconnectTimeout = setTimeout(() => {
523
640
  if (this.stopped || !this.lastMessage) return;
524
641
 
525
- // Remove the user message we added (will be re-added on retry)
526
- // Find and remove the last user message
527
- const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
528
- if (lastUserIndex >= 0) {
529
- this._messages = this._messages.slice(0, lastUserIndex);
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);
530
645
  }
531
646
 
532
647
  this._isReconnecting = false;
533
648
  this.notifyStateChange();
534
649
 
535
650
  // Retry the request
536
- this.sendMessage(this.lastMessage, this.lastImages);
651
+ this.sendMessage(this.lastMessage, this.lastImages, { hidden: this._lastWasHidden });
537
652
  }, delay);
538
653
  }
539
654
 
@@ -542,22 +657,32 @@ export class SessionStreamManager {
542
657
  // -------------------------------------------------------------------------
543
658
 
544
659
  /**
545
- * Send a message to Claude and stream the response
660
+ * Send a message to Claude and stream the response.
661
+ * When hidden is true, the user message is not added to visible messages —
662
+ * used for conversational sessions where Claude should speak first.
546
663
  */
547
- async sendMessage(message: string, images?: AttachedImage[]): Promise<void> {
664
+ async sendMessage(message: string, images?: AttachedImage[], options?: { hidden?: boolean }): Promise<void> {
665
+ const hidden = options?.hidden ?? false;
548
666
  this.stopped = false;
667
+ this._stderrLines = [];
549
668
 
550
669
  // Store for potential retry/reconnect
551
670
  this.lastMessage = message;
552
671
  this.lastImages = images;
553
-
554
- // Add user message immediately
555
- const userMessage: ClaudeMessage = {
556
- type: 'user',
557
- content: message,
558
- timestamp: Date.now(),
559
- };
560
- this.addMessage(userMessage);
672
+ this._lastWasHidden = hidden;
673
+
674
+ // Add user message immediately (unless hidden — conversational system instructions)
675
+ if (!hidden) {
676
+ const userMessage: ClaudeMessage = {
677
+ type: 'user',
678
+ content: message,
679
+ timestamp: Date.now(),
680
+ };
681
+ this._lastUserMessageIndex = this._messages.length;
682
+ this.addMessage(userMessage);
683
+ } else {
684
+ this._lastUserMessageIndex = this._messages.length;
685
+ }
561
686
 
562
687
  // For first message in a new session, show "creating" status
563
688
  // Conversational sessions skip the 5s delay and go straight to streaming
@@ -584,119 +709,152 @@ export class SessionStreamManager {
584
709
  this.notifyStateChange();
585
710
  }
586
711
 
587
- // Create new abort controller for this request
588
- if (this.abortController) {
589
- this.abortController.abort();
590
- }
591
- 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 };
592
720
 
593
721
  try {
594
- const response = await fetch(this.getEndpoint(), {
595
- method: 'POST',
596
- headers: {
597
- 'Content-Type': 'application/json',
598
- },
599
- 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,
600
728
  message,
729
+ work_item_id: this.sessionContext.standalone ? null : Number(this.sessionContext.workItemId) || null,
601
730
  images: images?.map(img => ({
602
731
  type: img.type,
603
732
  data: img.dataUrl,
604
- })),
605
- }),
606
- signal: this.abortController.signal,
733
+ })) || null,
734
+ is_continue: this._isContinue,
735
+ narrated_mode: this._narratedMode,
736
+ full_readout: this._fullReadoutMode,
737
+ },
607
738
  });
608
739
 
609
- if (!response.ok) {
610
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
611
- }
612
-
613
- if (!response.body) {
614
- throw new Error('Response body is null');
615
- }
616
-
617
- 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
618
744
 
619
- // Success - reset reconnect state
745
+ // Reset reconnect state on successful spawn
620
746
  this._reconnectAttempt = 0;
621
747
  this._isReconnecting = false;
622
748
  } catch (err) {
623
- if (err instanceof Error && err.name !== 'AbortError') {
624
- // Check if this is a network error that we should auto-retry
625
- if (this.isNetworkError(err) && this._reconnectAttempt < RECONNECT_CONFIG.maxAttempts) {
626
- this._error = `Connection lost. Reconnecting... (attempt ${this._reconnectAttempt + 1}/${RECONNECT_CONFIG.maxAttempts})`;
627
- this.notifyStateChange();
628
- this.scheduleReconnect();
629
- } else {
630
- // Non-network error or max attempts exhausted
631
- this._status = 'error';
632
- this._error = err.message;
633
- this._canRetry = true; // Allow manual retry
634
- this.notifyStateChange();
635
- }
636
- }
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();
637
756
  }
638
757
  }
639
758
 
640
759
  /**
641
- * 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.
642
762
  */
643
- private async processStream(body: ReadableStream<Uint8Array>): Promise<void> {
644
- const reader = body.getReader();
645
- const decoder = new TextDecoder();
646
- let buffer = '';
647
-
648
- try {
649
- while (true) {
650
- const { done, value } = await reader.read();
651
-
652
- if (done) {
653
- this._status = 'done';
654
- this.notifyStateChange();
655
- break;
656
- }
657
-
658
- buffer += decoder.decode(value, { stream: true });
659
-
660
- const lines = buffer.split('\n');
661
- buffer = lines.pop() || '';
662
-
663
- for (const line of lines) {
664
- if (line.startsWith('data: ')) {
665
- const data = line.slice(6);
666
- try {
667
- const parsed = JSON.parse(data);
668
- const claudeMessage = transformClaudeMessage(parsed);
669
-
670
- 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) {
671
780
  this.addMessage(claudeMessage);
672
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
+
673
790
  // Check for work item creation
674
791
  if (this.sessionContext.standalone && this.callbacks.onWorkItemCreated) {
675
- const created = checkMessageForWorkItemCreation(claudeMessage);
676
- if (created) {
677
- this.callbacks.onWorkItemCreated(created.id, created.title);
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
+ }
678
799
  }
679
800
  }
680
801
  }
802
+ }
681
803
 
682
- // Handle completion states
683
- if (parsed.type === 'result' || parsed.type === 'done') {
684
- this._status = 'done';
685
- this.notifyStateChange();
686
- } else if (parsed.type === 'error') {
687
- this._status = 'error';
688
- this._error = parsed.content || 'Unknown error';
689
- this.notifyStateChange();
690
- }
691
- } catch {
692
- // 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
693
819
  }
820
+ } catch {
821
+ // Non-JSON line from stdout, skip (e.g., progress indicators)
694
822
  }
695
823
  }
696
824
  }
697
- } finally {
698
- reader.releaseLock();
699
- }
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
+ });
700
858
  }
701
859
 
702
860
  /**
@@ -705,11 +863,17 @@ export class SessionStreamManager {
705
863
  stop(): void {
706
864
  this.stopped = true;
707
865
 
708
- if (this.abortController) {
709
- this.abortController.abort();
710
- 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;
711
872
  }
712
873
 
874
+ // Clean up event listeners
875
+ this.cleanupEventListeners();
876
+
713
877
  if (this.reconnectTimeout) {
714
878
  clearTimeout(this.reconnectTimeout);
715
879
  this.reconnectTimeout = null;
@@ -723,7 +887,7 @@ export class SessionStreamManager {
723
887
  this._status = 'idle';
724
888
  this._isReconnecting = false;
725
889
  this._reconnectAttempt = 0;
726
- this._queuedMessage = null; // Clear queue on stop
890
+ this._queuedMessage = null;
727
891
  this.notifyStateChange();
728
892
  }
729
893
 
@@ -731,6 +895,8 @@ export class SessionStreamManager {
731
895
  * Clear all messages and reset state
732
896
  */
733
897
  clear(): void {
898
+ this.cleanupEventListeners();
899
+ this._isContinue = false; // Reset continue flag
734
900
  this._messages = [];
735
901
  this._status = 'idle';
736
902
  this._error = null;
@@ -739,6 +905,8 @@ export class SessionStreamManager {
739
905
  this._isReconnecting = false;
740
906
  this._reconnectAttempt = 0;
741
907
  this._queuedMessage = null;
908
+ this._rawEvents = [];
909
+ this._stderrLines = [];
742
910
  this.lastMessage = null;
743
911
  this.lastImages = undefined;
744
912
  this.notifyStateChange();
@@ -758,16 +926,15 @@ export class SessionStreamManager {
758
926
  this._error = null;
759
927
  this._canRetry = false;
760
928
 
761
- // Remove the failed user message (will be re-added on retry)
762
- const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
763
- if (lastUserIndex >= 0) {
764
- this._messages = this._messages.slice(0, lastUserIndex);
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);
765
932
  }
766
933
 
767
934
  this.notifyStateChange();
768
935
 
769
936
  // Resend the last message
770
- this.sendMessage(this.lastMessage, this.lastImages);
937
+ this.sendMessage(this.lastMessage, this.lastImages, { hidden: this._lastWasHidden });
771
938
  }
772
939
 
773
940
  /**
@@ -787,7 +954,9 @@ export class SessionStreamManager {
787
954
  timestamp: Date.now(),
788
955
  };
789
956
  this._messages = [...this._messages, gate];
790
- this._narratedMode = true;
957
+ if (!this._userToggledNarratedMode) {
958
+ this._narratedMode = true;
959
+ }
791
960
  this.callbacks.onGate?.(gate);
792
961
  this.notifyStateChange();
793
962
  }
@@ -804,7 +973,12 @@ export class SessionStreamManager {
804
973
  */
805
974
  destroy(): void {
806
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
+ });
807
980
  this._messages = [];
981
+ this._rawEvents = [];
808
982
  }
809
983
  }
810
984