circuschief 0.5.0 → 0.7.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.
Files changed (169) hide show
  1. package/package.json +2 -1
  2. package/packages/server/src/agents/AgentGateway.js +36 -3
  3. package/packages/server/src/agents/BaseAgent.js +15 -1
  4. package/packages/server/src/agents/LoggingAgentWrapper.js +4 -0
  5. package/packages/server/src/agents/adapters/ClaudeCodeAdapter.js +9 -6
  6. package/packages/server/src/agents/adapters/CodexAdapter.js +262 -14
  7. package/packages/server/src/agents/adapters/codexCliRunner.js +185 -0
  8. package/packages/server/src/agents/adapters/codexEventMapper.js +235 -0
  9. package/packages/server/src/agents/types.js +1 -0
  10. package/packages/server/src/agents/vcr/VCRAgentAdapter.js +8 -0
  11. package/packages/server/src/api/agents.js +27 -0
  12. package/packages/server/src/api/canvas.js +20 -0
  13. package/packages/server/src/api/index.js +2 -0
  14. package/packages/server/src/api/projects-session-helpers.js +25 -0
  15. package/packages/server/src/api/projects.js +8 -0
  16. package/packages/server/src/api/providers.js +1 -0
  17. package/packages/server/src/api/sessions-draft.js +1 -0
  18. package/packages/server/src/api/sessions-messages.js +6 -0
  19. package/packages/server/src/api/settings.js +52 -4
  20. package/packages/server/src/db/ConversationRepository.js +16 -3
  21. package/packages/server/src/db/ProjectDefaultsRepository.js +47 -37
  22. package/packages/server/src/db/ProviderRepository.js +62 -6
  23. package/packages/server/src/db/SessionRepository.js +74 -14
  24. package/packages/server/src/db/SettingsRepository.js +44 -16
  25. package/packages/server/src/db/conversation-helpers.js +1 -0
  26. package/packages/server/src/db/migrations/conversationsMigrations.js +4 -0
  27. package/packages/server/src/db/migrations/index.js +4 -0
  28. package/packages/server/src/db/migrations/miscMigrations.js +53 -3
  29. package/packages/server/src/db/migrations/sessionsMigrations.js +6 -1
  30. package/packages/server/src/db/session-helpers.js +8 -0
  31. package/packages/server/src/schema.sql +9 -0
  32. package/packages/server/src/services/agentCallLogger.js +1 -1
  33. package/packages/server/src/services/codexSpawnHelper.js +37 -0
  34. package/packages/server/src/services/commandButtonPrompts.js +48 -0
  35. package/packages/server/src/services/conversationContext.js +27 -0
  36. package/packages/server/src/services/draftSessionService.js +15 -2
  37. package/packages/server/src/services/kanbanTriggers.js +3 -0
  38. package/packages/server/src/services/providerTestService.js +115 -15
  39. package/packages/server/src/services/sessionAgentGuard.js +38 -0
  40. package/packages/server/src/services/sessionExecution.js +127 -33
  41. package/packages/server/src/services/sessionManager.js +45 -8
  42. package/packages/server/src/services/sessionPrompts.js +29 -0
  43. package/packages/server/src/services/sessionProvider.js +160 -41
  44. package/packages/server/src/services/streamEventCallbacks.js +72 -40
  45. package/packages/server/src/services/streamEventHandler.js +16 -2
  46. package/packages/server/src/services/streamUsageHandler.js +6 -0
  47. package/packages/server/src/services/summaryClaudeClient.js +37 -12
  48. package/packages/server/src/services/summaryModelClient.js +154 -0
  49. package/packages/server/src/services/summaryModelResolver.js +148 -0
  50. package/packages/server/src/services/summaryService.js +6 -4
  51. package/packages/server/src/services/templateTriggerService.js +2 -0
  52. package/packages/server/src/services/usageTracker.js +5 -1
  53. package/packages/server/src/services/visibleFinalErrorMessage.js +123 -0
  54. package/packages/shared/src/constants.js +1 -2
  55. package/packages/shared/src/contracts/projects.js +2 -0
  56. package/packages/shared/src/contracts/providers.js +24 -7
  57. package/packages/shared/src/contracts/sessions.js +1 -1
  58. package/packages/shared/src/index.js +1 -0
  59. package/packages/shared/src/types.js +28 -0
  60. package/packages/shared/src/utils.js +9 -17
  61. package/packages/web/dist/assets/ActiveSessionsView-UJsCILDL.js +1 -0
  62. package/packages/web/dist/assets/{AgentLogsView-DCF2WvP2.js → AgentLogsView-BGFPLjLa.js} +1 -1
  63. package/packages/web/dist/assets/ApiClient-B4YTtyY4.js +1 -0
  64. package/packages/web/dist/assets/{ArchiveConfirmModal-fgoEQhfq.js → ArchiveConfirmModal-OFaj_uX5.js} +1 -1
  65. package/packages/web/dist/assets/{CommandButtonDetailView-DAg07cDQ.js → CommandButtonDetailView-D8S258uP.js} +1 -1
  66. package/packages/web/dist/assets/EffortLevelSelector-C2378L8e.js +1 -0
  67. package/packages/web/dist/assets/{GeneralSettingsView-Cn9VI2du.js → GeneralSettingsView-DsHChEhv.js} +1 -1
  68. package/packages/web/dist/assets/{InputWithButton-BvboBGbz.js → InputWithButton-Ci15ox0a.js} +1 -1
  69. package/packages/web/dist/assets/{InterpolationHelp-0GoSBPgf.js → InterpolationHelp-CIkOSkWX.js} +1 -1
  70. package/packages/web/dist/assets/MarkdownEditor-5-bexzUT.js +2 -0
  71. package/packages/web/dist/assets/ModelSelector-BMpR0DPr.js +1 -0
  72. package/packages/web/dist/assets/{ModelSelector-DPPD-92R.css → ModelSelector-D8hbTRIt.css} +1 -1
  73. package/packages/web/dist/assets/{NewSessionView-C77YVqgY.js → NewSessionView-BCqtIgWH.js} +2 -2
  74. package/packages/web/dist/assets/{NewSessionView-D_Hi7M9g.css → NewSessionView-CUUdHkfv.css} +1 -1
  75. package/packages/web/dist/assets/ProjectEditView-D9sK0fdH.css +1 -0
  76. package/packages/web/dist/assets/ProjectEditView-RFaxHhAX.js +1 -0
  77. package/packages/web/dist/assets/{ProjectListView-CLwtuJ0J.js → ProjectListView-B9FuWESY.js} +1 -1
  78. package/packages/web/dist/assets/{ProjectNewView-CzDtVibO.js → ProjectNewView-D62jYlBL.js} +1 -1
  79. package/packages/web/dist/assets/ProvidersView-DDKMIQWZ.js +1 -0
  80. package/packages/web/dist/assets/ProvidersView-DE82G_5W.css +1 -0
  81. package/packages/web/dist/assets/QuickResponseSettings-CDm5vwP7.js +1 -0
  82. package/packages/web/dist/assets/{QuickResponsesPanel-DIBQFj0W.css → QuickResponsesPanel-BlFDvnZ2.css} +1 -1
  83. package/packages/web/dist/assets/{QuickResponsesPanel-CTXYjMF-.js → QuickResponsesPanel-DZ_Lre_l.js} +1 -1
  84. package/packages/web/dist/assets/{ResizableTextarea-Cw6aL4rp.js → ResizableTextarea-DiIOEGjN.js} +1 -1
  85. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +1 -0
  86. package/packages/web/dist/assets/SessionCard-BMGC2HqI.css +1 -0
  87. package/packages/web/dist/assets/SessionCard-DmjnVYWn.js +1 -0
  88. package/packages/web/dist/assets/SessionDetailView-CL7nmfiB.js +36 -0
  89. package/packages/web/dist/assets/SessionDetailView-CupIkI7u.css +1 -0
  90. package/packages/web/dist/assets/SessionFormOptions-BpUALRKn.css +1 -0
  91. package/packages/web/dist/assets/SessionFormOptions-DYUISplS.js +1 -0
  92. package/packages/web/dist/assets/SessionListView-BcxGz4aC.js +1 -0
  93. package/packages/web/dist/assets/{SessionListView-DVhoZHN9.css → SessionListView-fHlQyecX.css} +1 -1
  94. package/packages/web/dist/assets/{SessionLogStream-DIndOyFR.js → SessionLogStream-DpUE6Xsh.js} +1 -1
  95. package/packages/web/dist/assets/{SettingsView-CmJ5JPd5.js → SettingsView-BC055tIA.js} +1 -1
  96. package/packages/web/dist/assets/SlashCommandWizard-DmTyNG9O.js +1 -0
  97. package/packages/web/dist/assets/SlashCommandWizard-Dn7sNaBd.css +1 -0
  98. package/packages/web/dist/assets/SummarySettingsView-BgnRCwlq.js +1 -0
  99. package/packages/web/dist/assets/SummarySettingsView-l2bxHmZZ.css +1 -0
  100. package/packages/web/dist/assets/TemplateDetailView-BlhOmLUX.js +1 -0
  101. package/packages/web/dist/assets/{commandButtons-D74TkPNU.js → commandButtons-D4RPpLiu.js} +1 -1
  102. package/packages/web/dist/assets/index-4rhEeO0B.js +1 -0
  103. package/packages/web/dist/assets/index-9vb2KaAd.js +1 -0
  104. package/packages/web/dist/assets/index-B0CvZXuN.js +7 -0
  105. package/packages/web/dist/assets/index-B6G18FqB.js +82 -0
  106. package/packages/web/dist/assets/{index-DMZZCi2u.js → index-BGwH4Cfn.js} +3 -3
  107. package/packages/web/dist/assets/index-BUhvkAdF.js +1 -0
  108. package/packages/web/dist/assets/index-BcnkUk2o.js +1 -0
  109. package/packages/web/dist/assets/{index-DQMHi05L.js → index-Bn5xdGFM.js} +2 -2
  110. package/packages/web/dist/assets/index-CNwkdB0T.js +1 -0
  111. package/packages/web/dist/assets/index-CfL84oGW.js +1 -0
  112. package/packages/web/dist/assets/index-CkmxO8Mm.js +1 -0
  113. package/packages/web/dist/assets/index-Cpy4-yv3.js +1 -0
  114. package/packages/web/dist/assets/index-CrAQJmoZ.js +1 -0
  115. package/packages/web/dist/assets/{index-gmCCsCQ1.css → index-Cs2nxhrT.css} +1 -1
  116. package/packages/web/dist/assets/index-D6Ky9vJe.js +3 -0
  117. package/packages/web/dist/assets/index-DfrE0gAC.js +1 -0
  118. package/packages/web/dist/assets/index-KwEyz0F3.js +1 -0
  119. package/packages/web/dist/assets/index-OfCywayk.js +1 -0
  120. package/packages/web/dist/assets/index-PDesaJc6.js +1 -0
  121. package/packages/web/dist/assets/index-uB6nhSvz.js +1 -0
  122. package/packages/web/dist/assets/{projects-D_C9dE9s.js → projects-BUiOGmmb.js} +1 -1
  123. package/packages/web/dist/assets/providers-Bh1ZiiJi.js +1 -0
  124. package/packages/web/dist/assets/sessions-DH1R-NhV.js +1 -0
  125. package/packages/web/dist/assets/settings-Z4AVVmkJ.js +1 -0
  126. package/packages/web/dist/index.html +2 -2
  127. package/packages/web/dist/assets/ActiveSessionsView-BafIafEu.js +0 -1
  128. package/packages/web/dist/assets/ApiClient-CcqJ-GAv.js +0 -1
  129. package/packages/web/dist/assets/EffortLevelSelector-xE3gidpq.js +0 -1
  130. package/packages/web/dist/assets/MarkdownEditor-HCKnwRye.js +0 -2
  131. package/packages/web/dist/assets/ModelSelector-B0RdlCHT.js +0 -1
  132. package/packages/web/dist/assets/ProjectEditView-BBHOsgBV.js +0 -1
  133. package/packages/web/dist/assets/ProjectEditView-CpeKj-_w.css +0 -1
  134. package/packages/web/dist/assets/ProvidersView-Eg93KbyC.js +0 -1
  135. package/packages/web/dist/assets/ProvidersView-uD8SKWpA.css +0 -1
  136. package/packages/web/dist/assets/QuickResponseSettings-BBHMapcA.js +0 -1
  137. package/packages/web/dist/assets/ResizableTextarea-B5nAA0RV.css +0 -1
  138. package/packages/web/dist/assets/SessionCard-CCapYVjy.js +0 -1
  139. package/packages/web/dist/assets/SessionCard-CcqIjL8q.css +0 -1
  140. package/packages/web/dist/assets/SessionDetailView-BL83oPiI.css +0 -1
  141. package/packages/web/dist/assets/SessionDetailView-CrZvMb3j.js +0 -36
  142. package/packages/web/dist/assets/SessionFormOptions-BuLlDF-7.css +0 -1
  143. package/packages/web/dist/assets/SessionFormOptions-Em7sQCGb.js +0 -1
  144. package/packages/web/dist/assets/SessionListView-3zdDtqhw.js +0 -1
  145. package/packages/web/dist/assets/SlashCommandWizard-BB30cSvo.css +0 -1
  146. package/packages/web/dist/assets/SlashCommandWizard-C_cSgF-P.js +0 -1
  147. package/packages/web/dist/assets/SummarySettingsView-DQM1n3bc.js +0 -1
  148. package/packages/web/dist/assets/SummarySettingsView-DcsmSVJI.css +0 -1
  149. package/packages/web/dist/assets/TemplateDetailView-B8clSBPk.js +0 -1
  150. package/packages/web/dist/assets/index-B5ocUoPf.js +0 -1
  151. package/packages/web/dist/assets/index-BELtFs3n.js +0 -1
  152. package/packages/web/dist/assets/index-BGAW2Nqa.js +0 -82
  153. package/packages/web/dist/assets/index-BsDR4w2c.js +0 -1
  154. package/packages/web/dist/assets/index-CVozYqQ-.js +0 -3
  155. package/packages/web/dist/assets/index-CefzeYRE.js +0 -1
  156. package/packages/web/dist/assets/index-CrLh8vw5.js +0 -1
  157. package/packages/web/dist/assets/index-DIvveuSK.js +0 -1
  158. package/packages/web/dist/assets/index-DPt6qBRK.js +0 -1
  159. package/packages/web/dist/assets/index-DYWZ8lD-.js +0 -1
  160. package/packages/web/dist/assets/index-DrlwE0Zo.js +0 -7
  161. package/packages/web/dist/assets/index-DuXChAe-.js +0 -1
  162. package/packages/web/dist/assets/index-Dz7jFUYU.js +0 -1
  163. package/packages/web/dist/assets/index-Gre8tUfC.js +0 -1
  164. package/packages/web/dist/assets/index-_Lv79l46.js +0 -1
  165. package/packages/web/dist/assets/index-f315nDFm.js +0 -1
  166. package/packages/web/dist/assets/index-rjbX81sm.js +0 -1
  167. package/packages/web/dist/assets/providers-BdvbPVdE.js +0 -1
  168. package/packages/web/dist/assets/sessions-Bs5FA6JZ.js +0 -1
  169. package/packages/web/dist/assets/settings-6Rw9xt-G.js +0 -1
@@ -3,71 +3,102 @@ import { broadcastToSession } from '../websocket.js';
3
3
  import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
4
4
  import * as summaryService from './summaryService.js';
5
5
  import * as kanbanService from './kanbanService.js';
6
+ import { createVisibleFinalErrorMessage } from './visibleFinalErrorMessage.js';
6
7
  import {
7
8
  lastMessageIds,
8
9
  activeSessions,
9
10
  activeConversationIds,
11
+ finalErrorSessionIds,
10
12
  associateAndBroadcastWorkLogs,
11
13
  broadcastSessionStatus,
12
14
  broadcastChangesUpdate,
13
15
  } from './streamEventHandler.js';
14
16
 
15
17
  /**
16
- * Handle post-turn completion logic (after stream loop ends successfully)
17
- * Encapsulates the duplicated completion block from runSession/continueSession/continueSessionWithExistingMessage
18
+ * Associate work logs with the last message and clean up tracking state.
18
19
  * @param {string} sessionId
19
- * @param {string} workingDirectory
20
- * @param {{ handleTemplateTriggerIfNeeded?: Function, checkProactiveReschedule?: Function, handleAutoSendIfNeeded?: Function }} callbacks
21
20
  */
22
- export async function handleTurnCompletion(sessionId, workingDirectory, callbacks = {}) {
23
- const { handleTemplateTriggerIfNeeded, checkProactiveReschedule: _checkProactiveReschedule, handleAutoSendIfNeeded } = callbacks;
24
- // Associate work logs with the last message now that the turn is complete
21
+ function associateAndCleanupWorkLogs(sessionId) {
25
22
  const lastMessageId = lastMessageIds.get(sessionId);
26
23
  if (lastMessageId) {
27
24
  associateAndBroadcastWorkLogs(sessionId, lastMessageId);
28
25
  lastMessageIds.delete(sessionId);
29
26
  }
27
+ }
30
28
 
31
- // Session ready for follow-up - set to waiting instead of completed
32
- const activeSession = activeSessions.get(sessionId);
33
- if (activeSession && !activeSession.controller?.signal?.aborted) {
34
- sessions.update(sessionId, { status: 'waiting' });
35
- broadcastSessionStatus(sessionId, 'waiting');
36
-
37
- // Check if session should be proactively rescheduled based on token threshold
38
- if (_checkProactiveReschedule) {
39
- const wasRescheduled = await _checkProactiveReschedule(sessionId);
40
- if (wasRescheduled) {
41
- return true; // Session was rescheduled, don't continue with normal completion
42
- }
29
+ /**
30
+ * Handle post-turn activities for a session that's ready for follow-up.
31
+ * @param {string} sessionId
32
+ * @param {string} workingDirectory
33
+ * @param {{ checkProactiveReschedule?: Function, handleAutoSendIfNeeded?: Function, handleTemplateTriggerIfNeeded?: Function }} callbacks
34
+ * @returns {Promise<boolean>} True if rescheduled, false otherwise
35
+ */
36
+ async function handleActiveSessionCompletion(sessionId, workingDirectory, callbacks) {
37
+ sessions.update(sessionId, { status: 'waiting', error: null });
38
+ broadcastSessionStatus(sessionId, 'waiting');
39
+
40
+ // Check if session should be proactively rescheduled based on token threshold
41
+ const { checkProactiveReschedule } = callbacks;
42
+ if (checkProactiveReschedule) {
43
+ const wasRescheduled = await checkProactiveReschedule(sessionId);
44
+ if (wasRescheduled) {
45
+ return true; // Session was rescheduled, don't continue with normal completion
43
46
  }
47
+ }
44
48
 
45
- // Extract PR URL immediately (lightweight, no API call)
46
- summaryService.extractPrUrlIfNeeded(sessionId);
47
- // Trigger debounced summary generation on turn completion (not complete yet)
48
- summaryService.onSessionActivity(sessionId);
49
+ // Extract PR URL immediately (lightweight, no API call)
50
+ summaryService.extractPrUrlIfNeeded(sessionId);
51
+ // Trigger debounced summary generation on turn completion (not complete yet)
52
+ summaryService.onSessionActivity(sessionId);
49
53
 
50
- // Broadcast changes update when turn completes (real-time indicator)
51
- const currentSession = sessions.getById(sessionId);
52
- if (currentSession) {
53
- await broadcastChangesUpdate(sessionId, currentSession.projectId, workingDirectory);
54
- }
54
+ // Broadcast changes update when turn completes (real-time indicator)
55
+ const currentSession = sessions.getById(sessionId);
56
+ if (currentSession) {
57
+ await broadcastChangesUpdate(sessionId, currentSession.projectId, workingDirectory);
58
+ }
55
59
 
56
- // Handle kanban lane movements based on targetLaneId
57
- await kanbanService.handleTurnCompletion(sessionId);
60
+ // Handle kanban lane movements based on targetLaneId
61
+ await kanbanService.handleTurnCompletion(sessionId);
58
62
 
59
- // Auto-send queued prompt if enabled (runs BEFORE template trigger)
60
- let autoSendFired = false;
61
- if (handleAutoSendIfNeeded) {
62
- autoSendFired = await handleAutoSendIfNeeded(sessionId);
63
- }
63
+ // Auto-send queued prompt if enabled (runs BEFORE template trigger)
64
+ const { handleAutoSendIfNeeded, handleTemplateTriggerIfNeeded } = callbacks;
65
+ let autoSendFired = false;
66
+ if (handleAutoSendIfNeeded) {
67
+ autoSendFired = await handleAutoSendIfNeeded(sessionId);
68
+ }
64
69
 
65
- // Only trigger next template if auto-send did NOT fire
66
- // (if auto-send fired, template will trigger after that turn completes)
67
- if (!autoSendFired && handleTemplateTriggerIfNeeded) {
68
- await handleTemplateTriggerIfNeeded(sessionId);
69
- }
70
+ // Only trigger next template if auto-send did NOT fire
71
+ // (if auto-send fired, template will trigger after that turn completes)
72
+ if (!autoSendFired && handleTemplateTriggerIfNeeded) {
73
+ await handleTemplateTriggerIfNeeded(sessionId);
70
74
  }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Handle post-turn completion logic (after stream loop ends successfully)
81
+ * Encapsulates the duplicated completion block from runSession/continueSession/continueSessionWithExistingMessage
82
+ * @param {string} sessionId
83
+ * @param {string} workingDirectory
84
+ * @param {{ handleTemplateTriggerIfNeeded?: Function, checkProactiveReschedule?: Function, handleAutoSendIfNeeded?: Function }} callbacks
85
+ */
86
+ export async function handleTurnCompletion(sessionId, workingDirectory, callbacks = {}) {
87
+ // Associate work logs with the last message now that the turn is complete
88
+ associateAndCleanupWorkLogs(sessionId);
89
+
90
+ // Sessions with final errors should not transition to waiting
91
+ if (finalErrorSessionIds.has(sessionId)) {
92
+ finalErrorSessionIds.delete(sessionId);
93
+ return false;
94
+ }
95
+
96
+ // Session ready for follow-up - set to waiting instead of completed
97
+ const activeSession = activeSessions.get(sessionId);
98
+ if (activeSession && !activeSession.controller?.signal?.aborted) {
99
+ return handleActiveSessionCompletion(sessionId, workingDirectory, callbacks);
100
+ }
101
+
71
102
  return false;
72
103
  }
73
104
 
@@ -149,6 +180,7 @@ export async function handleSessionError(sessionId, error, options = {}) {
149
180
 
150
181
  // Normal error handling (no reschedule or reschedule limits reached)
151
182
  sessions.update(sessionId, { status: 'error', error: error.message });
183
+ createVisibleFinalErrorMessage(sessionId, error, activeConversationIds);
152
184
  broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_ERROR, { sessionId, error: error.message });
153
185
 
154
186
  // Optionally broadcast final conversation state (continueSession does this)
@@ -10,6 +10,10 @@ import {
10
10
  handleTextDelta as _handleTextDelta,
11
11
  handleResultUsage,
12
12
  } from './streamUsageHandler.js';
13
+ import {
14
+ createVisibleFinalErrorMessage,
15
+ normalizeFinalErrorMessage,
16
+ } from './visibleFinalErrorMessage.js';
13
17
 
14
18
  // ── Shared module-level state ──────────────────────────────────────────────
15
19
 
@@ -34,6 +38,9 @@ export const currentModels = new Map();
34
38
  /** @type {Map<string, Set<string>>} Track tool_use IDs that have already been logged per session */
35
39
  export const loggedToolUseIds = new Map();
36
40
 
41
+ /** @type {Set<string>} Track sessions that received a final result.error event */
42
+ export const finalErrorSessionIds = new Set();
43
+
37
44
  // ── Helper functions ───────────────────────────────────────────────────────
38
45
 
39
46
  /**
@@ -206,6 +213,9 @@ function handleAssistantTextContent(sessionId, textContent, toolUseBlocks) {
206
213
  const currentModel = currentModels.get(sessionId) || null;
207
214
  const message = messages.create(sessionId, 'assistant', textContent, { toolUse, conversationId, model: currentModel });
208
215
 
216
+ // Touch the session to update its updated_at timestamp so it sorts to the top
217
+ sessions.touch(sessionId);
218
+
209
219
  // Associate pending work logs with this message immediately
210
220
  // This ensures work logs are attached to the correct message, not just the last one
211
221
  associateAndBroadcastWorkLogs(sessionId, message.id);
@@ -401,8 +411,11 @@ function handleResultEvent(sessionId, event) {
401
411
  * @param {Object} event
402
412
  */
403
413
  function handleResultError(sessionId, event) {
404
- sessions.update(sessionId, { status: 'error', error: event.error });
405
- broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_ERROR, { sessionId, error: event.error });
414
+ const errorMessage = normalizeFinalErrorMessage(event.error);
415
+ finalErrorSessionIds.add(sessionId);
416
+ sessions.update(sessionId, { status: 'error', error: errorMessage });
417
+ createVisibleFinalErrorMessage(sessionId, errorMessage, activeConversationIds);
418
+ broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_ERROR, { sessionId, error: errorMessage });
406
419
  // Broadcast error status to project subscribers for session list updates
407
420
  broadcastSessionStatus(sessionId, 'error');
408
421
  // Extract PR URL before generating summary (PR may have been created before error)
@@ -478,6 +491,7 @@ export function cleanupSessionState(sessionId, includeConversationId = false) {
478
491
  thinkingAccumulators.delete(sessionId);
479
492
  currentModels.delete(sessionId);
480
493
  loggedToolUseIds.delete(sessionId);
494
+ finalErrorSessionIds.delete(sessionId);
481
495
  activeSessions.delete(sessionId);
482
496
  if (includeConversationId) {
483
497
  activeConversationIds.delete(sessionId);
@@ -74,6 +74,7 @@ export function handleTextDelta(sessionId, delta, textAccumulators) {
74
74
  const turnData = currentTurnUsage.get(conversationId) || {
75
75
  inputTokens: 0,
76
76
  outputTokens: 0,
77
+ thinkingTokens: 0,
77
78
  lastMessageOutput: 0,
78
79
  cacheReadInputTokens: 0,
79
80
  cacheCreationInputTokens: 0,
@@ -83,6 +84,7 @@ export function handleTextDelta(sessionId, delta, textAccumulators) {
83
84
  const broadcastUsage = {
84
85
  inputTokens: turnData.inputTokens,
85
86
  outputTokens: turnData.outputTokens + Math.max(turnData.lastMessageOutput, newEstimate),
87
+ thinkingTokens: turnData.thinkingTokens,
86
88
  cacheReadInputTokens: turnData.cacheReadInputTokens,
87
89
  cacheCreationInputTokens: turnData.cacheCreationInputTokens,
88
90
  };
@@ -123,6 +125,7 @@ export function extractTurnUsage(sessionId, event) {
123
125
  return {
124
126
  inputTokens: resolveTokenField(modelUsageEntry, event.usage, { camel: 'inputTokens', snake: 'input_tokens' }),
125
127
  outputTokens: resolveTokenField(modelUsageEntry, event.usage, { camel: 'outputTokens', snake: 'output_tokens' }),
128
+ thinkingTokens: resolveTokenField(modelUsageEntry, event.usage, { camel: 'thinkingTokens', snake: 'thinking_tokens' }),
126
129
  cacheReadInputTokens: resolveTokenField(modelUsageEntry, event.usage, { camel: 'cacheReadInputTokens', snake: 'cache_read_input_tokens' }),
127
130
  cacheCreationInputTokens: resolveTokenField(modelUsageEntry, event.usage, { camel: 'cacheCreationInputTokens', snake: 'cache_creation_input_tokens' }),
128
131
  webSearchRequests: modelUsageEntry?.webSearchRequests || 0,
@@ -142,6 +145,7 @@ export function buildCumulativeSessionUsage(sessionId, turnUsage) {
142
145
  return {
143
146
  inputTokens: (currentSession.inputTokens || 0) + turnUsage.inputTokens,
144
147
  outputTokens: (currentSession.outputTokens || 0) + turnUsage.outputTokens,
148
+ thinkingTokens: (currentSession.thinkingTokens || 0) + turnUsage.thinkingTokens,
145
149
  cacheReadInputTokens: (currentSession.cacheReadInputTokens || 0) + turnUsage.cacheReadInputTokens,
146
150
  cacheCreationInputTokens: (currentSession.cacheCreationInputTokens || 0) + turnUsage.cacheCreationInputTokens,
147
151
  webSearchRequests: (currentSession.webSearchRequests || 0) + turnUsage.webSearchRequests,
@@ -162,6 +166,7 @@ export function updateConversationUsage(conversationId, currentConversation, tur
162
166
  const cumulativeConversationUsage = {
163
167
  inputTokens: (currentConversation.inputTokens || 0) + turnUsage.inputTokens,
164
168
  outputTokens: (currentConversation.outputTokens || 0) + turnUsage.outputTokens,
169
+ thinkingTokens: (currentConversation.thinkingTokens || 0) + turnUsage.thinkingTokens,
165
170
  cacheReadInputTokens: (currentConversation.cacheReadInputTokens || 0) + turnUsage.cacheReadInputTokens,
166
171
  cacheCreationInputTokens: (currentConversation.cacheCreationInputTokens || 0) + turnUsage.cacheCreationInputTokens,
167
172
  webSearchRequests: (currentConversation.webSearchRequests || 0) + turnUsage.webSearchRequests,
@@ -197,6 +202,7 @@ export function handleResultUsage(sessionId, event) {
197
202
  usage: updatedConversation ? {
198
203
  inputTokens: updatedConversation.inputTokens,
199
204
  outputTokens: updatedConversation.outputTokens,
205
+ thinkingTokens: updatedConversation.thinkingTokens,
200
206
  cacheReadInputTokens: updatedConversation.cacheReadInputTokens,
201
207
  cacheCreationInputTokens: updatedConversation.cacheCreationInputTokens,
202
208
  webSearchRequests: updatedConversation.webSearchRequests,
@@ -49,11 +49,16 @@ function logResultUsage(callId, event) {
49
49
  /**
50
50
  * Build the query parameters for the Claude SDK call.
51
51
  * @param {string} prompt - The prompt to send
52
- * @param {{ systemPrompt?: string, jsonSchema?: Object }} options
52
+ * @param {{ systemPrompt?: string, jsonSchema?: Object, model?: string, env?: Object }} options
53
53
  * @returns {Object} queryParams ready for the SDK query function
54
54
  */
55
55
  function buildClaudeRequest(prompt, options) {
56
- const { systemPrompt = null, jsonSchema = null } = options || {};
56
+ const {
57
+ systemPrompt = null,
58
+ jsonSchema = null,
59
+ model = 'claude-haiku-4-5-20251001',
60
+ env = null,
61
+ } = options || {};
57
62
  const schema = jsonSchema || SESSION_SUMMARY_SCHEMA;
58
63
 
59
64
  return {
@@ -62,7 +67,8 @@ function buildClaudeRequest(prompt, options) {
62
67
  cwd: process.cwd(),
63
68
  permissionMode: 'bypassPermissions',
64
69
  maxTurns: 1,
65
- model: 'claude-haiku-4-5-20251001',
70
+ model,
71
+ ...(env && { env }),
66
72
  ...(systemPrompt && { systemPrompt }),
67
73
  outputFormat: {
68
74
  type: 'json_schema',
@@ -108,6 +114,21 @@ async function handleClaudeResponse(eventStream, callId) {
108
114
  return state.responseText;
109
115
  }
110
116
 
117
+ function buildSummaryCallMetadata({ logMeta, model, providerId, selectionReason, promptLength }) {
118
+ return {
119
+ sessionId: logMeta.sessionId,
120
+ conversationId: logMeta.conversationId || null,
121
+ agentType: 'summary',
122
+ model,
123
+ callType: logMeta.callType,
124
+ promptLength,
125
+ metadata: {
126
+ ...(providerId ? { providerId } : {}),
127
+ ...(selectionReason ? { selectionReason } : {}),
128
+ },
129
+ };
130
+ }
131
+
111
132
  export const SESSION_SUMMARY_SCHEMA = {
112
133
  type: 'object',
113
134
  properties: {
@@ -130,11 +151,16 @@ export const SESSION_SUMMARY_SCHEMA = {
130
151
  * @param {string} prompt - The prompt to send
131
152
  * @param {Array} recentMessages - Messages (for mock mode context)
132
153
  * @param {string} sessionStatus - Session status (for mock mode context)
133
- * @param {{ logMeta?: Object, systemPrompt?: string, jsonSchema?: Object }} options - Optional parameters
154
+ * @param {{ logMeta?: Object, systemPrompt?: string, jsonSchema?: Object, model?: string, env?: Object, providerId?: string|null, selectionReason?: string }} options - Optional parameters
134
155
  * @returns {Promise<string>} The text response (JSON string)
135
156
  */
136
157
  export async function callClaude(prompt, recentMessages, sessionStatus, options = {}) {
137
- const { logMeta = null } = options || {};
158
+ const {
159
+ logMeta = null,
160
+ model = 'claude-haiku-4-5-20251001',
161
+ providerId = null,
162
+ selectionReason = null,
163
+ } = options || {};
138
164
  // Build stable key for VCR cassette (session prompts are hardcoded strings in E2E tests)
139
165
  let keyHint = null;
140
166
  if (process.env.VCR_MODE && logMeta?.sessionId) {
@@ -151,14 +177,13 @@ export async function callClaude(prompt, recentMessages, sessionStatus, options
151
177
  // Start logging if metadata provided
152
178
  let callId = null;
153
179
  if (logMeta) {
154
- callId = agentCallLogger.startCall({
155
- sessionId: logMeta.sessionId,
156
- conversationId: logMeta.conversationId || null,
157
- agentType: 'summary',
158
- model: 'claude-haiku-4-5-20251001',
159
- callType: logMeta.callType,
180
+ callId = agentCallLogger.startCall(buildSummaryCallMetadata({
181
+ logMeta,
182
+ model,
183
+ providerId,
184
+ selectionReason,
160
185
  promptLength: prompt.length,
161
- });
186
+ }));
162
187
  }
163
188
 
164
189
  try {
@@ -0,0 +1,154 @@
1
+ import OpenAI from 'openai';
2
+ import { callClaude, SESSION_SUMMARY_SCHEMA } from './summaryClaudeClient.js';
3
+ import { agentCallLogger } from './agentCallLogger.js';
4
+ import { buildProviderEnv } from './sessionProvider.js';
5
+ import { resolveSummaryModel } from './summaryModelResolver.js';
6
+
7
+ export { SESSION_SUMMARY_SCHEMA };
8
+
9
+ export async function callSummaryModel(prompt, recentMessages, sessionStatus, options = {}) {
10
+ const resolution = options.resolvedModel || resolveSummaryModel(options.summarySettings || {});
11
+ if (resolution.kind === 'openai') {
12
+ return callOpenAISummaryModel(prompt, resolution, options);
13
+ }
14
+ return callAnthropicSummaryModel({ prompt, recentMessages, sessionStatus, resolution, options });
15
+ }
16
+
17
+ function callAnthropicSummaryModel({ prompt, recentMessages, sessionStatus, resolution, options }) {
18
+ const providerEnv = resolution.provider && !resolution.provider.isBuiltIn
19
+ ? { ...process.env, ...buildProviderEnv(resolution.provider) }
20
+ : null;
21
+
22
+ return callClaude(prompt, recentMessages, sessionStatus, {
23
+ ...options,
24
+ model: resolution.model,
25
+ providerId: resolution.providerId,
26
+ selectionReason: resolution.selectionReason,
27
+ ...(providerEnv ? { env: providerEnv } : {}),
28
+ });
29
+ }
30
+
31
+ async function callOpenAISummaryModel(prompt, resolution, options) {
32
+ const { logMeta = null, systemPrompt = null, jsonSchema = null } = options || {};
33
+ const schema = jsonSchema || SESSION_SUMMARY_SCHEMA;
34
+ const provider = resolution.provider;
35
+ const client = createOpenAIClient(provider);
36
+
37
+ const callId = startOpenAISummaryLog(logMeta, resolution, prompt.length);
38
+
39
+ try {
40
+ const response = await createOpenAIChatCompletion(client, {
41
+ model: resolution.model,
42
+ prompt,
43
+ systemPrompt,
44
+ schema,
45
+ structured: true,
46
+ }).catch(async (error) => {
47
+ if (!isStructuredOutputUnsupported(error)) throw error;
48
+ return createOpenAIChatCompletion(client, {
49
+ model: resolution.model,
50
+ prompt: withJsonOnlyInstruction(prompt, schema),
51
+ systemPrompt,
52
+ schema,
53
+ structured: false,
54
+ });
55
+ });
56
+
57
+ logOpenAIUsage(callId, response.usage);
58
+
59
+ if (callId) {
60
+ agentCallLogger.completeCall(callId, { success: true });
61
+ }
62
+ return extractOpenAIContent(response);
63
+ } catch (error) {
64
+ if (callId) {
65
+ agentCallLogger.completeCall(callId, { success: false, error });
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function startOpenAISummaryLog(logMeta, resolution, promptLength) {
72
+ if (!logMeta) return null;
73
+ return agentCallLogger.startCall({
74
+ sessionId: logMeta.sessionId,
75
+ conversationId: logMeta.conversationId || null,
76
+ agentType: 'summary',
77
+ model: resolution.model,
78
+ callType: logMeta.callType,
79
+ promptLength,
80
+ metadata: {
81
+ ...(resolution.providerId ? { providerId: resolution.providerId } : {}),
82
+ ...(resolution.selectionReason ? { selectionReason: resolution.selectionReason } : {}),
83
+ },
84
+ });
85
+ }
86
+
87
+ function logOpenAIUsage(callId, usage) {
88
+ if (!callId || !usage) return;
89
+ agentCallLogger.updateUsage(callId, {
90
+ inputTokens: usage.prompt_tokens || 0,
91
+ outputTokens: usage.completion_tokens || 0,
92
+ thinkingTokens: 0,
93
+ cacheReadInputTokens: 0,
94
+ cacheCreationInputTokens: 0,
95
+ });
96
+ }
97
+
98
+ function createOpenAIClient(provider) {
99
+ const options = {
100
+ apiKey: provider?.authToken || process.env.OPENAI_API_KEY || 'missing',
101
+ };
102
+ if (provider?.baseUrl) options.baseURL = provider.baseUrl;
103
+ if (provider?.apiTimeoutMs) options.timeout = provider.apiTimeoutMs;
104
+ return new OpenAI(options);
105
+ }
106
+
107
+ function createOpenAIChatCompletion(client, { model, prompt, systemPrompt, schema, structured }) {
108
+ const messages = [];
109
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
110
+ messages.push({ role: 'user', content: prompt });
111
+
112
+ const request = {
113
+ model,
114
+ messages,
115
+ temperature: 0,
116
+ };
117
+
118
+ if (structured) {
119
+ request.response_format = {
120
+ type: 'json_schema',
121
+ json_schema: {
122
+ name: 'session_summary',
123
+ schema,
124
+ strict: false,
125
+ },
126
+ };
127
+ } else {
128
+ request.response_format = { type: 'json_object' };
129
+ }
130
+
131
+ return client.chat.completions.create(request);
132
+ }
133
+
134
+ function extractOpenAIContent(response) {
135
+ const content = response?.choices?.[0]?.message?.content;
136
+ if (Array.isArray(content)) {
137
+ return content
138
+ .map((part) => part?.text || part?.content || '')
139
+ .join('');
140
+ }
141
+ return content || '';
142
+ }
143
+
144
+ function isStructuredOutputUnsupported(error) {
145
+ const message = `${error?.message || ''} ${error?.code || ''} ${error?.type || ''}`.toLowerCase();
146
+ return [400, 404, 422].includes(error?.status)
147
+ || message.includes('response_format')
148
+ || message.includes('json_schema')
149
+ || message.includes('unsupported');
150
+ }
151
+
152
+ function withJsonOnlyInstruction(prompt, schema) {
153
+ return `${prompt}\n\nReturn only a valid JSON object matching this JSON Schema:\n${JSON.stringify(schema)}`;
154
+ }
@@ -0,0 +1,148 @@
1
+ import { modelProviders, sessions } from '../database.js';
2
+ import { ACTIVITY_FIELDS_SQL } from '../db/session-helpers.js';
3
+
4
+ export const DEFAULT_ANTHROPIC_SUMMARY_MODEL = 'claude-haiku-4-5-20251001';
5
+ export const DEFAULT_OPENAI_SUMMARY_MODEL = 'gpt-5.4-mini';
6
+ export const BUILT_IN_ANTHROPIC_PROVIDER_ID = 'anthropic-default';
7
+ export const BUILT_IN_OPENAI_PROVIDER_ID = 'openai-default';
8
+
9
+ export const CHEAPEST_SUMMARY_MODEL_BY_BUILT_IN_PROVIDER = Object.freeze({
10
+ [BUILT_IN_ANTHROPIC_PROVIDER_ID]: DEFAULT_ANTHROPIC_SUMMARY_MODEL,
11
+ [BUILT_IN_OPENAI_PROVIDER_ID]: DEFAULT_OPENAI_SUMMARY_MODEL,
12
+ });
13
+
14
+ const ANTHROPIC_TIER_NAMES = new Set(['sonnet', 'opus', 'haiku']);
15
+
16
+ export function isKnownBuiltInAnthropicModel(modelId) {
17
+ if (!modelId || typeof modelId !== 'string') return false;
18
+ if (ANTHROPIC_TIER_NAMES.has(modelId.toLowerCase())) return true;
19
+ if (modelId === DEFAULT_ANTHROPIC_SUMMARY_MODEL) return true;
20
+
21
+ const anthropicProvider = modelProviders.getById(BUILT_IN_ANTHROPIC_PROVIDER_ID);
22
+ return Boolean(
23
+ anthropicProvider?.models?.some((model) => model.modelId === modelId)
24
+ || modelId.startsWith('claude-')
25
+ );
26
+ }
27
+
28
+ export function resolveSummaryModel(summarySettings = {}) {
29
+ const summaryModel = summarySettings?.summaryModel || '';
30
+ const summaryProviderId = summarySettings?.summaryProviderId || null;
31
+
32
+ if (summaryModel) {
33
+ return resolveExplicitSummaryModel(summaryModel, summaryProviderId);
34
+ }
35
+ if (summaryProviderId) {
36
+ throw new Error('summaryModel is required when summaryProviderId is set');
37
+ }
38
+
39
+ const recentFamily = findRecentBuiltInProviderFamily();
40
+ if (recentFamily === BUILT_IN_OPENAI_PROVIDER_ID) {
41
+ return {
42
+ model: DEFAULT_OPENAI_SUMMARY_MODEL,
43
+ provider: modelProviders.getById(BUILT_IN_OPENAI_PROVIDER_ID),
44
+ providerId: BUILT_IN_OPENAI_PROVIDER_ID,
45
+ kind: 'openai',
46
+ isDefault: true,
47
+ selectionReason: 'recent-built-in-provider',
48
+ };
49
+ }
50
+
51
+ if (recentFamily === BUILT_IN_ANTHROPIC_PROVIDER_ID) {
52
+ return defaultAnthropicResolution('recent-built-in-provider');
53
+ }
54
+
55
+ return defaultAnthropicResolution('fallback');
56
+ }
57
+
58
+ function resolveExplicitSummaryModel(summaryModel, summaryProviderId) {
59
+ if (!summaryProviderId) {
60
+ throw new Error('summaryProviderId is required when summaryModel is set');
61
+ }
62
+
63
+ const provider = modelProviders.getById(summaryProviderId);
64
+ if (!provider) {
65
+ throw new Error(`Summary provider not found: ${summaryProviderId}`);
66
+ }
67
+
68
+ const ownsModel = provider.models?.some((model) => model.modelId === summaryModel);
69
+ if (!ownsModel) {
70
+ throw new Error(`Summary provider ${summaryProviderId} does not own model ${summaryModel}`);
71
+ }
72
+ return providerResolution(summaryModel, provider, 'explicit');
73
+ }
74
+
75
+ function providerResolution(model, provider, selectionReason) {
76
+ const kind = provider.kind || 'anthropic';
77
+ return {
78
+ model,
79
+ provider,
80
+ providerId: provider.id,
81
+ kind,
82
+ isDefault: false,
83
+ selectionReason,
84
+ };
85
+ }
86
+
87
+ function defaultAnthropicResolution(selectionReason) {
88
+ return {
89
+ model: DEFAULT_ANTHROPIC_SUMMARY_MODEL,
90
+ provider: null,
91
+ providerId: null,
92
+ kind: 'anthropic',
93
+ isDefault: true,
94
+ selectionReason,
95
+ };
96
+ }
97
+
98
+ function findRecentBuiltInProviderFamily() {
99
+ for (const usage of getRecentSessionModelUsage()) {
100
+ const providerId = usage.providerId || null;
101
+ if (providerId) {
102
+ const provider = modelProviders.getById(providerId);
103
+ const family = builtInFamilyForProvider(provider);
104
+ if (family) return family;
105
+ continue;
106
+ }
107
+
108
+ const model = usage.model || null;
109
+ if (!model) continue;
110
+
111
+ const provider = modelProviders.getProviderByModelId(model);
112
+ const family = builtInFamilyForProvider(provider);
113
+ if (family) return family;
114
+
115
+ if (!provider && isKnownBuiltInAnthropicModel(model)) {
116
+ return BUILT_IN_ANTHROPIC_PROVIDER_ID;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function builtInFamilyForProvider(provider) {
123
+ if (!provider?.isBuiltIn) return null;
124
+ if (provider.id === BUILT_IN_OPENAI_PROVIDER_ID || provider.kind === 'openai') return BUILT_IN_OPENAI_PROVIDER_ID;
125
+ if (provider.id === BUILT_IN_ANTHROPIC_PROVIDER_ID || (provider.kind || 'anthropic') === 'anthropic') {
126
+ return BUILT_IN_ANTHROPIC_PROVIDER_ID;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ export function getRecentSessionModelUsage(limit = 50) {
132
+ const rows = sessions.db
133
+ .prepare(
134
+ `SELECT s.model, s.provider_id, ${ACTIVITY_FIELDS_SQL}
135
+ FROM sessions s
136
+ ORDER BY COALESCE(last_activity_at, s.updated_at, s.created_at) DESC,
137
+ s.updated_at DESC,
138
+ s.created_at DESC,
139
+ s.rowid DESC
140
+ LIMIT ?`
141
+ )
142
+ .all(limit);
143
+
144
+ return rows.map((row) => ({
145
+ model: row.model || null,
146
+ providerId: row.provider_id || null,
147
+ }));
148
+ }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { sessions, messages, sessionSummaries, projects, settings } from '../database.js';
15
15
  import { createConcurrencyGuard } from './withConcurrencyGuard.js';
16
- import { callClaude } from './summaryClaudeClient.js';
16
+ import { callSummaryModel } from './summaryModelClient.js';
17
17
  import {
18
18
  MAX_MESSAGES,
19
19
  MIN_MESSAGES_FOR_SUMMARY,
@@ -299,10 +299,11 @@ async function _doGenerateSummary(sessionId, retryCount = 0, force = false, user
299
299
  // Build conversation context and prompt
300
300
  const { prompt } = fetchConversationContext(sessionId, { existingSummary, recentMessages, session, globalSettings });
301
301
 
302
- // Call Claude via SDK
303
- const responseText = await callClaude(prompt, recentMessages, session.status, {
302
+ // Call the configured summary model.
303
+ const responseText = await callSummaryModel(prompt, recentMessages, session.status, {
304
304
  logMeta: { sessionId, callType: 'generateSessionSummary' },
305
305
  systemPrompt: SUMMARY_SYSTEM_PROMPT,
306
+ summarySettings: globalSettings,
306
307
  });
307
308
 
308
309
  // Parse response and retry if needed
@@ -536,7 +537,8 @@ export {
536
537
  SUMMARY_SYSTEM_PROMPT, formatMessages, buildIncrementalPrompt, parseSummaryResponse,
537
538
  stripMarkdownCodeBlock as _stripMarkdownCodeBlock, trackMessageMetadata as _trackMessageMetadata,
538
539
  };
539
- export { callClaude };
540
+ export { callSummaryModel };
541
+ export { callClaude } from './summaryClaudeClient.js';
540
542
  export { parsePrUrl, validatePrUrl, extractPrUrlIfNeeded, enrichPrData as _enrichPrData };
541
543
  export { getChildSessions, buildChildSessionContext, aggregateFilesModified };
542
544