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,6 +1,6 @@
1
1
  import { BaseRepository } from './BaseRepository.js';
2
2
  import { databaseManager } from './DatabaseManager.js';
3
- import { messages, conversations } from './index.js';
3
+ import { messages, conversations, modelProviders } from './index.js';
4
4
  import {
5
5
  ACTIVITY_FIELDS_SQL,
6
6
  mapTokenUsage,
@@ -9,6 +9,26 @@ import {
9
9
  buildUpdateClauses,
10
10
  } from './session-helpers.js';
11
11
 
12
+ const DEFAULT_AGENT_TYPE = 'claude-code';
13
+
14
+ /**
15
+ * Resolve the agent type ('claude-code' or 'codex') from a model ID by looking
16
+ * up which provider owns the model. This is the same logic as
17
+ * sessionProvider.resolveAgentTypeFromModel but inlined here to avoid a
18
+ * circular dependency:
19
+ * database.js (index) → SessionRepository → sessionProvider → database.js
20
+ * @param {string|null} modelId
21
+ * @returns {'claude-code'|'codex'}
22
+ */
23
+ function resolveAgentTypeFromModel(modelId) {
24
+ if (!modelId) return DEFAULT_AGENT_TYPE;
25
+ const provider = modelProviders.getProviderByModelId(modelId);
26
+ if (!provider) return DEFAULT_AGENT_TYPE;
27
+ // ProviderRepository.getAgentTypeForProvider maps kind → agent adapter
28
+ const agentType = modelProviders.getAgentTypeForProvider(provider.id);
29
+ return agentType || DEFAULT_AGENT_TYPE;
30
+ }
31
+
12
32
  /**
13
33
  * Session repository class
14
34
  */
@@ -42,6 +62,8 @@ export class SessionRepository extends BaseRepository {
42
62
  effortLevel: row.effort_level || null,
43
63
  autoSendPendingPrompt: Boolean(row.auto_send_pending_prompt),
44
64
  slashCommands: row.slash_commands || null,
65
+ // Agent runtime driving this session (fallback to 'claude-code' for legacy rows).
66
+ agentType: row.agent_type || DEFAULT_AGENT_TYPE,
45
67
  ...mapTokenUsage(row),
46
68
  ...mapScheduling(row),
47
69
  // Kanban fields
@@ -49,7 +71,7 @@ export class SessionRepository extends BaseRepository {
49
71
  laneTriggerDepth: row.lane_trigger_depth || 0,
50
72
  createdAt: row.created_at,
51
73
  updatedAt: row.updated_at,
52
- lastActivityAt: row.last_activity_at || row.updated_at || row.created_at,
74
+ lastActivityAt: row.last_activity_at ?? null,
53
75
  activeTimeMs: row.active_time_ms || 0,
54
76
  };
55
77
  }
@@ -62,18 +84,38 @@ export class SessionRepository extends BaseRepository {
62
84
  return this.map(row);
63
85
  }
64
86
 
65
- /** Create a new session with optional config (mode, thinkingEnabled, gitBranch, parentSessionId, status, model, effortLevel) */
87
+ /** Create a new session with optional config (mode, thinkingEnabled, gitBranch, parentSessionId, status, model, effortLevel, agentType) */
66
88
  create(projectId, name, prompt, options = {}) {
67
89
  const config = parseCreateConfig(options, Array.prototype.slice.call(arguments, 4));
68
90
 
91
+ // Resolve agentType: explicit override → model-based derivation → fallback
92
+ const agentType =
93
+ config.agentType
94
+ ?? (config.model ? resolveAgentTypeFromModel(config.model) : null)
95
+ ?? DEFAULT_AGENT_TYPE;
96
+
69
97
  const id = databaseManager.generateId();
70
98
  const now = Date.now();
71
99
  this.db
72
100
  .prepare(
73
- `INSERT INTO sessions (id, project_id, name, status, mode, thinking_enabled, git_branch, parent_session_id, model, effort_level, created_at, updated_at)
74
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
101
+ `INSERT INTO sessions (id, project_id, name, status, mode, thinking_enabled, git_branch, parent_session_id, model, effort_level, agent_type, created_at, updated_at)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
75
103
  )
76
- .run(id, projectId, name, config.status, config.mode, config.thinkingEnabled ? 1 : 0, config.gitBranch, config.parentSessionId, config.model, config.effortLevel, now, now);
104
+ .run(
105
+ id,
106
+ projectId,
107
+ name,
108
+ config.status,
109
+ config.mode,
110
+ config.thinkingEnabled ? 1 : 0,
111
+ config.gitBranch,
112
+ config.parentSessionId,
113
+ config.model,
114
+ config.effortLevel,
115
+ agentType,
116
+ now,
117
+ now
118
+ );
77
119
 
78
120
  // Create initial conversation
79
121
  const conversation = conversations.create(id, 'Initial', true);
@@ -103,6 +145,7 @@ export class SessionRepository extends BaseRepository {
103
145
 
104
146
  sql += ` ORDER BY
105
147
  starred DESC,
148
+ COALESCE(last_activity_at, updated_at, created_at) DESC,
106
149
  updated_at DESC,
107
150
  created_at DESC,
108
151
  rowid DESC`;
@@ -141,7 +184,7 @@ export class SessionRepository extends BaseRepository {
141
184
  `SELECT s.*, p.name as project_name, p.working_directory as project_working_directory, ${ACTIVITY_FIELDS_SQL}
142
185
  FROM sessions s JOIN projects p ON s.project_id = p.id
143
186
  WHERE s.status IN ('starting', 'running', 'waiting') AND s.archived = 0
144
- ORDER BY s.starred DESC, s.updated_at DESC, s.created_at DESC, s.rowid DESC`
187
+ ORDER BY s.starred DESC, COALESCE(last_activity_at, s.updated_at, s.created_at) DESC, s.updated_at DESC, s.created_at DESC, s.rowid DESC`
145
188
  )
146
189
  .all();
147
190
  return rows.map(row => ({
@@ -157,7 +200,7 @@ export class SessionRepository extends BaseRepository {
157
200
  .prepare(
158
201
  `SELECT s.*, ${ACTIVITY_FIELDS_SQL} FROM sessions s
159
202
  WHERE parent_session_id = ?
160
- ORDER BY updated_at DESC, created_at DESC, rowid DESC`
203
+ ORDER BY COALESCE(last_activity_at, updated_at, created_at) DESC, updated_at DESC, created_at DESC, rowid DESC`
161
204
  )
162
205
  .all(parentSessionId);
163
206
  return this.mapAll(rows);
@@ -213,6 +256,18 @@ export class SessionRepository extends BaseRepository {
213
256
  return this.getById(id);
214
257
  }
215
258
 
259
+ /**
260
+ * Touch a session to update its updated_at timestamp without changing other fields.
261
+ * This is used to mark a session as recently active (e.g., when a message is added).
262
+ * @param {string} id - Session ID
263
+ * @returns {Object|null} The updated session or null if not found
264
+ */
265
+ touch(id) {
266
+ const now = Date.now();
267
+ this.db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, id);
268
+ return this.getById(id);
269
+ }
270
+
216
271
  /** Duplicate a session with a new ID and reset state (does NOT handle git or conversation setup) */
217
272
  duplicate(sourceSessionId, { name } = {}) {
218
273
  const source = this.getById(sourceSessionId);
@@ -227,10 +282,10 @@ export class SessionRepository extends BaseRepository {
227
282
  // Insert new session with same settings but new ID and status
228
283
  this.db
229
284
  .prepare(
230
- `INSERT INTO sessions (id, project_id, name, status, mode, thinking_enabled, git_branch, model, effort_level, context_window,
285
+ `INSERT INTO sessions (id, project_id, name, status, mode, thinking_enabled, git_branch, model, effort_level, agent_type, context_window,
231
286
  input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens,
232
287
  web_search_requests, cost_usd, created_at, updated_at)
233
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
288
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
234
289
  )
235
290
  .run(
236
291
  id,
@@ -242,6 +297,7 @@ export class SessionRepository extends BaseRepository {
242
297
  source.gitBranch, // Copy branch name (NOT worktree path)
243
298
  source.model,
244
299
  source.effortLevel,
300
+ source.agentType || DEFAULT_AGENT_TYPE,
245
301
  source.contextWindow,
246
302
  source.inputTokens,
247
303
  source.outputTokens,
@@ -261,7 +317,7 @@ export class SessionRepository extends BaseRepository {
261
317
  const rows = this.db
262
318
  .prepare(
263
319
  `SELECT s.*, ${ACTIVITY_FIELDS_SQL} FROM sessions s
264
- WHERE pr_url IS NOT NULL ORDER BY updated_at DESC, created_at DESC, rowid DESC`
320
+ WHERE pr_url IS NOT NULL ORDER BY COALESCE(last_activity_at, updated_at, created_at) DESC, updated_at DESC, created_at DESC, rowid DESC`
265
321
  )
266
322
  .all();
267
323
  return this.mapAll(rows);
@@ -168,7 +168,9 @@ export const allMigrations = validateMigrations([
168
168
 
169
169
  // --- Providers + provider_models tables + seed ---
170
170
  m.get('providers-create-tables'),
171
+ m.get('providers-add-kind'),
171
172
  m.get('providers-seed-built-in'),
173
+ m.get('providers-seed-built-in-openai'),
172
174
 
173
175
  // --- Sessions provider_id (from providers FK) ---
174
176
  s.get('sessions-add-provider_id-from-providers'),
@@ -4,12 +4,13 @@
4
4
  * Each export is an array of { name, up(db) } migration objects.
5
5
  */
6
6
  import { randomUUID } from 'node:crypto';
7
+ import { OPENAI_MODELS } from '../../../../shared/src/index.js';
7
8
  import { addColumnIfMissing, tableExists } from './migrationUtils.js';
8
9
 
9
10
  /**
10
11
  * Seed the built-in Anthropic provider if it doesn't exist.
11
12
  */
12
- function seedBuiltInProvider(db) {
13
+ function seedBuiltInAnthropicProvider(db) {
13
14
  const providerId = 'anthropic-default';
14
15
 
15
16
  const existing = db
@@ -43,6 +44,35 @@ function seedBuiltInProvider(db) {
43
44
  }
44
45
  }
45
46
 
47
+ /**
48
+ * Seed the built-in OpenAI/Codex provider if it doesn't exist.
49
+ */
50
+ function seedBuiltInOpenAIProvider(db) {
51
+ const providerId = 'openai-default';
52
+ const now = Date.now();
53
+
54
+ db.prepare(
55
+ `INSERT OR IGNORE INTO providers (
56
+ id, name, base_url, auth_token, kind, is_built_in, created_at, updated_at
57
+ )
58
+ VALUES (?, ?, NULL, NULL, 'openai', 1, ?, ?)`
59
+ ).run(providerId, 'OpenAI (Official)', now, now);
60
+
61
+ const insertModel = db.prepare(
62
+ `INSERT OR IGNORE INTO provider_models (id, provider_id, model_id, display_name, description, tier, created_at)
63
+ VALUES (?, ?, ?, ?, ?, 'custom', ?)`
64
+ );
65
+
66
+ for (const model of OPENAI_MODELS) {
67
+ insertModel.run(model.seedId, providerId, model.id, model.name, model.description, now);
68
+ }
69
+ }
70
+
71
+ function seedBuiltInProviders(db) {
72
+ seedBuiltInAnthropicProvider(db);
73
+ seedBuiltInOpenAIProvider(db);
74
+ }
75
+
46
76
  /**
47
77
  * Update built-in models to 4.6 versions.
48
78
  */
@@ -217,6 +247,7 @@ export const miscMigrations = [
217
247
  api_timeout_ms INTEGER,
218
248
  additional_env_vars TEXT,
219
249
  is_built_in INTEGER NOT NULL DEFAULT 0,
250
+ kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai')),
220
251
  created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
221
252
  updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
222
253
  );
@@ -235,10 +266,29 @@ export const miscMigrations = [
235
266
  },
236
267
  },
237
268
 
238
- // --- Seed built-in provider ---
269
+ // --- Add `kind` column to providers (for DBs that predate the CREATE TABLE change) ---
270
+ {
271
+ name: 'providers-add-kind',
272
+ up(db) {
273
+ addColumnIfMissing(
274
+ db,
275
+ 'providers',
276
+ 'kind',
277
+ "TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai'))"
278
+ );
279
+ },
280
+ },
281
+
282
+ // --- Seed built-in providers ---
239
283
  {
240
284
  name: 'providers-seed-built-in',
241
- up(db) { seedBuiltInProvider(db); },
285
+ up(db) { seedBuiltInProviders(db); },
286
+ },
287
+
288
+ // --- Seed built-in OpenAI provider for DBs that already ran the original seed ---
289
+ {
290
+ name: 'providers-seed-built-in-openai',
291
+ up(db) { seedBuiltInOpenAIProvider(db); },
242
292
  },
243
293
 
244
294
  // --- Update built-in models to 4.6 ---
@@ -59,6 +59,10 @@ const CONFIG_DEFAULTS = {
59
59
  status: 'starting',
60
60
  model: null,
61
61
  effortLevel: null,
62
+ // Agent runtime for the session: 'claude-code' (default) or 'codex'.
63
+ // Defaults to null so SessionRepository.create() can resolve it from the model.
64
+ // Explicit values from callers are preserved as-is.
65
+ agentType: null,
62
66
  };
63
67
 
64
68
  function buildConfig(src) {
@@ -109,6 +113,7 @@ export const DIRECT_FIELD_MAP = {
109
113
  effortLevel: 'effort_level',
110
114
  targetLaneId: 'target_lane_id',
111
115
  laneTriggerDepth: 'lane_trigger_depth',
116
+ agentType: 'agent_type',
112
117
  };
113
118
 
114
119
  /** camelCase -> snake_case column mapping for boolean fields (converted to 1/0) */
@@ -233,6 +233,7 @@ CREATE TABLE IF NOT EXISTS providers (
233
233
  api_timeout_ms INTEGER,
234
234
  additional_env_vars TEXT,
235
235
  is_built_in INTEGER NOT NULL DEFAULT 0,
236
+ kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai')),
236
237
  created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
237
238
  updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
238
239
  );
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'child_process';
2
+ import { createRobustEnv } from './nodeSpawnHelper.js';
3
+
4
+ /**
5
+ * Create a custom spawn function for the Codex CLI.
6
+ *
7
+ * Mirrors {@link createClaudeCodeSpawner} but keeps stderr piped (the Codex
8
+ * adapter surfaces stderr bytes as error events rather than silently ignoring
9
+ * them).
10
+ *
11
+ * As with the Claude helper:
12
+ * - The command 'node' is replaced with {@link process.execPath} so child
13
+ * processes use the same Node binary that's running the app (important
14
+ * for nvm/fnm/volta users).
15
+ * - `createRobustEnv` guarantees the Node bin directory is on PATH.
16
+ *
17
+ * @returns {Function} Spawn function of shape (options) => childProcess
18
+ */
19
+ export function createCodexSpawner() {
20
+ return (options) => {
21
+ const { command, args, cwd, env, signal } = options;
22
+
23
+ // Replace 'node' with the absolute path to the current Node executable
24
+ const actualCommand = command === 'node' ? process.execPath : command;
25
+
26
+ // Ensure PATH includes the directory containing Node
27
+ const robustEnv = createRobustEnv(env);
28
+
29
+ return spawn(actualCommand, args, {
30
+ cwd,
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ signal,
33
+ env: robustEnv,
34
+ windowsHide: true,
35
+ });
36
+ };
37
+ }
@@ -70,3 +70,30 @@ ${transcript}
70
70
 
71
71
  `;
72
72
  }
73
+
74
+ /**
75
+ * Build context for a continuation where the adapter cannot resume.
76
+ * This is the generic case for any non-resumable adapter (Codex, future adapters).
77
+ * @param {string} conversationId
78
+ * @returns {string}
79
+ */
80
+ export function buildConversationContextForContinuation(conversationId) {
81
+ const conversationMessages = messages.getByConversationId(conversationId);
82
+
83
+ // Don't include the last user message (that's the current prompt)
84
+ const previousMessages = conversationMessages.slice(0, -1);
85
+
86
+ if (previousMessages.length === 0) {
87
+ return '';
88
+ }
89
+
90
+ const transcript = formatConversationHistory(previousMessages);
91
+
92
+ return `<conversation_history>
93
+ The following is the conversation history from this session so far. Continue naturally from where the conversation left off.
94
+
95
+ ${transcript}
96
+ </conversation_history>
97
+
98
+ `;
99
+ }
@@ -2,6 +2,7 @@ import { sessions, messages, projects, conversations, attachments } from '../dat
2
2
  import { broadcastToSession, broadcastToProject } from '../websocket.js';
3
3
  import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
4
4
  import * as slashCommandService from './slashCommandService.js';
5
+ import { resolveAgentTypeFromModel } from './sessionProvider.js';
5
6
 
6
7
  /**
7
8
  * Validates that a session is a draft (waiting status with no assistant messages).
@@ -125,6 +126,11 @@ export async function startDraft(session, options = {}) {
125
126
  // Model to use for this session (optional - SDK will use default if not provided)
126
127
  const model = options.model || session.pendingModel || session.model || null;
127
128
 
129
+ // Resolve the agent type from the selected model before launching.
130
+ // Draft sessions have no assistant messages yet, so the session is still
131
+ // choosing its initial runtime – the selected model determines agentType.
132
+ const agentType = model ? resolveAgentTypeFromModel(model) : session.agentType;
133
+
128
134
  // Get or create the initial user message
129
135
  const initialMessage = getOrCreateInitialMessage(session, options);
130
136
  const finalPrompt = initialMessage.content;
@@ -132,8 +138,13 @@ export async function startDraft(session, options = {}) {
132
138
  // Get session attachments for context
133
139
  const sessionAttachments = attachments.getBySessionId(session.id);
134
140
 
135
- // Update session status to starting and clear pendingModel (mirrors pendingPrompt cleanup above)
136
- sessions.update(session.id, { status: 'starting', pendingModel: null });
141
+ // Update session status to starting, clear pendingModel, and persist the
142
+ // resolved model + agentType BEFORE runSession() reads them from storage.
143
+ sessions.update(session.id, {
144
+ status: 'starting',
145
+ pendingModel: null,
146
+ ...(model ? { model, agentType } : {}),
147
+ });
137
148
 
138
149
  // Resolve skill/command invocations so skill body goes into system prompt
139
150
  const resolved = await slashCommandService.resolvePromptSkillOrCommand(
@@ -9,6 +9,7 @@ import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
9
9
  import { renderTemplatePrompt, getRootSession } from './templateTriggerService.js';
10
10
  import { setupGitForSession } from './gitSessionSetup.js';
11
11
  import { runSession } from './sessionManager.js';
12
+ import { resolveAgentTypeFromModel } from './sessionProvider.js';
12
13
 
13
14
  // Maximum depth for recursive lane-entry template triggers
14
15
  export const MAX_LANE_TRIGGER_DEPTH = 5;
@@ -147,6 +148,7 @@ async function buildChildSessionFromTemplate(template, session, lane, depth) {
147
148
  gitBranch: settings.gitBranch,
148
149
  status: 'starting',
149
150
  model: settings.model,
151
+ agentType: resolveAgentTypeFromModel(settings.model),
150
152
  });
151
153
 
152
154
  // Configure session
@@ -240,6 +242,7 @@ async function buildChildSessionFromPrompt(lane, session, depth) {
240
242
  const newSession = sessions.create(session.projectId, `Lane prompt (lane: ${lane.name})`, renderedPrompt, {
241
243
  ...settings,
242
244
  status: 'starting',
245
+ agentType: resolveAgentTypeFromModel(settings.model),
243
246
  });
244
247
 
245
248
  // Configure session
@@ -1,35 +1,53 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ import OpenAI from 'openai';
2
3
 
3
4
  /**
4
- * Test a provider configuration by making a minimal API call
5
- * @param {Object} config - Provider configuration to test
5
+ * Test a provider configuration by making a minimal API call.
6
+ * Branches on `kind`:
7
+ * - 'anthropic' → send a tiny `messages.create` via `@anthropic-ai/sdk`.
8
+ * - 'openai' → prefer `models.list()` via `openai`; fall back to a
9
+ * `chat.completions.create({ max_tokens: 1 })` if
10
+ * `models.list` is not supported (chat-only endpoints).
11
+ *
12
+ * Both branches return the same response shape:
13
+ * - Success: { success: true, message, details: { model, usage? } }
14
+ * - Failure: { success: false, message, details: { code, type } }
15
+ * This function never throws. Errors are mapped to the failure shape above.
16
+ *
17
+ * @param {Object} config
18
+ * @param {'anthropic'|'openai'} [config.kind='anthropic'] - Provider kind
6
19
  * @param {string} [config.baseUrl] - Base URL for the provider
7
20
  * @param {string} [config.authToken] - Auth token for the provider
8
- * @param {string} [config.defaultSonnetModel] - Default Sonnet model ID
21
+ * @param {string} [config.defaultSonnetModel] - For anthropic: model to test against
9
22
  * @param {number} [config.apiTimeoutMs] - API timeout in milliseconds
10
23
  * @returns {Promise<{success: boolean, message: string, details?: Object}>}
11
24
  */
12
25
  export async function testProviderConnection(config) {
26
+ const { kind = 'anthropic' } = config || {};
27
+ if (kind === 'openai') {
28
+ return testOpenAIConnection(config);
29
+ }
30
+ return testAnthropicConnection(config);
31
+ }
32
+
33
+ /**
34
+ * Anthropic-kind connection test (unchanged from pre-kind behavior).
35
+ * @private
36
+ */
37
+ async function testAnthropicConnection(config) {
13
38
  const { baseUrl, authToken, defaultSonnetModel, apiTimeoutMs } = config;
14
39
 
15
40
  try {
16
- // Build client options
17
41
  const clientOptions = {};
18
42
 
19
- if (baseUrl) {
20
- clientOptions.baseURL = baseUrl;
21
- }
22
- if (authToken) {
23
- clientOptions.apiKey = authToken;
24
- }
25
- if (apiTimeoutMs) {
26
- clientOptions.timeout = apiTimeoutMs;
27
- }
43
+ if (baseUrl) clientOptions.baseURL = baseUrl;
44
+ if (authToken) clientOptions.apiKey = authToken;
45
+ if (apiTimeoutMs) clientOptions.timeout = apiTimeoutMs;
28
46
 
29
47
  const client = new Anthropic(clientOptions);
30
48
 
31
- // Use a minimal message to test connectivity
32
- // This verifies: network, auth, and model availability
49
+ // Use a minimal message to test connectivity.
50
+ // This verifies: network, auth, and model availability.
33
51
  const testModel = defaultSonnetModel || 'claude-sonnet-4-20250514';
34
52
 
35
53
  const response = await client.messages.create({
@@ -58,6 +76,88 @@ export async function testProviderConnection(config) {
58
76
  }
59
77
  }
60
78
 
79
+ /**
80
+ * OpenAI-kind connection test. Tries `models.list()` first; if the endpoint
81
+ * does not implement that (common for chat-only proxies like LM Studio), falls
82
+ * back to a minimal `chat.completions.create({ max_tokens: 1 })`.
83
+ * @private
84
+ */
85
+ async function testOpenAIConnection(config) {
86
+ try {
87
+ const client = createOpenAIClient(config);
88
+ return await testOpenAIClient(client, config);
89
+ } catch (error) {
90
+ return failureResponse(error);
91
+ }
92
+ }
93
+
94
+ function createOpenAIClient(config) {
95
+ const { baseUrl, authToken, apiTimeoutMs } = config;
96
+ const clientOptions = { apiKey: authToken || 'missing' };
97
+ if (baseUrl) clientOptions.baseURL = baseUrl;
98
+ if (apiTimeoutMs) clientOptions.timeout = apiTimeoutMs;
99
+ return new OpenAI(clientOptions);
100
+ }
101
+
102
+ async function testOpenAIClient(client, config) {
103
+ try {
104
+ return await testOpenAIModelsEndpoint(client, config);
105
+ } catch (error) {
106
+ if (error?.status !== 404) throw error;
107
+ return testOpenAIChatEndpoint(client, config);
108
+ }
109
+ }
110
+
111
+ async function testOpenAIModelsEndpoint(client, config) {
112
+ const listResult = await client.models.list();
113
+ const first = pickFirstModel(listResult) || config.defaultSonnetModel || null;
114
+ return connectionSuccess(first ? { model: first } : {});
115
+ }
116
+
117
+ async function testOpenAIChatEndpoint(client, config) {
118
+ const testModel = config.defaultSonnetModel || 'gpt-4o-mini';
119
+ const response = await client.chat.completions.create({
120
+ model: testModel,
121
+ max_tokens: 1,
122
+ messages: [{ role: 'user', content: 'Hi' }],
123
+ });
124
+ return connectionSuccess({
125
+ model: response?.model || testModel,
126
+ ...(response?.usage ? { usage: response.usage } : {}),
127
+ });
128
+ }
129
+
130
+ function connectionSuccess(details) {
131
+ return {
132
+ success: true,
133
+ message: 'Connection successful',
134
+ details,
135
+ };
136
+ }
137
+
138
+ function failureResponse(error) {
139
+ return {
140
+ success: false,
141
+ message: getErrorMessage(error),
142
+ details: { code: error.status || error.code, type: error.type || error.name },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Extract a representative model ID from whatever shape `models.list()` returns.
148
+ * @private
149
+ */
150
+ function pickFirstModel(listResult) {
151
+ if (!listResult) return null;
152
+ // Newer SDKs expose .data; older ones are plain arrays / async iterables.
153
+ const data = Array.isArray(listResult) ? listResult : listResult.data;
154
+ if (Array.isArray(data) && data.length > 0) {
155
+ const entry = data[0];
156
+ return entry?.id || entry?.model || null;
157
+ }
158
+ return null;
159
+ }
160
+
61
161
  /**
62
162
  * Get a human-readable error message from an error object
63
163
  * @param {Error} error - The error object
@@ -0,0 +1,38 @@
1
+ import { resolveAgentTypeFromModel } from './sessionProvider.js';
2
+
3
+ // Human-readable labels used in the cross-kind switch error message.
4
+ export const AGENT_TYPE_LABELS = Object.freeze({
5
+ 'claude-code': 'Claude Code',
6
+ codex: 'Codex',
7
+ });
8
+
9
+ /**
10
+ * @param {string} agentType
11
+ * @returns {string}
12
+ */
13
+ export function agentLabel(agentType) {
14
+ return AGENT_TYPE_LABELS[agentType] || agentType || 'unknown';
15
+ }
16
+
17
+ /**
18
+ * Cross-kind model switch guard. A session is bound to one agent type for its
19
+ * lifetime. If the caller selected a model that resolves to a different agent
20
+ * than the session's existing agentType, reject BEFORE dispatching
21
+ * continueSession or updating session.model — mixing Claude and Codex
22
+ * mid-conversation would produce a broken resume/context state. Same-kind
23
+ * model changes (sonnet↔opus, gpt-4o↔o1-mini) pass through.
24
+ *
25
+ * @param {Object} session - The session row (must include agentType + model).
26
+ * @param {string|null} requestedModel - Model ID from req.body.model, or null.
27
+ * @returns {{ error: string, message: string }|null} 400-body on block, or null to allow.
28
+ */
29
+ export function checkCrossKindSwitch(session, requestedModel) {
30
+ const sessionAgentType = session.agentType || 'claude-code';
31
+ const effectiveModel = requestedModel || session.model;
32
+ const requestedAgentType = resolveAgentTypeFromModel(effectiveModel);
33
+ if (requestedAgentType === sessionAgentType) return null;
34
+ return {
35
+ error: 'CROSS_KIND_MODEL_SWITCH',
36
+ message: `Cannot switch agent kind mid-session (${agentLabel(sessionAgentType)} → ${agentLabel(requestedAgentType)})`,
37
+ };
38
+ }