circuschief 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) 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-session-helpers.js +25 -0
  15. package/packages/server/src/api/projects.js +8 -0
  16. package/packages/server/src/api/providers.js +1 -0
  17. package/packages/server/src/api/sessions-draft.js +1 -0
  18. package/packages/server/src/api/sessions-messages.js +6 -0
  19. package/packages/server/src/api/settings.js +52 -4
  20. package/packages/server/src/db/ConversationRepository.js +16 -3
  21. package/packages/server/src/db/ProjectDefaultsRepository.js +47 -37
  22. package/packages/server/src/db/ProviderRepository.js +62 -6
  23. package/packages/server/src/db/SessionRepository.js +74 -14
  24. package/packages/server/src/db/SettingsRepository.js +44 -16
  25. package/packages/server/src/db/conversation-helpers.js +1 -0
  26. package/packages/server/src/db/migrations/conversationsMigrations.js +4 -0
  27. package/packages/server/src/db/migrations/index.js +4 -0
  28. package/packages/server/src/db/migrations/miscMigrations.js +53 -3
  29. package/packages/server/src/db/migrations/sessionsMigrations.js +6 -1
  30. package/packages/server/src/db/session-helpers.js +8 -0
  31. package/packages/server/src/schema.sql +9 -0
  32. package/packages/server/src/services/agentCallLogger.js +1 -1
  33. package/packages/server/src/services/codexSpawnHelper.js +37 -0
  34. package/packages/server/src/services/commandButtonPrompts.js +48 -0
  35. package/packages/server/src/services/conversationContext.js +27 -0
  36. package/packages/server/src/services/draftSessionService.js +15 -2
  37. package/packages/server/src/services/kanbanTriggers.js +3 -0
  38. package/packages/server/src/services/providerTestService.js +115 -15
  39. package/packages/server/src/services/sessionAgentGuard.js +38 -0
  40. package/packages/server/src/services/sessionExecution.js +127 -33
  41. package/packages/server/src/services/sessionManager.js +45 -8
  42. package/packages/server/src/services/sessionPrompts.js +29 -0
  43. package/packages/server/src/services/sessionProvider.js +160 -41
  44. package/packages/server/src/services/streamEventCallbacks.js +72 -40
  45. package/packages/server/src/services/streamEventHandler.js +16 -2
  46. package/packages/server/src/services/streamUsageHandler.js +6 -0
  47. package/packages/server/src/services/summaryClaudeClient.js +37 -12
  48. package/packages/server/src/services/summaryModelClient.js +154 -0
  49. package/packages/server/src/services/summaryModelResolver.js +148 -0
  50. package/packages/server/src/services/summaryService.js +6 -4
  51. package/packages/server/src/services/templateTriggerService.js +2 -0
  52. package/packages/server/src/services/usageTracker.js +5 -1
  53. package/packages/server/src/services/visibleFinalErrorMessage.js +123 -0
  54. package/packages/shared/src/constants.js +1 -2
  55. package/packages/shared/src/contracts/projects.js +2 -0
  56. package/packages/shared/src/contracts/providers.js +24 -7
  57. package/packages/shared/src/contracts/sessions.js +1 -1
  58. package/packages/shared/src/index.js +1 -0
  59. package/packages/shared/src/types.js +28 -0
  60. package/packages/shared/src/utils.js +9 -17
  61. package/packages/web/dist/assets/ActiveSessionsView-UJsCILDL.js +1 -0
  62. package/packages/web/dist/assets/{AgentLogsView-DCF2WvP2.js → AgentLogsView-BGFPLjLa.js} +1 -1
  63. package/packages/web/dist/assets/ApiClient-B4YTtyY4.js +1 -0
  64. package/packages/web/dist/assets/{ArchiveConfirmModal-fgoEQhfq.js → ArchiveConfirmModal-OFaj_uX5.js} +1 -1
  65. package/packages/web/dist/assets/{CommandButtonDetailView-DAg07cDQ.js → CommandButtonDetailView-D8S258uP.js} +1 -1
  66. package/packages/web/dist/assets/EffortLevelSelector-C2378L8e.js +1 -0
  67. package/packages/web/dist/assets/{GeneralSettingsView-Cn9VI2du.js → GeneralSettingsView-DsHChEhv.js} +1 -1
  68. package/packages/web/dist/assets/{InputWithButton-BvboBGbz.js → InputWithButton-Ci15ox0a.js} +1 -1
  69. package/packages/web/dist/assets/{InterpolationHelp-0GoSBPgf.js → InterpolationHelp-CIkOSkWX.js} +1 -1
  70. package/packages/web/dist/assets/MarkdownEditor-5-bexzUT.js +2 -0
  71. package/packages/web/dist/assets/ModelSelector-BMpR0DPr.js +1 -0
  72. package/packages/web/dist/assets/{ModelSelector-DPPD-92R.css → ModelSelector-D8hbTRIt.css} +1 -1
  73. package/packages/web/dist/assets/{NewSessionView-C77YVqgY.js → NewSessionView-BCqtIgWH.js} +2 -2
  74. package/packages/web/dist/assets/{NewSessionView-D_Hi7M9g.css → NewSessionView-CUUdHkfv.css} +1 -1
  75. package/packages/web/dist/assets/ProjectEditView-D9sK0fdH.css +1 -0
  76. package/packages/web/dist/assets/ProjectEditView-RFaxHhAX.js +1 -0
  77. package/packages/web/dist/assets/{ProjectListView-CLwtuJ0J.js → ProjectListView-B9FuWESY.js} +1 -1
  78. package/packages/web/dist/assets/{ProjectNewView-CzDtVibO.js → ProjectNewView-D62jYlBL.js} +1 -1
  79. package/packages/web/dist/assets/ProvidersView-DDKMIQWZ.js +1 -0
  80. package/packages/web/dist/assets/ProvidersView-DE82G_5W.css +1 -0
  81. package/packages/web/dist/assets/QuickResponseSettings-CDm5vwP7.js +1 -0
  82. package/packages/web/dist/assets/{QuickResponsesPanel-DIBQFj0W.css → QuickResponsesPanel-BlFDvnZ2.css} +1 -1
  83. package/packages/web/dist/assets/{QuickResponsesPanel-CTXYjMF-.js → QuickResponsesPanel-DZ_Lre_l.js} +1 -1
  84. package/packages/web/dist/assets/{ResizableTextarea-Cw6aL4rp.js → ResizableTextarea-DiIOEGjN.js} +1 -1
  85. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +1 -0
  86. package/packages/web/dist/assets/SessionCard-BMGC2HqI.css +1 -0
  87. package/packages/web/dist/assets/SessionCard-DmjnVYWn.js +1 -0
  88. package/packages/web/dist/assets/SessionDetailView-CL7nmfiB.js +36 -0
  89. package/packages/web/dist/assets/SessionDetailView-CupIkI7u.css +1 -0
  90. package/packages/web/dist/assets/SessionFormOptions-BpUALRKn.css +1 -0
  91. package/packages/web/dist/assets/SessionFormOptions-DYUISplS.js +1 -0
  92. package/packages/web/dist/assets/SessionListView-BcxGz4aC.js +1 -0
  93. package/packages/web/dist/assets/{SessionListView-DVhoZHN9.css → SessionListView-fHlQyecX.css} +1 -1
  94. package/packages/web/dist/assets/{SessionLogStream-DIndOyFR.js → SessionLogStream-DpUE6Xsh.js} +1 -1
  95. package/packages/web/dist/assets/{SettingsView-CmJ5JPd5.js → SettingsView-BC055tIA.js} +1 -1
  96. package/packages/web/dist/assets/SlashCommandWizard-DmTyNG9O.js +1 -0
  97. package/packages/web/dist/assets/SlashCommandWizard-Dn7sNaBd.css +1 -0
  98. package/packages/web/dist/assets/SummarySettingsView-BgnRCwlq.js +1 -0
  99. package/packages/web/dist/assets/SummarySettingsView-l2bxHmZZ.css +1 -0
  100. package/packages/web/dist/assets/TemplateDetailView-BlhOmLUX.js +1 -0
  101. package/packages/web/dist/assets/{commandButtons-D74TkPNU.js → commandButtons-D4RPpLiu.js} +1 -1
  102. package/packages/web/dist/assets/index-4rhEeO0B.js +1 -0
  103. package/packages/web/dist/assets/index-9vb2KaAd.js +1 -0
  104. package/packages/web/dist/assets/index-B0CvZXuN.js +7 -0
  105. package/packages/web/dist/assets/index-B6G18FqB.js +82 -0
  106. package/packages/web/dist/assets/{index-DMZZCi2u.js → index-BGwH4Cfn.js} +3 -3
  107. package/packages/web/dist/assets/index-BUhvkAdF.js +1 -0
  108. package/packages/web/dist/assets/index-BcnkUk2o.js +1 -0
  109. package/packages/web/dist/assets/{index-DQMHi05L.js → index-Bn5xdGFM.js} +2 -2
  110. package/packages/web/dist/assets/index-CNwkdB0T.js +1 -0
  111. package/packages/web/dist/assets/index-CfL84oGW.js +1 -0
  112. package/packages/web/dist/assets/index-CkmxO8Mm.js +1 -0
  113. package/packages/web/dist/assets/index-Cpy4-yv3.js +1 -0
  114. package/packages/web/dist/assets/index-CrAQJmoZ.js +1 -0
  115. package/packages/web/dist/assets/{index-gmCCsCQ1.css → index-Cs2nxhrT.css} +1 -1
  116. package/packages/web/dist/assets/index-D6Ky9vJe.js +3 -0
  117. package/packages/web/dist/assets/index-DfrE0gAC.js +1 -0
  118. package/packages/web/dist/assets/index-KwEyz0F3.js +1 -0
  119. package/packages/web/dist/assets/index-OfCywayk.js +1 -0
  120. package/packages/web/dist/assets/index-PDesaJc6.js +1 -0
  121. package/packages/web/dist/assets/index-uB6nhSvz.js +1 -0
  122. package/packages/web/dist/assets/{projects-D_C9dE9s.js → projects-BUiOGmmb.js} +1 -1
  123. package/packages/web/dist/assets/providers-Bh1ZiiJi.js +1 -0
  124. package/packages/web/dist/assets/sessions-DH1R-NhV.js +1 -0
  125. package/packages/web/dist/assets/settings-Z4AVVmkJ.js +1 -0
  126. package/packages/web/dist/index.html +2 -2
  127. package/packages/web/dist/assets/ActiveSessionsView-BafIafEu.js +0 -1
  128. package/packages/web/dist/assets/ApiClient-CcqJ-GAv.js +0 -1
  129. package/packages/web/dist/assets/EffortLevelSelector-xE3gidpq.js +0 -1
  130. package/packages/web/dist/assets/MarkdownEditor-HCKnwRye.js +0 -2
  131. package/packages/web/dist/assets/ModelSelector-B0RdlCHT.js +0 -1
  132. package/packages/web/dist/assets/ProjectEditView-BBHOsgBV.js +0 -1
  133. package/packages/web/dist/assets/ProjectEditView-CpeKj-_w.css +0 -1
  134. package/packages/web/dist/assets/ProvidersView-Eg93KbyC.js +0 -1
  135. package/packages/web/dist/assets/ProvidersView-uD8SKWpA.css +0 -1
  136. package/packages/web/dist/assets/QuickResponseSettings-BBHMapcA.js +0 -1
  137. package/packages/web/dist/assets/ResizableTextarea-B5nAA0RV.css +0 -1
  138. package/packages/web/dist/assets/SessionCard-CCapYVjy.js +0 -1
  139. package/packages/web/dist/assets/SessionCard-CcqIjL8q.css +0 -1
  140. package/packages/web/dist/assets/SessionDetailView-BL83oPiI.css +0 -1
  141. package/packages/web/dist/assets/SessionDetailView-CrZvMb3j.js +0 -36
  142. package/packages/web/dist/assets/SessionFormOptions-BuLlDF-7.css +0 -1
  143. package/packages/web/dist/assets/SessionFormOptions-Em7sQCGb.js +0 -1
  144. package/packages/web/dist/assets/SessionListView-3zdDtqhw.js +0 -1
  145. package/packages/web/dist/assets/SlashCommandWizard-BB30cSvo.css +0 -1
  146. package/packages/web/dist/assets/SlashCommandWizard-C_cSgF-P.js +0 -1
  147. package/packages/web/dist/assets/SummarySettingsView-DQM1n3bc.js +0 -1
  148. package/packages/web/dist/assets/SummarySettingsView-DcsmSVJI.css +0 -1
  149. package/packages/web/dist/assets/TemplateDetailView-B8clSBPk.js +0 -1
  150. package/packages/web/dist/assets/index-B5ocUoPf.js +0 -1
  151. package/packages/web/dist/assets/index-BELtFs3n.js +0 -1
  152. package/packages/web/dist/assets/index-BGAW2Nqa.js +0 -82
  153. package/packages/web/dist/assets/index-BsDR4w2c.js +0 -1
  154. package/packages/web/dist/assets/index-CVozYqQ-.js +0 -3
  155. package/packages/web/dist/assets/index-CefzeYRE.js +0 -1
  156. package/packages/web/dist/assets/index-CrLh8vw5.js +0 -1
  157. package/packages/web/dist/assets/index-DIvveuSK.js +0 -1
  158. package/packages/web/dist/assets/index-DPt6qBRK.js +0 -1
  159. package/packages/web/dist/assets/index-DYWZ8lD-.js +0 -1
  160. package/packages/web/dist/assets/index-DrlwE0Zo.js +0 -7
  161. package/packages/web/dist/assets/index-DuXChAe-.js +0 -1
  162. package/packages/web/dist/assets/index-Dz7jFUYU.js +0 -1
  163. package/packages/web/dist/assets/index-Gre8tUfC.js +0 -1
  164. package/packages/web/dist/assets/index-_Lv79l46.js +0 -1
  165. package/packages/web/dist/assets/index-f315nDFm.js +0 -1
  166. package/packages/web/dist/assets/index-rjbX81sm.js +0 -1
  167. package/packages/web/dist/assets/providers-BdvbPVdE.js +0 -1
  168. package/packages/web/dist/assets/sessions-Bs5FA6JZ.js +0 -1
  169. package/packages/web/dist/assets/settings-6Rw9xt-G.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuschief",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local-first web UI for managing Claude Code sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "liquidjs": "^10.24.0",
28
28
  "multer": "^1.4.5-lts.1",
29
29
  "nanoid": "^3.3.0",
30
+ "openai": "^4.104.0",
30
31
  "ws": "^8.18.0",
31
32
  "yaml": "^2.8.2"
32
33
  }
@@ -1,4 +1,5 @@
1
1
  import { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js';
2
+ import { CodexAdapter } from './adapters/CodexAdapter.js';
2
3
 
3
4
  /**
4
5
  * Factory/registry for agent adapters.
@@ -8,11 +9,14 @@ export class AgentGateway {
8
9
  constructor() {
9
10
  /** @type {Map<string, typeof import('./BaseAgent.js').BaseAgent>} */
10
11
  this.adapters = new Map();
12
+ /** @type {Map<string, Object>} */
13
+ this._capabilitiesCache = new Map();
11
14
  this._registerDefaultAdapters();
12
15
  }
13
16
 
14
17
  _registerDefaultAdapters() {
15
18
  this.registerAdapter('claude-code', ClaudeCodeAdapter);
19
+ this.registerAdapter('codex', CodexAdapter);
16
20
  }
17
21
 
18
22
  /**
@@ -22,6 +26,8 @@ export class AgentGateway {
22
26
  */
23
27
  registerAdapter(agentType, AdapterClass) {
24
28
  this.adapters.set(agentType, AdapterClass);
29
+ // Invalidate any cached capabilities for this type.
30
+ this._capabilitiesCache.delete(agentType);
25
31
  }
26
32
 
27
33
  /**
@@ -48,15 +54,42 @@ export class AgentGateway {
48
54
  }
49
55
 
50
56
  /**
51
- * Get capabilities for an agent type (uses a static check, no instantiation).
57
+ * Get capabilities for an agent type.
58
+ *
59
+ * Prefers the adapter's static `capabilities` field (no instantiation),
60
+ * falling back to constructing an empty instance and calling
61
+ * `getCapabilities()` for backward compatibility with adapters that
62
+ * haven't migrated yet.
63
+ *
52
64
  * @param {string} agentType
53
65
  * @returns {Object|null}
54
66
  */
55
67
  getAgentCapabilities(agentType) {
68
+ const cached = this._capabilitiesCache.get(agentType);
69
+ if (cached) return cached;
70
+
56
71
  const AdapterClass = this.adapters.get(agentType);
57
72
  if (!AdapterClass) return null;
58
- // Capabilities are static per adapter class
59
- return new AdapterClass({}).getCapabilities();
73
+
74
+ let caps;
75
+ if (AdapterClass.capabilities) {
76
+ caps = { ...AdapterClass.capabilities };
77
+ } else {
78
+ caps = new AdapterClass({}).getCapabilities();
79
+ }
80
+ this._capabilitiesCache.set(agentType, caps);
81
+ return caps;
82
+ }
83
+
84
+ /**
85
+ * Get capabilities for every registered adapter.
86
+ * @returns {Array<{ agentType: string, capabilities: Object }>}
87
+ */
88
+ getAllAgentCapabilities() {
89
+ return this.getAvailableAgents().map((agentType) => ({
90
+ agentType,
91
+ capabilities: this.getAgentCapabilities(agentType),
92
+ }));
60
93
  }
61
94
  }
62
95
 
@@ -26,14 +26,28 @@ export class BaseAgent {
26
26
  return false;
27
27
  }
28
28
 
29
+ /**
30
+ * Whether the adapter needs conversation history explicitly prepended
31
+ * to the prompt on continuation. Adapters that support resume (like
32
+ * Claude Code) maintain their own server-side context and return false.
33
+ * Adapters without resume capability return true — the execution layer
34
+ * must inject history as text so the model has context.
35
+ *
36
+ * @returns {boolean}
37
+ */
38
+ needsConversationContext() {
39
+ return !this.supportsResume();
40
+ }
41
+
29
42
  /**
30
43
  * Get agent capabilities
31
- * @returns {{ streaming: boolean, thinking: boolean, toolUse: boolean, resume: boolean }}
44
+ * @returns {{ streaming: boolean, thinking: boolean, reasoningEffort: boolean, toolUse: boolean, resume: boolean }}
32
45
  */
33
46
  getCapabilities() {
34
47
  return {
35
48
  streaming: false,
36
49
  thinking: false,
50
+ reasoningEffort: false,
37
51
  toolUse: false,
38
52
  resume: false,
39
53
  };
@@ -67,6 +67,10 @@ export class LoggingAgentWrapper {
67
67
  return this.agent.supportsResume();
68
68
  }
69
69
 
70
+ needsConversationContext() {
71
+ return this.agent.needsConversationContext();
72
+ }
73
+
70
74
  getCapabilities() {
71
75
  return this.agent.getCapabilities();
72
76
  }
@@ -9,6 +9,14 @@ import { BaseAgent } from '../BaseAgent.js';
9
9
  * Event handling remains in sessionManager's handleStreamEvent().
10
10
  */
11
11
  export class ClaudeCodeAdapter extends BaseAgent {
12
+ static capabilities = Object.freeze({
13
+ streaming: true,
14
+ thinking: true,
15
+ reasoningEffort: true,
16
+ toolUse: true,
17
+ resume: true,
18
+ });
19
+
12
20
  /**
13
21
  * Execute a query against the Claude Code SDK.
14
22
  * @param {import('../types.js').AgentQueryParams} queryParams - { prompt, options? }
@@ -23,11 +31,6 @@ export class ClaudeCodeAdapter extends BaseAgent {
23
31
  }
24
32
 
25
33
  getCapabilities() {
26
- return {
27
- streaming: true,
28
- thinking: true,
29
- toolUse: true,
30
- resume: true,
31
- };
34
+ return { ...ClaudeCodeAdapter.capabilities };
32
35
  }
33
36
  }
@@ -1,26 +1,274 @@
1
1
  import { BaseAgent } from '../BaseAgent.js';
2
+ import { executeCodexCli } from './codexCliRunner.js';
3
+ import { createCodexSpawner } from '../../services/codexSpawnHelper.js';
2
4
 
3
5
  /**
4
- * Stub adapter for OpenAI Codex / future agents.
5
- * When implemented, this would:
6
- * 1. Transform AgentQueryParams into Codex API format
7
- * 2. Call the Codex API
8
- * 3. Yield events normalized to the SDK event format (system, assistant, tool_result, stream_event, result)
6
+ * Module-level flag: once an ENOENT is observed for the Codex CLI, remember
7
+ * it so subsequent calls skip the spawn attempt and can short-circuit the
8
+ * direct-API path selection.
9
+ */
10
+ let codexCliUnavailable = false;
11
+
12
+ /**
13
+ * Adapter for OpenAI Codex / any OpenAI-Chat-Completions-compatible model.
14
+ *
15
+ * Two execution paths:
9
16
  *
10
- * Phase 7 note: A NormalizedEvent format should be introduced at this point,
11
- * along with refactoring handleStreamEvent to consume normalized events.
17
+ * 1. CLI path (default) spawns the `codex --json ...` CLI and parses its
18
+ * line-delimited JSON stdout. Uses {@link createCodexEventMapper} to
19
+ * normalize events into the SDK-shaped envelope the rest of the app
20
+ * already understands.
21
+ *
22
+ * 2. Direct-API path — activated by {@code USE_CODEX_DIRECT_API=1}. Uses
23
+ * the official {@code openai} SDK with Chat Completions streaming
24
+ * against the provider's configured baseURL/apiKey. Intended for
25
+ * environments where the Codex CLI isn't installable and for the
26
+ * Step-0 contingency path in the implementation plan.
27
+ *
28
+ * Capabilities in v1:
29
+ * - streaming: true
30
+ * - thinking: false
31
+ * - reasoningEffort: true
32
+ * - toolUse: true
33
+ * - resume: false (Codex CLI v0.124.0 supports `codex resume` and
34
+ * `codex exec resume`, but Circus Chief defers
35
+ * wiring to a later phase — see
36
+ * docs/plans/openai-codex-agent.md §Phase 4.5)
12
37
  */
13
38
  export class CodexAdapter extends BaseAgent {
14
- async *execute(_queryParams) { // eslint-disable-line require-yield
15
- throw new Error('CodexAdapter is not yet implemented');
39
+ static capabilities = Object.freeze({
40
+ streaming: true,
41
+ thinking: false,
42
+ reasoningEffort: true,
43
+ toolUse: true,
44
+ resume: false,
45
+ });
46
+
47
+ /**
48
+ * @param {Object} [opts]
49
+ * @param {Function} [opts.spawnCodexProcess] - Optional DI for testing the
50
+ * CLI path. Shape matches {@link createCodexSpawner} output.
51
+ * @param {Function} [opts.openaiClientFactory] - Optional DI for testing
52
+ * the direct-API path: {@code ({ baseURL, apiKey, timeout }) => client}
53
+ * where {@code client.chat.completions.create} is OpenAI-SDK-compatible.
54
+ * @param {Object} [opts.rest] - Passed to {@link BaseAgent}.
55
+ */
56
+ constructor({ spawnCodexProcess, openaiClientFactory, ...rest } = {}) {
57
+ super(rest);
58
+ this._spawnCodex = spawnCodexProcess;
59
+ this._openaiClientFactory = openaiClientFactory;
16
60
  }
17
61
 
18
62
  getCapabilities() {
19
- return {
20
- streaming: true,
21
- thinking: false, // Codex doesn't have explicit thinking
22
- toolUse: true,
23
- resume: false, // Codex may not support session resume
63
+ return { ...CodexAdapter.capabilities };
64
+ }
65
+
66
+ supportsResume() {
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Execute a Codex query and yield SDK-shaped events.
72
+ *
73
+ * @param {import('../types.js').AgentQueryParams} queryParams
74
+ * @yields {Object} Normalized SDK events
75
+ */
76
+ async *execute(queryParams, _meta) {
77
+ const options = queryParams.options || {};
78
+ if (this._shouldUseDirectApi()) {
79
+ yield* this._executeDirectApi(queryParams, options);
80
+ return;
81
+ }
82
+ yield* this._executeCli(queryParams, options);
83
+ }
84
+
85
+ _shouldUseDirectApi() {
86
+ if (process.env.USE_CODEX_DIRECT_API === '1') return true;
87
+ if (this._spawnCodex === null) return true;
88
+ if (codexCliUnavailable) return true;
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * CLI path — spawn the Codex CLI and stream JSON events.
94
+ *
95
+ * Real invocation (v0.124.0):
96
+ * codex exec --json --skip-git-repo-check --sandbox <mode> -m <model>
97
+ *
98
+ * Notes:
99
+ * - The `exec` subcommand is required (bare `codex --json` does not work).
100
+ * - There is no `--system` flag; system prompts are prepended to the
101
+ * stdin prompt via {@link composeCliPrompt}.
102
+ * - Sandbox mode is driven by {@code options.sandboxMode} (defaults to
103
+ * `workspace-write`) which is set by
104
+ * {@code buildCodexQueryParams} from {@code session.mode}.
105
+ */
106
+ async *_executeCli(queryParams, options) {
107
+ const child = this._spawnCodexChild(queryParams, options);
108
+ yield* executeCodexCli(child, queryParams, options, markCodexCliUnavailable);
109
+ }
110
+
111
+ _spawnCodexChild(queryParams, options) {
112
+ const spawnFn = this._spawnCodex ?? createCodexSpawner();
113
+ const { cwd, env, abortController, model, sandboxMode, effortLevel } = options;
114
+ const effectiveSandbox = sandboxMode || 'workspace-write';
115
+ const codexReasoningEffort = resolveCodexReasoningEffort(effortLevel);
116
+ const args = [
117
+ 'exec',
118
+ '--json',
119
+ '--skip-git-repo-check',
120
+ '--sandbox', effectiveSandbox,
121
+ '-m', model,
122
+ ];
123
+
124
+ if (codexReasoningEffort) {
125
+ args.push(
126
+ '-c', `model_reasoning_effort=${codexReasoningEffort}`,
127
+ '-c', `plan_mode_reasoning_effort=${codexReasoningEffort}`
128
+ );
129
+ }
130
+
131
+ // Defense in depth: when no API key is in the env, force ChatGPT auth
132
+ // so the CLI uses its own OAuth flow even if some env var leaks through.
133
+ if (!env?.OPENAI_API_KEY) {
134
+ args.push('-c', 'preferred_auth_method=chatgpt');
135
+ }
136
+
137
+ console.log('[CodexAdapter] auth_mode_hint =', env?.OPENAI_API_KEY ? 'apikey' : 'chatgpt');
138
+
139
+ try {
140
+ return spawnFn({
141
+ command: 'codex',
142
+ args,
143
+ cwd,
144
+ env,
145
+ signal: abortController?.signal,
146
+ });
147
+ } catch (err) {
148
+ if (err && err.code === 'ENOENT') {
149
+ codexCliUnavailable = true;
150
+ const notFound = new Error('Codex CLI not found');
151
+ notFound.code = 'CODEX_CLI_NOT_FOUND';
152
+ throw notFound;
153
+ }
154
+ throw err;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Direct-API path — stream Chat Completions via the OpenAI SDK.
160
+ */
161
+ async *_executeDirectApi(queryParams, options) {
162
+ const { model, systemPrompt, abortController } = resolveDirectApiInputs(options);
163
+ const client = await this._resolveOpenAiClient(options);
164
+
165
+ yield {
166
+ type: 'system',
167
+ subtype: 'init',
168
+ session_id: `codex-${Date.now()}`,
169
+ model,
170
+ };
171
+
172
+ const stream = await client.chat.completions.create({
173
+ model,
174
+ messages: buildChatMessages(queryParams.prompt, systemPrompt),
175
+ stream: true,
176
+ });
177
+
178
+ const onAbort = () => {
179
+ try { stream?.controller?.abort?.(); } catch { /* ignore */ }
180
+ };
181
+ abortController?.signal?.addEventListener('abort', onAbort);
182
+
183
+ let accumulated = '';
184
+ let finalUsage = null;
185
+
186
+ try {
187
+ for await (const chunk of stream) {
188
+ const text = chunk?.choices?.[0]?.delta?.content;
189
+ if (text) {
190
+ accumulated += text;
191
+ yield makeTextDeltaEvent(text);
192
+ }
193
+ if (chunk?.usage) finalUsage = chunk.usage;
194
+ }
195
+ } finally {
196
+ abortController?.signal?.removeEventListener('abort', onAbort);
197
+ }
198
+
199
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: accumulated }] } };
200
+ yield {
201
+ type: 'result',
202
+ subtype: 'success',
203
+ usage: {
204
+ input_tokens: finalUsage?.prompt_tokens ?? 0,
205
+ output_tokens: finalUsage?.completion_tokens ?? 0,
206
+ },
24
207
  };
25
208
  }
209
+
210
+ async _resolveOpenAiClient(options) {
211
+ const env = options.env || process.env;
212
+ const baseURL = env.OPENAI_BASE_URL || env.OPENAI_API_BASE;
213
+ const apiKey = env.OPENAI_API_KEY;
214
+ const timeout = env.API_TIMEOUT_MS ? Number(env.API_TIMEOUT_MS) : undefined;
215
+
216
+ if (this._openaiClientFactory) {
217
+ return this._openaiClientFactory({ baseURL, apiKey, timeout });
218
+ }
219
+ if (!apiKey) {
220
+ const err = new Error('OPENAI_API_KEY not set — cannot use Codex direct-API path');
221
+ err.code = 'OPENAI_API_KEY_MISSING';
222
+ throw err;
223
+ }
224
+ const { default: OpenAI } = await import('openai');
225
+ return new OpenAI({ baseURL, apiKey, timeout });
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Test-only: reset the module-level ENOENT cache so each test case starts
231
+ * from a known state.
232
+ * @private
233
+ */
234
+ export function _resetCodexCliUnavailableForTests() {
235
+ codexCliUnavailable = false;
236
+ }
237
+
238
+ // --- Direct-API helpers ----------------------------------------------------
239
+
240
+ function markCodexCliUnavailable() {
241
+ codexCliUnavailable = true;
242
+ }
243
+
244
+ function resolveDirectApiInputs(options) {
245
+ return {
246
+ model: options.model || 'gpt-4o-mini',
247
+ systemPrompt: typeof options.systemPrompt === 'string' ? options.systemPrompt : null,
248
+ abortController: options.abortController,
249
+ };
250
+ }
251
+
252
+ function resolveCodexReasoningEffort(effortLevel) {
253
+ if (!effortLevel || effortLevel === 'auto') return null;
254
+ if (effortLevel === 'max') return 'xhigh';
255
+ if (['low', 'medium', 'high'].includes(effortLevel)) return effortLevel;
256
+ return null;
257
+ }
258
+
259
+ function buildChatMessages(prompt, systemPrompt) {
260
+ const messages = [];
261
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
262
+ messages.push({ role: 'user', content: prompt ?? '' });
263
+ return messages;
264
+ }
265
+
266
+ function makeTextDeltaEvent(text) {
267
+ return {
268
+ type: 'stream_event',
269
+ event: {
270
+ type: 'content_block_delta',
271
+ delta: { type: 'text_delta', text },
272
+ },
273
+ };
26
274
  }
@@ -0,0 +1,185 @@
1
+ import readline from 'readline';
2
+ import { createCodexEventMapper } from './codexEventMapper.js';
3
+
4
+ export async function *executeCodexCli(child, queryParams, options, markCliUnavailable) {
5
+ const state = new CliState(options.model, markCliUnavailable);
6
+
7
+ attachAbortHandling(child, options.abortController, state);
8
+ writePromptToStdin(child, composeCliPrompt(options.systemPrompt, queryParams.prompt));
9
+ attachStdoutReader(child, state);
10
+ attachStderrReader(child, state);
11
+ attachProcessLifecycleHandlers(child, state);
12
+
13
+ try {
14
+ yield* drainCliEvents(state);
15
+ } finally {
16
+ cleanupCli(options.abortController, state);
17
+ }
18
+ }
19
+
20
+ class CliState {
21
+ constructor(model, markCliUnavailable) {
22
+ this.pending = [];
23
+ this.error = null;
24
+ this.ended = false;
25
+ this.resolveNext = null;
26
+ this.rejectAll = null;
27
+ this.mapper = createCodexEventMapper({ model });
28
+ this.rl = null;
29
+ this.killTimer = null;
30
+ this.onAbort = null;
31
+ this.stderrBuffer = '';
32
+ this.markCliUnavailable = markCliUnavailable;
33
+ }
34
+
35
+ assign(patch) {
36
+ Object.assign(this, patch);
37
+ }
38
+
39
+ pushEvents(events) {
40
+ for (const event of events) this.pending.push(event);
41
+ this.tickWaiter();
42
+ }
43
+
44
+ tickWaiter() {
45
+ if (!this.resolveNext) return;
46
+ const resolve = this.resolveNext;
47
+ this.resolveNext = null;
48
+ resolve();
49
+ }
50
+
51
+ failWith(error) {
52
+ this.error = error;
53
+ if (this.rejectAll) this.rejectAll(error);
54
+ }
55
+
56
+ markEnded() {
57
+ this.ended = true;
58
+ }
59
+ }
60
+
61
+ function attachAbortHandling(child, abortController, state) {
62
+ const onAbort = () => {
63
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
64
+ const timer = setTimeout(() => {
65
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
66
+ }, 2000);
67
+ state.assign({ killTimer: timer });
68
+ };
69
+ state.assign({ onAbort });
70
+ abortController?.signal?.addEventListener('abort', onAbort);
71
+ }
72
+
73
+ function writePromptToStdin(child, prompt) {
74
+ try {
75
+ if (child.stdin) child.stdin.end(prompt ?? '');
76
+ } catch { /* ignore */ }
77
+ }
78
+
79
+ function composeCliPrompt(systemPrompt, prompt) {
80
+ const user = prompt ?? '';
81
+ if (typeof systemPrompt === 'string' && systemPrompt.length > 0) {
82
+ return `SYSTEM PROMPT:\n${systemPrompt}\n\nUSER:\n${user}`;
83
+ }
84
+ return user;
85
+ }
86
+
87
+ function attachStdoutReader(child, state) {
88
+ const rl = readline.createInterface({ input: child.stdout });
89
+ state.assign({ rl });
90
+ rl.on('line', (line) => handleCliStdoutLine(line, state));
91
+ }
92
+
93
+ function handleCliStdoutLine(line, state) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) return;
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(trimmed);
99
+ } catch {
100
+ return;
101
+ }
102
+ try {
103
+ const mapped = state.mapper.map(parsed);
104
+ if (mapped.length > 0) state.pushEvents(mapped);
105
+ } catch (error) {
106
+ state.failWith(error);
107
+ }
108
+ }
109
+
110
+ function attachStderrReader(child, state) {
111
+ if (!child.stderr) return;
112
+ child.stderr.on('data', (chunk) => {
113
+ if (state.error || state.ended) return;
114
+ state.assign({ stderrBuffer: state.stderrBuffer + chunk.toString() });
115
+ });
116
+ }
117
+
118
+ function attachProcessLifecycleHandlers(child, state) {
119
+ child.on('error', (error) => {
120
+ if (error?.code === 'ENOENT') {
121
+ state.markCliUnavailable?.();
122
+ state.failWith(makeCliNotFoundError());
123
+ return;
124
+ }
125
+ state.failWith(error);
126
+ });
127
+
128
+ child.on('exit', (code) => handleChildExit(code, state));
129
+ }
130
+
131
+ function makeCliNotFoundError() {
132
+ const error = new Error('Codex CLI not found');
133
+ error.code = 'CODEX_CLI_NOT_FOUND';
134
+ return error;
135
+ }
136
+
137
+ function handleChildExit(code, state) {
138
+ state.markEnded();
139
+ if (code !== 0 && !state.error) {
140
+ state.failWith(makeCliExitError(code, state.stderrBuffer));
141
+ } else if (!state.error) {
142
+ finalizeMappedEvents(state);
143
+ }
144
+ state.tickWaiter();
145
+ }
146
+
147
+ function makeCliExitError(code, stderrBuffer) {
148
+ const stderrTrimmed = stderrBuffer.trim();
149
+ const error = stderrTrimmed.length > 0
150
+ ? new Error(stderrTrimmed)
151
+ : new Error(`Codex CLI exited with code ${code}`);
152
+ error.code = 'CODEX_CLI_EXIT';
153
+ error.exitCode = code;
154
+ return error;
155
+ }
156
+
157
+ function finalizeMappedEvents(state) {
158
+ try {
159
+ const events = state.mapper.finalize();
160
+ if (events.length > 0) state.pushEvents(events);
161
+ } catch { /* ignore */ }
162
+ }
163
+
164
+ async function *drainCliEvents(state) {
165
+ while (true) {
166
+ if (state.error) throw state.error;
167
+ if (state.pending.length > 0) {
168
+ yield state.pending.shift();
169
+ continue;
170
+ }
171
+ if (state.ended) break;
172
+ await new Promise((resolve, reject) => {
173
+ state.assign({ resolveNext: resolve, rejectAll: reject });
174
+ });
175
+ }
176
+ if (state.error) throw state.error;
177
+ }
178
+
179
+ function cleanupCli(abortController, state) {
180
+ if (state.onAbort) {
181
+ abortController?.signal?.removeEventListener('abort', state.onAbort);
182
+ }
183
+ if (state.killTimer) clearTimeout(state.killTimer);
184
+ try { state.rl?.close(); } catch { /* ignore */ }
185
+ }