@assistkick/create 1.9.0 → 1.11.0
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/dist/src/scaffolder.d.ts +12 -1
- package/dist/src/scaffolder.js +40 -3
- package/dist/src/scaffolder.js.map +1 -1
- package/package.json +1 -1
- package/templates/assistkick-product-system/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/package.json +1 -0
- package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
- package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
- package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
- package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
- package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
- package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
- package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
- package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
- package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
- package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
- package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
- package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
- package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
- package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
- package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
- package/templates/assistkick-product-system/packages/shared/package.json +2 -1
- package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
- package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
- package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
- package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
- package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
- package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
- package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
- package/templates/skills/assistkick-debugger/SKILL.md +23 -23
- package/templates/skills/assistkick-developer/SKILL.md +59 -63
- package/templates/skills/assistkick-interview/SKILL.md +26 -26
- package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
- package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket handler for Chat v2 message streaming.
|
|
3
|
+
* Authenticates via cookie, receives user messages, spawns Claude CLI
|
|
4
|
+
* via ChatCliBridge, and forwards parsed stream-json events in real time.
|
|
5
|
+
* Persists messages to DB on successful stream completion; discards on cancel.
|
|
6
|
+
*
|
|
7
|
+
* Client → Server messages:
|
|
8
|
+
* { type: 'send_message', message, projectId, sessionId, claudeSessionId,
|
|
9
|
+
* isNewSession, permissionMode?, allowedTools?, permissionPromptTool? }
|
|
10
|
+
* { type: 'permission_response', requestId, decision }
|
|
11
|
+
* { type: 'stop_stream' }
|
|
12
|
+
*
|
|
13
|
+
* Server → Client messages:
|
|
14
|
+
* { type: 'stream_start', claudeSessionId }
|
|
15
|
+
* { type: 'stream_event', event } — forwarded parsed stream-json event
|
|
16
|
+
* { type: 'stream_end', exitCode, claudeSessionId }
|
|
17
|
+
* { type: 'stream_cancelled', claudeSessionId } — user-initiated stop
|
|
18
|
+
* { type: 'stream_error', error }
|
|
19
|
+
* { type: 'permission_request', requestId, toolName, input }
|
|
20
|
+
* { type: 'error', message }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { IncomingMessage } from 'node:http';
|
|
24
|
+
import type { Duplex } from 'node:stream';
|
|
25
|
+
import type { WebSocketServer, WebSocket } from 'ws';
|
|
26
|
+
import type { AuthService } from './auth_service.js';
|
|
27
|
+
import type { ChatCliBridge, PermissionMode } from './chat_cli_bridge.js';
|
|
28
|
+
import type { PermissionService, PermissionDecision, PermissionRequest } from './permission_service.js';
|
|
29
|
+
import type { ChatMessageRepository } from './chat_message_repository.js';
|
|
30
|
+
import type { ChatSessionService } from './chat_session_service.js';
|
|
31
|
+
import type { TitleGeneratorService } from './title_generator_service.js';
|
|
32
|
+
|
|
33
|
+
export interface ChatWsHandlerDeps {
|
|
34
|
+
wss: WebSocketServer;
|
|
35
|
+
authService: AuthService;
|
|
36
|
+
chatCliBridge: ChatCliBridge;
|
|
37
|
+
permissionService?: PermissionService;
|
|
38
|
+
chatMessageRepository?: ChatMessageRepository;
|
|
39
|
+
chatSessionService?: ChatSessionService;
|
|
40
|
+
titleGeneratorService?: TitleGeneratorService;
|
|
41
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SendMessagePayload {
|
|
45
|
+
type: 'send_message';
|
|
46
|
+
message: string;
|
|
47
|
+
projectId: string;
|
|
48
|
+
/** The chat session ID (csess_xxx) for message persistence. */
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
claudeSessionId: string;
|
|
51
|
+
isNewSession: boolean;
|
|
52
|
+
permissionMode?: PermissionMode;
|
|
53
|
+
allowedTools?: string[];
|
|
54
|
+
/** Relative workspace paths of attached files to include in the prompt */
|
|
55
|
+
attachments?: string[];
|
|
56
|
+
/** Attachment content blocks for DB persistence alongside the user message */
|
|
57
|
+
attachmentBlocks?: Array<{ type: 'image' | 'file'; path: string; name: string; mimeType: string }>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PermissionResponsePayload {
|
|
61
|
+
type: 'permission_response';
|
|
62
|
+
requestId: string;
|
|
63
|
+
projectId: string;
|
|
64
|
+
decision: PermissionDecision;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StopStreamPayload {
|
|
68
|
+
type: 'stop_stream';
|
|
69
|
+
/** Target a specific Claude session. If omitted, stops all active streams on this WS. */
|
|
70
|
+
claudeSessionId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AttachSessionPayload {
|
|
74
|
+
type: 'attach_session';
|
|
75
|
+
claudeSessionId: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ChatClientMessage = SendMessagePayload | PermissionResponsePayload | StopStreamPayload | AttachSessionPayload;
|
|
79
|
+
|
|
80
|
+
export interface StreamStartMessage {
|
|
81
|
+
type: 'stream_start';
|
|
82
|
+
claudeSessionId: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface StreamEventMessage {
|
|
86
|
+
type: 'stream_event';
|
|
87
|
+
event: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StreamEndMessage {
|
|
91
|
+
type: 'stream_end';
|
|
92
|
+
exitCode: number | null;
|
|
93
|
+
claudeSessionId: string;
|
|
94
|
+
/** Error details when exitCode is non-zero (stderr + non-JSON stdout from CLI) */
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface StreamCancelledMessage {
|
|
99
|
+
type: 'stream_cancelled';
|
|
100
|
+
claudeSessionId: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface StreamErrorMessage {
|
|
104
|
+
type: 'stream_error';
|
|
105
|
+
error: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PermissionRequestMessage {
|
|
109
|
+
type: 'permission_request';
|
|
110
|
+
requestId: string;
|
|
111
|
+
toolName: string;
|
|
112
|
+
input: Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface StreamResumedMessage {
|
|
116
|
+
type: 'stream_resumed';
|
|
117
|
+
claudeSessionId: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SystemPromptMessage {
|
|
121
|
+
type: 'system_prompt';
|
|
122
|
+
prompt: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SessionRenamedMessage {
|
|
126
|
+
type: 'session_renamed';
|
|
127
|
+
sessionId: string;
|
|
128
|
+
name: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ErrorMessage {
|
|
132
|
+
type: 'error';
|
|
133
|
+
message: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type ChatServerMessage =
|
|
137
|
+
| StreamStartMessage
|
|
138
|
+
| StreamEventMessage
|
|
139
|
+
| StreamEndMessage
|
|
140
|
+
| StreamCancelledMessage
|
|
141
|
+
| StreamErrorMessage
|
|
142
|
+
| StreamResumedMessage
|
|
143
|
+
| PermissionRequestMessage
|
|
144
|
+
| SystemPromptMessage
|
|
145
|
+
| SessionRenamedMessage
|
|
146
|
+
| ErrorMessage;
|
|
147
|
+
|
|
148
|
+
/** Tracks in-flight stream context for message persistence. */
|
|
149
|
+
interface StreamContext {
|
|
150
|
+
/** The chat session ID (csess_xxx) — null if frontend didn't provide it. */
|
|
151
|
+
sessionId: string | null;
|
|
152
|
+
/** The original user message text. */
|
|
153
|
+
userMessage: string;
|
|
154
|
+
/** Attachment content blocks from the frontend (image/file metadata). */
|
|
155
|
+
attachmentBlocks: Array<{ type: 'image' | 'file'; path: string; name: string; mimeType: string }>;
|
|
156
|
+
/** The last known assistant content blocks (JSON string), accumulated from stream events. */
|
|
157
|
+
lastAssistantContent: string;
|
|
158
|
+
/** Whether messages have been persisted (on WS disconnect). */
|
|
159
|
+
persisted: boolean;
|
|
160
|
+
/** ID of the persisted assistant message, set after partial persistence completes. */
|
|
161
|
+
persistedAssistantMessageId: string | null;
|
|
162
|
+
/** Promise that resolves when partial persistence is complete (for sequencing with final persist). */
|
|
163
|
+
persistPromise: Promise<void> | null;
|
|
164
|
+
/** Last known context usage JSON from the most recent assistant event (per-call, not cumulative). */
|
|
165
|
+
lastContextUsageJson: string | null;
|
|
166
|
+
/** Context window size from the model's modelUsage (extracted from result event). */
|
|
167
|
+
contextWindow: number | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
|
|
171
|
+
if (!cookieHeader) return {};
|
|
172
|
+
const cookies: Record<string, string> = {};
|
|
173
|
+
for (const pair of cookieHeader.split(';')) {
|
|
174
|
+
const [key, ...rest] = pair.trim().split('=');
|
|
175
|
+
if (key) {
|
|
176
|
+
cookies[key] = rest.join('=');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return cookies;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract the content array from a stream-json event for persistence.
|
|
184
|
+
* Returns the parsed content blocks, or null if not extractable.
|
|
185
|
+
*/
|
|
186
|
+
const extractEventContent = (event: Record<string, unknown>): Array<Record<string, unknown>> | null => {
|
|
187
|
+
const message = event.message as Record<string, unknown> | undefined;
|
|
188
|
+
if (!message) return null;
|
|
189
|
+
const content = message.content;
|
|
190
|
+
if (!Array.isArray(content)) return null;
|
|
191
|
+
return content as Array<Record<string, unknown>>;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Merge new assistant content blocks with accumulated content, preserving
|
|
196
|
+
* tool_use and tool_result blocks from previous turns.
|
|
197
|
+
* Within a turn, assistant events are cumulative (replace current turn blocks).
|
|
198
|
+
* Across turns (detected by missing tool_use IDs), previous content is preserved.
|
|
199
|
+
*/
|
|
200
|
+
const mergeAssistantContent = (
|
|
201
|
+
existing: Array<Record<string, unknown>>,
|
|
202
|
+
newBlocks: Array<Record<string, unknown>>,
|
|
203
|
+
): Array<Record<string, unknown>> => {
|
|
204
|
+
const existingToolUseIds = new Set(
|
|
205
|
+
existing.filter(b => b.type === 'tool_use' && typeof b.id === 'string').map(b => b.id as string),
|
|
206
|
+
);
|
|
207
|
+
const newToolUseIds = new Set(
|
|
208
|
+
newBlocks.filter(b => b.type === 'tool_use' && typeof b.id === 'string').map(b => b.id as string),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const isNewTurn = existingToolUseIds.size > 0 &&
|
|
212
|
+
![...existingToolUseIds].some(id => newToolUseIds.has(id));
|
|
213
|
+
|
|
214
|
+
if (isNewTurn) {
|
|
215
|
+
return [...existing, ...newBlocks];
|
|
216
|
+
}
|
|
217
|
+
// Same turn: replace with cumulative snapshot, preserving tool_result blocks
|
|
218
|
+
const existingToolResults = existing.filter(b => b.type === 'tool_result');
|
|
219
|
+
return [...newBlocks, ...existingToolResults];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract tool_result blocks from a human/tool_result event.
|
|
224
|
+
*/
|
|
225
|
+
const extractToolResults = (event: Record<string, unknown>): Array<Record<string, unknown>> => {
|
|
226
|
+
if (event.type !== 'tool_result' && event.type !== 'human' && event.type !== 'user') return [];
|
|
227
|
+
const content = extractEventContent(event);
|
|
228
|
+
if (!content) return [];
|
|
229
|
+
return content.filter(b => b.type === 'tool_result');
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export class ChatWsHandler {
|
|
233
|
+
private readonly wss: WebSocketServer;
|
|
234
|
+
private readonly authService: AuthService;
|
|
235
|
+
private readonly chatCliBridge: ChatCliBridge;
|
|
236
|
+
private readonly permissionService: PermissionService | null;
|
|
237
|
+
private readonly chatMessageRepository: ChatMessageRepository | null;
|
|
238
|
+
private readonly chatSessionService: ChatSessionService | null;
|
|
239
|
+
private readonly titleGeneratorService: TitleGeneratorService | null;
|
|
240
|
+
private readonly log: ChatWsHandlerDeps['log'];
|
|
241
|
+
private readonly activeStreams = new Map<WebSocket, Set<string>>();
|
|
242
|
+
|
|
243
|
+
/** Map Claude session IDs to their active WebSocket for permission routing */
|
|
244
|
+
private readonly sessionToWs = new Map<string, WebSocket>();
|
|
245
|
+
|
|
246
|
+
/** Track session IDs that were cancelled by user (stop_stream) so completion sends stream_cancelled instead of stream_end */
|
|
247
|
+
private readonly cancelledStreams = new Set<string>();
|
|
248
|
+
|
|
249
|
+
/** Track stream context per Claude session ID for message persistence. */
|
|
250
|
+
private readonly streamContexts = new Map<string, StreamContext>();
|
|
251
|
+
|
|
252
|
+
constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, log }: ChatWsHandlerDeps) {
|
|
253
|
+
this.wss = wss;
|
|
254
|
+
this.authService = authService;
|
|
255
|
+
this.chatCliBridge = chatCliBridge;
|
|
256
|
+
this.permissionService = permissionService || null;
|
|
257
|
+
this.chatMessageRepository = chatMessageRepository || null;
|
|
258
|
+
this.chatSessionService = chatSessionService || null;
|
|
259
|
+
this.titleGeneratorService = titleGeneratorService || null;
|
|
260
|
+
this.log = log;
|
|
261
|
+
|
|
262
|
+
// Register the permission request handler to route requests to the right WebSocket
|
|
263
|
+
if (this.permissionService) {
|
|
264
|
+
this.permissionService.setPermissionRequestHandler(this.onPermissionRequest);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Remove a single Claude session from a WebSocket's active stream set. */
|
|
269
|
+
private removeActiveStream = (ws: WebSocket, claudeSessionId: string): void => {
|
|
270
|
+
const streams = this.activeStreams.get(ws);
|
|
271
|
+
if (streams) {
|
|
272
|
+
streams.delete(claudeSessionId);
|
|
273
|
+
if (streams.size === 0) this.activeStreams.delete(ws);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
|
278
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
279
|
+
if (url.pathname !== '/api/chat') {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
284
|
+
this.onConnection(ws, req);
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
private sendWsMessage = (ws: WebSocket, msg: ChatServerMessage): void => {
|
|
289
|
+
if (ws.readyState === ws.OPEN) {
|
|
290
|
+
ws.send(JSON.stringify(msg));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
private onConnection = async (ws: WebSocket, req: IncomingMessage): Promise<void> => {
|
|
295
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
296
|
+
const token = cookies['access_token'];
|
|
297
|
+
|
|
298
|
+
if (!token) {
|
|
299
|
+
this.log('CHAT_WS', 'Connection rejected — no access token');
|
|
300
|
+
ws.close(4001, 'Unauthorized');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let payload: { sub: string; email?: string; role?: string };
|
|
305
|
+
try {
|
|
306
|
+
payload = await this.authService.verifyToken(token);
|
|
307
|
+
} catch {
|
|
308
|
+
this.log('CHAT_WS', 'Connection rejected — invalid token');
|
|
309
|
+
ws.close(4001, 'Invalid token');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (payload.role !== 'admin') {
|
|
314
|
+
this.log('CHAT_WS', `Connection rejected — user ${payload.sub} is not admin`);
|
|
315
|
+
ws.close(4003, 'Forbidden');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.log('CHAT_WS', `Admin ${payload.email} connected`);
|
|
320
|
+
|
|
321
|
+
ws.on('message', (raw: Buffer | string) => {
|
|
322
|
+
let msg: ChatClientMessage;
|
|
323
|
+
try {
|
|
324
|
+
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
325
|
+
} catch {
|
|
326
|
+
this.sendWsMessage(ws, { type: 'error', message: 'Invalid JSON' });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (msg.type === 'send_message') {
|
|
331
|
+
this.handleSendMessage(ws, msg);
|
|
332
|
+
} else if (msg.type === 'permission_response') {
|
|
333
|
+
this.handlePermissionResponse(msg);
|
|
334
|
+
} else if (msg.type === 'stop_stream') {
|
|
335
|
+
this.handleStopStream(ws, msg);
|
|
336
|
+
} else if (msg.type === 'attach_session') {
|
|
337
|
+
this.handleAttachSession(ws, msg);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
ws.on('close', () => {
|
|
342
|
+
this.log('CHAT_WS', `Admin ${payload.email} disconnected`);
|
|
343
|
+
this.cancelActiveStream(ws);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
ws.on('error', (err) => {
|
|
347
|
+
this.log('CHAT_WS', `WebSocket error: ${err.message}`);
|
|
348
|
+
this.cancelActiveStream(ws);
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Format the user message with attachment file path references appended.
|
|
354
|
+
*/
|
|
355
|
+
private formatMessageWithAttachments = (message: string, attachments?: string[]): string => {
|
|
356
|
+
if (!attachments || attachments.length === 0) return message;
|
|
357
|
+
const fileList = attachments.map((p) => `- ${p}`).join('\n');
|
|
358
|
+
return `${message}\n\nAttached files:\n${fileList}`;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
private handleSendMessage = async (ws: WebSocket, msg: SendMessagePayload): Promise<void> => {
|
|
362
|
+
const { message, projectId, claudeSessionId, isNewSession } = msg;
|
|
363
|
+
|
|
364
|
+
// Reject if this specific Claude session is already streaming
|
|
365
|
+
const wsStreams = this.activeStreams.get(ws);
|
|
366
|
+
if (wsStreams?.has(claudeSessionId)) {
|
|
367
|
+
this.sendWsMessage(ws, {
|
|
368
|
+
type: 'error',
|
|
369
|
+
message: 'A message is already being processed. Wait for it to complete.',
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Validate required fields
|
|
375
|
+
if (!message || !projectId || !claudeSessionId) {
|
|
376
|
+
this.sendWsMessage(ws, {
|
|
377
|
+
type: 'error',
|
|
378
|
+
message: 'Missing required fields: message, projectId, claudeSessionId',
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let activeSet = this.activeStreams.get(ws);
|
|
384
|
+
if (!activeSet) {
|
|
385
|
+
activeSet = new Set();
|
|
386
|
+
this.activeStreams.set(ws, activeSet);
|
|
387
|
+
}
|
|
388
|
+
activeSet.add(claudeSessionId);
|
|
389
|
+
this.sessionToWs.set(claudeSessionId, ws);
|
|
390
|
+
|
|
391
|
+
// Track stream context for persistence (save on completion, discard on cancel)
|
|
392
|
+
this.streamContexts.set(claudeSessionId, {
|
|
393
|
+
sessionId: msg.sessionId || null,
|
|
394
|
+
userMessage: message,
|
|
395
|
+
attachmentBlocks: msg.attachmentBlocks || [],
|
|
396
|
+
lastAssistantContent: '[]',
|
|
397
|
+
persisted: false,
|
|
398
|
+
persistedAssistantMessageId: null,
|
|
399
|
+
persistPromise: null,
|
|
400
|
+
lastContextUsageJson: null,
|
|
401
|
+
contextWindow: null,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
this.sendWsMessage(ws, { type: 'stream_start', claudeSessionId });
|
|
405
|
+
this.log('CHAT_WS', `Streaming started for session ${claudeSessionId}`);
|
|
406
|
+
|
|
407
|
+
const permissionMode = msg.permissionMode || 'skip';
|
|
408
|
+
const formattedMessage = this.formatMessageWithAttachments(message, msg.attachments);
|
|
409
|
+
|
|
410
|
+
// Look up continuation context from session DB when starting a new session
|
|
411
|
+
let continuationContext: string | undefined;
|
|
412
|
+
if (isNewSession && msg.sessionId && this.chatSessionService) {
|
|
413
|
+
const session = await this.chatSessionService.getSession(msg.sessionId);
|
|
414
|
+
if (session?.continuationContext) {
|
|
415
|
+
continuationContext = session.continuationContext;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const { completion, systemPrompt } = this.chatCliBridge.spawn({
|
|
421
|
+
projectId,
|
|
422
|
+
message: formattedMessage,
|
|
423
|
+
claudeSessionId,
|
|
424
|
+
isNewSession,
|
|
425
|
+
permissionMode,
|
|
426
|
+
allowedTools: msg.allowedTools,
|
|
427
|
+
continuationContext,
|
|
428
|
+
onEvent: (event) => {
|
|
429
|
+
// Forward to current WS (may have been re-attached after disconnect)
|
|
430
|
+
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
431
|
+
if (currentWs) {
|
|
432
|
+
this.sendWsMessage(currentWs, { type: 'stream_event', event });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Accumulate assistant content and context usage for persistence
|
|
436
|
+
const ctx = this.streamContexts.get(claudeSessionId);
|
|
437
|
+
if (!ctx) return;
|
|
438
|
+
|
|
439
|
+
// Extract per-call context usage from assistant events only.
|
|
440
|
+
// The result event contains CUMULATIVE usage across all turns — not the context window size.
|
|
441
|
+
if (event.type === 'assistant') {
|
|
442
|
+
const message = event.message as Record<string, unknown> | undefined;
|
|
443
|
+
const usage = message?.usage as Record<string, unknown> | undefined;
|
|
444
|
+
if (usage) {
|
|
445
|
+
const input = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
|
|
446
|
+
const output = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
|
|
447
|
+
const cacheCreation = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0;
|
|
448
|
+
const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
|
|
449
|
+
ctx.lastContextUsageJson = JSON.stringify({
|
|
450
|
+
inputTokens: input,
|
|
451
|
+
outputTokens: output,
|
|
452
|
+
cacheCreationTokens: cacheCreation,
|
|
453
|
+
cacheReadTokens: cacheRead,
|
|
454
|
+
// totalTokens = input context only (output tokens don't count toward context window)
|
|
455
|
+
totalTokens: input + cacheCreation + cacheRead,
|
|
456
|
+
contextWindow: ctx.contextWindow,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Extract contextWindow from result event's modelUsage
|
|
462
|
+
if (event.type === 'result') {
|
|
463
|
+
const modelUsage = event.modelUsage as Record<string, Record<string, unknown>> | undefined;
|
|
464
|
+
if (modelUsage) {
|
|
465
|
+
for (const info of Object.values(modelUsage)) {
|
|
466
|
+
if (typeof info.contextWindow === 'number') {
|
|
467
|
+
ctx.contextWindow = info.contextWindow;
|
|
468
|
+
// Re-emit the last context usage JSON with the contextWindow included
|
|
469
|
+
if (ctx.lastContextUsageJson) {
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(ctx.lastContextUsageJson);
|
|
472
|
+
parsed.contextWindow = ctx.contextWindow;
|
|
473
|
+
ctx.lastContextUsageJson = JSON.stringify(parsed);
|
|
474
|
+
} catch { /* ignore */ }
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (event.type === 'assistant') {
|
|
483
|
+
const newBlocks = extractEventContent(event);
|
|
484
|
+
if (newBlocks && newBlocks.length > 0) {
|
|
485
|
+
const existing = JSON.parse(ctx.lastAssistantContent) as Array<Record<string, unknown>>;
|
|
486
|
+
const merged = mergeAssistantContent(existing, newBlocks);
|
|
487
|
+
ctx.lastAssistantContent = JSON.stringify(merged);
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
// Capture tool_result blocks from human/tool_result events
|
|
491
|
+
const toolResults = extractToolResults(event);
|
|
492
|
+
if (toolResults.length > 0) {
|
|
493
|
+
const existing = JSON.parse(ctx.lastAssistantContent) as Array<Record<string, unknown>>;
|
|
494
|
+
const existingToolResultIds = new Set(
|
|
495
|
+
existing.filter(b => b.type === 'tool_result').map(b => b.tool_use_id as string),
|
|
496
|
+
);
|
|
497
|
+
const newResults = toolResults.filter(b => !existingToolResultIds.has(b.tool_use_id as string));
|
|
498
|
+
if (newResults.length > 0) {
|
|
499
|
+
ctx.lastAssistantContent = JSON.stringify([...existing, ...newResults]);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Send composed system prompt to frontend and persist to session
|
|
507
|
+
if (systemPrompt) {
|
|
508
|
+
this.sendWsMessage(ws, { type: 'system_prompt', prompt: systemPrompt });
|
|
509
|
+
|
|
510
|
+
if (msg.sessionId && this.chatSessionService) {
|
|
511
|
+
this.chatSessionService.updateSystemPrompt(msg.sessionId, systemPrompt)
|
|
512
|
+
.catch((err: Error) => {
|
|
513
|
+
this.log('CHAT_WS', `Failed to persist system prompt for session ${msg.sessionId}: ${err.message}`);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
completion
|
|
519
|
+
.then((result) => {
|
|
520
|
+
// Look up the current WS — may differ from the original if client reconnected
|
|
521
|
+
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
522
|
+
if (currentWs) {
|
|
523
|
+
this.removeActiveStream(currentWs, claudeSessionId);
|
|
524
|
+
}
|
|
525
|
+
this.sessionToWs.delete(claudeSessionId);
|
|
526
|
+
|
|
527
|
+
// If this was a user-initiated stop, send stream_cancelled instead of stream_end
|
|
528
|
+
if (this.cancelledStreams.has(claudeSessionId)) {
|
|
529
|
+
this.cancelledStreams.delete(claudeSessionId);
|
|
530
|
+
this.streamContexts.delete(claudeSessionId);
|
|
531
|
+
if (currentWs) {
|
|
532
|
+
this.sendWsMessage(currentWs, {
|
|
533
|
+
type: 'stream_cancelled',
|
|
534
|
+
claudeSessionId,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
this.log('CHAT_WS', `Stream cancelled by user for session ${claudeSessionId}`);
|
|
538
|
+
} else {
|
|
539
|
+
// Capture stream context for title generation before persistMessages deletes it
|
|
540
|
+
const titleCtx = this.streamContexts.get(claudeSessionId);
|
|
541
|
+
const titleSessionId = titleCtx?.sessionId ?? null;
|
|
542
|
+
const titleUserMessage = titleCtx?.userMessage ?? '';
|
|
543
|
+
const titleAssistantContent = titleCtx?.lastAssistantContent ?? '[]';
|
|
544
|
+
|
|
545
|
+
// Persist messages on successful completion (handles both fresh and partial-persist cases)
|
|
546
|
+
this.persistMessages(claudeSessionId);
|
|
547
|
+
|
|
548
|
+
// Fire-and-forget auto-title generation
|
|
549
|
+
if (titleSessionId) {
|
|
550
|
+
this.maybeGenerateTitle(claudeSessionId, titleSessionId, titleUserMessage, titleAssistantContent);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Build error details for non-zero exit codes
|
|
554
|
+
let errorDetail: string | undefined;
|
|
555
|
+
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
556
|
+
const parts: string[] = [];
|
|
557
|
+
if (result.stderr) parts.push(result.stderr);
|
|
558
|
+
if (result.stdoutNonJsonLines) parts.push(result.stdoutNonJsonLines);
|
|
559
|
+
errorDetail = parts.length > 0
|
|
560
|
+
? parts.join('\n')
|
|
561
|
+
: `CLI exited with code ${result.exitCode}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (currentWs) {
|
|
565
|
+
const msg: StreamEndMessage = {
|
|
566
|
+
type: 'stream_end',
|
|
567
|
+
exitCode: result.exitCode,
|
|
568
|
+
claudeSessionId: result.claudeSessionId,
|
|
569
|
+
};
|
|
570
|
+
if (errorDetail) msg.error = errorDetail;
|
|
571
|
+
this.sendWsMessage(currentWs, msg);
|
|
572
|
+
}
|
|
573
|
+
this.log('CHAT_WS', `Streaming ended for session ${claudeSessionId}, exit code ${result.exitCode}${errorDetail ? `, error: ${errorDetail}` : ''}`);
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
.catch((err: Error) => {
|
|
577
|
+
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
578
|
+
if (currentWs) {
|
|
579
|
+
this.removeActiveStream(currentWs, claudeSessionId);
|
|
580
|
+
}
|
|
581
|
+
this.sessionToWs.delete(claudeSessionId);
|
|
582
|
+
|
|
583
|
+
// Persist whatever we have even on error
|
|
584
|
+
this.persistMessages(claudeSessionId);
|
|
585
|
+
|
|
586
|
+
if (currentWs) {
|
|
587
|
+
this.sendWsMessage(currentWs, {
|
|
588
|
+
type: 'stream_error',
|
|
589
|
+
error: err.message,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
this.log('CHAT_WS', `Streaming error for session ${claudeSessionId}: ${err.message}`);
|
|
593
|
+
});
|
|
594
|
+
} catch (err: unknown) {
|
|
595
|
+
this.removeActiveStream(ws, claudeSessionId);
|
|
596
|
+
this.sessionToWs.delete(claudeSessionId);
|
|
597
|
+
this.streamContexts.delete(claudeSessionId);
|
|
598
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
599
|
+
this.sendWsMessage(ws, { type: 'stream_error', error: errorMsg });
|
|
600
|
+
this.log('CHAT_WS', `Failed to spawn CLI for session ${claudeSessionId}: ${errorMsg}`);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Persist the user message and assistant response to the database.
|
|
606
|
+
* Handles two cases:
|
|
607
|
+
* - Fresh: no prior persistence — save both messages.
|
|
608
|
+
* - Partial: messages were already saved on WS disconnect — update the assistant message with final content.
|
|
609
|
+
*/
|
|
610
|
+
private persistMessages = (claudeSessionId: string): void => {
|
|
611
|
+
const ctx = this.streamContexts.get(claudeSessionId);
|
|
612
|
+
this.streamContexts.delete(claudeSessionId);
|
|
613
|
+
|
|
614
|
+
if (!ctx || !ctx.sessionId || !this.chatMessageRepository) return;
|
|
615
|
+
|
|
616
|
+
// Persist context usage to the session
|
|
617
|
+
if (ctx.lastContextUsageJson && this.chatSessionService) {
|
|
618
|
+
this.chatSessionService.updateContextUsage(ctx.sessionId, ctx.lastContextUsageJson)
|
|
619
|
+
.catch((err: Error) => {
|
|
620
|
+
this.log('CHAT_WS', `Failed to persist context usage for session ${ctx.sessionId}: ${err.message}`);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const { sessionId, lastAssistantContent } = ctx;
|
|
625
|
+
|
|
626
|
+
if (ctx.persisted) {
|
|
627
|
+
// Messages were already saved on WS disconnect — update the assistant message with final content
|
|
628
|
+
const doUpdate = () => {
|
|
629
|
+
if (ctx.persistedAssistantMessageId) {
|
|
630
|
+
this.chatMessageRepository!.updateMessageContent(ctx.persistedAssistantMessageId, lastAssistantContent)
|
|
631
|
+
.then(() => {
|
|
632
|
+
this.log('CHAT_WS', `Updated assistant message ${ctx.persistedAssistantMessageId} with final content for session ${sessionId}`);
|
|
633
|
+
})
|
|
634
|
+
.catch((err: Error) => {
|
|
635
|
+
this.log('CHAT_WS', `Failed to update assistant message for session ${sessionId}: ${err.message}`);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Wait for partial persist to complete before updating
|
|
641
|
+
if (ctx.persistPromise) {
|
|
642
|
+
ctx.persistPromise.then(doUpdate);
|
|
643
|
+
} else {
|
|
644
|
+
doUpdate();
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
// Fresh path — save both messages
|
|
648
|
+
const userBlocks: Array<Record<string, unknown>> = [{ type: 'text', text: ctx.userMessage }];
|
|
649
|
+
for (const block of ctx.attachmentBlocks) {
|
|
650
|
+
userBlocks.push({ type: block.type, path: block.path, name: block.name, mimeType: block.mimeType });
|
|
651
|
+
}
|
|
652
|
+
const userContent = JSON.stringify(userBlocks);
|
|
653
|
+
|
|
654
|
+
this.chatMessageRepository.getNextOrderIndex(sessionId)
|
|
655
|
+
.then((nextIndex) => {
|
|
656
|
+
return Promise.all([
|
|
657
|
+
this.chatMessageRepository!.saveMessage(sessionId, 'user', userContent, nextIndex),
|
|
658
|
+
this.chatMessageRepository!.saveMessage(sessionId, 'assistant', lastAssistantContent, nextIndex + 1),
|
|
659
|
+
]);
|
|
660
|
+
})
|
|
661
|
+
.catch((err: Error) => {
|
|
662
|
+
this.log('CHAT_WS', `Failed to persist messages for session ${sessionId}: ${err.message}`);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Persist partial messages immediately when a WebSocket disconnects mid-stream.
|
|
669
|
+
* Saves the user message and whatever assistant content has been accumulated so far.
|
|
670
|
+
*/
|
|
671
|
+
private persistPartialMessages = (claudeSessionId: string): void => {
|
|
672
|
+
const ctx = this.streamContexts.get(claudeSessionId);
|
|
673
|
+
if (!ctx || !ctx.sessionId || !this.chatMessageRepository || ctx.persisted) return;
|
|
674
|
+
|
|
675
|
+
ctx.persisted = true;
|
|
676
|
+
|
|
677
|
+
const { sessionId, userMessage, lastAssistantContent, attachmentBlocks } = ctx;
|
|
678
|
+
const userBlocks: Array<Record<string, unknown>> = [{ type: 'text', text: userMessage }];
|
|
679
|
+
for (const block of attachmentBlocks) {
|
|
680
|
+
userBlocks.push({ type: block.type, path: block.path, name: block.name, mimeType: block.mimeType });
|
|
681
|
+
}
|
|
682
|
+
const userContent = JSON.stringify(userBlocks);
|
|
683
|
+
|
|
684
|
+
ctx.persistPromise = this.chatMessageRepository.getNextOrderIndex(sessionId)
|
|
685
|
+
.then((nextIndex) => {
|
|
686
|
+
return Promise.all([
|
|
687
|
+
this.chatMessageRepository!.saveMessage(sessionId, 'user', userContent, nextIndex),
|
|
688
|
+
this.chatMessageRepository!.saveMessage(sessionId, 'assistant', lastAssistantContent, nextIndex + 1),
|
|
689
|
+
]);
|
|
690
|
+
})
|
|
691
|
+
.then(([, assistantRow]) => {
|
|
692
|
+
ctx.persistedAssistantMessageId = assistantRow.id;
|
|
693
|
+
this.log('CHAT_WS', `Persisted partial messages for session ${sessionId} (assistant msg: ${assistantRow.id})`);
|
|
694
|
+
})
|
|
695
|
+
.catch((err: Error) => {
|
|
696
|
+
this.log('CHAT_WS', `Failed to persist partial messages for session ${sessionId}: ${err.message}`);
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Fire-and-forget: attempt to auto-generate a title for a session that still has
|
|
702
|
+
* the default timestamp-based name. Runs after messages are persisted so the title
|
|
703
|
+
* can consider both the user message and assistant reply.
|
|
704
|
+
*/
|
|
705
|
+
private maybeGenerateTitle = (claudeSessionId: string, sessionId: string, userMessage: string, assistantContent: string): void => {
|
|
706
|
+
if (!this.titleGeneratorService || !this.chatSessionService) return;
|
|
707
|
+
|
|
708
|
+
const titleGen = this.titleGeneratorService;
|
|
709
|
+
const sessionService = this.chatSessionService;
|
|
710
|
+
|
|
711
|
+
// Fire-and-forget — never blocks the main stream
|
|
712
|
+
sessionService.getSession(sessionId)
|
|
713
|
+
.then((session) => {
|
|
714
|
+
if (!session) return;
|
|
715
|
+
if (!titleGen.isDefaultName(session.name)) return;
|
|
716
|
+
|
|
717
|
+
return titleGen.generateTitle(userMessage, assistantContent)
|
|
718
|
+
.then((title) => {
|
|
719
|
+
if (!title) return;
|
|
720
|
+
return sessionService.renameSession(sessionId, title)
|
|
721
|
+
.then(() => {
|
|
722
|
+
// Push session_renamed to the connected WebSocket client
|
|
723
|
+
const ws = this.sessionToWs.get(claudeSessionId);
|
|
724
|
+
if (ws) {
|
|
725
|
+
this.sendWsMessage(ws, {
|
|
726
|
+
type: 'session_renamed',
|
|
727
|
+
sessionId,
|
|
728
|
+
name: title,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
// Also broadcast to all connected clients in case the sidebar is open on another tab
|
|
732
|
+
this.broadcastSessionRenamed(sessionId, title, claudeSessionId);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
})
|
|
736
|
+
.catch((err: Error) => {
|
|
737
|
+
this.log('CHAT_WS', `Auto-title generation failed for session ${sessionId}: ${err.message}`);
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Broadcast session_renamed to all connected WebSocket clients except the one
|
|
743
|
+
* already notified (identified by claudeSessionId).
|
|
744
|
+
*/
|
|
745
|
+
private broadcastSessionRenamed = (sessionId: string, name: string, excludeClaudeSessionId: string): void => {
|
|
746
|
+
const excludeWs = this.sessionToWs.get(excludeClaudeSessionId);
|
|
747
|
+
for (const client of this.wss.clients) {
|
|
748
|
+
if (client !== excludeWs && client.readyState === client.OPEN) {
|
|
749
|
+
this.sendWsMessage(client as WebSocket, {
|
|
750
|
+
type: 'session_renamed',
|
|
751
|
+
sessionId,
|
|
752
|
+
name,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Handle permission_response from the frontend.
|
|
760
|
+
* Routes it to the PermissionService to resolve the pending request.
|
|
761
|
+
*/
|
|
762
|
+
private handlePermissionResponse = (msg: PermissionResponsePayload): void => {
|
|
763
|
+
if (!this.permissionService) {
|
|
764
|
+
this.log('CHAT_WS', 'Permission response received but no permission service configured');
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const { requestId, projectId, decision } = msg;
|
|
769
|
+
if (!requestId || !decision) {
|
|
770
|
+
this.log('CHAT_WS', 'Permission response missing requestId or decision');
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
this.permissionService.resolvePermission(requestId, projectId, decision).catch((err: Error) => {
|
|
775
|
+
this.log('CHAT_WS', `Failed to resolve permission: ${err.message}`);
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Called by PermissionService when a new permission request arrives
|
|
781
|
+
* from the MCP server and needs to be forwarded to the frontend.
|
|
782
|
+
*/
|
|
783
|
+
private onPermissionRequest = (request: PermissionRequest): void => {
|
|
784
|
+
const ws = this.sessionToWs.get(request.claudeSessionId);
|
|
785
|
+
if (!ws) {
|
|
786
|
+
this.log('CHAT_WS', `No WebSocket found for session ${request.claudeSessionId} — cannot send permission request`);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
this.sendWsMessage(ws, {
|
|
791
|
+
type: 'permission_request',
|
|
792
|
+
requestId: request.requestId,
|
|
793
|
+
toolName: request.toolName,
|
|
794
|
+
input: request.input,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
this.log('CHAT_WS', `Forwarded permission request ${request.requestId} for tool "${request.toolName}"`);
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Handle user-initiated stop: kill the CLI process but keep the WebSocket open.
|
|
802
|
+
* The completion handler will detect the cancellation and send stream_cancelled.
|
|
803
|
+
*/
|
|
804
|
+
private handleStopStream = (ws: WebSocket, msg: StopStreamPayload): void => {
|
|
805
|
+
const streams = this.activeStreams.get(ws);
|
|
806
|
+
if (!streams || streams.size === 0) {
|
|
807
|
+
this.sendWsMessage(ws, { type: 'error', message: 'No active stream to stop' });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// If a specific session was targeted, stop just that one; otherwise stop all
|
|
812
|
+
const targets = msg.claudeSessionId
|
|
813
|
+
? (streams.has(msg.claudeSessionId) ? [msg.claudeSessionId] : [])
|
|
814
|
+
: [...streams];
|
|
815
|
+
|
|
816
|
+
if (targets.length === 0) {
|
|
817
|
+
this.sendWsMessage(ws, { type: 'error', message: 'No active stream to stop' });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
for (const sessionId of targets) {
|
|
822
|
+
this.cancelledStreams.add(sessionId);
|
|
823
|
+
this.chatCliBridge.kill(sessionId);
|
|
824
|
+
|
|
825
|
+
if (this.permissionService) {
|
|
826
|
+
this.permissionService.cancelPendingRequests(sessionId);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
this.log('CHAT_WS', `User requested stop for session ${sessionId}`);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Handle WS disconnect: clean up WS mappings but let the CLI process continue running.
|
|
835
|
+
* Persists partial messages so they survive the disconnect.
|
|
836
|
+
* The completion handler will still run and finalize persistence.
|
|
837
|
+
*/
|
|
838
|
+
private cancelActiveStream = (ws: WebSocket): void => {
|
|
839
|
+
const streams = this.activeStreams.get(ws);
|
|
840
|
+
if (streams && streams.size > 0) {
|
|
841
|
+
// Do NOT kill the CLI — let the agent finish its work
|
|
842
|
+
// Only clean up WS-related mappings
|
|
843
|
+
for (const sessionId of streams) {
|
|
844
|
+
this.sessionToWs.delete(sessionId);
|
|
845
|
+
|
|
846
|
+
if (this.permissionService) {
|
|
847
|
+
this.permissionService.cancelPendingRequests(sessionId);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
this.persistPartialMessages(sessionId);
|
|
851
|
+
|
|
852
|
+
this.log('CHAT_WS', `WS disconnected for session ${sessionId} — CLI continues running`);
|
|
853
|
+
}
|
|
854
|
+
this.activeStreams.delete(ws);
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Handle attach_session: client reconnects and wants to re-attach to an in-flight stream.
|
|
860
|
+
* If an active stream exists, re-maps the WS and sends catch-up content.
|
|
861
|
+
*/
|
|
862
|
+
private handleAttachSession = (ws: WebSocket, msg: AttachSessionPayload): void => {
|
|
863
|
+
const { claudeSessionId } = msg;
|
|
864
|
+
const ctx = this.streamContexts.get(claudeSessionId);
|
|
865
|
+
|
|
866
|
+
if (!ctx) {
|
|
867
|
+
// No active stream — the CLI has already finished. Nothing to attach to.
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Re-map the new WS to receive events for this session
|
|
872
|
+
this.sessionToWs.set(claudeSessionId, ws);
|
|
873
|
+
let activeSet = this.activeStreams.get(ws);
|
|
874
|
+
if (!activeSet) {
|
|
875
|
+
activeSet = new Set();
|
|
876
|
+
this.activeStreams.set(ws, activeSet);
|
|
877
|
+
}
|
|
878
|
+
activeSet.add(claudeSessionId);
|
|
879
|
+
|
|
880
|
+
// Send stream_resumed to signal re-attachment
|
|
881
|
+
this.sendWsMessage(ws, { type: 'stream_resumed', claudeSessionId });
|
|
882
|
+
|
|
883
|
+
// Send accumulated assistant content as a catch-up stream_event
|
|
884
|
+
if (ctx.lastAssistantContent !== '[]') {
|
|
885
|
+
const catchUpEvent: Record<string, unknown> = {
|
|
886
|
+
type: 'assistant',
|
|
887
|
+
message: { content: JSON.parse(ctx.lastAssistantContent) },
|
|
888
|
+
};
|
|
889
|
+
this.sendWsMessage(ws, { type: 'stream_event', event: catchUpEvent });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
this.log('CHAT_WS', `Client re-attached to in-flight stream for session ${claudeSessionId}`);
|
|
893
|
+
};
|
|
894
|
+
}
|