circuschief 0.4.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-D1daFFvI.js → ActiveSessionsView-UCbQrF1b.js} +1 -1
  40. package/packages/web/dist/assets/{AgentLogsView-B_NDIx2_.js → AgentLogsView-Cdw4nmvd.js} +1 -1
  41. package/packages/web/dist/assets/ApiClient-CWbXWDUY.js +1 -0
  42. package/packages/web/dist/assets/{ArchiveConfirmModal-BHqbCCX2.js → ArchiveConfirmModal-J48eh3zw.js} +1 -1
  43. package/packages/web/dist/assets/{CommandButtonDetailView-B9crZey8.js → CommandButtonDetailView-DnFhJY5A.js} +1 -1
  44. package/packages/web/dist/assets/EffortLevelSelector-bXbPo4Zw.js +1 -0
  45. package/packages/web/dist/assets/{GeneralSettingsView-CzBagrDs.js → GeneralSettingsView-CQkmdczf.js} +1 -1
  46. package/packages/web/dist/assets/{InputWithButton-Cplkm8Ze.js → InputWithButton-XyM3k6lN.js} +1 -1
  47. package/packages/web/dist/assets/{InterpolationHelp-bG_y10VY.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-91V-4qLi.js → NewSessionView-DkjFLvHU.js} +1 -1
  52. package/packages/web/dist/assets/{ProjectEditView-C3bOYnD6.js → ProjectEditView-embVT7NC.js} +1 -1
  53. package/packages/web/dist/assets/{ProjectListView-BEo1p4dO.js → ProjectListView-CuYMmd3O.js} +1 -1
  54. package/packages/web/dist/assets/{ProjectNewView-CXGjy3OL.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-Ds4X-J-t.js → QuickResponseSettings-BTQEKhwJ.js} +1 -1
  58. package/packages/web/dist/assets/{QuickResponsesPanel-BY2v23c5.js → QuickResponsesPanel-BqMYSHb0.js} +1 -1
  59. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +1 -0
  60. package/packages/web/dist/assets/{ResizableTextarea-C2s6_7X9.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-D3f0FEV1.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-78k6TTz6.css → SessionListView-fHlQyecX.css} +1 -1
  69. package/packages/web/dist/assets/{SessionLogStream-NR-AS676.js → SessionLogStream-DTnDAF95.js} +1 -1
  70. package/packages/web/dist/assets/{SettingsView-DKINDb2z.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-D_2bSsYD.js → SummarySettingsView-C7G_suHp.js} +1 -1
  74. package/packages/web/dist/assets/{TemplateDetailView-C5rbgXwU.js → TemplateDetailView-B78_DLMR.js} +1 -1
  75. package/packages/web/dist/assets/{commandButtons-D_-wR8zJ.js → commandButtons-Bbjf3fCt.js} +1 -1
  76. package/packages/web/dist/assets/{index-D9VgH58U.js → index--V7c-VZf.js} +1 -1
  77. package/packages/web/dist/assets/{index-Dp3Eg3c0.js → index-8Q04yd7H.js} +1 -1
  78. package/packages/web/dist/assets/{index-Cs-001Bx.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-CcCiJkwz.js → index-BXbgZrhS.js} +1 -1
  81. package/packages/web/dist/assets/{index-DveLfEiG.js → index-Bs7Qf5D6.js} +1 -1
  82. package/packages/web/dist/assets/{index-DaL3nu0U.js → index-CGhDVPen.js} +1 -1
  83. package/packages/web/dist/assets/{index-D9Z6zsGS.js → index-CKcRO1A6.js} +1 -1
  84. package/packages/web/dist/assets/{index-CxiSnR0R.js → index-CTq-SLIW.js} +1 -1
  85. package/packages/web/dist/assets/{index-DP2i58hO.js → index-CYyos3iC.js} +1 -1
  86. package/packages/web/dist/assets/{index-Krfrs3sc.js → index-Cf6vdW-B.js} +3 -3
  87. package/packages/web/dist/assets/{index-Dfy1JGZs.js → index-CsCREAxF.js} +1 -1
  88. package/packages/web/dist/assets/{index-BmQRt229.js → index-DJTTk_8T.js} +1 -1
  89. package/packages/web/dist/assets/{index-D-lQSDZh.js → index-DPqUJ5JK.js} +1 -1
  90. package/packages/web/dist/assets/{index-BCazaXF8.js → index-EwAe1dKg.js} +1 -1
  91. package/packages/web/dist/assets/{index-DyQ22-ut.js → index-JBA8axyA.js} +1 -1
  92. package/packages/web/dist/assets/{index-gylMFbgn.js → index-JkVHFtK5.js} +1 -1
  93. package/packages/web/dist/assets/{index-CCQGqJXX.js → index-gMPUwT55.js} +1 -1
  94. package/packages/web/dist/assets/{index-DgIOe3cM.js → index-wadc_0zT.js} +1 -1
  95. package/packages/web/dist/assets/{projects-CMJJca64.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-BN_W4nwV.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-HuOPegKI.js +0 -1
  102. package/packages/web/dist/assets/MarkdownEditor-CXDVTLvp.js +0 -2
  103. package/packages/web/dist/assets/ModelSelector-CgpqdZtV.js +0 -1
  104. package/packages/web/dist/assets/ProvidersView-CMAnPTEX.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-C7vFzR16.js +0 -1
  108. package/packages/web/dist/assets/SessionCard-CcqIjL8q.css +0 -1
  109. package/packages/web/dist/assets/SessionFormOptions-3LfqiLiR.js +0 -1
  110. package/packages/web/dist/assets/SessionFormOptions-BuLlDF-7.css +0 -1
  111. package/packages/web/dist/assets/SessionListView-NGW-u434.js +0 -1
  112. package/packages/web/dist/assets/SlashCommandWizard-BB30cSvo.css +0 -1
  113. package/packages/web/dist/assets/SlashCommandWizard-PXipO1yA.js +0 -1
  114. package/packages/web/dist/assets/index-D1Lg5reX.js +0 -82
  115. package/packages/web/dist/assets/providers-BCtNZWYw.js +0 -1
  116. package/packages/web/dist/assets/sessions-CoRS-wuR.js +0 -1
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Codex event mapper.
3
+ *
4
+ * Translates the real Codex CLI JSON-line event schema (as emitted by
5
+ * `codex exec --json`) into the normalized SDK-shaped events that Circus
6
+ * Chief's stream event handler already understands for Claude Code:
7
+ *
8
+ * - {@code system(init)}
9
+ * - {@code stream_event(content_block_delta)}
10
+ * - {@code assistant}
11
+ * - {@code result(success, usage)}
12
+ *
13
+ * Real Codex event types (v0.124.0, confirmed via
14
+ * `codex app-server generate-json-schema`):
15
+ *
16
+ * - {@code thread.started} — { thread_id }
17
+ * - {@code turn.started} — no payload
18
+ * - {@code item.started} — { item: ThreadItem }
19
+ * - {@code item.completed} — { item: ThreadItem }
20
+ * - {@code turn.completed} — { usage: { input_tokens, cached_input_tokens, output_tokens } }
21
+ * - {@code turn.failed} — { error: { message } }
22
+ * - {@code error} — { message } (transient, treated as fatal)
23
+ *
24
+ * ThreadItem.type variants handled in v1:
25
+ * - {@code agent_message} — { id, text, ... } → emitted as text_delta + assistant
26
+ * - {@code command_execution} — { id, command, aggregated_output, exit_code, status } → tool_result
27
+ * - {@code file_change} — { id, changes: [{path, kind}] } → tool_result
28
+ * - {@code reasoning} — { id, text } or legacy { content: [{type, text}] } → tool_result
29
+ *
30
+ * All other variants (mcp_tool_call,
31
+ * dynamic_tool_call, collab_agent_tool_call, web_search, image_view, image_generation,
32
+ * plan, context_compaction, hook_prompt, entered_review_mode, exited_review_mode,
33
+ * user_message) are currently ignored.
34
+ *
35
+ * The mapper is stateful across calls so it can accumulate agent message
36
+ * text across multiple `item.completed` events within a turn and stash
37
+ * usage counters until the terminal `turn.completed`.
38
+ *
39
+ * Pure in-process — no I/O, no timers, no child processes.
40
+ *
41
+ * @param {Object} [options]
42
+ * @param {string} [options.model] - Optional model name to surface in the
43
+ * {@code system(init)} event. Codex's {@code thread.started} event does
44
+ * not carry the model, so the adapter must pass it in.
45
+ * @returns {{
46
+ * map: (codexEvent: Object) => Array<Object>,
47
+ * reset: () => void,
48
+ * finalize: () => Array<Object>
49
+ * }}
50
+ */
51
+ export function createCodexEventMapper({ model } = {}) {
52
+ const mapperState = new MapperState();
53
+ const warnedUnknownItemTypes = new Set();
54
+
55
+ const handlers = {
56
+ 'thread.started': (evt) => handleThreadStarted(evt, model),
57
+ 'turn.started': () => [],
58
+ 'item.started': () => [],
59
+ 'item.completed': (evt) => handleItemCompleted(evt, mapperState, warnedUnknownItemTypes),
60
+ 'turn.completed': (evt) => mapperState.onTurnCompleted(evt),
61
+ 'turn.failed': (evt) => handleTurnFailed(evt),
62
+ 'error': (evt) => handleError(evt),
63
+ };
64
+
65
+ function map(codexEvent) {
66
+ if (!codexEvent || typeof codexEvent !== 'object') return [];
67
+ const handler = handlers[codexEvent.type];
68
+ if (!handler) {
69
+ console.warn(`[codexEventMapper] Unknown Codex event type: "${codexEvent.type}"`);
70
+ return [];
71
+ }
72
+ return handler(codexEvent);
73
+ }
74
+
75
+ return {
76
+ map,
77
+ reset: () => mapperState.reset(),
78
+ finalize: () => mapperState.finalize(),
79
+ };
80
+ }
81
+
82
+ // --- Mapper state class ----------------------------------------------------
83
+
84
+ class MapperState {
85
+ constructor() {
86
+ this.reset();
87
+ }
88
+
89
+ reset() {
90
+ this.lastUsage = null;
91
+ this.terminated = false;
92
+ }
93
+
94
+ /**
95
+ * Called by the adapter when the underlying stream ends without an explicit
96
+ * {@code turn.completed}. Returns a terminal result event if one hasn't been
97
+ * emitted yet; otherwise an empty array.
98
+ */
99
+ finalize() {
100
+ if (this.terminated) return [];
101
+ this.terminated = true;
102
+ return [this.buildResultEvent()];
103
+ }
104
+
105
+ onTurnCompleted(evt) {
106
+ if (evt && evt.usage) {
107
+ this.lastUsage = {
108
+ input_tokens: evt.usage.input_tokens,
109
+ output_tokens: evt.usage.output_tokens,
110
+ };
111
+ }
112
+ this.terminated = true;
113
+ return [this.buildResultEvent()];
114
+ }
115
+
116
+ buildResultEvent() {
117
+ const usage = this.lastUsage || { input_tokens: 0, output_tokens: 0 };
118
+ return {
119
+ type: 'result',
120
+ subtype: 'success',
121
+ usage: {
122
+ input_tokens: usage.input_tokens || 0,
123
+ output_tokens: usage.output_tokens || 0,
124
+ },
125
+ };
126
+ }
127
+ }
128
+
129
+ // --- Pure event handlers ---------------------------------------------------
130
+
131
+ function handleThreadStarted(evt, model) {
132
+ const init = {
133
+ type: 'system',
134
+ subtype: 'init',
135
+ session_id: evt.thread_id,
136
+ };
137
+ if (model) init.model = model;
138
+ return [init];
139
+ }
140
+
141
+ function handleItemCompleted(evt, _state, warnedTypes) {
142
+ const item = evt.item;
143
+ if (!item || typeof item !== 'object') return [];
144
+
145
+ if (isAgentMessageItem(item)) {
146
+ const text = typeof item.text === 'string' ? item.text : '';
147
+ return [
148
+ {
149
+ type: 'stream_event',
150
+ event: {
151
+ type: 'content_block_delta',
152
+ delta: { type: 'text_delta', text },
153
+ },
154
+ },
155
+ {
156
+ type: 'assistant',
157
+ message: { content: [{ type: 'text', text }] },
158
+ },
159
+ ];
160
+ }
161
+
162
+ if (item.type === 'command_execution') {
163
+ return [mapCommandExecution(item)];
164
+ }
165
+
166
+ if (item.type === 'file_change') {
167
+ return [mapFileChange(item)];
168
+ }
169
+
170
+ if (item.type === 'reasoning') {
171
+ return [mapReasoning(item)];
172
+ }
173
+
174
+ // Unknown types — warn once per type
175
+ if (item.type && !warnedTypes.has(item.type)) {
176
+ warnedTypes.add(item.type);
177
+ console.warn(`[codexEventMapper] Ignoring unsupported item.type "${item.type}"`);
178
+ }
179
+ return [];
180
+ }
181
+
182
+ function isAgentMessageItem(item) {
183
+ return item.type === 'agent_message' || item.type === 'agentMessage';
184
+ }
185
+
186
+ function handleTurnFailed(evt) {
187
+ const message = evt?.error?.message || 'Codex turn failed';
188
+ throw new Error(message);
189
+ }
190
+
191
+ function handleError(evt) {
192
+ const message = evt?.message || 'Codex error';
193
+ throw new Error(message);
194
+ }
195
+
196
+ // --- Tool-use mapping helpers -----------------------------------------------
197
+
198
+ function mapCommandExecution(item) {
199
+ const cmd = item.command || '';
200
+ const parts = [`$ ${cmd}`];
201
+ if (item.exit_code !== undefined && item.exit_code !== 0) {
202
+ parts.push(`exit code: ${item.exit_code}`);
203
+ }
204
+ if (item.aggregated_output) parts.push(item.aggregated_output);
205
+ return {
206
+ type: 'tool_result',
207
+ tool_name: 'command_execution',
208
+ content: parts.join('\n'),
209
+ };
210
+ }
211
+
212
+ function mapFileChange(item) {
213
+ const changes = Array.isArray(item.changes)
214
+ ? item.changes.map((c) => `${c.kind || 'change'}: ${c.path || 'unknown'}`).join('\n')
215
+ : 'unknown file change';
216
+ return {
217
+ type: 'tool_result',
218
+ tool_name: 'file_change',
219
+ content: changes,
220
+ };
221
+ }
222
+
223
+ function mapReasoning(item) {
224
+ // v0.124.0 shape: plain `text` string (e.g. gpt-5.2)
225
+ // Legacy shape: `content` array of {type, text} objects
226
+ const text = item.text
227
+ || (Array.isArray(item.content)
228
+ ? item.content.map((c) => c.text || '').join('\n')
229
+ : '');
230
+ return {
231
+ type: 'tool_result',
232
+ tool_name: 'reasoning',
233
+ content: text,
234
+ };
235
+ }
@@ -24,6 +24,7 @@
24
24
  * @property {Object} env - Environment variables
25
25
  * @property {Function} spawnClaudeCodeProcess - Process spawner function
26
26
  * @property {string} [model] - Model to use
27
+ * @property {string|null} [effortLevel] - Reasoning effort override, or null/auto for provider default
27
28
  * @property {string} systemPrompt - System prompt string
28
29
  */
29
30
 
@@ -116,6 +116,14 @@ export class VCRAgentAdapter {
116
116
  return this.innerAgent.supportsResume?.() ?? false;
117
117
  }
118
118
 
119
+ /**
120
+ * Proxy conversation context need to inner agent
121
+ * @returns {boolean}
122
+ */
123
+ needsConversationContext() {
124
+ return this.innerAgent.needsConversationContext?.() ?? true;
125
+ }
126
+
119
127
  /**
120
128
  * Proxy capabilities to inner agent
121
129
  * @returns {object}
@@ -0,0 +1,27 @@
1
+ import { Router } from 'express';
2
+ import { agentGateway } from '../agents/AgentGateway.js';
3
+
4
+ const router = Router();
5
+
6
+ /**
7
+ * GET /api/agents
8
+ *
9
+ * Returns the capabilities of every registered agent adapter, sourced from the
10
+ * adapter's static `capabilities` field (no adapter instantiation).
11
+ *
12
+ * Response shape:
13
+ * [
14
+ * { agentType: 'claude-code', capabilities: { streaming, thinking, reasoningEffort, toolUse, resume } },
15
+ * { agentType: 'codex', capabilities: { streaming, thinking, reasoningEffort, toolUse, resume } },
16
+ * ]
17
+ */
18
+ router.get('/', (_req, res) => {
19
+ try {
20
+ const agents = agentGateway.getAllAgentCapabilities();
21
+ res.json(agents);
22
+ } catch (error) {
23
+ res.status(500).json({ error: error.message });
24
+ }
25
+ });
26
+
27
+ export default router;
@@ -279,6 +279,26 @@ router.get('/:id/canvas/file/:filename/content', (req, res) => {
279
279
  });
280
280
  });
281
281
 
282
+ // GET /api/sessions/:id/canvas/:itemId/content - Get one canvas item content inline
283
+ router.get('/:id/canvas/:itemId/content', (req, res) => {
284
+ const session = sessions.getById(req.params.id);
285
+ if (!session) return res.status(404).json({ error: ERR_SESSION_NOT_FOUND });
286
+
287
+ const item = canvasItems.getById(req.params.itemId);
288
+ if (!item) return res.status(404).json({ error: 'Canvas item not found' });
289
+ if (item.sessionId !== req.params.id) {
290
+ return res.status(400).json({ error: 'Canvas item does not belong to this session' });
291
+ }
292
+
293
+ res.json({
294
+ content: item.content ?? null,
295
+ data: item.data ?? null,
296
+ type: item.type,
297
+ mimeType: item.mimeType,
298
+ filename: item.filename,
299
+ });
300
+ });
301
+
282
302
  // GET /api/sessions/:id/canvas/file/:filename - Get canvas file by filename
283
303
  // Writes the file to /tmp and returns the file path for Claude's Read tool
284
304
  // Always returns the latest version
@@ -11,6 +11,7 @@ import providersRouter from './providers.js';
11
11
  import commandsRouter from './commands.js';
12
12
  import metricsRouter from './metrics.js';
13
13
  import kanbanRouter from './kanban.js';
14
+ import agentsRouter from './agents.js';
14
15
  import { getDbPath } from '../database.js';
15
16
  import { schedulerService } from '../services/schedulerService.js';
16
17
 
@@ -38,6 +39,7 @@ router.use('/git', gitRouter);
38
39
  router.use('/filesystem', filesystemRouter);
39
40
  router.use('/settings', settingsRouter);
40
41
  router.use('/providers', providersRouter);
42
+ router.use('/agents', agentsRouter);
41
43
  router.use('/commands', commandsRouter)
42
44
 
43
45
  // Canvas routes are nested under sessions
@@ -18,6 +18,7 @@ import {
18
18
  setupAndStartSession,
19
19
  } from './projects-session-helpers.js';
20
20
  import { validateGitSettings, buildRunsBySession } from './projects-helpers.js';
21
+ import { resolveAgentTypeFromModel } from '../services/sessionProvider.js';
21
22
  import { access, constants } from 'fs/promises';
22
23
  import { dirname, isAbsolute } from 'path';
23
24
 
@@ -243,6 +244,7 @@ function createSessionRow(projectId, config, nextTemplateId, initialStatus) {
243
244
  status: initialStatus,
244
245
  model: config.model,
245
246
  effortLevel: config.effortLevel,
247
+ agentType: config.agentType,
246
248
  });
247
249
 
248
250
  const postCreateUpdate = {
@@ -298,6 +300,11 @@ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, a
298
300
  }
299
301
 
300
302
  const { config, nextTemplateId } = prepared;
303
+ // Derive the agent type from the resolved model (after template overrides
304
+ // have been applied inside validateAndPrepareSessionConfig). Null/unknown
305
+ // model IDs fall back to 'claude-code'. This is the single source of truth
306
+ // for which adapter the session will use; sessions.create() persists it.
307
+ config.agentType = resolveAgentTypeFromModel(config.model);
301
308
  const initialStatus = determineInitialStatus(config);
302
309
  session = createSessionRow(req.params.id, config, nextTemplateId, initialStatus);
303
310
  return await startSessionOrFail(req, res, { session, config, project });
@@ -139,6 +139,7 @@ router.post('/:id/test', async (req, res) => {
139
139
  // Pick the sonnet-tiered model (if any) as the test model, falling back to any first model
140
140
  const sonnetModel = provider.models?.find((m) => m.tier === 'sonnet');
141
141
  const testConfig = {
142
+ kind: provider.kind || 'anthropic',
142
143
  baseUrl: provider.baseUrl,
143
144
  authToken: provider.authToken,
144
145
  defaultSonnetModel: sonnetModel?.modelId,
@@ -4,6 +4,7 @@ import { continueSession } from '../services/sessionManager.js';
4
4
  import { upload as _upload, handleUploadError } from '../middleware/upload.js';
5
5
  import { requireSession, requireSessionAndProject } from '../middleware/sessionLookup.js';
6
6
  import * as slashCommandService from '../services/slashCommandService.js';
7
+ import { checkCrossKindSwitch } from '../services/sessionAgentGuard.js';
7
8
 
8
9
  const router = Router();
9
10
 
@@ -61,6 +62,11 @@ router.post('/:id/message', _upload.array('files', 10), handleUploadError, requi
61
62
  return res.status(400).json({ error: 'Session is not waiting for input' });
62
63
  }
63
64
 
65
+ const crossKindError = checkCrossKindSwitch(req.session_, model);
66
+ if (crossKindError) {
67
+ return res.status(400).json(crossKindError);
68
+ }
69
+
64
70
  try {
65
71
  // Store file attachments if any - saves to disk in workingDirectory/.attachments
66
72
  const messageAttachments = attachments.createBatch(req.session_.id, null, files, req.workingDirectory);
@@ -2,6 +2,22 @@ import { BaseRepository } from './BaseRepository.js';
2
2
  import { databaseManager } from './DatabaseManager.js';
3
3
  import { encrypt, decrypt } from '../services/encryption.js';
4
4
 
5
+ /**
6
+ * Valid values for `providers.kind`. Maps 1:1 to an agent adapter:
7
+ * - 'anthropic' → 'claude-code'
8
+ * - 'openai' → 'codex'
9
+ */
10
+ export const PROVIDER_KINDS = Object.freeze(['anthropic', 'openai']);
11
+
12
+ /**
13
+ * Mapping from provider kind to the agent adapter that should drive sessions
14
+ * backed by that provider.
15
+ */
16
+ export const AGENT_TYPE_BY_KIND = Object.freeze({
17
+ anthropic: 'claude-code',
18
+ openai: 'codex',
19
+ });
20
+
5
21
  /**
6
22
  * Provider repository class (replaces ModelProviderRepository).
7
23
  *
@@ -11,6 +27,8 @@ import { encrypt, decrypt } from '../services/encryption.js';
11
27
  * - No auto-sync logic (#syncDefaultModels removed)
12
28
  * - Auth tokens are encrypted at rest (AES-256-GCM via encryption service)
13
29
  * - `getProviderByModelId` includes models (needed for buildProviderEnv)
30
+ * - Providers carry a `kind` (`'anthropic'` | `'openai'`) that selects the
31
+ * agent adapter and env-var convention. `kind` is **immutable** after create.
14
32
  */
15
33
  export class ProviderRepository extends BaseRepository {
16
34
  constructor() {
@@ -27,6 +45,7 @@ export class ProviderRepository extends BaseRepository {
27
45
  apiTimeoutMs: row.api_timeout_ms,
28
46
  additionalEnvVars: row.additional_env_vars ? JSON.parse(row.additional_env_vars) : null,
29
47
  isBuiltIn: row.is_built_in === 1,
48
+ kind: row.kind || 'anthropic',
30
49
  createdAt: row.created_at,
31
50
  updatedAt: row.updated_at,
32
51
  };
@@ -64,12 +83,20 @@ export class ProviderRepository extends BaseRepository {
64
83
  authToken = null,
65
84
  apiTimeoutMs = null,
66
85
  additionalEnvVars = null,
86
+ kind = 'anthropic',
67
87
  } = data;
68
88
 
89
+ // Application-layer validation: give a clear error ahead of the DB CHECK.
90
+ if (!PROVIDER_KINDS.includes(kind)) {
91
+ throw new Error(
92
+ `Invalid provider kind "${kind}". Must be one of: ${PROVIDER_KINDS.join(', ')}.`
93
+ );
94
+ }
95
+
69
96
  this.db
70
97
  .prepare(
71
- `INSERT INTO providers (id, name, base_url, auth_token, api_timeout_ms, additional_env_vars, created_at, updated_at)
72
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
98
+ `INSERT INTO providers (id, name, base_url, auth_token, api_timeout_ms, additional_env_vars, kind, created_at, updated_at)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
73
100
  )
74
101
  .run(
75
102
  id,
@@ -78,6 +105,7 @@ export class ProviderRepository extends BaseRepository {
78
105
  encrypt(authToken),
79
106
  apiTimeoutMs,
80
107
  additionalEnvVars ? JSON.stringify(additionalEnvVars) : null,
108
+ kind,
81
109
  now,
82
110
  now
83
111
  );
@@ -115,6 +143,15 @@ export class ProviderRepository extends BaseRepository {
115
143
  * @returns {Object} Updated provider (with models array)
116
144
  */
117
145
  update(id, data) {
146
+ // `kind` is immutable after create. Existing models + env wiring depend on it,
147
+ // so changing it in place would silently corrupt sessions already attached to
148
+ // this provider.
149
+ if (data && Object.prototype.hasOwnProperty.call(data, 'kind')) {
150
+ throw new Error(
151
+ "Provider kind is immutable after create. Delete and recreate the provider to change kind."
152
+ );
153
+ }
154
+
118
155
  const updates = [];
119
156
  const values = [];
120
157
 
@@ -279,12 +316,15 @@ export class ProviderRepository extends BaseRepository {
279
316
  return null;
280
317
  }
281
318
 
282
- // Find which provider owns this model ID
319
+ // Prefer custom providers over built-ins for duplicate model IDs. This
320
+ // preserves user-managed OpenAI providers (alternate base URLs, keys, or
321
+ // env vars) even when official OpenAI models are also seeded built-ins.
283
322
  const row = this.db
284
323
  .prepare(
285
324
  `SELECT p.id FROM providers p
286
325
  JOIN provider_models pm ON p.id = pm.provider_id
287
- WHERE pm.model_id = ?`
326
+ WHERE pm.model_id = ?
327
+ ORDER BY p.is_built_in ASC, p.name ASC`
288
328
  )
289
329
  .get(modelId);
290
330
 
@@ -297,11 +337,27 @@ export class ProviderRepository extends BaseRepository {
297
337
  const provider = this.getById(row.id);
298
338
  if (!provider) return null;
299
339
 
300
- // Built-in Anthropic provider falls through to SDK defaults
301
- if (provider.isBuiltIn) {
340
+ // Built-in **Anthropic** provider falls through to SDK defaults (keeps
341
+ // historical behavior of letting @anthropic-ai/claude-agent-sdk pick its
342
+ // own env). Built-in OpenAI (or any future non-Anthropic built-in) still
343
+ // needs its env vars to flow, so we return the provider object.
344
+ if (provider.isBuiltIn && provider.kind === 'anthropic') {
302
345
  return null;
303
346
  }
304
347
 
305
348
  return provider;
306
349
  }
350
+
351
+ /**
352
+ * Resolve a provider's agent type from its id.
353
+ * @param {string|null|undefined} providerId
354
+ * @returns {string|null} 'claude-code' for anthropic-kind, 'codex' for openai-kind,
355
+ * or null if the provider is unknown.
356
+ */
357
+ getAgentTypeForProvider(providerId) {
358
+ if (!providerId) return null;
359
+ const provider = this.getById(providerId);
360
+ if (!provider) return null;
361
+ return AGENT_TYPE_BY_KIND[provider.kind] || null;
362
+ }
307
363
  }