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.
- package/package.json +1 -1
- package/packages/server/src/agents/AgentGateway.js +2 -0
- package/packages/server/src/agents/adapters/GeminiAdapter.js +105 -0
- package/packages/server/src/agents/adapters/cliUtils.js +15 -0
- package/packages/server/src/agents/adapters/codexCliRunner.js +1 -8
- package/packages/server/src/agents/adapters/geminiCliRunner.js +183 -0
- package/packages/server/src/agents/adapters/geminiEventMapper.js +195 -0
- package/packages/server/src/api/commandButtons.js +16 -15
- package/packages/server/src/api/projects-commandButtons.js +6 -6
- package/packages/server/src/api/projects-session-create.js +109 -0
- package/packages/server/src/api/projects-session-defaults.js +51 -0
- package/packages/server/src/api/projects-session-helpers.js +47 -1
- package/packages/server/src/api/projects-templates.js +38 -0
- package/packages/server/src/api/projects.js +28 -180
- package/packages/server/src/api/sessions-commands.js +21 -18
- package/packages/server/src/api/sessions-patch.js +41 -1
- package/packages/server/src/db/ProviderRepository.js +4 -2
- package/packages/server/src/db/SessionRepository.js +1 -1
- package/packages/server/src/db/SessionTemplateRepository.js +23 -2
- package/packages/server/src/db/migrations/canvasItemsMigrations.js +109 -0
- package/packages/server/src/db/migrations/conversationsMigrations.js +187 -0
- package/packages/server/src/db/migrations/index.js +234 -6
- package/packages/server/src/db/migrations/kanbanMigrations.js +99 -0
- package/packages/server/src/db/migrations/miscMigrations.js +244 -0
- package/packages/server/src/db/migrations/projectsMigrations.js +130 -0
- package/packages/server/src/db/migrations/providerCommitAttributionMigrations.js +30 -0
- package/packages/server/src/db/migrations/providerMigrations.js +250 -0
- package/packages/server/src/db/migrations/sessionTableRecreate.js +136 -0
- package/packages/server/src/db/migrations/sessionsMigrations.js +300 -0
- package/packages/server/src/db/seedBaselineData.js +23 -1
- package/packages/server/src/db/session-helpers.js +26 -1
- package/packages/server/src/schema.sql +5 -1
- package/packages/server/src/services/commandButtonPrompts.js +9 -7
- package/packages/server/src/services/e2eSpawnCapture.js +47 -6
- package/packages/server/src/services/geminiSpawnHelper.js +47 -0
- package/packages/server/src/services/gitCommitAttribution.js +38 -8
- package/packages/server/src/services/gitDiff.js +107 -0
- package/packages/server/src/services/gitRepoUrl.js +174 -0
- package/packages/server/src/services/gitService.js +43 -311
- package/packages/server/src/services/gitWorktree.js +127 -0
- package/packages/server/src/services/providerTestService.js +59 -1
- package/packages/server/src/services/queryParamBuilder.js +33 -1
- package/packages/server/src/services/sessionExecution.js +4 -0
- package/packages/server/src/services/sessionPrompts.js +23 -1
- package/packages/server/src/services/sessionProvider.js +41 -1
- package/packages/shared/src/constants.js +1 -1
- package/packages/shared/src/contracts/providers.js +1 -1
- package/packages/shared/src/contracts/sessions.js +27 -1
- package/packages/shared/src/contracts/templates.js +10 -0
- package/packages/shared/src/types.js +7 -0
- package/packages/web/dist/assets/{ActiveSessionsView-B0XHqLmv.js → ActiveSessionsView-EdNxmPmZ.js} +1 -1
- package/packages/web/dist/assets/{AgentLogsView-DmsjUMlB.js → AgentLogsView-C2wX0JPP.js} +2 -2
- package/packages/web/dist/assets/ApiClient-DfbJwzpz.js +1 -0
- package/packages/web/dist/assets/ArchiveConfirmModal-DJERn5XO.js +1 -0
- package/packages/web/dist/assets/CommandButtonDetailView-CBPI8-US.js +1 -0
- package/packages/web/dist/assets/CommandButtonDetailView-D9zjx9ME.css +1 -0
- package/packages/web/dist/assets/EffortLevelSelector-PaBpUveC.js +1 -0
- package/packages/web/dist/assets/{GeneralSettingsView-D1nI8_zk.js → GeneralSettingsView-Dw-x83R0.js} +1 -1
- package/packages/web/dist/assets/{InputWithButton-CAkttyqx.js → InputWithButton-CHHcpF4I.js} +1 -1
- package/packages/web/dist/assets/{InterpolationHelp-BO1j9Z3_.js → InterpolationHelp-CLNPz8s8.js} +1 -1
- package/packages/web/dist/assets/MarkdownEditor-DYi1igfT.js +2 -0
- package/packages/web/dist/assets/ModelSelector-Cko_yTO5.js +1 -0
- package/packages/web/dist/assets/{ModelSelector-BSxKUSus.css → ModelSelector-Dtwe5xLH.css} +1 -1
- package/packages/web/dist/assets/{NewSessionView-BDPb-1qr.css → NewSessionView-DBl7T2Xp.css} +1 -1
- package/packages/web/dist/assets/NewSessionView-DwUfBg70.js +3 -0
- package/packages/web/dist/assets/ProjectEditView-CSbsea3U.js +1 -0
- package/packages/web/dist/assets/ProjectEditView-DbqTbA0q.css +1 -0
- package/packages/web/dist/assets/{ProjectListView-DcNyuINs.js → ProjectListView-CEc_LWZL.js} +1 -1
- package/packages/web/dist/assets/{ProjectNewView-B5YV62hv.js → ProjectNewView-D4U0uRlp.js} +1 -1
- package/packages/web/dist/assets/ProvidersView-2KCOiY6Q.css +1 -0
- package/packages/web/dist/assets/ProvidersView-CD1j8BOv.js +1 -0
- package/packages/web/dist/assets/QuickResponsesPanel-Dp39f12o.js +1 -0
- package/packages/web/dist/assets/QuickResponsesPanel-dk-Rj8xx.css +1 -0
- package/packages/web/dist/assets/ResizableTextarea-BWywIqOv.js +1 -0
- package/packages/web/dist/assets/ResizableTextarea-DERSH3Wz.css +1 -0
- package/packages/web/dist/assets/SessionCard-B6d5ijDW.js +1 -0
- package/packages/web/dist/assets/SessionDetailView-DWbXdx7A.js +36 -0
- package/packages/web/dist/assets/SessionDetailView-ULeIkWS0.css +1 -0
- package/packages/web/dist/assets/{SessionFormOptions-B6AxyREh.js → SessionFormOptions-Dz9ik4Fo.js} +1 -1
- package/packages/web/dist/assets/{SessionListView-B5_6gW49.css → SessionListView-3-xx6EVs.css} +1 -1
- package/packages/web/dist/assets/SessionListView-C129buBe.js +1 -0
- package/packages/web/dist/assets/{SessionLogStream-LlZ3z_Xj.js → SessionLogStream-BvXUNNBZ.js} +6 -6
- package/packages/web/dist/assets/{SettingsView-CTGiGvR2.js → SettingsView-DW1NvpX_.js} +1 -1
- package/packages/web/dist/assets/SlashCommandWizard-DleYBxrE.js +1 -0
- package/packages/web/dist/assets/{SummarySettingsView-BR2ZjEa3.js → SummarySettingsView-CLUfcWvf.js} +1 -1
- package/packages/web/dist/assets/TemplateDetailView-B5NI2oTR.css +1 -0
- package/packages/web/dist/assets/TemplateDetailView-Cukb205e.js +1 -0
- package/packages/web/dist/assets/{commandButtons-BfqR-fqq.js → commandButtons-DejH0rVN.js} +1 -1
- package/packages/web/dist/assets/index-BD7Y3rBE.js +3 -0
- package/packages/web/dist/assets/{index-BY174HVJ.css → index-Bd20AzX1.css} +1 -1
- package/packages/web/dist/assets/index-BgJiarKe.js +1 -0
- package/packages/web/dist/assets/index-Bk32fSSG.js +1 -0
- package/packages/web/dist/assets/index-BkA6pF2Z.js +1 -0
- package/packages/web/dist/assets/index-Cltr-Ldt.js +7 -0
- package/packages/web/dist/assets/index-Co-46Tp3.js +1 -0
- package/packages/web/dist/assets/index-Cpykk857.js +1 -0
- package/packages/web/dist/assets/index-CtABl0D1.js +1 -0
- package/packages/web/dist/assets/index-Cuqk5m9S.js +1 -0
- package/packages/web/dist/assets/{index-fK8FIZgP.js → index-CvXApbVC.js} +15 -15
- package/packages/web/dist/assets/index-D2gN-xEH.js +1 -0
- package/packages/web/dist/assets/index-Dd3WpmyQ.js +1 -0
- package/packages/web/dist/assets/index-Dk6--9rj.js +1 -0
- package/packages/web/dist/assets/{index-DgkC10TW.js → index-MZf7MlPX.js} +3 -3
- package/packages/web/dist/assets/{index-DtfUt785.js → index-NShCcwfj.js} +1 -1
- package/packages/web/dist/assets/index-hA3VEuSq.js +1 -0
- package/packages/web/dist/assets/index-p0mp3nca.js +1 -0
- package/packages/web/dist/assets/index-qntNa5r_.js +1 -0
- package/packages/web/dist/assets/index-qq9ceNSK.js +1 -0
- package/packages/web/dist/assets/projectDefaults-D9xkp2XR.js +1 -0
- package/packages/web/dist/assets/{projects-DXYQNJIi.js → projects-BvLADGKx.js} +1 -1
- package/packages/web/dist/assets/{providers-1bnH-exJ.js → providers-DZ-fOa4G.js} +1 -1
- package/packages/web/dist/assets/{sessions-6zGUlFrt.js → sessions-DETEyjPI.js} +1 -1
- package/packages/web/dist/assets/{settings-MbfRir0d.js → settings-TWfbahn5.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/ApiClient-C3ztI9s9.js +0 -1
- package/packages/web/dist/assets/ArchiveConfirmModal-BlCyn5Vt.js +0 -1
- package/packages/web/dist/assets/CommandButtonDetailView-CdSCPp78.js +0 -1
- package/packages/web/dist/assets/CommandButtonDetailView-DBm3rzhw.css +0 -1
- package/packages/web/dist/assets/EffortLevelSelector-hc2MNKg6.js +0 -1
- package/packages/web/dist/assets/MarkdownEditor-ucRAP_UM.js +0 -2
- package/packages/web/dist/assets/ModelSelector-CwTz8ZWO.js +0 -1
- package/packages/web/dist/assets/NewSessionView-BsDrp8mj.js +0 -3
- package/packages/web/dist/assets/ProjectEditView-CwTOeSun.js +0 -1
- package/packages/web/dist/assets/ProjectEditView-J15mcsWz.css +0 -1
- package/packages/web/dist/assets/ProvidersView-bZemq_Rv.css +0 -1
- package/packages/web/dist/assets/ProvidersView-nY9GnDdO.js +0 -1
- package/packages/web/dist/assets/QuickResponseSettings-B352c75l.css +0 -1
- package/packages/web/dist/assets/QuickResponseSettings-BQwQXuL7.js +0 -1
- package/packages/web/dist/assets/QuickResponsesPanel-BlFDvnZ2.css +0 -1
- package/packages/web/dist/assets/QuickResponsesPanel-BzSYcCSP.js +0 -1
- package/packages/web/dist/assets/ResizableTextarea-B3YIdIXv.js +0 -1
- package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +0 -1
- package/packages/web/dist/assets/SessionCard-CjE1tXiT.js +0 -1
- package/packages/web/dist/assets/SessionDetailView-3cPZrbS3.js +0 -36
- package/packages/web/dist/assets/SessionDetailView-CZRZMrfM.css +0 -1
- package/packages/web/dist/assets/SessionListView-CLXBfLcq.js +0 -1
- package/packages/web/dist/assets/SlashCommandWizard-Cy04d7-o.js +0 -1
- package/packages/web/dist/assets/TemplateDetailView-DH6Oswsp.js +0 -1
- package/packages/web/dist/assets/TemplateDetailView-DT2m06W7.css +0 -1
- package/packages/web/dist/assets/index-1zziPL6l.js +0 -1
- package/packages/web/dist/assets/index-7kzHPxSF.js +0 -1
- package/packages/web/dist/assets/index-B0N_obMc.js +0 -1
- package/packages/web/dist/assets/index-BNk_gdfI.js +0 -1
- package/packages/web/dist/assets/index-CSqaAH-0.js +0 -1
- package/packages/web/dist/assets/index-C_q4WlK8.js +0 -1
- package/packages/web/dist/assets/index-D1wpU4y0.js +0 -7
- package/packages/web/dist/assets/index-D5zCA8sD.js +0 -1
- package/packages/web/dist/assets/index-DGR8ELWY.js +0 -1
- package/packages/web/dist/assets/index-DHga8pXo.js +0 -1
- package/packages/web/dist/assets/index-DSby02Wl.js +0 -1
- package/packages/web/dist/assets/index-DqjXJTVI.js +0 -1
- package/packages/web/dist/assets/index-_4S2uLDI.js +0 -1
- package/packages/web/dist/assets/index-gmiZeFXN.js +0 -1
- package/packages/web/dist/assets/index-irD539ZM.js +0 -3
- package/packages/web/dist/assets/index-yq-E1Y00.js +0 -1
package/package.json
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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:
|
|
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/
|
|
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:
|
|
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/
|
|
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:
|
|
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/
|
|
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:
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
|