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.
- package/.env +2 -1
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
- package/apps/dashboard/app/demo/gates/page.tsx +3 -5
- package/apps/dashboard/app/design-system/page.tsx +1 -1
- package/apps/dashboard/app/globals.css +74 -2
- package/apps/dashboard/app/install-claude/page.tsx +3 -5
- package/apps/dashboard/app/login/page.tsx +17 -20
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +60 -12
- package/apps/dashboard/app/signup/page.tsx +14 -17
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +12 -15
- package/apps/dashboard/app/work/[id]/page.tsx +90 -75
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +70 -61
- package/apps/dashboard/components/CardMenu.tsx +0 -1
- package/apps/dashboard/components/ClaudePanel.tsx +541 -283
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/CopyableId.tsx +1 -2
- package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
- package/apps/dashboard/components/DragContext.tsx +132 -62
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +5 -6
- package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +0 -1
- package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
- package/apps/dashboard/components/EpicGroup.tsx +100 -70
- package/apps/dashboard/components/GateCard.tsx +0 -1
- package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
- package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/JettyLoader.tsx +0 -1
- package/apps/dashboard/components/KanbanBoard.tsx +319 -173
- package/apps/dashboard/components/KanbanCard.tsx +341 -107
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
- package/apps/dashboard/components/MainNav.tsx +24 -25
- package/apps/dashboard/components/MessageBlock.tsx +93 -16
- package/apps/dashboard/components/ModeStartCard.tsx +0 -1
- package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
- package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
- package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
- package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
- package/apps/dashboard/components/ReviewFooter.tsx +12 -14
- package/apps/dashboard/components/SessionList.tsx +0 -1
- package/apps/dashboard/components/SubscribeContent.tsx +40 -11
- package/apps/dashboard/components/TestTree.tsx +1 -2
- package/apps/dashboard/components/TipCard.tsx +2 -4
- package/apps/dashboard/components/Toast.tsx +0 -1
- package/apps/dashboard/components/TypeIcon.tsx +7 -8
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
- package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
- package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
- package/apps/dashboard/components/WorkItemTree.tsx +2 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
- package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
- package/apps/dashboard/components/ui/Button.tsx +1 -1
- package/apps/dashboard/components/ui/Input.tsx +1 -1
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
- package/apps/dashboard/contexts/UsageContext.tsx +62 -31
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1302
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +226 -26
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +253 -122
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +3 -3
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -33
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +167 -30
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/jettypod.js +96 -4
- package/lib/bdd-preflight.js +96 -0
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/work-commands/index.js +58 -16
- package/lib/work-tracking/index.js +108 -8
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +43 -1
- package/skills-templates/chore-mode/SKILL.md +40 -1
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +14 -0
- package/skills-templates/feature-planning/SKILL.md +90 -1
- package/skills-templates/production-mode/SKILL.md +20 -0
- package/skills-templates/simple-improvement/SKILL.md +39 -2
- package/skills-templates/speed-mode/SKILL.md +10 -15
- package/skills-templates/stable-mode/SKILL.md +47 -0
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -55
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
- package/apps/dashboard/electron/ipc-handlers.js +0 -1026
- package/apps/dashboard/electron/main.js +0 -2306
- package/apps/dashboard/electron/preload.js +0 -125
- package/apps/dashboard/electron/session-manager.js +0 -163
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/backlog-parser.ts +0 -50
- package/apps/dashboard/lib/claude-process-manager.ts +0 -529
- package/apps/dashboard/lib/db-bridge.ts +0 -283
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -66
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/bug-icon.svg +0 -9
- package/apps/dashboard/public/buoy-icon.svg +0 -9
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/in-flight-seagull.svg +0 -9
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/pier-icon.svg +0 -14
- package/apps/dashboard/public/star-icon.svg +0 -9
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/public/wrench-icon.svg +0 -9
- package/apps/dashboard/scripts/download-node.js +0 -104
- 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
|
-
|
|
131
|
+
messages.push({ type: 'assistant', content: textParts, timestamp });
|
|
104
132
|
}
|
|
105
133
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
558
|
-
if (
|
|
559
|
-
|
|
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
|
-
//
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
733
|
+
})) || null,
|
|
734
|
+
is_continue: this._isContinue,
|
|
735
|
+
narrated_mode: this._narratedMode,
|
|
736
|
+
full_readout: this._fullReadoutMode,
|
|
737
|
+
},
|
|
646
738
|
});
|
|
647
739
|
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
//
|
|
745
|
+
// Reset reconnect state on successful spawn
|
|
659
746
|
this._reconnectAttempt = 0;
|
|
660
747
|
this._isReconnecting = false;
|
|
661
748
|
} catch (err) {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
*
|
|
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
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
731
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
this.
|
|
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;
|
|
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
|
|
801
|
-
if (
|
|
802
|
-
|
|
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 '
|
|
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
|
|
169
|
+
// Skip actively-running managers if configured
|
|
168
170
|
if (this.cleanupConfig.onlyCleanupIdle) {
|
|
169
171
|
const manager = this.managers.get(sessionId);
|
|
170
|
-
|
|
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(
|
|
275
|
-
this.emit('stateChange',
|
|
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
|
*/
|