circuschief 0.1.4 → 0.2.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 (81) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/api/commands.js +50 -55
  3. package/packages/server/src/api/projects-helpers.js +13 -4
  4. package/packages/server/src/api/projects.js +33 -18
  5. package/packages/server/src/cli.js +82 -0
  6. package/packages/server/src/db/AgentCallLogRepository.js +30 -31
  7. package/packages/server/src/db/ConversationRepository.js +27 -16
  8. package/packages/server/src/db/ProjectRepository.js +21 -31
  9. package/packages/server/src/db/QuickResponseRepository.js +14 -19
  10. package/packages/server/src/db/migrations/sessionsMigrations.js +61 -61
  11. package/packages/server/src/index.js +42 -29
  12. package/packages/server/src/services/commandRunner.js +52 -99
  13. package/packages/server/src/services/kanbanTriggers.js +83 -56
  14. package/packages/server/src/services/schedulerService.js +68 -44
  15. package/packages/server/src/services/sessionExecution.js +102 -61
  16. package/packages/server/src/services/sessionManager.js +63 -37
  17. package/packages/server/src/services/summaryService.js +56 -53
  18. package/packages/server/src/services/templateTriggerService.js +58 -31
  19. package/packages/server/src/ws/WebSocketManager.js +5 -0
  20. package/packages/web/dist/assets/ActiveSessionsView-Bd8FWObJ.js +1 -0
  21. package/packages/web/dist/assets/ActiveSessionsView-DfYXc6dz.css +1 -0
  22. package/packages/web/dist/assets/{AgentLogsView-c42v_j_5.js → AgentLogsView-C4FTXUH8.js} +1 -1
  23. package/packages/web/dist/assets/{ArchiveConfirmModal-DBuOmtXu.js → ArchiveConfirmModal-MpqhAjWZ.js} +1 -1
  24. package/packages/web/dist/assets/{CommandButtonDetailView-CkKJ3Htz.js → CommandButtonDetailView-_tOjEGjk.js} +1 -1
  25. package/packages/web/dist/assets/{EffortLevelSelector-BHJHSqul.js → EffortLevelSelector-B4z3zfJ6.js} +1 -1
  26. package/packages/web/dist/assets/{GeneralSettingsView-CdxfteZ2.js → GeneralSettingsView-BZ4x6T_N.js} +1 -1
  27. package/packages/web/dist/assets/{InterpolationHelp-DabnHhE4.js → InterpolationHelp-CIlrD5JA.js} +1 -1
  28. package/packages/web/dist/assets/MarkdownEditor-DBvC-1OX.js +2 -0
  29. package/packages/web/dist/assets/{ModelSelector-BWIU4ud7.js → ModelSelector-BTmHxqCs.js} +1 -1
  30. package/packages/web/dist/assets/{NewSessionView-BIZl8QlH.js → NewSessionView-Cdgt6wnk.js} +2 -2
  31. package/packages/web/dist/assets/{PathChooser-nhat_Pz4.js → PathChooser-CPEkT0uu.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectEditView-DD-2_VrW.js → ProjectEditView-D59hr-v2.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectListView-BOWbfoXQ.js → ProjectListView-gG4AR1i9.js} +1 -1
  34. package/packages/web/dist/assets/{ProjectNewView-DC4uvSn2.js → ProjectNewView-BPjn1O4f.js} +1 -1
  35. package/packages/web/dist/assets/ProvidersView-C04jD9NZ.js +1 -0
  36. package/packages/web/dist/assets/{QuickResponseSettings-Bk9mq96x.js → QuickResponseSettings-DaEXIp3-.js} +1 -1
  37. package/packages/web/dist/assets/{QuickResponsesPanel-BRvcnkQr.js → QuickResponsesPanel-CIdblIbt.js} +1 -1
  38. package/packages/web/dist/assets/{ResizableTextarea-CwGM4P3c.js → ResizableTextarea-DJekfIXO.js} +1 -1
  39. package/packages/web/dist/assets/{SessionCard-BGDVHU9u.js → SessionCard-CAdetaVH.js} +1 -1
  40. package/packages/web/dist/assets/{SessionCard-D20G3bX8.css → SessionCard-CcqIjL8q.css} +1 -1
  41. package/packages/web/dist/assets/{SessionDetailView-CHYrx2Ab.js → SessionDetailView-BOdnH-cW.js} +17 -17
  42. package/packages/web/dist/assets/{SessionDetailView-7bWgC7Es.css → SessionDetailView-mnGRMaLY.css} +1 -1
  43. package/packages/web/dist/assets/{SessionFormOptions-8qvL25ca.js → SessionFormOptions-Dy57kl-x.js} +1 -1
  44. package/packages/web/dist/assets/{SessionListView-BAIBtJF7.css → SessionListView-78k6TTz6.css} +1 -1
  45. package/packages/web/dist/assets/SessionListView-DUMUXfp4.js +1 -0
  46. package/packages/web/dist/assets/{SessionLogStream-B-w3n4c3.js → SessionLogStream-DHPxkaaK.js} +1 -1
  47. package/packages/web/dist/assets/{SettingsView-Dd0ZJ4Nv.js → SettingsView-BzrkWbH3.js} +1 -1
  48. package/packages/web/dist/assets/{SlashCommandWizard-CzyLjsdJ.js → SlashCommandWizard-BAC_oF5i.js} +1 -1
  49. package/packages/web/dist/assets/{SummarySettingsView-DTbh7uAF.js → SummarySettingsView-J8BQBEe9.js} +1 -1
  50. package/packages/web/dist/assets/{TemplateDetailView-BOnhkdtH.js → TemplateDetailView-2ilWbANb.js} +1 -1
  51. package/packages/web/dist/assets/{commandButtons-CY87n64i.js → commandButtons-aIO6hqZn.js} +1 -1
  52. package/packages/web/dist/assets/{index-DcA6pqXV.js → index-B6W39ctH.js} +1 -1
  53. package/packages/web/dist/assets/{index-NzLFVaCi.js → index-BGmIjKYB.js} +1 -1
  54. package/packages/web/dist/assets/{index-CO4EBOFw.js → index-BwChYYnJ.js} +1 -1
  55. package/packages/web/dist/assets/{index-BshkV3r5.js → index-CDRRIqmL.js} +1 -1
  56. package/packages/web/dist/assets/{index-Dx0sYW7H.js → index-CSA0abwg.js} +1 -1
  57. package/packages/web/dist/assets/{index-Ce6sL47U.js → index-CWTVEGZv.js} +1 -1
  58. package/packages/web/dist/assets/{index-i1o916sk.js → index-CjJX0Eli.js} +1 -1
  59. package/packages/web/dist/assets/{index-BRUlEEHm.js → index-CpNgrGiE.js} +1 -1
  60. package/packages/web/dist/assets/{index-gMpnPf1V.js → index-DCxYGijD.js} +1 -1
  61. package/packages/web/dist/assets/{index-jGjvGBfk.js → index-DF6g7nEj.js} +1 -1
  62. package/packages/web/dist/assets/{index-aCw-iXPX.js → index-DMl4xPIQ.js} +1 -1
  63. package/packages/web/dist/assets/{index--OtPwBbF.js → index-DMsWg7Ax.js} +3 -3
  64. package/packages/web/dist/assets/{index-CjHb9rXv.js → index-DePUHO3n.js} +1 -1
  65. package/packages/web/dist/assets/{index-C6m-WfqP.js → index-DvfYqZgb.js} +1 -1
  66. package/packages/web/dist/assets/{index-DkLkDgig.js → index-DxUd3T5E.js} +23 -23
  67. package/packages/web/dist/assets/{index-BXUcbV4K.js → index-GLkcnEcc.js} +1 -1
  68. package/packages/web/dist/assets/{index-DPwwgloE.js → index-ZDCxncSd.js} +1 -1
  69. package/packages/web/dist/assets/{index-DxboI9i-.js → index-f24-2-RT.js} +1 -1
  70. package/packages/web/dist/assets/{index-Bi4bQ_UB.js → index-uZfsnHcN.js} +1 -1
  71. package/packages/web/dist/assets/{projects-C2Y29PSJ.js → projects-DGF_kWVA.js} +1 -1
  72. package/packages/web/dist/assets/{providers-CeJXuo0Q.js → providers-Cm7emO-N.js} +1 -1
  73. package/packages/web/dist/assets/sessions-CH7PeypH.js +1 -0
  74. package/packages/web/dist/assets/{settings-BplIxCbi.js → settings-C7sXXJ-n.js} +1 -1
  75. package/packages/web/dist/index.html +1 -1
  76. package/packages/web/dist/assets/ActiveSessionsView-BryJ-V3f.js +0 -1
  77. package/packages/web/dist/assets/ActiveSessionsView-ofSvx-K1.css +0 -1
  78. package/packages/web/dist/assets/MarkdownEditor-k4zBLGqU.js +0 -2
  79. package/packages/web/dist/assets/ProvidersView-DT5afh1V.js +0 -1
  80. package/packages/web/dist/assets/SessionListView-927Yq6Il.js +0 -1
  81. package/packages/web/dist/assets/sessions-CMby7ij3.js +0 -1
@@ -75,6 +75,70 @@ class SchedulerService {
75
75
  }
76
76
  }
77
77
 
78
+ /**
79
+ * Resolve skill/command invocations in the prompt, returning the effective prompt and system prompt.
80
+ * @param {object} session - Session object
81
+ * @param {string} workingDirectory - Working directory
82
+ * @param {string} projectSystemPrompt - Project-level system prompt
83
+ * @returns {Promise<{prompt: string, effectivePrompt: string, effectiveSystemPrompt: string, sessionAttachments: Array}>}
84
+ */
85
+ async resolveScheduledPrompt(session, workingDirectory, projectSystemPrompt) {
86
+ const prompt = session.pendingPrompt.trim();
87
+ const sessionAttachments = attachments.getBySessionId(session.id);
88
+
89
+ const resolved = await slashCommandService.resolvePromptSkillOrCommand(
90
+ workingDirectory, prompt, projectSystemPrompt
91
+ );
92
+
93
+ return {
94
+ prompt,
95
+ effectivePrompt: resolved ? resolved.userMessage : prompt,
96
+ effectiveSystemPrompt: resolved ? resolved.systemPrompt : projectSystemPrompt,
97
+ sessionAttachments,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Handle the fresh-session branch of startScheduledSession.
103
+ * Creates the initial user message, updates status, and starts the session.
104
+ * @param {object} session - Session object
105
+ * @param {string} prompt - Raw prompt text
106
+ * @param {string} effectivePrompt - Prompt after slash command resolution
107
+ * @param {string} effectiveSystemPrompt - System prompt after resolution
108
+ * @param {string} workingDirectory - Working directory
109
+ * @param {Array} sessionAttachments - Attachments for context
110
+ */
111
+ async startFreshScheduledSession({ session, prompt, effectivePrompt, effectiveSystemPrompt, workingDirectory, sessionAttachments }) {
112
+ const activeConv = conversations.getActiveBySessionId(session.id);
113
+ if (!activeConv) {
114
+ throw new Error(`No active conversation found for session ${session.id}`);
115
+ }
116
+
117
+ // Create the initial user message
118
+ const userMessage = messages.create(session.id, 'user', prompt, { toolUse: null, conversationId: activeConv.id });
119
+
120
+ // Broadcast the new message so UI updates
121
+ broadcastToSession(session.id, WS_MESSAGE_TYPES.MESSAGE_CREATED, {
122
+ sessionId: session.id,
123
+ message: userMessage,
124
+ });
125
+
126
+ // Update status from 'scheduled' to 'starting' and clear pendingPrompt
127
+ sessions.update(session.id, {
128
+ status: 'starting',
129
+ scheduledAt: null,
130
+ pendingPrompt: null,
131
+ });
132
+ broadcastToSession(session.id, WS_MESSAGE_TYPES.SESSION_STATUS, { sessionId: session.id, status: 'starting' });
133
+
134
+ await this.sessionManager.runSession(
135
+ session.id,
136
+ effectivePrompt,
137
+ workingDirectory,
138
+ { systemPrompt: effectiveSystemPrompt, fileAttachments: sessionAttachments, model: session.pendingModel }
139
+ );
140
+ }
141
+
78
142
  /**
79
143
  * Start a scheduled session
80
144
  * @param {object} session - Session to start
@@ -100,27 +164,17 @@ class SchedulerService {
100
164
  throw new Error(`No pendingPrompt found for session ${session.id}`);
101
165
  }
102
166
 
103
- const prompt = session.pendingPrompt.trim();
167
+ // Resolve skill/command invocations
168
+ const { prompt, effectivePrompt, effectiveSystemPrompt, sessionAttachments } =
169
+ await this.resolveScheduledPrompt(session, workingDirectory, project.systemPrompt);
104
170
 
105
171
  // Get the session messages to determine if this is initial or continuation
106
172
  const sessionMessages = messages.getBySessionId(session.id);
107
173
  const hasAssistantResponses = sessionMessages.some((msg) => msg.role === 'assistant');
108
174
 
109
- // Get attachments for context
110
- const sessionAttachments = attachments.getBySessionId(session.id);
111
-
112
- // Resolve skill/command invocations so skill body goes into system prompt
113
- const resolved = await slashCommandService.resolvePromptSkillOrCommand(
114
- workingDirectory, prompt, project.systemPrompt
115
- );
116
- const effectivePrompt = resolved ? resolved.userMessage : prompt;
117
- const effectiveSystemPrompt = resolved ? resolved.systemPrompt : project.systemPrompt;
118
-
119
175
  // Determine if this is an initial run or a continuation
120
176
  if (hasAssistantResponses) {
121
177
  // Session has conversation history - this is a scheduled continuation
122
-
123
- // Update status from 'scheduled' to 'starting' and clear pendingPrompt
124
178
  sessions.update(session.id, {
125
179
  status: 'starting',
126
180
  scheduledAt: null,
@@ -136,37 +190,7 @@ class SchedulerService {
136
190
  );
137
191
  } else {
138
192
  // Fresh session - initial run
139
- // First, create the user message so it appears in the conversation
140
-
141
- // Get the active conversation
142
- const activeConv = conversations.getActiveBySessionId(session.id);
143
- if (!activeConv) {
144
- throw new Error(`No active conversation found for session ${session.id}`);
145
- }
146
-
147
- // Create the initial user message
148
- const userMessage = messages.create(session.id, 'user', prompt, { toolUse: null, conversationId: activeConv.id });
149
-
150
- // Broadcast the new message so UI updates
151
- broadcastToSession(session.id, WS_MESSAGE_TYPES.MESSAGE_CREATED, {
152
- sessionId: session.id,
153
- message: userMessage,
154
- });
155
-
156
- // Update status from 'scheduled' to 'starting' and clear pendingPrompt
157
- sessions.update(session.id, {
158
- status: 'starting',
159
- scheduledAt: null,
160
- pendingPrompt: null,
161
- });
162
- broadcastToSession(session.id, WS_MESSAGE_TYPES.SESSION_STATUS, { sessionId: session.id, status: 'starting' });
163
-
164
- await this.sessionManager.runSession(
165
- session.id,
166
- effectivePrompt,
167
- workingDirectory,
168
- { systemPrompt: effectiveSystemPrompt, fileAttachments: sessionAttachments, model: session.pendingModel }
169
- );
193
+ await this.startFreshScheduledSession({ session, prompt, effectivePrompt, effectiveSystemPrompt, workingDirectory, sessionAttachments });
170
194
  }
171
195
  }
172
196
 
@@ -158,66 +158,14 @@ async function buildPromptForContinue(modelChanged, conversationId, promptWithAt
158
158
  }
159
159
 
160
160
  /**
161
- * Continue a session with a follow-up message (core implementation)
162
- * @param {string} sessionId
163
- * @param {string} content
164
- * @param {string} workingDirectory
165
- * @param {Object} config - Session options and callbacks
166
- * @param {Object} [config.options] - Session options (systemPrompt, fileAttachments, model)
167
- * @param {Object} config.callbacks - Callback functions from sessionManager
161
+ * Resolve model/provider and build session environment for a continue operation.
162
+ * Also detects model changes and updates the session record.
163
+ * @param {Object} session - Current session object
164
+ * @param {string} sessionId - Session ID
165
+ * @param {string|null} model - Requested model (null to keep current)
166
+ * @returns {{ effectiveModel: string|null, sessionEnv: Object, modelChanged: boolean, session: Object }}
168
167
  */
169
- export async function continueSessionCore(sessionId, content, workingDirectory, config = {}) {
170
- const { options = {}, callbacks } = config;
171
- const { systemPrompt = null, fileAttachments = [], model = null } = options;
172
- // Check if session is already running
173
- if (activeSessions.has(sessionId)) {
174
- throw new Error('Session is already processing');
175
- }
176
-
177
- // Get the session to retrieve the Claude session ID and settings
178
- let session = sessions.getById(sessionId);
179
- if (!session) {
180
- throw new Error('Session not found');
181
- }
182
-
183
- const controller = new AbortController();
184
- activeSessions.set(sessionId, { controller });
185
-
186
- // Ensure there's an active conversation for this session
187
- const activeConversation = conversations.ensureActiveConversation(sessionId);
188
- activeConversationIds.set(sessionId, activeConversation.id);
189
- console.log(`[SESSION] continueSession: ensured active conversation ${activeConversation.id} for session ${sessionId}`);
190
-
191
- // Each conversation has its own Claude session context
192
- // If null, Claude will start a fresh session (no resume)
193
-
194
- // Store the user message with conversation ID
195
- const { broadcastToSession } = await import('../websocket.js');
196
- const { WS_MESSAGE_TYPES } = await import('@circuschief/shared');
197
- const message = messages.create(sessionId, 'user', content, { toolUse: null, conversationId: activeConversation.id });
198
- console.log(`[SESSION] continueSession: created user message ${message.id} in conversation ${activeConversation.id}`);
199
- broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_MESSAGE, {
200
- message,
201
- conversationId: activeConversation.id, // Include conversation context
202
- });
203
- console.log(`[SESSION] continueSession: broadcast user message ${message.id} to conversation ${activeConversation.id}`);
204
-
205
- // Associate any pending attachments with the message
206
- if (fileAttachments.length > 0) {
207
- attachments.updateMessageIdForSession(sessionId, message.id);
208
- }
209
-
210
- // Build prompt with attachment context
211
- const promptWithAttachments = buildPromptWithAttachments(content, fileAttachments);
212
-
213
- // Update status to running
214
- sessions.update(sessionId, { status: 'running' });
215
- broadcastSessionStatus(sessionId, 'running');
216
-
217
- // Create agent via gateway (or mock agent in mock mode)
218
- const agentType = session.agentType || 'claude-code';
219
- const agent = createAgentForSession(agentType);
220
-
168
+ function buildContinueModelAndEnv(session, sessionId, model) {
221
169
  // Resolve the effective model: fall back to session.model so that resuming
222
170
  // without an explicit model still resolves the correct provider (e.g.
223
171
  // third-party base URL and auth tokens).
@@ -230,15 +178,29 @@ export async function continueSessionCore(sessionId, content, workingDirectory,
230
178
  // Check if model changed from the session's last requested model
231
179
  // When model changes, we can't resume the previous session - thinking blocks and
232
180
  // session context may be incompatible between different models/providers
233
- const modelChanged = model && session.model && model !== session.model;
181
+ const modelChanged = Boolean(model && session.model && model !== session.model);
234
182
 
235
183
  // Update session.model to track the user-requested model (short format)
236
184
  // This must happen AFTER modelChanged detection so we compare old vs new
185
+ let updatedSession = session;
237
186
  if (model) {
238
187
  sessions.update(sessionId, { model });
239
- session = sessions.getById(sessionId); // refresh
188
+ updatedSession = sessions.getById(sessionId); // refresh
240
189
  }
241
190
 
191
+ return { effectiveModel, sessionEnv, modelChanged, session: updatedSession };
192
+ }
193
+
194
+ /**
195
+ * Build query params and agent call meta for a continue session operation.
196
+ * @param {Object} opts
197
+ * @returns {{ queryParams: Object, agentCallMeta: Object }}
198
+ */
199
+ async function buildContinueParams({
200
+ sessionId, session, model, systemPrompt, effectiveModel, sessionEnv,
201
+ modelChanged, activeConversation, promptWithAttachments,
202
+ workingDirectory, controller, agentType,
203
+ }) {
242
204
  // Only resume if we have a session ID AND model hasn't changed
243
205
  const canResume = activeConversation.claudeSessionId && !modelChanged;
244
206
 
@@ -269,6 +231,85 @@ export async function continueSessionCore(sessionId, content, workingDirectory,
269
231
  promptLength: promptWithContext.length,
270
232
  };
271
233
 
234
+ return { queryParams, agentCallMeta };
235
+ }
236
+
237
+ /**
238
+ * Set up the active conversation, create the user message, broadcast it,
239
+ * associate attachments, and build the prompt with attachment context.
240
+ * @returns {{ activeConversation: Object, promptWithAttachments: string }}
241
+ */
242
+ async function setupConversationAndMessage(sessionId, content, fileAttachments) {
243
+ const activeConversation = conversations.ensureActiveConversation(sessionId);
244
+ activeConversationIds.set(sessionId, activeConversation.id);
245
+
246
+ const { broadcastToSession } = await import('../websocket.js');
247
+ const { WS_MESSAGE_TYPES } = await import('@circuschief/shared');
248
+ const message = messages.create(sessionId, 'user', content, { toolUse: null, conversationId: activeConversation.id });
249
+ broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_MESSAGE, {
250
+ message,
251
+ conversationId: activeConversation.id,
252
+ });
253
+
254
+ if (fileAttachments.length > 0) {
255
+ attachments.updateMessageIdForSession(sessionId, message.id);
256
+ }
257
+
258
+ const promptWithAttachments = buildPromptWithAttachments(content, fileAttachments);
259
+ return { activeConversation, promptWithAttachments };
260
+ }
261
+
262
+ /**
263
+ * Continue a session with a follow-up message (core implementation)
264
+ * @param {string} sessionId
265
+ * @param {string} content
266
+ * @param {string} workingDirectory
267
+ * @param {Object} config - Session options and callbacks
268
+ * @param {Object} [config.options] - Session options (systemPrompt, fileAttachments, model)
269
+ * @param {Object} config.callbacks - Callback functions from sessionManager
270
+ */
271
+ export async function continueSessionCore(sessionId, content, workingDirectory, config = {}) {
272
+ const { options = {}, callbacks } = config;
273
+ const { systemPrompt = null, fileAttachments = [], model = null } = options;
274
+ // Check if session is already running
275
+ if (activeSessions.has(sessionId)) {
276
+ throw new Error('Session is already processing');
277
+ }
278
+
279
+ // Get the session to retrieve the Claude session ID and settings
280
+ let session = sessions.getById(sessionId);
281
+ if (!session) {
282
+ throw new Error('Session not found');
283
+ }
284
+
285
+ const controller = new AbortController();
286
+ activeSessions.set(sessionId, { controller });
287
+
288
+ // Ensure there's an active conversation and create the user message
289
+ const { activeConversation, promptWithAttachments } = await setupConversationAndMessage(
290
+ sessionId, content, fileAttachments
291
+ );
292
+
293
+ // Update status to running
294
+ sessions.update(sessionId, { status: 'running' });
295
+ broadcastSessionStatus(sessionId, 'running');
296
+
297
+ // Create agent via gateway (or mock agent in mock mode)
298
+ const agentType = session.agentType || 'claude-code';
299
+ const agent = createAgentForSession(agentType);
300
+
301
+ // Resolve model/provider and detect model changes
302
+ const modelEnv = buildContinueModelAndEnv(session, sessionId, model);
303
+ session = modelEnv.session;
304
+
305
+ // Build query params and agent call meta
306
+ const { queryParams, agentCallMeta } = await buildContinueParams({
307
+ sessionId, session, model, systemPrompt,
308
+ effectiveModel: modelEnv.effectiveModel, sessionEnv: modelEnv.sessionEnv,
309
+ modelChanged: modelEnv.modelChanged, activeConversation, promptWithAttachments,
310
+ workingDirectory, controller, agentType,
311
+ });
312
+
272
313
  await _executeSession({
273
314
  sessionId,
274
315
  agent,
@@ -217,50 +217,39 @@ function validateAndFetchContinueContext(sessionId, conversationId) {
217
217
  return { session, conversation, lastUserMessage };
218
218
  }
219
219
 
220
- export async function continueSessionWithExistingMessage(sessionId, conversationId, workingDirectory, options = {}) {
221
- const { systemPrompt = null, model = null } = options;
222
- const context = validateAndFetchContinueContext(sessionId, conversationId);
223
- let session = context.session;
224
- const { conversation, lastUserMessage } = context;
225
-
226
- const controller = new AbortController();
227
- activeSessions.set(sessionId, { controller });
228
-
229
- // Make sure this conversation is active
230
- if (!conversation.isActive) {
231
- conversations.update(conversationId, { isActive: true });
232
- }
233
- activeConversationIds.set(sessionId, conversationId);
234
-
235
- // Update status to running
236
- sessions.update(sessionId, { status: 'running' });
237
- broadcastSessionStatus(sessionId, 'running');
238
-
239
- // Use the existing user message content as the prompt
240
- // Note: We do NOT create a new user message here - it already exists
241
-
242
- // Create agent via gateway (or mock agent in mock mode)
243
- const agentType = session.agentType || 'claude-code';
244
- const agent = createAgentForSession(agentType);
245
-
246
- // Resolve the effective model: fall back to session.model so that resuming
247
- // without an explicit model still resolves the correct provider (e.g.
248
- // third-party base URL and auth tokens).
220
+ /**
221
+ * Resolve the effective model, provider, and session env from a model override.
222
+ * Detects model changes and updates the session record when needed.
223
+ * @param {Object} session - Current session object
224
+ * @param {string} sessionId - Session ID
225
+ * @param {string|null} model - Requested model (null to keep current)
226
+ * @returns {{ effectiveModel: string|null, sessionEnv: Object, modelChanged: boolean, session: Object }}
227
+ */
228
+ function buildModelAndProvider(session, sessionId, model) {
249
229
  const effectiveModel = model || session.model;
250
-
251
- // Derive provider from the effective model ID (returns null for Anthropic/SDK defaults)
252
230
  const provider = resolveProviderFromModel(effectiveModel);
253
231
  const sessionEnv = buildSessionEnv(provider, session.thinkingEnabled, session.effortLevel);
232
+ const modelChanged = Boolean(model && session.model && model !== session.model);
254
233
 
255
- // Check if model changed (can't resume with different model/provider)
256
- const modelChanged = model && session.model && model !== session.model;
257
-
258
- // Update session.model after detecting change
234
+ let updatedSession = session;
259
235
  if (model) {
260
236
  sessions.update(sessionId, { model });
261
- session = sessions.getById(sessionId);
237
+ updatedSession = sessions.getById(sessionId);
262
238
  }
263
239
 
240
+ return { effectiveModel, sessionEnv, modelChanged, session: updatedSession };
241
+ }
242
+
243
+ /**
244
+ * Build query params for continueSessionWithExistingMessage.
245
+ * Handles context building (model switch / branch) and resume detection.
246
+ * @returns {{ queryParams: Object, agentCallMeta: Object }}
247
+ */
248
+ function buildExistingMessageQueryParams({
249
+ sessionId, conversationId, session, model, systemPrompt,
250
+ effectiveModel, sessionEnv, modelChanged, conversation,
251
+ lastUserMessage, workingDirectory, controller, agentType,
252
+ }) {
264
253
  // Determine context needs and build context
265
254
  const { needsContext, contextType } = determineContextNeed(conversation, modelChanged);
266
255
  if (needsContext) {
@@ -284,7 +273,6 @@ export async function continueSessionWithExistingMessage(sessionId, conversation
284
273
  resumeSessionId: canResume ? conversation.claudeSessionId : null,
285
274
  });
286
275
 
287
- // Logging metadata for agent call tracking
288
276
  const agentCallMeta = {
289
277
  sessionId,
290
278
  conversationId,
@@ -296,6 +284,44 @@ export async function continueSessionWithExistingMessage(sessionId, conversation
296
284
  promptLength: promptWithContext.length,
297
285
  };
298
286
 
287
+ return { queryParams, agentCallMeta };
288
+ }
289
+
290
+ export async function continueSessionWithExistingMessage(sessionId, conversationId, workingDirectory, options = {}) {
291
+ const { systemPrompt = null, model = null } = options;
292
+ const context = validateAndFetchContinueContext(sessionId, conversationId);
293
+ let session = context.session;
294
+ const { conversation, lastUserMessage } = context;
295
+
296
+ const controller = new AbortController();
297
+ activeSessions.set(sessionId, { controller });
298
+
299
+ // Make sure this conversation is active
300
+ if (!conversation.isActive) {
301
+ conversations.update(conversationId, { isActive: true });
302
+ }
303
+ activeConversationIds.set(sessionId, conversationId);
304
+
305
+ // Update status to running
306
+ sessions.update(sessionId, { status: 'running' });
307
+ broadcastSessionStatus(sessionId, 'running');
308
+
309
+ // Create agent via gateway (or mock agent in mock mode)
310
+ const agentType = session.agentType || 'claude-code';
311
+ const agent = createAgentForSession(agentType);
312
+
313
+ // Resolve model/provider and detect model changes
314
+ const modelEnv = buildModelAndProvider(session, sessionId, model);
315
+ session = modelEnv.session;
316
+
317
+ // Build query params and agent call meta
318
+ const { queryParams, agentCallMeta } = buildExistingMessageQueryParams({
319
+ sessionId, conversationId, session, model, systemPrompt,
320
+ effectiveModel: modelEnv.effectiveModel, sessionEnv: modelEnv.sessionEnv,
321
+ modelChanged: modelEnv.modelChanged, conversation,
322
+ lastUserMessage, workingDirectory, controller, agentType,
323
+ });
324
+
299
325
  await _executeSession({
300
326
  sessionId,
301
327
  agent,
@@ -36,6 +36,9 @@ import { isSummaryStale } from './summaryStaleCheck.js';
36
36
  // Create the concurrency guard instance for summary generation
37
37
  const guard = createConcurrencyGuard();
38
38
 
39
+ // Track scheduled CI-check timers so they can be cleared on shutdown
40
+ const activeTimers = new Set();
41
+
39
42
  /**
40
43
  * Generate summary for a session using Claude Code SDK (with concurrency guard)
41
44
  * Only one generation can be in-flight per session at a time. If a generation is already
@@ -252,30 +255,45 @@ function createMinimalSummary(sessionId, session, allMessages) {
252
255
  }
253
256
 
254
257
  /**
255
- * Internal implementation of generateSummary (called by concurrency guard wrapper)
258
+ * Retry summary generation if parsing failed and retries remain.
259
+ * Returns { shouldRetry: true, result } if a retry was performed,
260
+ * or { shouldRetry: false } if no retry is needed.
261
+ * @param {Object} summaryData - Parsed summary data (may have _parseFailed flag)
262
+ * @param {number} retryCount - Current retry count
256
263
  * @param {string} sessionId
257
- * @param {number} retryCount
258
264
  * @param {boolean} force
259
265
  * @param {boolean} userInitiated
260
- * @returns {Promise<Object|null>}
266
+ * @returns {Promise<{ shouldRetry: boolean, result?: Object|null }>}
261
267
  */
268
+ async function retryIfParseFailed(summaryData, retryCount, { sessionId, force, userInitiated }) {
269
+ if (!summaryData._parseFailed || retryCount >= MAX_RETRIES) {
270
+ return { shouldRetry: false };
271
+ }
272
+
273
+ console.log(
274
+ `[SummaryService] Parse failed for session ${sessionId}, retrying (attempt ${retryCount + 2}/${MAX_RETRIES + 1})`
275
+ );
276
+ const backoffMs = 1000 * (retryCount + 1);
277
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
278
+ const result = await _doGenerateSummary(sessionId, retryCount + 1, force, userInitiated);
279
+ return { shouldRetry: true, result };
280
+ }
281
+
262
282
  async function _doGenerateSummary(sessionId, retryCount = 0, force = false, userInitiated = false) {
263
- try {
264
- // Early-exit checks
265
- const check = shouldGenerateSummary(sessionId, force, userInitiated);
266
- if (check.skip) return check.result;
283
+ // Early-exit checks
284
+ const check = shouldGenerateSummary(sessionId, force, userInitiated);
285
+ if (check.skip) return check.result;
267
286
 
268
- const { session, globalSettings, existingSummary, allMessages } = check;
269
- const recentMessages = allMessages.slice(-MAX_MESSAGES);
287
+ const { session, globalSettings, existingSummary, allMessages } = check;
288
+ const recentMessages = allMessages.slice(-MAX_MESSAGES);
270
289
 
271
- // Broadcast that we're generating (do this early so UI always gets the event)
272
- broadcastGeneratingStatus(sessionId, true);
290
+ // Broadcast that we're generating (do this early so UI always gets the event)
291
+ broadcastGeneratingStatus(sessionId, true);
273
292
 
293
+ try {
274
294
  // Handle sessions with too few messages
275
295
  if (allMessages.length < MIN_MESSAGES_FOR_SUMMARY) {
276
- const summary = createMinimalSummary(sessionId, session, allMessages);
277
- broadcastGeneratingStatus(sessionId, false);
278
- return summary;
296
+ return createMinimalSummary(sessionId, session, allMessages);
279
297
  }
280
298
 
281
299
  // Build conversation context and prompt
@@ -287,25 +305,14 @@ async function _doGenerateSummary(sessionId, retryCount = 0, force = false, user
287
305
  systemPrompt: SUMMARY_SYSTEM_PROMPT,
288
306
  });
289
307
 
290
- // Parse response
308
+ // Parse response and retry if needed
291
309
  const summaryData = parseSummaryResponse(responseText);
292
-
293
- // Retry if parsing failed and we haven't exhausted retries
294
- if (summaryData._parseFailed && retryCount < MAX_RETRIES) {
295
- console.log(
296
- `[SummaryService] Parse failed for session ${sessionId}, retrying (attempt ${retryCount + 2}/${MAX_RETRIES + 1})`
297
- );
298
- const backoffMs = 1000 * (retryCount + 1);
299
- await new Promise((resolve) => setTimeout(resolve, backoffMs));
300
- return _doGenerateSummary(sessionId, retryCount + 1, force, userInitiated);
301
- }
310
+ const retryResult = await retryIfParseFailed(summaryData, retryCount, { sessionId, force, userInitiated });
311
+ if (retryResult.shouldRetry) return retryResult.result;
302
312
 
303
313
  // Persist summary and broadcast updates
304
314
  const summary = await saveSummaryResult(sessionId, summaryData, session, allMessages);
305
315
 
306
- // Clear the generating flag so the UI knows generation is complete
307
- broadcastGeneratingStatus(sessionId, false);
308
-
309
316
  console.log(`[SummaryService] Successfully generated summary for session ${sessionId}`);
310
317
 
311
318
  // Propagate summary updates to parent sessions (workflow-aware)
@@ -320,11 +327,9 @@ async function _doGenerateSummary(sessionId, retryCount = 0, force = false, user
320
327
  stack: error.stack,
321
328
  sessionId,
322
329
  });
323
-
324
- // Broadcast that generation stopped
325
- broadcastGeneratingStatus(sessionId, false);
326
-
327
330
  return null;
331
+ } finally {
332
+ broadcastGeneratingStatus(sessionId, false);
328
333
  }
329
334
  }
330
335
 
@@ -366,14 +371,17 @@ export function onSessionActivity(sessionId) {
366
371
  * @param {string} sessionId
367
372
  */
368
373
  function scheduleCiChecks(sessionId) {
369
- const scheduleCiCheck = async () => {
374
+ const makeCheck = (timerId) => async () => {
375
+ activeTimers.delete(timerId);
370
376
  const prStatusService = await import('./prStatusService.js');
371
377
  prStatusService.checkSessionCiStatusNow(sessionId);
372
378
  };
373
379
  // Check after 2 minutes (CI often takes a few minutes)
374
- setTimeout(scheduleCiCheck, 2 * 60 * 1000);
380
+ const timerId1 = setTimeout(() => makeCheck(timerId1)(), 2 * 60 * 1000);
381
+ activeTimers.add(timerId1);
375
382
  // Check again after 5 minutes
376
- setTimeout(scheduleCiCheck, 5 * 60 * 1000);
383
+ const timerId2 = setTimeout(() => makeCheck(timerId2)(), 5 * 60 * 1000);
384
+ activeTimers.add(timerId2);
377
385
  }
378
386
 
379
387
  /**
@@ -523,30 +531,25 @@ export function propagatePrUrlToParent(sessionId, prUrl) {
523
531
  }
524
532
 
525
533
  // Re-export from extracted modules for backward compatibility
526
- // These are used by external consumers and tests
527
534
  export {
528
- // From summaryPrompts.js
529
- MAX_MESSAGES,
530
- MIN_MESSAGES_FOR_SUMMARY,
531
- MAX_RETRIES,
532
- DEFAULT_SESSION_TITLE_PROMPT,
533
- SUMMARY_SYSTEM_PROMPT,
534
- formatMessages,
535
- buildIncrementalPrompt,
536
- parseSummaryResponse,
537
- stripMarkdownCodeBlock as _stripMarkdownCodeBlock,
538
- trackMessageMetadata as _trackMessageMetadata,
535
+ MAX_MESSAGES, MIN_MESSAGES_FOR_SUMMARY, MAX_RETRIES, DEFAULT_SESSION_TITLE_PROMPT,
536
+ SUMMARY_SYSTEM_PROMPT, formatMessages, buildIncrementalPrompt, parseSummaryResponse,
537
+ stripMarkdownCodeBlock as _stripMarkdownCodeBlock, trackMessageMetadata as _trackMessageMetadata,
539
538
  };
540
-
541
- // From summaryClaudeClient.js
542
539
  export { callClaude };
543
-
544
- // From prUrlService.js
545
540
  export { parsePrUrl, validatePrUrl, extractPrUrlIfNeeded, enrichPrData as _enrichPrData };
546
-
547
- // From childSessionContext.js
548
541
  export { getChildSessions, buildChildSessionContext, aggregateFilesModified };
549
542
 
543
+ /**
544
+ * Clear all pending CI-check timers (called during graceful shutdown).
545
+ */
546
+ export function clearScheduledTimers() {
547
+ for (const id of activeTimers) {
548
+ clearTimeout(id);
549
+ }
550
+ activeTimers.clear();
551
+ }
552
+
550
553
  // Read-only accessors for concurrency guard state
551
554
  export const isGenerationActive = (key) => guard.isActive(key);
552
555
  export const isRegenerationPending = (key) => guard.isPending(key);