circuschief 0.5.0 → 0.6.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 (116) 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.js +7 -0
  15. package/packages/server/src/api/providers.js +1 -0
  16. package/packages/server/src/api/sessions-messages.js +6 -0
  17. package/packages/server/src/db/ProviderRepository.js +62 -6
  18. package/packages/server/src/db/SessionRepository.js +67 -11
  19. package/packages/server/src/db/migrations/index.js +2 -0
  20. package/packages/server/src/db/migrations/miscMigrations.js +53 -3
  21. package/packages/server/src/db/session-helpers.js +5 -0
  22. package/packages/server/src/schema.sql +1 -0
  23. package/packages/server/src/services/codexSpawnHelper.js +37 -0
  24. package/packages/server/src/services/conversationContext.js +27 -0
  25. package/packages/server/src/services/draftSessionService.js +13 -2
  26. package/packages/server/src/services/kanbanTriggers.js +3 -0
  27. package/packages/server/src/services/providerTestService.js +115 -15
  28. package/packages/server/src/services/sessionAgentGuard.js +38 -0
  29. package/packages/server/src/services/sessionExecution.js +124 -32
  30. package/packages/server/src/services/sessionManager.js +45 -8
  31. package/packages/server/src/services/sessionPrompts.js +25 -0
  32. package/packages/server/src/services/sessionProvider.js +162 -41
  33. package/packages/server/src/services/streamEventHandler.js +3 -0
  34. package/packages/server/src/services/templateTriggerService.js +2 -0
  35. package/packages/shared/src/contracts/providers.js +24 -7
  36. package/packages/shared/src/contracts/sessions.js +1 -1
  37. package/packages/shared/src/index.js +1 -0
  38. package/packages/shared/src/types.js +28 -0
  39. package/packages/web/dist/assets/{ActiveSessionsView-BafIafEu.js → ActiveSessionsView-UCbQrF1b.js} +1 -1
  40. package/packages/web/dist/assets/{AgentLogsView-DCF2WvP2.js → AgentLogsView-Cdw4nmvd.js} +1 -1
  41. package/packages/web/dist/assets/ApiClient-CWbXWDUY.js +1 -0
  42. package/packages/web/dist/assets/{ArchiveConfirmModal-fgoEQhfq.js → ArchiveConfirmModal-J48eh3zw.js} +1 -1
  43. package/packages/web/dist/assets/{CommandButtonDetailView-DAg07cDQ.js → CommandButtonDetailView-DnFhJY5A.js} +1 -1
  44. package/packages/web/dist/assets/EffortLevelSelector-bXbPo4Zw.js +1 -0
  45. package/packages/web/dist/assets/{GeneralSettingsView-Cn9VI2du.js → GeneralSettingsView-CQkmdczf.js} +1 -1
  46. package/packages/web/dist/assets/{InputWithButton-BvboBGbz.js → InputWithButton-XyM3k6lN.js} +1 -1
  47. package/packages/web/dist/assets/{InterpolationHelp-0GoSBPgf.js → InterpolationHelp-PfYR3KJo.js} +1 -1
  48. package/packages/web/dist/assets/MarkdownEditor-P8F5kO-o.js +2 -0
  49. package/packages/web/dist/assets/{ModelSelector-DPPD-92R.css → ModelSelector-BZOT1Jc6.css} +1 -1
  50. package/packages/web/dist/assets/ModelSelector-CowKfGMP.js +1 -0
  51. package/packages/web/dist/assets/{NewSessionView-C77YVqgY.js → NewSessionView-DkjFLvHU.js} +1 -1
  52. package/packages/web/dist/assets/{ProjectEditView-BBHOsgBV.js → ProjectEditView-embVT7NC.js} +1 -1
  53. package/packages/web/dist/assets/{ProjectListView-CLwtuJ0J.js → ProjectListView-CuYMmd3O.js} +1 -1
  54. package/packages/web/dist/assets/{ProjectNewView-CzDtVibO.js → ProjectNewView-CNaA4Maf.js} +1 -1
  55. package/packages/web/dist/assets/ProvidersView-C7rydtOd.js +1 -0
  56. package/packages/web/dist/assets/ProvidersView-DE82G_5W.css +1 -0
  57. package/packages/web/dist/assets/{QuickResponseSettings-BBHMapcA.js → QuickResponseSettings-BTQEKhwJ.js} +1 -1
  58. package/packages/web/dist/assets/{QuickResponsesPanel-CTXYjMF-.js → QuickResponsesPanel-BqMYSHb0.js} +1 -1
  59. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +1 -0
  60. package/packages/web/dist/assets/{ResizableTextarea-Cw6aL4rp.js → ResizableTextarea-wYF3K2RO.js} +1 -1
  61. package/packages/web/dist/assets/SessionCard-BMGC2HqI.css +1 -0
  62. package/packages/web/dist/assets/SessionCard-bLaQEWWX.js +1 -0
  63. package/packages/web/dist/assets/{SessionDetailView-BL83oPiI.css → SessionDetailView-Cv-xMzXp.css} +1 -1
  64. package/packages/web/dist/assets/{SessionDetailView-CrZvMb3j.js → SessionDetailView-CvQOUsW2.js} +27 -27
  65. package/packages/web/dist/assets/SessionFormOptions-3pzbgI2Q.js +1 -0
  66. package/packages/web/dist/assets/SessionFormOptions-DhhIkjIS.css +1 -0
  67. package/packages/web/dist/assets/SessionListView-Dranfb72.js +1 -0
  68. package/packages/web/dist/assets/{SessionListView-DVhoZHN9.css → SessionListView-fHlQyecX.css} +1 -1
  69. package/packages/web/dist/assets/{SessionLogStream-DIndOyFR.js → SessionLogStream-DTnDAF95.js} +1 -1
  70. package/packages/web/dist/assets/{SettingsView-CmJ5JPd5.js → SettingsView-DNLUSsHV.js} +1 -1
  71. package/packages/web/dist/assets/SlashCommandWizard-CRGFaO8t.js +1 -0
  72. package/packages/web/dist/assets/SlashCommandWizard-Dn7sNaBd.css +1 -0
  73. package/packages/web/dist/assets/{SummarySettingsView-DQM1n3bc.js → SummarySettingsView-C7G_suHp.js} +1 -1
  74. package/packages/web/dist/assets/{TemplateDetailView-B8clSBPk.js → TemplateDetailView-B78_DLMR.js} +1 -1
  75. package/packages/web/dist/assets/{commandButtons-D74TkPNU.js → commandButtons-Bbjf3fCt.js} +1 -1
  76. package/packages/web/dist/assets/{index-CefzeYRE.js → index--V7c-VZf.js} +1 -1
  77. package/packages/web/dist/assets/{index-BELtFs3n.js → index-8Q04yd7H.js} +1 -1
  78. package/packages/web/dist/assets/{index-rjbX81sm.js → index-B47XRBDH.js} +1 -1
  79. package/packages/web/dist/assets/index-BQL_L4gL.js +82 -0
  80. package/packages/web/dist/assets/{index-BsDR4w2c.js → index-BXbgZrhS.js} +1 -1
  81. package/packages/web/dist/assets/{index-DQMHi05L.js → index-Bs7Qf5D6.js} +1 -1
  82. package/packages/web/dist/assets/{index-Dz7jFUYU.js → index-CGhDVPen.js} +1 -1
  83. package/packages/web/dist/assets/{index-f315nDFm.js → index-CKcRO1A6.js} +1 -1
  84. package/packages/web/dist/assets/{index-Gre8tUfC.js → index-CTq-SLIW.js} +1 -1
  85. package/packages/web/dist/assets/{index-_Lv79l46.js → index-CYyos3iC.js} +1 -1
  86. package/packages/web/dist/assets/{index-DMZZCi2u.js → index-Cf6vdW-B.js} +3 -3
  87. package/packages/web/dist/assets/{index-DIvveuSK.js → index-CsCREAxF.js} +1 -1
  88. package/packages/web/dist/assets/{index-CVozYqQ-.js → index-DJTTk_8T.js} +1 -1
  89. package/packages/web/dist/assets/{index-DPt6qBRK.js → index-DPqUJ5JK.js} +1 -1
  90. package/packages/web/dist/assets/{index-B5ocUoPf.js → index-EwAe1dKg.js} +1 -1
  91. package/packages/web/dist/assets/{index-CrLh8vw5.js → index-JBA8axyA.js} +1 -1
  92. package/packages/web/dist/assets/{index-DrlwE0Zo.js → index-JkVHFtK5.js} +1 -1
  93. package/packages/web/dist/assets/{index-DuXChAe-.js → index-gMPUwT55.js} +1 -1
  94. package/packages/web/dist/assets/{index-DYWZ8lD-.js → index-wadc_0zT.js} +1 -1
  95. package/packages/web/dist/assets/{projects-D_C9dE9s.js → projects-CPt3AB7U.js} +1 -1
  96. package/packages/web/dist/assets/providers-ChfeMvUq.js +1 -0
  97. package/packages/web/dist/assets/sessions-CwPsJOb1.js +1 -0
  98. package/packages/web/dist/assets/{settings-6Rw9xt-G.js → settings-BOj6wq6t.js} +1 -1
  99. package/packages/web/dist/index.html +1 -1
  100. package/packages/web/dist/assets/ApiClient-CcqJ-GAv.js +0 -1
  101. package/packages/web/dist/assets/EffortLevelSelector-xE3gidpq.js +0 -1
  102. package/packages/web/dist/assets/MarkdownEditor-HCKnwRye.js +0 -2
  103. package/packages/web/dist/assets/ModelSelector-B0RdlCHT.js +0 -1
  104. package/packages/web/dist/assets/ProvidersView-Eg93KbyC.js +0 -1
  105. package/packages/web/dist/assets/ProvidersView-uD8SKWpA.css +0 -1
  106. package/packages/web/dist/assets/ResizableTextarea-B5nAA0RV.css +0 -1
  107. package/packages/web/dist/assets/SessionCard-CCapYVjy.js +0 -1
  108. package/packages/web/dist/assets/SessionCard-CcqIjL8q.css +0 -1
  109. package/packages/web/dist/assets/SessionFormOptions-BuLlDF-7.css +0 -1
  110. package/packages/web/dist/assets/SessionFormOptions-Em7sQCGb.js +0 -1
  111. package/packages/web/dist/assets/SessionListView-3zdDtqhw.js +0 -1
  112. package/packages/web/dist/assets/SlashCommandWizard-BB30cSvo.css +0 -1
  113. package/packages/web/dist/assets/SlashCommandWizard-C_cSgF-P.js +0 -1
  114. package/packages/web/dist/assets/index-BGAW2Nqa.js +0 -82
  115. package/packages/web/dist/assets/providers-BdvbPVdE.js +0 -1
  116. package/packages/web/dist/assets/sessions-Bs5FA6JZ.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { sessions, messages, attachments, conversations } from '../database.js';
2
2
  import { createClaudeCodeSpawner } from './nodeSpawnHelper.js';
3
+ import { createCodexSpawner } from './codexSpawnHelper.js';
3
4
  import { resolveProviderFromModel, buildSessionEnv } from './sessionProvider.js';
4
5
  import { agentGateway } from '../agents/AgentGateway.js';
5
6
  import { LoggingAgentWrapper } from '../agents/LoggingAgentWrapper.js';
@@ -7,6 +8,7 @@ import { VCRAgentAdapter } from '../agents/vcr/VCRAgentAdapter.js';
7
8
  import {
8
9
  buildSystemPromptConfig,
9
10
  getPermissionModeForSession,
11
+ getSandboxModeForSession,
10
12
  buildPromptWithAttachments,
11
13
  } from './sessionPrompts.js';
12
14
  import {
@@ -20,18 +22,38 @@ import {
20
22
  } from './streamEventHandler.js';
21
23
  import { shouldRescheduleOnError, _checkProactiveReschedule } from './sessionErrors.js';
22
24
  import { schedulerService } from './schedulerService.js';
23
- import { buildConversationContextForModelSwitch } from './conversationContext.js';
25
+ import { buildConversationContextForModelSwitch, buildConversationContextForContinuation } from './conversationContext.js';
24
26
  import { broadcastToSession } from '../websocket.js';
25
27
  import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
26
28
 
29
+ /**
30
+ * Build the adapter-specific default config object for
31
+ * {@link createAgentForSession}. Callers may pass an explicit `config` to
32
+ * override these defaults.
33
+ * @param {string} agentType
34
+ * @returns {Object}
35
+ */
36
+ function buildAgentConfig(agentType) {
37
+ if (agentType === 'codex') {
38
+ return { spawnCodexProcess: createCodexSpawner() };
39
+ }
40
+ return {};
41
+ }
42
+
27
43
  /**
28
44
  * Create the agent for a session, using gateway + logging + VCR.
29
45
  *
30
- * @param {string} agentType - The agent type (e.g., 'claude-code')
46
+ * If `config` is empty, the adapter-specific default config is applied
47
+ * (e.g. codex receives a fresh `spawnCodexProcess` spawner). Explicit
48
+ * `config` keys win over defaults.
49
+ *
50
+ * @param {string} agentType - The agent type (e.g., 'claude-code', 'codex')
51
+ * @param {Object} [config] - Optional adapter config forwarded to the gateway.
31
52
  * @returns {{ execute: (queryParams: any, meta?: any) => AsyncGenerator }}
32
53
  */
33
- export function createAgentForSession(agentType = 'claude-code') {
34
- const baseAgent = agentGateway.createAgent(agentType);
54
+ export function createAgentForSession(agentType = 'claude-code', config = {}) {
55
+ const mergedConfig = { ...buildAgentConfig(agentType), ...config };
56
+ const baseAgent = agentGateway.createAgent(agentType, mergedConfig);
35
57
 
36
58
  // Wrap with VCR adapter if in VCR mode
37
59
  const agent = process.env.VCR_MODE
@@ -43,22 +65,13 @@ export function createAgentForSession(agentType = 'claude-code') {
43
65
  }
44
66
 
45
67
  /**
46
- * Build query parameters for executing a session via the Claude agent.
47
- * Shared by runSession, continueSession, and continueSessionWithExistingMessage.
48
- * @param {Object} options
49
- * @param {string} options.prompt - The prompt text to send
50
- * @param {string} options.workingDirectory - Session working directory
51
- * @param {AbortController} options.controller - Abort controller for the session
52
- * @param {Object} options.session - Session object from DB
53
- * @param {string} options.sessionId - Session ID
54
- * @param {string|null} options.systemPrompt - Custom system prompt from project settings
55
- * @param {string|null} options.model - Model to use
56
- * @param {Object} options.sessionEnv - Environment variables for the session
57
- * @param {string|null} [options.resumeSessionId] - Claude session ID to resume (null for new session)
58
- * @returns {Object} Query parameters for agent.execute()
68
+ * Build query parameters for the Claude Code adapter.
69
+ * @returns {Object}
59
70
  */
60
- export function buildQueryParams({ prompt, workingDirectory, controller, session, sessionId, systemPrompt, model, sessionEnv, resumeSessionId = null }) {
61
- // Determine model (force Haiku in VCR mode to minimize cost)
71
+ function buildClaudeCodeQueryParams({
72
+ prompt, workingDirectory, controller, session, sessionId, systemPrompt,
73
+ model, sessionEnv, resumeSessionId = null,
74
+ }) {
62
75
  const isVCR = Boolean(process.env.VCR_MODE);
63
76
  const effectiveModel = isVCR ? 'claude-haiku-4-5-20251001' : model;
64
77
 
@@ -79,6 +92,67 @@ export function buildQueryParams({ prompt, workingDirectory, controller, session
79
92
  };
80
93
  }
81
94
 
95
+ /**
96
+ * Build query parameters for the Codex adapter.
97
+ *
98
+ * Codex in v1 is a simple Chat-Completions-shaped executor — it doesn't need
99
+ * or accept Claude-specific options (permissionMode, settingSources,
100
+ * includePartialMessages, spawnClaudeCodeProcess, resume).
101
+ *
102
+ * Codex does have its own sandboxing model, driven from {@code session.mode}
103
+ * via {@link getSandboxModeForSession}. Codex CLI v0.124.0 also supports
104
+ * resume via `codex resume` / `codex exec resume`, but Circus Chief v1
105
+ * intentionally does NOT pass a resume token — wiring is deferred to a
106
+ * later phase (see canvas plan §Phase 4.5).
107
+ *
108
+ * @returns {Object}
109
+ */
110
+ function buildCodexQueryParams({
111
+ prompt, workingDirectory, controller, session, sessionId, systemPrompt, model, sessionEnv,
112
+ }) {
113
+ const isVCR = Boolean(process.env.VCR_MODE);
114
+ // In VCR mode, force the cheapest commonly-cassetted OpenAI model.
115
+ const effectiveModel = isVCR ? 'gpt-4o-mini' : model;
116
+
117
+ return {
118
+ prompt,
119
+ options: {
120
+ cwd: workingDirectory,
121
+ abortController: controller,
122
+ env: sessionEnv,
123
+ model: effectiveModel,
124
+ effortLevel: session?.effortLevel ?? null,
125
+ systemPrompt: buildSystemPromptConfig(sessionId, session.projectId, systemPrompt, session.mode),
126
+ sandboxMode: getSandboxModeForSession(session?.mode),
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Build query parameters for executing a session via the configured agent.
133
+ * Shared by runSession, continueSession, and continueSessionWithExistingMessage.
134
+ *
135
+ * @param {Object} options
136
+ * @param {string} options.prompt - The prompt text to send
137
+ * @param {string} options.workingDirectory - Session working directory
138
+ * @param {AbortController} options.controller - Abort controller for the session
139
+ * @param {Object} options.session - Session object from DB
140
+ * @param {string} options.sessionId - Session ID
141
+ * @param {string|null} options.systemPrompt - Custom system prompt from project settings
142
+ * @param {string|null} options.model - Model to use
143
+ * @param {Object} options.sessionEnv - Environment variables for the session
144
+ * @param {string|null} [options.resumeSessionId] - Session ID to resume (null for new session)
145
+ * @param {string} [options.agentType] - 'claude-code' (default) | 'codex'
146
+ * @returns {Object} Query parameters for agent.execute()
147
+ */
148
+ export function buildQueryParams(options) {
149
+ const { agentType = 'claude-code' } = options || {};
150
+ if (agentType === 'codex') {
151
+ return buildCodexQueryParams(options);
152
+ }
153
+ return buildClaudeCodeQueryParams(options);
154
+ }
155
+
82
156
  /**
83
157
  * Execute the agent stream loop and handle post-turn completion, errors, and cleanup.
84
158
  * This is the shared core of runSession, continueSession, and continueSessionWithExistingMessage.
@@ -146,17 +220,26 @@ export async function _executeSession({
146
220
  }
147
221
 
148
222
  /**
149
- * Build prompt with conversation context when switching models.
223
+ * Build prompt with conversation context for a continuation.
150
224
  * When the model changes, we can't resume the previous session, so we include
151
225
  * conversation history as context so the new model can continue naturally.
152
- * @param {boolean} modelChanged
153
- * @param {string} conversationId
154
- * @param {string} promptWithAttachments
226
+ * When the adapter cannot resume, we include conversation history so the
227
+ * model has context of previous turns.
228
+ * @param {Object} opts
229
+ * @param {boolean} opts.modelChanged
230
+ * @param {Object} opts.agent - Agent instance
231
+ * @param {string} opts.conversationId
232
+ * @param {string} opts.prompt
155
233
  * @returns {Promise<string>}
156
234
  */
157
- async function buildPromptForContinue(modelChanged, conversationId, promptWithAttachments) {
158
- if (!modelChanged) return promptWithAttachments;
159
- return buildConversationContextForModelSwitch(conversationId) + promptWithAttachments;
235
+ async function buildPromptForContinue({ modelChanged, agent, conversationId, prompt }) {
236
+ if (modelChanged) {
237
+ return buildConversationContextForModelSwitch(conversationId) + prompt;
238
+ }
239
+ if (agent.needsConversationContext()) {
240
+ return buildConversationContextForContinuation(conversationId) + prompt;
241
+ }
242
+ return prompt;
160
243
  }
161
244
 
162
245
  /**
@@ -201,13 +284,16 @@ function buildContinueModelAndEnv(session, sessionId, model) {
201
284
  async function buildContinueParams({
202
285
  sessionId, session, model, systemPrompt, effectiveModel, sessionEnv,
203
286
  modelChanged, activeConversation, promptWithAttachments,
204
- workingDirectory, controller, agentType,
287
+ workingDirectory, controller, agentType, agent,
205
288
  }) {
206
- // Only resume if we have a session ID AND model hasn't changed
207
- const canResume = activeConversation.claudeSessionId && !modelChanged;
289
+ // Only resume if we have a session ID AND model hasn't changed AND the
290
+ // agent supports resume.
291
+ const canResume = activeConversation.claudeSessionId && !modelChanged && agent.supportsResume();
208
292
 
209
- // Build prompt with conversation context when model changes
210
- const promptWithContext = await buildPromptForContinue(modelChanged, activeConversation.id, promptWithAttachments);
293
+ // Build prompt with conversation context when model changes or adapter needs it
294
+ const promptWithContext = await buildPromptForContinue({
295
+ modelChanged, agent, conversationId: activeConversation.id, prompt: promptWithAttachments,
296
+ });
211
297
 
212
298
  const queryParams = buildQueryParams({
213
299
  prompt: promptWithContext,
@@ -219,6 +305,7 @@ async function buildContinueParams({
219
305
  model: effectiveModel,
220
306
  sessionEnv,
221
307
  resumeSessionId: canResume ? activeConversation.claudeSessionId : null,
308
+ agentType,
222
309
  });
223
310
 
224
311
  // Logging metadata for agent call tracking
@@ -246,6 +333,10 @@ async function setupConversationAndMessage(sessionId, content, fileAttachments)
246
333
  activeConversationIds.set(sessionId, activeConversation.id);
247
334
 
248
335
  const message = messages.create(sessionId, 'user', content, { toolUse: null, conversationId: activeConversation.id });
336
+
337
+ // Touch the session to update its updated_at timestamp so it sorts to the top
338
+ sessions.touch(sessionId);
339
+
249
340
  broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_MESSAGE, {
250
341
  message,
251
342
  conversationId: activeConversation.id,
@@ -307,7 +398,7 @@ export async function continueSessionCore(sessionId, content, workingDirectory,
307
398
  sessionId, session, model, systemPrompt,
308
399
  effectiveModel: modelEnv.effectiveModel, sessionEnv: modelEnv.sessionEnv,
309
400
  modelChanged: modelEnv.modelChanged, activeConversation, promptWithAttachments,
310
- workingDirectory, controller, agentType,
401
+ workingDirectory, controller, agentType, agent,
311
402
  });
312
403
 
313
404
  await _executeSession({
@@ -384,6 +475,7 @@ export async function runSessionCore(sessionId, prompt, workingDirectory, config
384
475
  systemPrompt,
385
476
  model: effectiveModel,
386
477
  sessionEnv,
478
+ agentType,
387
479
  });
388
480
 
389
481
  // Log query params for debugging third-party provider issues
@@ -3,7 +3,7 @@ import { broadcastToSession, broadcastToProject } 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 { checkAndTriggerNextTemplate } from './templateTriggerService.js';
6
- import { resolveProviderFromModel, buildSessionEnv } from './sessionProvider.js';
6
+ import { resolveProviderFromModel, buildSessionEnv, resolveAgentTypeFromModel } from './sessionProvider.js';
7
7
  import {
8
8
  shouldRescheduleOnError,
9
9
  _checkProactiveReschedule,
@@ -18,7 +18,7 @@ import {
18
18
  buildPromptWithAttachments,
19
19
  getApiBaseUrl,
20
20
  } from './sessionPrompts.js';
21
- import { buildConversationContextForModelSwitch, buildConversationContextForBranch } from './conversationContext.js';
21
+ import { buildConversationContextForModelSwitch, buildConversationContextForBranch, buildConversationContextForContinuation } from './conversationContext.js';
22
22
  import {
23
23
  activeSessions,
24
24
  activeConversationIds,
@@ -220,6 +220,13 @@ function validateAndFetchContinueContext(sessionId, conversationId) {
220
220
  /**
221
221
  * Resolve the effective model, provider, and session env from a model override.
222
222
  * Detects model changes and updates the session record when needed.
223
+ *
224
+ * Defense in depth: when a new model arrives and the session has no assistant
225
+ * messages yet (i.e. it is still effectively a draft), re-derive agent_type
226
+ * and persist it together with model. Once the session has produced at least
227
+ * one assistant message we MUST NOT mutate agent_type — that would corrupt
228
+ * resume/context state across kinds.
229
+ *
223
230
  * @param {Object} session - Current session object
224
231
  * @param {string} sessionId - Session ID
225
232
  * @param {string|null} model - Requested model (null to keep current)
@@ -233,13 +240,35 @@ function buildModelAndProvider(session, sessionId, model) {
233
240
 
234
241
  let updatedSession = session;
235
242
  if (model) {
236
- sessions.update(sessionId, { model });
243
+ const update = { model };
244
+
245
+ // Defense in depth: if this is still a draft (no assistant messages),
246
+ // also re-derive agent_type so it stays in sync with the chosen model.
247
+ // After the first assistant turn this is locked.
248
+ if (sessionHasNoAssistantMessages(sessionId)) {
249
+ const derivedAgentType = resolveAgentTypeFromModel(model);
250
+ if (derivedAgentType && derivedAgentType !== session.agentType) {
251
+ update.agentType = derivedAgentType;
252
+ }
253
+ }
254
+
255
+ sessions.update(sessionId, update);
237
256
  updatedSession = sessions.getById(sessionId);
238
257
  }
239
258
 
240
259
  return { effectiveModel, sessionEnv, modelChanged, session: updatedSession };
241
260
  }
242
261
 
262
+ /**
263
+ * Check whether a session has no assistant messages (i.e. is still a draft).
264
+ * @param {string} sessionId
265
+ * @returns {boolean}
266
+ */
267
+ function sessionHasNoAssistantMessages(sessionId) {
268
+ const allMessages = messages.getBySessionId(sessionId);
269
+ return !allMessages.some(m => m.role === 'assistant');
270
+ }
271
+
243
272
  /**
244
273
  * Build query params for continueSessionWithExistingMessage.
245
274
  * Handles context building (model switch / branch) and resume detection.
@@ -248,18 +277,26 @@ function buildModelAndProvider(session, sessionId, model) {
248
277
  function buildExistingMessageQueryParams({
249
278
  sessionId, conversationId, session, model, systemPrompt,
250
279
  effectiveModel, sessionEnv, modelChanged, conversation,
251
- lastUserMessage, workingDirectory, controller, agentType,
280
+ lastUserMessage, workingDirectory, controller, agentType, agent,
252
281
  }) {
253
282
  // Determine context needs and build context
254
283
  const { needsContext, contextType } = determineContextNeed(conversation, modelChanged);
255
284
  if (needsContext) {
256
285
  console.log(`[SESSION] ${contextType === 'modelSwitch' ? 'Model changed' : 'Branched conversation'} - including context`);
257
286
  }
258
- const conversationContext = buildContextForType(conversationId, contextType);
287
+ let conversationContext = buildContextForType(conversationId, contextType);
288
+
289
+ // Fallback: if no specific context was built but the adapter needs
290
+ // conversation context (i.e. it can't resume), inject continuation history.
291
+ if (!conversationContext && agent.needsConversationContext()) {
292
+ conversationContext = buildConversationContextForContinuation(conversationId);
293
+ }
294
+
259
295
  const promptWithContext = conversationContext + lastUserMessage.content;
260
296
 
261
- // Only resume if we have a session ID AND model hasn't changed
262
- const canResume = conversation.claudeSessionId && !modelChanged;
297
+ // Only resume if we have a session ID AND model hasn't changed AND the
298
+ // agent supports resume.
299
+ const canResume = conversation.claudeSessionId && !modelChanged && agent.supportsResume();
263
300
 
264
301
  const queryParams = buildQueryParams({
265
302
  prompt: promptWithContext,
@@ -319,7 +356,7 @@ export async function continueSessionWithExistingMessage(sessionId, conversation
319
356
  sessionId, conversationId, session, model, systemPrompt,
320
357
  effectiveModel: modelEnv.effectiveModel, sessionEnv: modelEnv.sessionEnv,
321
358
  modelChanged: modelEnv.modelChanged, conversation,
322
- lastUserMessage, workingDirectory, controller, agentType,
359
+ lastUserMessage, workingDirectory, controller, agentType, agent,
323
360
  });
324
361
 
325
362
  await _executeSession({
@@ -108,6 +108,31 @@ export function getPermissionModeForSession(mode) {
108
108
  }
109
109
  }
110
110
 
111
+ /**
112
+ * Map session mode to the Codex CLI --sandbox flag value.
113
+ *
114
+ * plan → read-only (parity with Claude plan mode's read-mostly posture)
115
+ * standard → workspace-write (default; Codex can edit files in cwd)
116
+ * yolo → danger-full-access (parallels Claude's bypassPermissions)
117
+ *
118
+ * Note: `--full-auto` is intentionally NOT used — it is a shorthand that also
119
+ * overrides approval policies, which would conflate two orthogonal concerns.
120
+ *
121
+ * @param {string} mode - Session mode ('plan', 'standard', 'yolo')
122
+ * @returns {string} Codex sandbox mode
123
+ */
124
+ export function getSandboxModeForSession(mode) {
125
+ switch (mode) {
126
+ case 'plan':
127
+ return 'read-only';
128
+ case 'yolo':
129
+ return 'danger-full-access';
130
+ case 'standard':
131
+ default:
132
+ return 'workspace-write';
133
+ }
134
+ }
135
+
111
136
  /** Plan mode system prompt instructions */
112
137
  export const PLAN_MODE_PROMPT = `## Plan Mode Active
113
138
 
@@ -12,7 +12,34 @@ export function resolveProviderFromModel(modelId) {
12
12
  }
13
13
 
14
14
  /**
15
- * Build environment variables from provider configuration
15
+ * Resolve the agent type (claude-code vs codex) for a given model ID.
16
+ * Uses the owning provider's kind:
17
+ * - anthropic → claude-code
18
+ * - openai → codex
19
+ * Falls back to 'claude-code' for null / unknown / tier-name inputs.
20
+ * @param {string|null} modelId
21
+ * @returns {string} 'claude-code' | 'codex'
22
+ */
23
+ export function resolveAgentTypeFromModel(modelId) {
24
+ if (!modelId) return 'claude-code';
25
+ const provider = modelProviders.getProviderByModelId(modelId);
26
+ if (!provider) return 'claude-code';
27
+ if (typeof modelProviders.getAgentTypeForProvider === 'function') {
28
+ const agentType = modelProviders.getAgentTypeForProvider(provider.id);
29
+ return agentType || 'claude-code';
30
+ }
31
+ // Fallback for test doubles that don't implement getAgentTypeForProvider:
32
+ // derive from kind directly.
33
+ if (provider.kind === 'openai') return 'codex';
34
+ return 'claude-code';
35
+ }
36
+
37
+ /**
38
+ * Build environment variables from provider configuration.
39
+ * Branches on provider.kind so Anthropic-kind and OpenAI-kind providers
40
+ * emit only their own wire-protocol env vars (no cross-kind leaks).
41
+ * Providers without a `kind` field default to Anthropic behavior for
42
+ * backward compatibility.
16
43
  * @param {Object|null} provider - Provider object
17
44
  * @returns {Object} Environment variables to add to session env
18
45
  */
@@ -22,41 +49,68 @@ export function buildProviderEnv(provider) {
22
49
  return {}; // Use SDK defaults
23
50
  }
24
51
 
25
- const env = {};
52
+ const kind = provider.kind || 'anthropic';
53
+ const env = kind === 'openai'
54
+ ? buildOpenAIProviderEnv(provider)
55
+ : buildAnthropicProviderEnv(provider);
26
56
 
27
- if (provider.baseUrl) {
28
- env.ANTHROPIC_BASE_URL = provider.baseUrl;
57
+ if (provider.apiTimeoutMs) {
58
+ env.API_TIMEOUT_MS = String(provider.apiTimeoutMs);
29
59
  }
30
- if (provider.authToken) {
31
- // Set BOTH ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN
32
- // The SDK prioritizes ANTHROPIC_API_KEY, so we must set it to override
33
- // any user's existing ANTHROPIC_API_KEY in their environment
34
- env.ANTHROPIC_API_KEY = provider.authToken;
35
- env.ANTHROPIC_AUTH_TOKEN = provider.authToken;
60
+
61
+ // Parse additional env vars (applied last so users can override anything above)
62
+ if (provider.additionalEnvVars) {
63
+ Object.assign(env, provider.additionalEnvVars);
36
64
  }
37
65
 
38
- // Derive default model env vars from provider_models by tier (single source of truth)
39
- if (Array.isArray(provider.models)) {
40
- const opusModel = provider.models.find((m) => m.tier === 'opus');
41
- if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel.modelId;
66
+ logProviderEnv(provider, kind, env);
67
+
68
+ return env;
69
+ }
42
70
 
43
- const sonnetModel = provider.models.find((m) => m.tier === 'sonnet');
44
- if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel.modelId;
71
+ function buildOpenAIProviderEnv(provider) {
72
+ const env = {};
73
+ if (provider.baseUrl) env.OPENAI_BASE_URL = provider.baseUrl;
74
+ if (provider.authToken) env.OPENAI_API_KEY = provider.authToken;
75
+ return env;
76
+ }
45
77
 
46
- const haikuModel = provider.models.find((m) => m.tier === 'haiku');
47
- if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel.modelId;
78
+ function buildAnthropicProviderEnv(provider) {
79
+ const env = {};
80
+ if (provider.baseUrl) env.ANTHROPIC_BASE_URL = provider.baseUrl;
81
+ if (provider.authToken) {
82
+ env.ANTHROPIC_API_KEY = provider.authToken;
83
+ env.ANTHROPIC_AUTH_TOKEN = provider.authToken;
48
84
  }
85
+ addAnthropicModelEnv(env, provider.models);
86
+ return env;
87
+ }
49
88
 
50
- if (provider.apiTimeoutMs) {
51
- env.API_TIMEOUT_MS = String(provider.apiTimeoutMs);
89
+ function addAnthropicModelEnv(env, models) {
90
+ if (!Array.isArray(models)) return;
91
+ const target = env;
92
+ const tiers = {
93
+ opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
94
+ sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
95
+ haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
96
+ };
97
+ for (const [tier, envKey] of Object.entries(tiers)) {
98
+ const model = models.find((entry) => entry.tier === tier);
99
+ if (model) target[envKey] = model.modelId;
52
100
  }
101
+ }
53
102
 
54
- // Parse additional env vars
55
- if (provider.additionalEnvVars) {
56
- Object.assign(env, provider.additionalEnvVars);
103
+ function logProviderEnv(provider, kind, env) {
104
+ if (kind === 'openai') {
105
+ console.log(`[SessionManager] buildProviderEnv: Provider "${provider.name}" (openai) env vars:`, {
106
+ OPENAI_BASE_URL: env.OPENAI_BASE_URL,
107
+ OPENAI_API_KEY: env.OPENAI_API_KEY ? '[SET]' : '[NOT SET]',
108
+ API_TIMEOUT_MS: env.API_TIMEOUT_MS,
109
+ });
110
+ return;
57
111
  }
58
112
 
59
- console.log(`[SessionManager] buildProviderEnv: Provider "${provider.name}" env vars:`, {
113
+ console.log(`[SessionManager] buildProviderEnv: Provider "${provider.name}" (anthropic) env vars:`, {
60
114
  ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL,
61
115
  ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY ? '[SET]' : '[NOT SET]',
62
116
  ANTHROPIC_AUTH_TOKEN: env.ANTHROPIC_AUTH_TOKEN ? '[SET]' : '[NOT SET]',
@@ -64,15 +118,24 @@ export function buildProviderEnv(provider) {
64
118
  ANTHROPIC_DEFAULT_OPUS_MODEL: env.ANTHROPIC_DEFAULT_OPUS_MODEL,
65
119
  ANTHROPIC_DEFAULT_HAIKU_MODEL: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
66
120
  });
67
-
68
- return env;
69
121
  }
70
122
 
71
123
  /**
72
- * Build environment variables for Claude SDK based on provider and session settings.
124
+ * Build environment variables for the agent runtime based on provider and session settings.
73
125
  * Always returns a robust env with Node in PATH to prevent ENOENT errors.
74
- * @param {Object|null} provider - Provider object or null for Anthropic defaults
126
+ *
127
+ * Kind-aware behavior:
128
+ * - provider.kind === 'anthropic' (or legacy/unspecified): keeps today's behavior
129
+ * (MAX_THINKING_TOKENS + CLAUDE_CODE_EFFORT_LEVEL are applied as before).
130
+ * - provider.kind === 'openai': Claude-only envs (MAX_THINKING_TOKENS,
131
+ * CLAUDE_CODE_EFFORT_LEVEL) are NOT set, and any ANTHROPIC_* vars from
132
+ * process.env are stripped so Claude env doesn't leak into Codex sessions.
133
+ * - provider === null: strip BOTH kinds' auth/base-url vars so host env
134
+ * doesn't bleed into the SDK defaults.
135
+ *
136
+ * @param {Object|null} provider - Provider object or null for agent defaults
75
137
  * @param {boolean} thinkingEnabled - Whether thinking mode is enabled
138
+ * @param {string|null} effortLevel - Optional effort level
76
139
  * @returns {Object}
77
140
  */
78
141
  export function buildSessionEnv(provider, thinkingEnabled = false, effortLevel = null) {
@@ -82,26 +145,84 @@ export function buildSessionEnv(provider, thinkingEnabled = false, effortLevel =
82
145
  // Combine all env vars
83
146
  const sessionEnv = {
84
147
  ...baseEnv,
85
- ...providerEnv, // Add provider env vars
148
+ ...providerEnv, // Add provider env vars (wins over host env for its own keys)
86
149
  };
87
150
 
88
- // When no custom provider is configured, explicitly exclude ANTHROPIC_* variables
89
- // from the environment to ensure SDK uses its defaults (not user's env vars)
151
+ const kind = provider?.kind || (provider ? 'anthropic' : null);
152
+
90
153
  if (!provider) {
91
- delete sessionEnv.ANTHROPIC_API_KEY;
92
- delete sessionEnv.ANTHROPIC_AUTH_TOKEN;
93
- delete sessionEnv.ANTHROPIC_BASE_URL;
154
+ stripProviderRuntimeEnv(sessionEnv);
155
+ } else if (kind === 'openai') {
156
+ applyOpenAISessionEnv(sessionEnv, providerEnv);
157
+ } else {
158
+ stripOpenAIHostEnv(sessionEnv);
94
159
  }
95
160
 
96
- // Add thinking tokens if enabled (but suppress in VCR mode to minimize cost)
97
- if (thinkingEnabled && !process.env.VCR_MODE) {
98
- sessionEnv.MAX_THINKING_TOKENS = '10240';
99
- }
161
+ // Claude-only session env vars. Only set for Anthropic-kind providers
162
+ // (or when no provider is configured → Claude-default flow).
163
+ const isClaudeFlow = !provider || kind === 'anthropic';
100
164
 
101
- // Set effort level if provided
102
- if (effortLevel) {
103
- sessionEnv.CLAUDE_CODE_EFFORT_LEVEL = effortLevel;
165
+ if (isClaudeFlow) {
166
+ // Add thinking tokens if enabled (but suppress in VCR mode to minimize cost)
167
+ if (thinkingEnabled && !process.env.VCR_MODE) {
168
+ sessionEnv.MAX_THINKING_TOKENS = '10240';
169
+ }
170
+
171
+ // Set effort level if provided
172
+ if (effortLevel) {
173
+ sessionEnv.CLAUDE_CODE_EFFORT_LEVEL = effortLevel;
174
+ }
104
175
  }
105
176
 
106
177
  return sessionEnv;
107
178
  }
179
+
180
+ function stripProviderRuntimeEnv(env) {
181
+ const target = env;
182
+ delete target.ANTHROPIC_API_KEY;
183
+ delete target.ANTHROPIC_AUTH_TOKEN;
184
+ delete target.ANTHROPIC_BASE_URL;
185
+ delete target.OPENAI_API_KEY;
186
+ delete target.OPENAI_BASE_URL;
187
+ }
188
+
189
+ function applyOpenAISessionEnv(sessionEnv, providerEnv) {
190
+ stripAnthropicHostEnv(sessionEnv);
191
+ if (!providerEnv.OPENAI_API_KEY) {
192
+ replaceWithCodexCliEnv(sessionEnv, providerEnv);
193
+ return;
194
+ }
195
+ stripOpenAIBaseUrlUnlessProvided(sessionEnv, providerEnv);
196
+ }
197
+
198
+ function stripAnthropicHostEnv(env) {
199
+ const target = env;
200
+ delete target.ANTHROPIC_API_KEY;
201
+ delete target.ANTHROPIC_AUTH_TOKEN;
202
+ delete target.ANTHROPIC_BASE_URL;
203
+ }
204
+
205
+ function replaceWithCodexCliEnv(sessionEnv, providerEnv) {
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);
215
+ }
216
+
217
+ function stripOpenAIBaseUrlUnlessProvided(sessionEnv, providerEnv) {
218
+ if (providerEnv.OPENAI_BASE_URL || providerEnv.OPENAI_API_BASE) return;
219
+ const target = sessionEnv;
220
+ delete target.OPENAI_BASE_URL;
221
+ delete target.OPENAI_API_BASE;
222
+ }
223
+
224
+ function stripOpenAIHostEnv(env) {
225
+ const target = env;
226
+ delete target.OPENAI_API_KEY;
227
+ delete target.OPENAI_BASE_URL;
228
+ }
@@ -206,6 +206,9 @@ function handleAssistantTextContent(sessionId, textContent, toolUseBlocks) {
206
206
  const currentModel = currentModels.get(sessionId) || null;
207
207
  const message = messages.create(sessionId, 'assistant', textContent, { toolUse, conversationId, model: currentModel });
208
208
 
209
+ // Touch the session to update its updated_at timestamp so it sorts to the top
210
+ sessions.touch(sessionId);
211
+
209
212
  // Associate pending work logs with this message immediately
210
213
  // This ensures work logs are attached to the correct message, not just the last one
211
214
  associateAndBroadcastWorkLogs(sessionId, message.id);
@@ -4,6 +4,7 @@ import { setupGitForSession } from './gitSessionSetup.js';
4
4
  import { runSession } from './sessionManager.js';
5
5
  import { broadcastToProject } from '../websocket.js';
6
6
  import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
7
+ import { resolveAgentTypeFromModel } from './sessionProvider.js';
7
8
 
8
9
  const liquid = new Liquid();
9
10
 
@@ -157,6 +158,7 @@ function buildChildSessionOptions(template, parentSession, settings) {
157
158
  status: 'starting',
158
159
  model: settings.model,
159
160
  effortLevel: settings.effortLevel,
161
+ agentType: resolveAgentTypeFromModel(settings.model),
160
162
  };
161
163
 
162
164
  const postCreateUpdate = {