circuschief 0.8.0 → 1.1.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 (155) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/agents/AgentGateway.js +2 -0
  3. package/packages/server/src/agents/adapters/GeminiAdapter.js +105 -0
  4. package/packages/server/src/agents/adapters/cliUtils.js +15 -0
  5. package/packages/server/src/agents/adapters/codexCliRunner.js +1 -8
  6. package/packages/server/src/agents/adapters/geminiCliRunner.js +183 -0
  7. package/packages/server/src/agents/adapters/geminiEventMapper.js +195 -0
  8. package/packages/server/src/api/commandButtons.js +16 -15
  9. package/packages/server/src/api/projects-commandButtons.js +6 -6
  10. package/packages/server/src/api/projects-session-create.js +109 -0
  11. package/packages/server/src/api/projects-session-defaults.js +51 -0
  12. package/packages/server/src/api/projects-session-helpers.js +47 -1
  13. package/packages/server/src/api/projects-templates.js +38 -0
  14. package/packages/server/src/api/projects.js +28 -180
  15. package/packages/server/src/api/sessions-commands.js +21 -18
  16. package/packages/server/src/api/sessions-patch.js +41 -1
  17. package/packages/server/src/db/ProviderRepository.js +4 -2
  18. package/packages/server/src/db/SessionRepository.js +1 -1
  19. package/packages/server/src/db/SessionTemplateRepository.js +23 -2
  20. package/packages/server/src/db/migrations/canvasItemsMigrations.js +109 -0
  21. package/packages/server/src/db/migrations/conversationsMigrations.js +187 -0
  22. package/packages/server/src/db/migrations/index.js +234 -6
  23. package/packages/server/src/db/migrations/kanbanMigrations.js +99 -0
  24. package/packages/server/src/db/migrations/miscMigrations.js +244 -0
  25. package/packages/server/src/db/migrations/projectsMigrations.js +130 -0
  26. package/packages/server/src/db/migrations/providerCommitAttributionMigrations.js +30 -0
  27. package/packages/server/src/db/migrations/providerMigrations.js +250 -0
  28. package/packages/server/src/db/migrations/sessionTableRecreate.js +136 -0
  29. package/packages/server/src/db/migrations/sessionsMigrations.js +300 -0
  30. package/packages/server/src/db/seedBaselineData.js +23 -1
  31. package/packages/server/src/db/session-helpers.js +26 -1
  32. package/packages/server/src/schema.sql +5 -1
  33. package/packages/server/src/services/commandButtonPrompts.js +9 -7
  34. package/packages/server/src/services/e2eSpawnCapture.js +47 -6
  35. package/packages/server/src/services/geminiSpawnHelper.js +47 -0
  36. package/packages/server/src/services/gitCommitAttribution.js +38 -8
  37. package/packages/server/src/services/gitDiff.js +107 -0
  38. package/packages/server/src/services/gitRepoUrl.js +174 -0
  39. package/packages/server/src/services/gitService.js +43 -311
  40. package/packages/server/src/services/gitWorktree.js +127 -0
  41. package/packages/server/src/services/providerTestService.js +59 -1
  42. package/packages/server/src/services/queryParamBuilder.js +33 -1
  43. package/packages/server/src/services/sessionExecution.js +4 -0
  44. package/packages/server/src/services/sessionPrompts.js +23 -1
  45. package/packages/server/src/services/sessionProvider.js +41 -1
  46. package/packages/shared/src/constants.js +1 -1
  47. package/packages/shared/src/contracts/providers.js +1 -1
  48. package/packages/shared/src/contracts/sessions.js +27 -1
  49. package/packages/shared/src/contracts/templates.js +10 -0
  50. package/packages/shared/src/types.js +7 -0
  51. package/packages/web/dist/assets/{ActiveSessionsView-B0XHqLmv.js → ActiveSessionsView-EdNxmPmZ.js} +1 -1
  52. package/packages/web/dist/assets/{AgentLogsView-DmsjUMlB.js → AgentLogsView-C2wX0JPP.js} +2 -2
  53. package/packages/web/dist/assets/ApiClient-DfbJwzpz.js +1 -0
  54. package/packages/web/dist/assets/ArchiveConfirmModal-DJERn5XO.js +1 -0
  55. package/packages/web/dist/assets/CommandButtonDetailView-CBPI8-US.js +1 -0
  56. package/packages/web/dist/assets/CommandButtonDetailView-D9zjx9ME.css +1 -0
  57. package/packages/web/dist/assets/EffortLevelSelector-PaBpUveC.js +1 -0
  58. package/packages/web/dist/assets/{GeneralSettingsView-D1nI8_zk.js → GeneralSettingsView-Dw-x83R0.js} +1 -1
  59. package/packages/web/dist/assets/{InputWithButton-CAkttyqx.js → InputWithButton-CHHcpF4I.js} +1 -1
  60. package/packages/web/dist/assets/{InterpolationHelp-BO1j9Z3_.js → InterpolationHelp-CLNPz8s8.js} +1 -1
  61. package/packages/web/dist/assets/MarkdownEditor-DYi1igfT.js +2 -0
  62. package/packages/web/dist/assets/ModelSelector-Cko_yTO5.js +1 -0
  63. package/packages/web/dist/assets/{ModelSelector-BSxKUSus.css → ModelSelector-Dtwe5xLH.css} +1 -1
  64. package/packages/web/dist/assets/{NewSessionView-BDPb-1qr.css → NewSessionView-DBl7T2Xp.css} +1 -1
  65. package/packages/web/dist/assets/NewSessionView-DwUfBg70.js +3 -0
  66. package/packages/web/dist/assets/ProjectEditView-CSbsea3U.js +1 -0
  67. package/packages/web/dist/assets/ProjectEditView-DbqTbA0q.css +1 -0
  68. package/packages/web/dist/assets/{ProjectListView-DcNyuINs.js → ProjectListView-CEc_LWZL.js} +1 -1
  69. package/packages/web/dist/assets/{ProjectNewView-B5YV62hv.js → ProjectNewView-D4U0uRlp.js} +1 -1
  70. package/packages/web/dist/assets/ProvidersView-2KCOiY6Q.css +1 -0
  71. package/packages/web/dist/assets/ProvidersView-CD1j8BOv.js +1 -0
  72. package/packages/web/dist/assets/QuickResponsesPanel-Dp39f12o.js +1 -0
  73. package/packages/web/dist/assets/QuickResponsesPanel-dk-Rj8xx.css +1 -0
  74. package/packages/web/dist/assets/ResizableTextarea-BWywIqOv.js +1 -0
  75. package/packages/web/dist/assets/ResizableTextarea-DERSH3Wz.css +1 -0
  76. package/packages/web/dist/assets/SessionCard-B6d5ijDW.js +1 -0
  77. package/packages/web/dist/assets/SessionDetailView-DWbXdx7A.js +36 -0
  78. package/packages/web/dist/assets/SessionDetailView-ULeIkWS0.css +1 -0
  79. package/packages/web/dist/assets/{SessionFormOptions-B6AxyREh.js → SessionFormOptions-Dz9ik4Fo.js} +1 -1
  80. package/packages/web/dist/assets/{SessionListView-B5_6gW49.css → SessionListView-3-xx6EVs.css} +1 -1
  81. package/packages/web/dist/assets/SessionListView-C129buBe.js +1 -0
  82. package/packages/web/dist/assets/{SessionLogStream-LlZ3z_Xj.js → SessionLogStream-BvXUNNBZ.js} +6 -6
  83. package/packages/web/dist/assets/{SettingsView-CTGiGvR2.js → SettingsView-DW1NvpX_.js} +1 -1
  84. package/packages/web/dist/assets/SlashCommandWizard-DleYBxrE.js +1 -0
  85. package/packages/web/dist/assets/{SummarySettingsView-BR2ZjEa3.js → SummarySettingsView-CLUfcWvf.js} +1 -1
  86. package/packages/web/dist/assets/TemplateDetailView-B5NI2oTR.css +1 -0
  87. package/packages/web/dist/assets/TemplateDetailView-Cukb205e.js +1 -0
  88. package/packages/web/dist/assets/{commandButtons-BfqR-fqq.js → commandButtons-DejH0rVN.js} +1 -1
  89. package/packages/web/dist/assets/index-BD7Y3rBE.js +3 -0
  90. package/packages/web/dist/assets/{index-BY174HVJ.css → index-Bd20AzX1.css} +1 -1
  91. package/packages/web/dist/assets/index-BgJiarKe.js +1 -0
  92. package/packages/web/dist/assets/index-Bk32fSSG.js +1 -0
  93. package/packages/web/dist/assets/index-BkA6pF2Z.js +1 -0
  94. package/packages/web/dist/assets/index-Cltr-Ldt.js +7 -0
  95. package/packages/web/dist/assets/index-Co-46Tp3.js +1 -0
  96. package/packages/web/dist/assets/index-Cpykk857.js +1 -0
  97. package/packages/web/dist/assets/index-CtABl0D1.js +1 -0
  98. package/packages/web/dist/assets/index-Cuqk5m9S.js +1 -0
  99. package/packages/web/dist/assets/{index-fK8FIZgP.js → index-CvXApbVC.js} +15 -15
  100. package/packages/web/dist/assets/index-D2gN-xEH.js +1 -0
  101. package/packages/web/dist/assets/index-Dd3WpmyQ.js +1 -0
  102. package/packages/web/dist/assets/index-Dk6--9rj.js +1 -0
  103. package/packages/web/dist/assets/{index-DgkC10TW.js → index-MZf7MlPX.js} +3 -3
  104. package/packages/web/dist/assets/{index-DtfUt785.js → index-NShCcwfj.js} +1 -1
  105. package/packages/web/dist/assets/index-hA3VEuSq.js +1 -0
  106. package/packages/web/dist/assets/index-p0mp3nca.js +1 -0
  107. package/packages/web/dist/assets/index-qntNa5r_.js +1 -0
  108. package/packages/web/dist/assets/index-qq9ceNSK.js +1 -0
  109. package/packages/web/dist/assets/projectDefaults-D9xkp2XR.js +1 -0
  110. package/packages/web/dist/assets/{projects-DXYQNJIi.js → projects-BvLADGKx.js} +1 -1
  111. package/packages/web/dist/assets/{providers-1bnH-exJ.js → providers-DZ-fOa4G.js} +1 -1
  112. package/packages/web/dist/assets/{sessions-6zGUlFrt.js → sessions-DETEyjPI.js} +1 -1
  113. package/packages/web/dist/assets/{settings-MbfRir0d.js → settings-TWfbahn5.js} +1 -1
  114. package/packages/web/dist/index.html +2 -2
  115. package/packages/web/dist/assets/ApiClient-C3ztI9s9.js +0 -1
  116. package/packages/web/dist/assets/ArchiveConfirmModal-BlCyn5Vt.js +0 -1
  117. package/packages/web/dist/assets/CommandButtonDetailView-CdSCPp78.js +0 -1
  118. package/packages/web/dist/assets/CommandButtonDetailView-DBm3rzhw.css +0 -1
  119. package/packages/web/dist/assets/EffortLevelSelector-hc2MNKg6.js +0 -1
  120. package/packages/web/dist/assets/MarkdownEditor-ucRAP_UM.js +0 -2
  121. package/packages/web/dist/assets/ModelSelector-CwTz8ZWO.js +0 -1
  122. package/packages/web/dist/assets/NewSessionView-BsDrp8mj.js +0 -3
  123. package/packages/web/dist/assets/ProjectEditView-CwTOeSun.js +0 -1
  124. package/packages/web/dist/assets/ProjectEditView-J15mcsWz.css +0 -1
  125. package/packages/web/dist/assets/ProvidersView-bZemq_Rv.css +0 -1
  126. package/packages/web/dist/assets/ProvidersView-nY9GnDdO.js +0 -1
  127. package/packages/web/dist/assets/QuickResponseSettings-B352c75l.css +0 -1
  128. package/packages/web/dist/assets/QuickResponseSettings-BQwQXuL7.js +0 -1
  129. package/packages/web/dist/assets/QuickResponsesPanel-BlFDvnZ2.css +0 -1
  130. package/packages/web/dist/assets/QuickResponsesPanel-BzSYcCSP.js +0 -1
  131. package/packages/web/dist/assets/ResizableTextarea-B3YIdIXv.js +0 -1
  132. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +0 -1
  133. package/packages/web/dist/assets/SessionCard-CjE1tXiT.js +0 -1
  134. package/packages/web/dist/assets/SessionDetailView-3cPZrbS3.js +0 -36
  135. package/packages/web/dist/assets/SessionDetailView-CZRZMrfM.css +0 -1
  136. package/packages/web/dist/assets/SessionListView-CLXBfLcq.js +0 -1
  137. package/packages/web/dist/assets/SlashCommandWizard-Cy04d7-o.js +0 -1
  138. package/packages/web/dist/assets/TemplateDetailView-DH6Oswsp.js +0 -1
  139. package/packages/web/dist/assets/TemplateDetailView-DT2m06W7.css +0 -1
  140. package/packages/web/dist/assets/index-1zziPL6l.js +0 -1
  141. package/packages/web/dist/assets/index-7kzHPxSF.js +0 -1
  142. package/packages/web/dist/assets/index-B0N_obMc.js +0 -1
  143. package/packages/web/dist/assets/index-BNk_gdfI.js +0 -1
  144. package/packages/web/dist/assets/index-CSqaAH-0.js +0 -1
  145. package/packages/web/dist/assets/index-C_q4WlK8.js +0 -1
  146. package/packages/web/dist/assets/index-D1wpU4y0.js +0 -7
  147. package/packages/web/dist/assets/index-D5zCA8sD.js +0 -1
  148. package/packages/web/dist/assets/index-DGR8ELWY.js +0 -1
  149. package/packages/web/dist/assets/index-DHga8pXo.js +0 -1
  150. package/packages/web/dist/assets/index-DSby02Wl.js +0 -1
  151. package/packages/web/dist/assets/index-DqjXJTVI.js +0 -1
  152. package/packages/web/dist/assets/index-_4S2uLDI.js +0 -1
  153. package/packages/web/dist/assets/index-gmiZeFXN.js +0 -1
  154. package/packages/web/dist/assets/index-irD539ZM.js +0 -3
  155. package/packages/web/dist/assets/index-yq-E1Y00.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuschief",
3
- "version": "0.8.0",
3
+ "version": "1.1.0",
4
4
  "description": "Local-first web UI for managing Claude Code sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js';
2
2
  import { CodexAdapter } from './adapters/CodexAdapter.js';
3
+ import { GeminiAdapter } from './adapters/GeminiAdapter.js';
3
4
 
4
5
  /**
5
6
  * Factory/registry for agent adapters.
@@ -17,6 +18,7 @@ export class AgentGateway {
17
18
  _registerDefaultAdapters() {
18
19
  this.registerAdapter('claude-code', ClaudeCodeAdapter);
19
20
  this.registerAdapter('codex', CodexAdapter);
21
+ this.registerAdapter('gemini', GeminiAdapter);
20
22
  }
21
23
 
22
24
  /**
@@ -0,0 +1,105 @@
1
+ import { BaseAgent } from '../BaseAgent.js';
2
+ import { executeGeminiCli } from './geminiCliRunner.js';
3
+ import { composeCliPrompt } from './cliUtils.js';
4
+ import { createGeminiSpawner } from '../../services/geminiSpawnHelper.js';
5
+
6
+ /**
7
+ * Module-level flag: once an ENOENT is observed for the Gemini CLI, remember
8
+ * it so subsequent calls can short-circuit.
9
+ */
10
+ const geminiCliState = { unavailable: false };
11
+
12
+ function markGeminiCliUnavailable() {
13
+ geminiCliState.unavailable = true;
14
+ }
15
+
16
+ /**
17
+ * Test-only: reset the module-level ENOENT cache.
18
+ * @private
19
+ */
20
+ export function _resetGeminiCliUnavailableForTests() {
21
+ geminiCliState.unavailable = false;
22
+ }
23
+
24
+ /**
25
+ * Adapter for Google Gemini CLI.
26
+ *
27
+ * Execution path:
28
+ * Spawns `gemini -p "prompt" --output-format stream-json -m <model>` and
29
+ * parses its newline-delimited JSON stdout. Uses {@link createGeminiEventMapper}
30
+ * to normalize events into the SDK-shaped envelope the rest of the app
31
+ * already understands for Claude Code.
32
+ *
33
+ * Capabilities:
34
+ * - streaming: true — stream-json provides real-time events
35
+ * - thinking: false — no separate thinking mode toggle
36
+ * - reasoningEffort: false — no effort level mapping yet
37
+ * - toolUse: true — Gemini CLI has built-in shell, file, and web tools
38
+ * - resume: false — no session resume support in headless mode
39
+ */
40
+ export class GeminiAdapter extends BaseAgent {
41
+ static capabilities = Object.freeze({
42
+ streaming: true,
43
+ thinking: false,
44
+ reasoningEffort: false,
45
+ toolUse: true,
46
+ resume: false,
47
+ });
48
+
49
+ /**
50
+ * @param {Object} [opts]
51
+ * @param {Function} [opts.spawnGeminiProcess] - Optional DI for testing.
52
+ * Shape matches {@link createGeminiSpawner} output.
53
+ * @param {Object} [opts.rest] - Passed to {@link BaseAgent}.
54
+ */
55
+ constructor({ spawnGeminiProcess, ...rest } = {}) {
56
+ super(rest);
57
+ this._spawnGemini = spawnGeminiProcess;
58
+ }
59
+
60
+ getCapabilities() {
61
+ return { ...GeminiAdapter.capabilities };
62
+ }
63
+
64
+ supportsResume() {
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Execute a Gemini query and yield SDK-shaped events.
70
+ *
71
+ * @param {Object} queryParams
72
+ * @yields {Object} Normalized SDK events
73
+ */
74
+ async *execute(queryParams, _meta) {
75
+ const options = queryParams.options || {};
76
+ yield* this._executeCli(queryParams, options);
77
+ }
78
+
79
+ _executeCli(queryParams, options) {
80
+ const child = this._spawnGeminiChild(queryParams, options);
81
+ return executeGeminiCli(child, queryParams, options, markGeminiCliUnavailable);
82
+ }
83
+
84
+ _spawnGeminiChild(queryParams, options) {
85
+ const spawnFn = this._spawnGemini ?? createGeminiSpawner();
86
+ const { cwd, env, abortController, model, systemPrompt, approvalMode } = options;
87
+ const composedPrompt = composeCliPrompt(systemPrompt, queryParams.prompt);
88
+ const args = ['-p', composedPrompt, '--output-format', 'stream-json', '--skip-trust', '-m', model];
89
+ if (approvalMode) {
90
+ args.push(`--approval-mode=${approvalMode}`);
91
+ }
92
+
93
+ try {
94
+ return spawnFn({ command: 'gemini', args, cwd, env, signal: abortController?.signal });
95
+ } catch (err) {
96
+ if (err?.code === 'ENOENT') {
97
+ geminiCliState.unavailable = true;
98
+ const notFound = new Error('Gemini CLI not found');
99
+ notFound.code = 'GEMINI_CLI_NOT_FOUND';
100
+ throw notFound;
101
+ }
102
+ throw err;
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compose a CLI prompt by prepending the system prompt (if provided) to the
3
+ * user prompt. Used by both Codex and Gemini CLI runners.
4
+ *
5
+ * @param {string|null} systemPrompt
6
+ * @param {string|null} prompt
7
+ * @returns {string}
8
+ */
9
+ export function composeCliPrompt(systemPrompt, prompt) {
10
+ const user = prompt ?? '';
11
+ if (typeof systemPrompt === 'string' && systemPrompt.length > 0) {
12
+ return `SYSTEM PROMPT:\n${systemPrompt}\n\nUSER:\n${user}`;
13
+ }
14
+ return user;
15
+ }
@@ -1,5 +1,6 @@
1
1
  import readline from 'readline';
2
2
  import { createCodexEventMapper } from './codexEventMapper.js';
3
+ import { composeCliPrompt } from './cliUtils.js';
3
4
 
4
5
  export async function *executeCodexCli(child, queryParams, options, markCliUnavailable) {
5
6
  const state = new CliState(options.model, markCliUnavailable);
@@ -76,14 +77,6 @@ function writePromptToStdin(child, prompt) {
76
77
  } catch { /* ignore */ }
77
78
  }
78
79
 
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
80
  function attachStdoutReader(child, state) {
88
81
  const rl = readline.createInterface({ input: child.stdout });
89
82
  state.assign({ rl });
@@ -0,0 +1,183 @@
1
+ import readline from 'readline';
2
+ import { createGeminiEventMapper } from './geminiEventMapper.js';
3
+
4
+ /**
5
+ * Execute the Gemini CLI child process and yield SDK-shaped events.
6
+ *
7
+ * Unlike Codex, the prompt is NOT passed via stdin — it's passed as the `-p`
8
+ * CLI argument by GeminiAdapter._spawnGeminiChild(). This runner only reads
9
+ * stdout/stderr and handles process lifecycle.
10
+ *
11
+ * @param {import('child_process').ChildProcess} child
12
+ * @param {Object} queryParams
13
+ * @param {Object} options
14
+ * @param {Function} markCliUnavailable
15
+ * @yields {Object} SDK-shaped events
16
+ */
17
+ export async function *executeGeminiCli(child, queryParams, options, markCliUnavailable) {
18
+ const state = new CliState(options.model, markCliUnavailable);
19
+
20
+ attachAbortHandling(child, options.abortController, state);
21
+ attachStdoutReader(child, state);
22
+ attachStderrReader(child, state);
23
+ attachProcessLifecycleHandlers(child, state);
24
+
25
+ try {
26
+ yield* drainCliEvents(state);
27
+ } finally {
28
+ cleanupCli(options.abortController, state);
29
+ }
30
+ }
31
+
32
+ class CliState {
33
+ constructor(model, markCliUnavailable) {
34
+ this.pending = [];
35
+ this.error = null;
36
+ this.ended = false;
37
+ this.resolveNext = null;
38
+ this.rejectAll = null;
39
+ this.mapper = createGeminiEventMapper({ model });
40
+ this.rl = null;
41
+ this.killTimer = null;
42
+ this.onAbort = null;
43
+ this.stderrBuffer = '';
44
+ this.markCliUnavailable = markCliUnavailable;
45
+ }
46
+
47
+ assign(patch) {
48
+ Object.assign(this, patch);
49
+ }
50
+
51
+ pushEvents(events) {
52
+ for (const event of events) this.pending.push(event);
53
+ this.tickWaiter();
54
+ }
55
+
56
+ tickWaiter() {
57
+ if (!this.resolveNext) return;
58
+ const resolve = this.resolveNext;
59
+ this.resolveNext = null;
60
+ resolve();
61
+ }
62
+
63
+ failWith(error) {
64
+ this.error = error;
65
+ if (this.rejectAll) this.rejectAll(error);
66
+ }
67
+
68
+ markEnded() {
69
+ this.ended = true;
70
+ }
71
+ }
72
+
73
+ function attachAbortHandling(child, abortController, state) {
74
+ const onAbort = () => {
75
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
76
+ const timer = setTimeout(() => {
77
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
78
+ }, 2000);
79
+ state.assign({ killTimer: timer });
80
+ };
81
+ state.assign({ onAbort });
82
+ abortController?.signal?.addEventListener('abort', onAbort);
83
+ }
84
+
85
+ function attachStdoutReader(child, state) {
86
+ const rl = readline.createInterface({ input: child.stdout });
87
+ state.assign({ rl });
88
+ rl.on('line', (line) => handleCliStdoutLine(line, state));
89
+ }
90
+
91
+ function handleCliStdoutLine(line, state) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed) return;
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(trimmed);
97
+ } catch {
98
+ return; // Ignore malformed JSON lines
99
+ }
100
+ try {
101
+ const mapped = state.mapper.map(parsed);
102
+ if (mapped.length > 0) state.pushEvents(mapped);
103
+ } catch (error) {
104
+ state.failWith(error);
105
+ }
106
+ }
107
+
108
+ function attachStderrReader(child, state) {
109
+ if (!child.stderr) return;
110
+ child.stderr.on('data', (chunk) => {
111
+ if (state.error || state.ended) return;
112
+ state.assign({ stderrBuffer: state.stderrBuffer + chunk.toString() });
113
+ });
114
+ }
115
+
116
+ function attachProcessLifecycleHandlers(child, state) {
117
+ child.on('error', (error) => {
118
+ if (error?.code === 'ENOENT') {
119
+ state.markCliUnavailable?.();
120
+ state.failWith(makeCliNotFoundError());
121
+ return;
122
+ }
123
+ state.failWith(error);
124
+ });
125
+
126
+ child.on('exit', (code) => handleChildExit(code, state));
127
+ }
128
+
129
+ function makeCliNotFoundError() {
130
+ const error = new Error('Gemini CLI not found');
131
+ error.code = 'GEMINI_CLI_NOT_FOUND';
132
+ return error;
133
+ }
134
+
135
+ function handleChildExit(code, state) {
136
+ state.markEnded();
137
+ if (code !== 0 && !state.error) {
138
+ state.failWith(makeCliExitError(code, state.stderrBuffer));
139
+ } else if (!state.error) {
140
+ finalizeMappedEvents(state);
141
+ }
142
+ state.tickWaiter();
143
+ }
144
+
145
+ function makeCliExitError(code, stderrBuffer) {
146
+ const stderrTrimmed = stderrBuffer.trim();
147
+ const error = stderrTrimmed.length > 0
148
+ ? new Error(stderrTrimmed)
149
+ : new Error(`Gemini CLI exited with code ${code}`);
150
+ error.code = 'GEMINI_CLI_EXIT';
151
+ error.exitCode = code;
152
+ return error;
153
+ }
154
+
155
+ function finalizeMappedEvents(state) {
156
+ try {
157
+ const events = state.mapper.finalize();
158
+ if (events.length > 0) state.pushEvents(events);
159
+ } catch { /* ignore */ }
160
+ }
161
+
162
+ async function *drainCliEvents(state) {
163
+ while (true) {
164
+ if (state.error) throw state.error;
165
+ if (state.pending.length > 0) {
166
+ yield state.pending.shift();
167
+ continue;
168
+ }
169
+ if (state.ended) break;
170
+ await new Promise((resolve, reject) => {
171
+ state.assign({ resolveNext: resolve, rejectAll: reject });
172
+ });
173
+ }
174
+ if (state.error) throw state.error;
175
+ }
176
+
177
+ function cleanupCli(abortController, state) {
178
+ if (state.onAbort) {
179
+ abortController?.signal?.removeEventListener('abort', state.onAbort);
180
+ }
181
+ if (state.killTimer) clearTimeout(state.killTimer);
182
+ try { state.rl?.close(); } catch { /* ignore */ }
183
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Gemini CLI event mapper.
3
+ *
4
+ * Translates Gemini CLI `--output-format stream-json` JSONL events into the
5
+ * normalized SDK-shaped events that Circus Chief already understands:
6
+ *
7
+ * - system(init)
8
+ * - stream_event(content_block_delta)
9
+ * - assistant
10
+ * - tool_result
11
+ * - result(success, usage)
12
+ *
13
+ * @param {Object} [options]
14
+ * @param {string} [options.model] - Model name to surface in the system(init) event.
15
+ * @returns {{
16
+ * map: (geminiEvent: Object) => Array<Object>,
17
+ * reset: () => void,
18
+ * finalize: () => Array<Object>
19
+ * }}
20
+ */
21
+ export function createGeminiEventMapper({ model } = {}) {
22
+ const state = new GeminiMapperState();
23
+ const warnedUnknownTypes = new Set();
24
+
25
+ function map(geminiEvent) {
26
+ if (!geminiEvent || typeof geminiEvent !== 'object') return [];
27
+
28
+ const type = geminiEvent.type;
29
+
30
+ if (type === 'init') {
31
+ return handleInit(geminiEvent, model);
32
+ }
33
+ if (type === 'message') {
34
+ return handleMessage(geminiEvent, state);
35
+ }
36
+ if (type === 'tool_use') {
37
+ return [...state.flushAccumulatedText(), ...handleToolUse(geminiEvent, state)];
38
+ }
39
+ if (type === 'tool_result') {
40
+ return [...state.flushAccumulatedText(), ...handleToolResult(geminiEvent, state)];
41
+ }
42
+ if (type === 'result') {
43
+ return [...state.flushAccumulatedText(), ...state.onResult(geminiEvent)];
44
+ }
45
+
46
+ // Unknown event type — warn once
47
+ if (type && !warnedUnknownTypes.has(type)) {
48
+ warnedUnknownTypes.add(type);
49
+ console.warn(`[geminiEventMapper] Unknown Gemini event type: "${type}"`);
50
+ }
51
+ return [];
52
+ }
53
+
54
+ return {
55
+ map,
56
+ reset: () => state.reset(),
57
+ finalize: () => state.finalize(),
58
+ };
59
+ }
60
+
61
+ // --- State class -----------------------------------------------------------
62
+
63
+ class GeminiMapperState {
64
+ constructor() {
65
+ this.reset();
66
+ }
67
+
68
+ reset() {
69
+ this.lastUsage = null;
70
+ this.terminated = false;
71
+ this.pendingToolUse = new Map();
72
+ this.accumulatedText = '';
73
+ }
74
+
75
+ /**
76
+ * Flush any accumulated delta text as a synthetic `assistant` event.
77
+ * Returns an array (possibly empty) of events to prepend.
78
+ */
79
+ flushAccumulatedText() {
80
+ if (!this.accumulatedText) return [];
81
+ const text = this.accumulatedText;
82
+ this.accumulatedText = '';
83
+ return [{
84
+ type: 'assistant',
85
+ message: { content: [{ type: 'text', text }] },
86
+ }];
87
+ }
88
+
89
+ appendDeltaText(text) {
90
+ this.accumulatedText += text;
91
+ }
92
+
93
+ clearAccumulatedText() {
94
+ this.accumulatedText = '';
95
+ }
96
+
97
+ finalize() {
98
+ if (this.terminated) return [];
99
+ this.terminated = true;
100
+ return [...this.flushAccumulatedText(), this.buildResultEvent()];
101
+ }
102
+
103
+ onResult(evt) {
104
+ if (evt.stats) {
105
+ this.lastUsage = {
106
+ input_tokens: evt.stats.input_tokens || 0,
107
+ output_tokens: evt.stats.output_tokens || 0,
108
+ };
109
+ }
110
+ this.terminated = true;
111
+ return [this.buildResultEvent()];
112
+ }
113
+
114
+ buildResultEvent() {
115
+ const usage = this.lastUsage || { input_tokens: 0, output_tokens: 0 };
116
+ return {
117
+ type: 'result',
118
+ subtype: 'success',
119
+ usage: {
120
+ input_tokens: usage.input_tokens || 0,
121
+ output_tokens: usage.output_tokens || 0,
122
+ },
123
+ };
124
+ }
125
+ }
126
+
127
+ // --- Event handlers --------------------------------------------------------
128
+
129
+ function handleInit(evt, constructorModel) {
130
+ return [{
131
+ type: 'system',
132
+ subtype: 'init',
133
+ session_id: evt.session_id || `gemini-${Date.now()}`,
134
+ model: evt.model || constructorModel || undefined,
135
+ }];
136
+ }
137
+
138
+ function handleMessage(evt, state) {
139
+ // Ignore user message echoes
140
+ if (evt.role === 'user') return [];
141
+
142
+ if (evt.role === 'assistant') {
143
+ const text = evt.content || '';
144
+
145
+ // Delta (streaming partial) — accumulate for later persistence AND emit for live streaming
146
+ if (evt.delta) {
147
+ state.appendDeltaText(text);
148
+ return [{
149
+ type: 'stream_event',
150
+ event: {
151
+ type: 'content_block_delta',
152
+ delta: { type: 'text_delta', text },
153
+ },
154
+ }];
155
+ }
156
+
157
+ // Full message (non-delta) replaces any prior streamed delta accumulation.
158
+ // Some Gemini streams include both live deltas and a final full message; only
159
+ // the full message should be persisted in that mixed case.
160
+ state.clearAccumulatedText();
161
+ return [{
162
+ type: 'assistant',
163
+ message: { content: [{ type: 'text', text }] },
164
+ }];
165
+ }
166
+
167
+ return [];
168
+ }
169
+
170
+ function handleToolUse(evt, state) {
171
+ const toolName = evt.tool_name || 'unknown';
172
+ const toolId = evt.tool_id || `tool-${Date.now()}`;
173
+ const parameters = evt.parameters || {};
174
+
175
+ // Track for matching tool_result back
176
+ state.pendingToolUse.set(toolId, toolName);
177
+
178
+ return [{
179
+ type: 'tool_result',
180
+ tool_name: toolName,
181
+ content: JSON.stringify(parameters),
182
+ }];
183
+ }
184
+
185
+ function handleToolResult(evt, state) {
186
+ const toolId = evt.tool_id || '';
187
+ const toolName = state.pendingToolUse.get(toolId) || 'unknown';
188
+ const output = evt.output || '';
189
+
190
+ return [{
191
+ type: 'tool_result',
192
+ tool_name: toolName,
193
+ content: output,
194
+ }];
195
+ }
@@ -8,6 +8,7 @@ import { databaseManager } from '../db/DatabaseManager.js';
8
8
 
9
9
  // Error message constants
10
10
  const ERR_SESSION_NOT_FOUND = 'Session not found';
11
+ const ERR_BUTTON_NOT_FOUND = 'Circus Command not found';
11
12
 
12
13
  const router = Router({ mergeParams: true });
13
14
 
@@ -64,14 +65,14 @@ function broadcastCommandRunError({ sessionId, projectId, runId, buttonId, error
64
65
  });
65
66
  }
66
67
 
67
- // GET /api/projects/:projectId/command-buttons - List all command buttons for project
68
+ // GET /api/projects/:projectId/circus-commands - List all command buttons for project
68
69
  router.get('/', (req, res) => {
69
70
  const { projectId } = req.params;
70
71
  const buttons = commandButtons.getByProjectId(projectId);
71
72
  res.json(buttons);
72
73
  });
73
74
 
74
- // GET /api/projects/:projectId/command-buttons/latest-runs - Get latest run for each button per session in project
75
+ // GET /api/projects/:projectId/circus-commands/latest-runs - Get latest run for each button per session in project
75
76
  router.get('/latest-runs', (req, res) => {
76
77
  const { projectId } = req.params;
77
78
 
@@ -85,7 +86,7 @@ router.get('/latest-runs', (req, res) => {
85
86
  res.json(latestRuns);
86
87
  });
87
88
 
88
- // POST /api/projects/:projectId/command-buttons - Create new command button
89
+ // POST /api/projects/:projectId/circus-commands - Create new command button
89
90
  router.post('/', (req, res) => {
90
91
  const result = CreateCommandButtonRequest.safeParse(req.body);
91
92
  if (!result.success) {
@@ -103,20 +104,20 @@ router.post('/', (req, res) => {
103
104
  res.status(201).json(button);
104
105
  });
105
106
 
106
- // GET /api/projects/:projectId/command-buttons/:id - Get single button
107
+ // GET /api/projects/:projectId/circus-commands/:id - Get single button
107
108
  router.get('/:id', (req, res) => {
108
109
  const button = commandButtons.getById(req.params.id);
109
110
  if (!button) {
110
- return res.status(404).json({ error: 'Command button not found' });
111
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
111
112
  }
112
113
  res.json(button);
113
114
  });
114
115
 
115
- // PATCH /api/projects/:projectId/command-buttons/:id - Update button
116
+ // PATCH /api/projects/:projectId/circus-commands/:id - Update button
116
117
  router.patch('/:id', (req, res) => {
117
118
  const button = commandButtons.getById(req.params.id);
118
119
  if (!button) {
119
- return res.status(404).json({ error: 'Command button not found' });
120
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
120
121
  }
121
122
 
122
123
  const result = UpdateCommandButtonRequest.safeParse(req.body);
@@ -135,18 +136,18 @@ router.patch('/:id', (req, res) => {
135
136
  res.json(updated);
136
137
  });
137
138
 
138
- // DELETE /api/projects/:projectId/command-buttons/:id - Delete button
139
+ // DELETE /api/projects/:projectId/circus-commands/:id - Delete button
139
140
  router.delete('/:id', (req, res) => {
140
141
  const button = commandButtons.getById(req.params.id);
141
142
  if (!button) {
142
- return res.status(404).json({ error: 'Command button not found' });
143
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
143
144
  }
144
145
 
145
146
  commandButtons.delete(req.params.id);
146
147
  res.status(204).send();
147
148
  });
148
149
 
149
- // POST /api/sessions/:sessionId/command-buttons/:buttonId/run - Execute button command
150
+ // POST /api/sessions/:sessionId/circus-commands/:buttonId/run - Execute button command
150
151
  router.post('/run/:buttonId', (req, res) => {
151
152
  const { sessionId, buttonId } = req.params;
152
153
 
@@ -157,7 +158,7 @@ router.post('/run/:buttonId', (req, res) => {
157
158
 
158
159
  const button = commandButtons.getById(buttonId);
159
160
  if (!button) {
160
- return res.status(404).json({ error: 'Command button not found' });
161
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
161
162
  }
162
163
 
163
164
  const workingDirectory = session.gitWorktree || session.project?.workingDirectory || process.cwd();
@@ -181,7 +182,7 @@ router.post('/run/:buttonId', (req, res) => {
181
182
  })();
182
183
  });
183
184
 
184
- // GET /api/sessions/:sessionId/command-buttons/runs - Get active runs for session
185
+ // GET /api/sessions/:sessionId/circus-commands/runs - Get active runs for session
185
186
  router.get('/runs', (req, res) => {
186
187
  const { sessionId } = req.params;
187
188
 
@@ -221,7 +222,7 @@ router.get('/runs', (req, res) => {
221
222
  res.json(Array.from(runMap.values()));
222
223
  });
223
224
 
224
- // GET /api/sessions/:sessionId/command-buttons/runs/:runId - Get single run by ID
225
+ // GET /api/sessions/:sessionId/circus-commands/runs/:runId - Get single run by ID
225
226
  router.get('/runs/:runId', (req, res) => {
226
227
  const { sessionId, runId } = req.params;
227
228
 
@@ -256,7 +257,7 @@ router.get('/runs/:runId', (req, res) => {
256
257
  });
257
258
  });
258
259
 
259
- // DELETE /api/sessions/:sessionId/command-buttons/runs/:runId - Delete a command run record
260
+ // DELETE /api/sessions/:sessionId/circus-commands/runs/:runId - Delete a command run record
260
261
  router.delete('/runs/:runId', (req, res) => {
261
262
  const { sessionId, runId } = req.params;
262
263
 
@@ -292,7 +293,7 @@ router.delete('/runs/:runId', (req, res) => {
292
293
  res.status(204).send();
293
294
  });
294
295
 
295
- // POST /api/sessions/:sessionId/command-buttons/runs/:runId/kill - Kill running command
296
+ // POST /api/sessions/:sessionId/circus-commands/runs/:runId/kill - Kill running command
296
297
  router.post('/runs/:runId/kill', (req, res) => {
297
298
  const { sessionId, runId } = req.params;
298
299