circuschief 0.6.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 (129) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/api/projects-session-helpers.js +25 -0
  3. package/packages/server/src/api/projects.js +1 -0
  4. package/packages/server/src/api/sessions-draft.js +1 -0
  5. package/packages/server/src/api/settings.js +52 -4
  6. package/packages/server/src/db/ConversationRepository.js +16 -3
  7. package/packages/server/src/db/ProjectDefaultsRepository.js +47 -37
  8. package/packages/server/src/db/SessionRepository.js +12 -8
  9. package/packages/server/src/db/SettingsRepository.js +44 -16
  10. package/packages/server/src/db/conversation-helpers.js +1 -0
  11. package/packages/server/src/db/migrations/conversationsMigrations.js +4 -0
  12. package/packages/server/src/db/migrations/index.js +2 -0
  13. package/packages/server/src/db/migrations/sessionsMigrations.js +6 -1
  14. package/packages/server/src/db/session-helpers.js +3 -0
  15. package/packages/server/src/schema.sql +8 -0
  16. package/packages/server/src/services/agentCallLogger.js +1 -1
  17. package/packages/server/src/services/commandButtonPrompts.js +48 -0
  18. package/packages/server/src/services/draftSessionService.js +2 -0
  19. package/packages/server/src/services/sessionExecution.js +3 -1
  20. package/packages/server/src/services/sessionPrompts.js +4 -0
  21. package/packages/server/src/services/sessionProvider.js +6 -8
  22. package/packages/server/src/services/streamEventCallbacks.js +72 -40
  23. package/packages/server/src/services/streamEventHandler.js +13 -2
  24. package/packages/server/src/services/streamUsageHandler.js +6 -0
  25. package/packages/server/src/services/summaryClaudeClient.js +37 -12
  26. package/packages/server/src/services/summaryModelClient.js +154 -0
  27. package/packages/server/src/services/summaryModelResolver.js +148 -0
  28. package/packages/server/src/services/summaryService.js +6 -4
  29. package/packages/server/src/services/usageTracker.js +5 -1
  30. package/packages/server/src/services/visibleFinalErrorMessage.js +123 -0
  31. package/packages/shared/src/constants.js +1 -2
  32. package/packages/shared/src/contracts/projects.js +2 -0
  33. package/packages/shared/src/utils.js +9 -17
  34. package/packages/web/dist/assets/ActiveSessionsView-UJsCILDL.js +1 -0
  35. package/packages/web/dist/assets/{AgentLogsView-Cdw4nmvd.js → AgentLogsView-BGFPLjLa.js} +1 -1
  36. package/packages/web/dist/assets/ApiClient-B4YTtyY4.js +1 -0
  37. package/packages/web/dist/assets/{ArchiveConfirmModal-J48eh3zw.js → ArchiveConfirmModal-OFaj_uX5.js} +1 -1
  38. package/packages/web/dist/assets/{CommandButtonDetailView-DnFhJY5A.js → CommandButtonDetailView-D8S258uP.js} +1 -1
  39. package/packages/web/dist/assets/EffortLevelSelector-C2378L8e.js +1 -0
  40. package/packages/web/dist/assets/{GeneralSettingsView-CQkmdczf.js → GeneralSettingsView-DsHChEhv.js} +1 -1
  41. package/packages/web/dist/assets/{InputWithButton-XyM3k6lN.js → InputWithButton-Ci15ox0a.js} +1 -1
  42. package/packages/web/dist/assets/{InterpolationHelp-PfYR3KJo.js → InterpolationHelp-CIkOSkWX.js} +1 -1
  43. package/packages/web/dist/assets/MarkdownEditor-5-bexzUT.js +2 -0
  44. package/packages/web/dist/assets/ModelSelector-BMpR0DPr.js +1 -0
  45. package/packages/web/dist/assets/{ModelSelector-BZOT1Jc6.css → ModelSelector-D8hbTRIt.css} +1 -1
  46. package/packages/web/dist/assets/{NewSessionView-DkjFLvHU.js → NewSessionView-BCqtIgWH.js} +2 -2
  47. package/packages/web/dist/assets/{NewSessionView-D_Hi7M9g.css → NewSessionView-CUUdHkfv.css} +1 -1
  48. package/packages/web/dist/assets/ProjectEditView-D9sK0fdH.css +1 -0
  49. package/packages/web/dist/assets/ProjectEditView-RFaxHhAX.js +1 -0
  50. package/packages/web/dist/assets/{ProjectListView-CuYMmd3O.js → ProjectListView-B9FuWESY.js} +1 -1
  51. package/packages/web/dist/assets/{ProjectNewView-CNaA4Maf.js → ProjectNewView-D62jYlBL.js} +1 -1
  52. package/packages/web/dist/assets/ProvidersView-DDKMIQWZ.js +1 -0
  53. package/packages/web/dist/assets/QuickResponseSettings-CDm5vwP7.js +1 -0
  54. package/packages/web/dist/assets/{QuickResponsesPanel-DIBQFj0W.css → QuickResponsesPanel-BlFDvnZ2.css} +1 -1
  55. package/packages/web/dist/assets/{QuickResponsesPanel-BqMYSHb0.js → QuickResponsesPanel-DZ_Lre_l.js} +1 -1
  56. package/packages/web/dist/assets/{ResizableTextarea-wYF3K2RO.js → ResizableTextarea-DiIOEGjN.js} +1 -1
  57. package/packages/web/dist/assets/{SessionCard-bLaQEWWX.js → SessionCard-DmjnVYWn.js} +1 -1
  58. package/packages/web/dist/assets/SessionDetailView-CL7nmfiB.js +36 -0
  59. package/packages/web/dist/assets/SessionDetailView-CupIkI7u.css +1 -0
  60. package/packages/web/dist/assets/SessionFormOptions-BpUALRKn.css +1 -0
  61. package/packages/web/dist/assets/SessionFormOptions-DYUISplS.js +1 -0
  62. package/packages/web/dist/assets/SessionListView-BcxGz4aC.js +1 -0
  63. package/packages/web/dist/assets/{SessionLogStream-DTnDAF95.js → SessionLogStream-DpUE6Xsh.js} +1 -1
  64. package/packages/web/dist/assets/{SettingsView-DNLUSsHV.js → SettingsView-BC055tIA.js} +1 -1
  65. package/packages/web/dist/assets/{SlashCommandWizard-CRGFaO8t.js → SlashCommandWizard-DmTyNG9O.js} +1 -1
  66. package/packages/web/dist/assets/SummarySettingsView-BgnRCwlq.js +1 -0
  67. package/packages/web/dist/assets/SummarySettingsView-l2bxHmZZ.css +1 -0
  68. package/packages/web/dist/assets/TemplateDetailView-BlhOmLUX.js +1 -0
  69. package/packages/web/dist/assets/{commandButtons-Bbjf3fCt.js → commandButtons-D4RPpLiu.js} +1 -1
  70. package/packages/web/dist/assets/index-4rhEeO0B.js +1 -0
  71. package/packages/web/dist/assets/index-9vb2KaAd.js +1 -0
  72. package/packages/web/dist/assets/index-B0CvZXuN.js +7 -0
  73. package/packages/web/dist/assets/{index-BQL_L4gL.js → index-B6G18FqB.js} +14 -14
  74. package/packages/web/dist/assets/{index-Cf6vdW-B.js → index-BGwH4Cfn.js} +3 -3
  75. package/packages/web/dist/assets/index-BUhvkAdF.js +1 -0
  76. package/packages/web/dist/assets/index-BcnkUk2o.js +1 -0
  77. package/packages/web/dist/assets/{index-Bs7Qf5D6.js → index-Bn5xdGFM.js} +2 -2
  78. package/packages/web/dist/assets/index-CNwkdB0T.js +1 -0
  79. package/packages/web/dist/assets/index-CfL84oGW.js +1 -0
  80. package/packages/web/dist/assets/index-CkmxO8Mm.js +1 -0
  81. package/packages/web/dist/assets/index-Cpy4-yv3.js +1 -0
  82. package/packages/web/dist/assets/index-CrAQJmoZ.js +1 -0
  83. package/packages/web/dist/assets/{index-gmCCsCQ1.css → index-Cs2nxhrT.css} +1 -1
  84. package/packages/web/dist/assets/index-D6Ky9vJe.js +3 -0
  85. package/packages/web/dist/assets/index-DfrE0gAC.js +1 -0
  86. package/packages/web/dist/assets/index-KwEyz0F3.js +1 -0
  87. package/packages/web/dist/assets/index-OfCywayk.js +1 -0
  88. package/packages/web/dist/assets/index-PDesaJc6.js +1 -0
  89. package/packages/web/dist/assets/index-uB6nhSvz.js +1 -0
  90. package/packages/web/dist/assets/{projects-CPt3AB7U.js → projects-BUiOGmmb.js} +1 -1
  91. package/packages/web/dist/assets/{providers-ChfeMvUq.js → providers-Bh1ZiiJi.js} +1 -1
  92. package/packages/web/dist/assets/sessions-DH1R-NhV.js +1 -0
  93. package/packages/web/dist/assets/settings-Z4AVVmkJ.js +1 -0
  94. package/packages/web/dist/index.html +2 -2
  95. package/packages/web/dist/assets/ActiveSessionsView-UCbQrF1b.js +0 -1
  96. package/packages/web/dist/assets/ApiClient-CWbXWDUY.js +0 -1
  97. package/packages/web/dist/assets/EffortLevelSelector-bXbPo4Zw.js +0 -1
  98. package/packages/web/dist/assets/MarkdownEditor-P8F5kO-o.js +0 -2
  99. package/packages/web/dist/assets/ModelSelector-CowKfGMP.js +0 -1
  100. package/packages/web/dist/assets/ProjectEditView-CpeKj-_w.css +0 -1
  101. package/packages/web/dist/assets/ProjectEditView-embVT7NC.js +0 -1
  102. package/packages/web/dist/assets/ProvidersView-C7rydtOd.js +0 -1
  103. package/packages/web/dist/assets/QuickResponseSettings-BTQEKhwJ.js +0 -1
  104. package/packages/web/dist/assets/SessionDetailView-Cv-xMzXp.css +0 -1
  105. package/packages/web/dist/assets/SessionDetailView-CvQOUsW2.js +0 -36
  106. package/packages/web/dist/assets/SessionFormOptions-3pzbgI2Q.js +0 -1
  107. package/packages/web/dist/assets/SessionFormOptions-DhhIkjIS.css +0 -1
  108. package/packages/web/dist/assets/SessionListView-Dranfb72.js +0 -1
  109. package/packages/web/dist/assets/SummarySettingsView-C7G_suHp.js +0 -1
  110. package/packages/web/dist/assets/SummarySettingsView-DcsmSVJI.css +0 -1
  111. package/packages/web/dist/assets/TemplateDetailView-B78_DLMR.js +0 -1
  112. package/packages/web/dist/assets/index--V7c-VZf.js +0 -1
  113. package/packages/web/dist/assets/index-8Q04yd7H.js +0 -1
  114. package/packages/web/dist/assets/index-B47XRBDH.js +0 -1
  115. package/packages/web/dist/assets/index-BXbgZrhS.js +0 -1
  116. package/packages/web/dist/assets/index-CGhDVPen.js +0 -1
  117. package/packages/web/dist/assets/index-CKcRO1A6.js +0 -1
  118. package/packages/web/dist/assets/index-CTq-SLIW.js +0 -1
  119. package/packages/web/dist/assets/index-CYyos3iC.js +0 -1
  120. package/packages/web/dist/assets/index-CsCREAxF.js +0 -1
  121. package/packages/web/dist/assets/index-DJTTk_8T.js +0 -3
  122. package/packages/web/dist/assets/index-DPqUJ5JK.js +0 -1
  123. package/packages/web/dist/assets/index-EwAe1dKg.js +0 -1
  124. package/packages/web/dist/assets/index-JBA8axyA.js +0 -1
  125. package/packages/web/dist/assets/index-JkVHFtK5.js +0 -7
  126. package/packages/web/dist/assets/index-gMPUwT55.js +0 -1
  127. package/packages/web/dist/assets/index-wadc_0zT.js +0 -1
  128. package/packages/web/dist/assets/sessions-CwPsJOb1.js +0 -1
  129. package/packages/web/dist/assets/settings-BOj6wq6t.js +0 -1
@@ -0,0 +1,48 @@
1
+ import { commandButtons } from '../database.js';
2
+
3
+ /**
4
+ * Build Command Button API instructions for system prompt if the project has command buttons.
5
+ * @param {string} apiUrl - Base API URL
6
+ * @param {string} sessionId - Current session ID
7
+ * @param {string} projectId - Current project ID
8
+ * @returns {string} Command button instructions or empty string if no buttons configured
9
+ */
10
+ export function buildCommandButtonApiInstructions(apiUrl, sessionId, projectId) {
11
+ const buttons = commandButtons.getByProjectId(projectId);
12
+ if (!buttons || buttons.length === 0) {
13
+ return '';
14
+ }
15
+
16
+ return `## Command Buttons API
17
+
18
+ This project has command buttons configured - reusable shell commands you can execute. Use the Bash tool to run these curl commands.
19
+
20
+ ### List Available Buttons
21
+ \`\`\`bash
22
+ curl ${apiUrl}/api/projects/${projectId}/command-buttons
23
+ \`\`\`
24
+
25
+ ### Run a Button
26
+ \`\`\`bash
27
+ curl -X POST ${apiUrl}/api/sessions/${sessionId}/command-buttons/<button_id>/run
28
+ \`\`\`
29
+
30
+ Response: { runId, buttonId, status: "running", output: "" }
31
+
32
+ ### Check Run Status & Output
33
+ \`\`\`bash
34
+ curl ${apiUrl}/api/sessions/${sessionId}/command-buttons/runs/<run_id>
35
+ \`\`\`
36
+
37
+ Response: { runId, buttonId, status, exitCode, output, startedAt, completedAt }
38
+
39
+ ### List All Runs for This Session
40
+ \`\`\`bash
41
+ curl ${apiUrl}/api/sessions/${sessionId}/command-buttons/runs
42
+ \`\`\`
43
+
44
+ ### Kill a Running Command
45
+ \`\`\`bash
46
+ curl -X POST ${apiUrl}/api/sessions/${sessionId}/command-buttons/runs/<run_id>/kill
47
+ \`\`\``;
48
+ }
@@ -112,6 +112,7 @@ function getOrCreateInitialMessage(session, options) {
112
112
  * @param {object} options
113
113
  * @param {string} [options.prompt] - Optional new prompt to use/override
114
114
  * @param {string} [options.model] - Optional model override
115
+ * @param {string|null} [options.providerId] - Optional provider override
115
116
  * @returns {Promise<object>} The updated session
116
117
  */
117
118
  export async function startDraft(session, options = {}) {
@@ -144,6 +145,7 @@ export async function startDraft(session, options = {}) {
144
145
  status: 'starting',
145
146
  pendingModel: null,
146
147
  ...(model ? { model, agentType } : {}),
148
+ ...(options.providerId !== undefined ? { providerId: options.providerId } : {}),
147
149
  });
148
150
 
149
151
  // Resolve skill/command invocations so skill body goes into system prompt
@@ -82,7 +82,9 @@ function buildClaudeCodeQueryParams({
82
82
  abortController: controller,
83
83
  includePartialMessages: true,
84
84
  permissionMode: getPermissionModeForSession(session.mode),
85
- settingSources: ['project'],
85
+ // Match normal Claude Code CLI behavior: load user-level settings
86
+ // such as configured MCP servers, then project/local overrides.
87
+ settingSources: ['user', 'project', 'local'],
86
88
  ...(resumeSessionId && { resume: resumeSessionId }),
87
89
  env: sessionEnv,
88
90
  spawnClaudeCodeProcess: createClaudeCodeSpawner(),
@@ -1,5 +1,6 @@
1
1
  import { sessions, attachments, projects, kanbanBoards, kanbanLanes } from '../database.js';
2
2
  import { DEFAULT_SERVER_PORT, DEFAULT_SYSTEM_PROMPT } from '../../../shared/src/index.js';
3
+ import { buildCommandButtonApiInstructions } from './commandButtonPrompts.js';
3
4
 
4
5
  /**
5
6
  * Get the base API URL for canvas and session operations.
@@ -422,10 +423,12 @@ This session is part of a multi-session workflow:
422
423
  * @returns {string} System prompt string
423
424
  */
424
425
  export function buildSystemPromptConfig(sessionId, projectId, customSystemPrompt, mode) {
426
+ const apiUrl = getApiBaseUrl();
425
427
  const session = sessions.getById(sessionId);
426
428
  const canvasWriteInstructions = buildCanvasWriteSystemPrompt(session); // Pass session object
427
429
  const canvasReadInstructions = buildCanvasReadSystemPrompt(session); // Pass session object
428
430
  const sessionApiInstructions = buildSessionApiInstructions(sessionId, projectId);
431
+ const commandButtonApiInstructions = buildCommandButtonApiInstructions(apiUrl, sessionId, projectId);
429
432
  const kanbanApiInstructions = buildKanbanApiInstructions(sessionId, projectId);
430
433
  const attachmentsContext = getSessionAttachmentsContext(sessionId);
431
434
  const worktreeContext = buildWorktreeContext(session);
@@ -445,6 +448,7 @@ export function buildSystemPromptConfig(sessionId, projectId, customSystemPrompt
445
448
  canvasWriteInstructions,
446
449
  canvasReadInstructions,
447
450
  sessionApiInstructions,
451
+ commandButtonApiInstructions,
448
452
  kanbanApiInstructions
449
453
  ].filter(Boolean);
450
454
 
@@ -204,14 +204,12 @@ function stripAnthropicHostEnv(env) {
204
204
 
205
205
  function replaceWithCodexCliEnv(sessionEnv, providerEnv) {
206
206
  const target = sessionEnv;
207
- const allowed = ['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TMPDIR'];
208
- const cleaned = {};
209
- for (const key of allowed) {
210
- if (target[key] !== undefined) cleaned[key] = target[key];
211
- }
212
- Object.assign(cleaned, providerEnv);
213
- for (const key of Object.keys(target)) delete target[key];
214
- Object.assign(target, cleaned);
207
+ delete target.OPENAI_API_KEY;
208
+ delete target.OPENAI_BASE_URL;
209
+ delete target.OPENAI_API_BASE;
210
+ delete target.OPENAI_ORG_ID;
211
+ delete target.OPENAI_PROJECT;
212
+ Object.assign(target, providerEnv);
215
213
  }
216
214
 
217
215
  function stripOpenAIBaseUrlUnlessProvided(sessionEnv, providerEnv) {
@@ -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
  /**
@@ -404,8 +411,11 @@ function handleResultEvent(sessionId, event) {
404
411
  * @param {Object} event
405
412
  */
406
413
  function handleResultError(sessionId, event) {
407
- sessions.update(sessionId, { status: 'error', error: event.error });
408
- 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 });
409
419
  // Broadcast error status to project subscribers for session list updates
410
420
  broadcastSessionStatus(sessionId, 'error');
411
421
  // Extract PR URL before generating summary (PR may have been created before error)
@@ -481,6 +491,7 @@ export function cleanupSessionState(sessionId, includeConversationId = false) {
481
491
  thinkingAccumulators.delete(sessionId);
482
492
  currentModels.delete(sessionId);
483
493
  loggedToolUseIds.delete(sessionId);
494
+ finalErrorSessionIds.delete(sessionId);
484
495
  activeSessions.delete(sessionId);
485
496
  if (includeConversationId) {
486
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
+ }