pikiloop 0.4.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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* driver-gemini.ts — Gemini CLI agent driver.
|
|
3
|
+
*
|
|
4
|
+
* Requires `gemini` CLI installed (https://github.com/google-gemini/gemini-cli).
|
|
5
|
+
* Stream protocol: spawns `gemini` with JSON output and parses stdout line-by-line.
|
|
6
|
+
*/
|
|
7
|
+
import { registerDriver } from '../driver.js';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
import { GEMINI_USAGE_TIMEOUTS, SESSION_RUNNING_THRESHOLD_MS } from '../../core/constants.js';
|
|
13
|
+
import { run, agentLog, appendSystemPrompt, pushRecentActivity, firstNonEmptyLine, shortValue, normalizeErrorMessage, sanitizeSessionUserPreviewText, emitSessionIdUpdate, listPikiloopSessions, mergeManagedAndNativeSessions, applyTurnWindow, stripInjectedPrompts, attachAgentImage, roundPercent, emptyUsage, Q, } from '../index.js';
|
|
14
|
+
import { getHome } from '../../core/platform.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Command & parser
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
function hasGeminiFlag(args, names) {
|
|
19
|
+
if (!args?.length)
|
|
20
|
+
return false;
|
|
21
|
+
return args.some(arg => {
|
|
22
|
+
const trimmed = String(arg || '').trim();
|
|
23
|
+
if (!trimmed.startsWith('-'))
|
|
24
|
+
return false;
|
|
25
|
+
return names.some(name => trimmed === name || trimmed.startsWith(`${name}=`));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// Gemini CLI's -p mode is text-only — there's no flag for binary inputs. The
|
|
29
|
+
// CLI does, however, parse `@<path>` references in the prompt and inlines the
|
|
30
|
+
// file's content (text or image) into the model's context. We splice those
|
|
31
|
+
// references at the front of the prompt so attachments survive the trip.
|
|
32
|
+
export function buildGeminiPromptText(prompt, attachments) {
|
|
33
|
+
if (!attachments.length)
|
|
34
|
+
return prompt;
|
|
35
|
+
// Quote paths that contain spaces — gemini's tokenizer reads `@"..."` as a
|
|
36
|
+
// single reference. Plain paths can be left bare for cleaner display.
|
|
37
|
+
const refs = attachments.map(p => /\s/.test(p) ? `@"${p}"` : `@${p}`).join(' ');
|
|
38
|
+
return prompt ? `${refs}\n\n${prompt}` : refs;
|
|
39
|
+
}
|
|
40
|
+
function geminiCmd(o) {
|
|
41
|
+
const approvalMode = o.geminiApprovalMode || 'yolo';
|
|
42
|
+
const sandbox = typeof o.geminiSandbox === 'boolean' ? o.geminiSandbox : false;
|
|
43
|
+
const args = ['gemini', '--output-format', 'stream-json'];
|
|
44
|
+
if (o.geminiModel)
|
|
45
|
+
args.push('--model', o.geminiModel);
|
|
46
|
+
if (o.sessionId)
|
|
47
|
+
args.push('--resume', o.sessionId);
|
|
48
|
+
if (!hasGeminiFlag(o.geminiExtraArgs, ['--approval-mode', '--yolo', '-y'])) {
|
|
49
|
+
args.push('--approval-mode', approvalMode);
|
|
50
|
+
}
|
|
51
|
+
if (!hasGeminiFlag(o.geminiExtraArgs, ['--sandbox', '-s'])) {
|
|
52
|
+
args.push('--sandbox', String(sandbox));
|
|
53
|
+
}
|
|
54
|
+
if (o.geminiExtraArgs?.length)
|
|
55
|
+
args.push(...o.geminiExtraArgs);
|
|
56
|
+
// gemini's -p requires the prompt as its value (not via stdin)
|
|
57
|
+
const userPrompt = buildGeminiPromptText(o.prompt, o.attachments || []);
|
|
58
|
+
const promptText = o.geminiSystemInstruction
|
|
59
|
+
? appendSystemPrompt(o.geminiSystemInstruction, userPrompt)
|
|
60
|
+
: userPrompt;
|
|
61
|
+
args.push('-p', promptText);
|
|
62
|
+
return args;
|
|
63
|
+
}
|
|
64
|
+
function geminiContextWindowFromModel(model) {
|
|
65
|
+
const id = typeof model === 'string' ? model.trim().toLowerCase() : '';
|
|
66
|
+
if (!id)
|
|
67
|
+
return null;
|
|
68
|
+
if (/^(auto-gemini-(2\.5|3)|gemini-(2\.5|3|3\.1)-)/.test(id))
|
|
69
|
+
return 1_048_576;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function geminiToolName(value) {
|
|
73
|
+
const name = typeof value === 'string' ? value.trim() : '';
|
|
74
|
+
return name || 'tool';
|
|
75
|
+
}
|
|
76
|
+
function geminiToolLabel(name) {
|
|
77
|
+
return name
|
|
78
|
+
.replace(/^mcp_/, '')
|
|
79
|
+
.replace(/^discovered_tool_/, '')
|
|
80
|
+
.replace(/_/g, ' ')
|
|
81
|
+
.replace(/\s+/g, ' ')
|
|
82
|
+
.trim() || 'tool';
|
|
83
|
+
}
|
|
84
|
+
function geminiToolSummary(name, parameters) {
|
|
85
|
+
const tool = geminiToolName(name);
|
|
86
|
+
const params = parameters && typeof parameters === 'object' ? parameters : {};
|
|
87
|
+
switch (tool) {
|
|
88
|
+
case 'read_file': {
|
|
89
|
+
const target = shortValue(params.file_path || params.path, 140);
|
|
90
|
+
return target ? `Read ${target}` : 'Read file';
|
|
91
|
+
}
|
|
92
|
+
case 'read_many_files': {
|
|
93
|
+
const include = shortValue(params.include || params.pattern, 120);
|
|
94
|
+
return include ? `Read files: ${include}` : 'Read files';
|
|
95
|
+
}
|
|
96
|
+
case 'write_file': {
|
|
97
|
+
const target = shortValue(params.file_path || params.path, 140);
|
|
98
|
+
return target ? `Write ${target}` : 'Write file';
|
|
99
|
+
}
|
|
100
|
+
case 'replace': {
|
|
101
|
+
const target = shortValue(params.file_path || params.path, 140);
|
|
102
|
+
return target ? `Edit ${target}` : 'Edit file';
|
|
103
|
+
}
|
|
104
|
+
case 'list_directory': {
|
|
105
|
+
const dir = shortValue(params.dir_path || params.path, 120);
|
|
106
|
+
return dir ? `List files: ${dir}` : 'List files';
|
|
107
|
+
}
|
|
108
|
+
case 'glob': {
|
|
109
|
+
const pattern = shortValue(params.pattern || params.glob, 120);
|
|
110
|
+
return pattern ? `Find files: ${pattern}` : 'Find files';
|
|
111
|
+
}
|
|
112
|
+
case 'grep_search':
|
|
113
|
+
case 'search_file_content': {
|
|
114
|
+
const pattern = shortValue(params.pattern || params.query, 120);
|
|
115
|
+
return pattern ? `Search text: ${pattern}` : 'Search text';
|
|
116
|
+
}
|
|
117
|
+
case 'run_shell_command': {
|
|
118
|
+
const command = shortValue(params.command, 120);
|
|
119
|
+
return command ? `Run shell: ${command}` : 'Run shell';
|
|
120
|
+
}
|
|
121
|
+
case 'web_fetch': {
|
|
122
|
+
const target = shortValue(params.url || params.prompt, 120);
|
|
123
|
+
return target ? `Fetch ${target}` : 'Fetch web page';
|
|
124
|
+
}
|
|
125
|
+
case 'google_web_search': {
|
|
126
|
+
const query = shortValue(params.query, 120);
|
|
127
|
+
return query ? `Search web: ${query}` : 'Search web';
|
|
128
|
+
}
|
|
129
|
+
case 'write_todos': return 'Update todo list';
|
|
130
|
+
case 'save_memory': return 'Save memory';
|
|
131
|
+
case 'ask_user': return 'Request user input';
|
|
132
|
+
case 'activate_skill': {
|
|
133
|
+
const skill = shortValue(params.name, 80);
|
|
134
|
+
return skill ? `Activate skill: ${skill}` : 'Activate skill';
|
|
135
|
+
}
|
|
136
|
+
case 'get_internal_docs': {
|
|
137
|
+
const target = shortValue(params.path, 120);
|
|
138
|
+
return target ? `Read docs: ${target}` : 'Read docs';
|
|
139
|
+
}
|
|
140
|
+
case 'enter_plan_mode': return 'Enter plan mode';
|
|
141
|
+
case 'exit_plan_mode': return 'Exit plan mode';
|
|
142
|
+
default: {
|
|
143
|
+
const detail = shortValue(params.file_path
|
|
144
|
+
|| params.path
|
|
145
|
+
|| params.dir_path
|
|
146
|
+
|| params.pattern
|
|
147
|
+
|| params.query
|
|
148
|
+
|| params.command
|
|
149
|
+
|| params.url
|
|
150
|
+
|| params.name, 120);
|
|
151
|
+
const label = shortValue(geminiToolLabel(tool), 80);
|
|
152
|
+
return detail ? `Use ${label}: ${detail}` : `Use ${label}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function geminiToolResultSummary(tool, ev) {
|
|
157
|
+
const fallbackSummary = geminiToolSummary(tool?.name || ev.tool_name || ev.name || ev.tool, ev.parameters || ev.args || ev.input || {});
|
|
158
|
+
const summary = tool?.summary || fallbackSummary;
|
|
159
|
+
const detail = shortValue(firstNonEmptyLine(normalizeErrorMessage(ev.error)
|
|
160
|
+
|| ev.output
|
|
161
|
+
|| ev.message
|
|
162
|
+
|| ''), 120);
|
|
163
|
+
if (ev.status === 'error')
|
|
164
|
+
return detail ? `${summary} failed: ${detail}` : `${summary} failed`;
|
|
165
|
+
return detail ? `${summary} -> ${detail}` : `${summary} done`;
|
|
166
|
+
}
|
|
167
|
+
function geminiParse(ev, s) {
|
|
168
|
+
const t = ev.type || '';
|
|
169
|
+
// init event: {"type":"init","session_id":"...","model":"..."}
|
|
170
|
+
if (t === 'init') {
|
|
171
|
+
emitSessionIdUpdate(s, ev.session_id);
|
|
172
|
+
s.model = ev.model ?? s.model;
|
|
173
|
+
s.contextWindow = geminiContextWindowFromModel(s.model) ?? s.contextWindow;
|
|
174
|
+
// Gemini's stream-json drops `thought` parts and every `agent_*`/`tool_update`
|
|
175
|
+
// event, so between init and the first tool_use/message there's nothing to
|
|
176
|
+
// surface — easily 10–30s on Gemini 3 Pro with HIGH thinking, longer when
|
|
177
|
+
// 429 backoffs kick in. Plant a sentinel so the IM/dashboard activity area
|
|
178
|
+
// shows progress instead of staying blank.
|
|
179
|
+
pushRecentActivity(s.recentActivity, 'Thinking...');
|
|
180
|
+
s.activity = s.recentActivity.join('\n');
|
|
181
|
+
}
|
|
182
|
+
// message delta: {"type":"message","role":"assistant","content":"...","delta":true}
|
|
183
|
+
if (t === 'message' && ev.role === 'assistant') {
|
|
184
|
+
if (ev.delta)
|
|
185
|
+
s.text += ev.content || '';
|
|
186
|
+
else if (!s.text.trim())
|
|
187
|
+
s.text = ev.content || '';
|
|
188
|
+
}
|
|
189
|
+
if (t === 'tool_use' || t === 'tool_call') {
|
|
190
|
+
const name = geminiToolName(ev.tool_name || ev.name || ev.tool);
|
|
191
|
+
const summary = geminiToolSummary(name, ev.parameters || ev.args || ev.input || {});
|
|
192
|
+
const toolId = String(ev.tool_id || ev.id || '').trim();
|
|
193
|
+
if (toolId)
|
|
194
|
+
s.geminiToolsById.set(toolId, { name, summary });
|
|
195
|
+
pushRecentActivity(s.recentActivity, summary);
|
|
196
|
+
s.activity = s.recentActivity.join('\n');
|
|
197
|
+
}
|
|
198
|
+
if (t === 'tool_result') {
|
|
199
|
+
const toolId = String(ev.tool_id || ev.id || '').trim();
|
|
200
|
+
const tool = toolId ? s.geminiToolsById.get(toolId) : undefined;
|
|
201
|
+
pushRecentActivity(s.recentActivity, geminiToolResultSummary(tool, ev));
|
|
202
|
+
s.activity = s.recentActivity.join('\n');
|
|
203
|
+
}
|
|
204
|
+
if (t === 'error') {
|
|
205
|
+
const message = normalizeErrorMessage(ev.message || ev.error) || 'Gemini reported an error';
|
|
206
|
+
if (ev.severity === 'error') {
|
|
207
|
+
s.errors = [...(s.errors || []), message];
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
pushRecentActivity(s.recentActivity, message);
|
|
211
|
+
s.activity = s.recentActivity.join('\n');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// result event: {"type":"result","status":"success","stats":{...}}
|
|
215
|
+
if (t === 'result') {
|
|
216
|
+
emitSessionIdUpdate(s, ev.session_id);
|
|
217
|
+
if (ev.status === 'error' || ev.status === 'failure') {
|
|
218
|
+
const message = normalizeErrorMessage(ev.error)
|
|
219
|
+
|| normalizeErrorMessage(ev.errors)
|
|
220
|
+
|| normalizeErrorMessage(ev.message)
|
|
221
|
+
|| `Gemini returned status: ${ev.status}`;
|
|
222
|
+
s.errors = [message];
|
|
223
|
+
}
|
|
224
|
+
s.stopReason = ev.status === 'success' ? 'end_turn' : ev.status;
|
|
225
|
+
const u = ev.stats;
|
|
226
|
+
if (u) {
|
|
227
|
+
s.inputTokens = u.input_tokens ?? u.input ?? s.inputTokens;
|
|
228
|
+
s.outputTokens = u.output_tokens ?? u.output ?? s.outputTokens;
|
|
229
|
+
s.cachedInputTokens = u.cached ?? s.cachedInputTokens;
|
|
230
|
+
// Gemini's `input_tokens` is the full prompt size (cached portion is
|
|
231
|
+
// already a subset of it). Use it directly as the context-window
|
|
232
|
+
// occupancy — adding `cached` would double-count.
|
|
233
|
+
if (s.inputTokens != null)
|
|
234
|
+
s.contextUsedTokens = s.inputTokens;
|
|
235
|
+
}
|
|
236
|
+
s.contextWindow = geminiContextWindowFromModel(s.model) ?? s.contextWindow;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Gemini-cli does an exponential backoff on 429s and other transient errors
|
|
240
|
+
// without emitting any stream-json event — only stderr gets a line like
|
|
241
|
+
// `Attempt 1 failed with status 429. Retrying with backoff...`. Surface those
|
|
242
|
+
// lines as activity so users don't see a frozen UI during MODEL_CAPACITY_EXHAUSTED.
|
|
243
|
+
const GEMINI_RETRY_RE = /^Attempt\s+(\d+)\s+failed\s+with\s+status\s+(\d+)/i;
|
|
244
|
+
function geminiParseStderrLine(line, s) {
|
|
245
|
+
const m = GEMINI_RETRY_RE.exec(line);
|
|
246
|
+
if (!m)
|
|
247
|
+
return;
|
|
248
|
+
const attempt = m[1];
|
|
249
|
+
const status = m[2];
|
|
250
|
+
const reason = status === '429' ? 'rate limit / capacity exhausted'
|
|
251
|
+
: status === '503' ? 'service unavailable'
|
|
252
|
+
: `status ${status}`;
|
|
253
|
+
pushRecentActivity(s.recentActivity, `Retrying after ${reason} (attempt ${attempt})`);
|
|
254
|
+
s.activity = s.recentActivity.join('\n');
|
|
255
|
+
}
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Thinking effort overlay
|
|
258
|
+
//
|
|
259
|
+
// Gemini CLI exposes thinking via two knobs depending on model family:
|
|
260
|
+
// - Gemini 3.x: thinkingLevel: "LOW" | "HIGH"
|
|
261
|
+
// - Gemini 2.5: thinkingBudget: number (0=off, 8192=default, -1=dynamic)
|
|
262
|
+
// There is no CLI flag — the only place the CLI reads them is settings.json
|
|
263
|
+
// under `agents.<chat-base*>.modelConfig.generateContentConfig.thinkingConfig`.
|
|
264
|
+
//
|
|
265
|
+
// We don't want to mutate the user's ~/.gemini/settings.json, so for streams
|
|
266
|
+
// where an effort is set we materialise a fake $HOME via GEMINI_CLI_HOME and
|
|
267
|
+
// place a synthetic `.gemini/` inside it: symlinks for everything in the
|
|
268
|
+
// real ~/.gemini/ (oauth, projects, history, tmp, …) plus our merged
|
|
269
|
+
// settings.json. Note that gemini-cli reads GEMINI_CLI_HOME as the *parent*
|
|
270
|
+
// of `.gemini/`, not as `.gemini/` itself — getting that wrong makes gemini
|
|
271
|
+
// fail with "Please set an Auth method" because it can't find any creds.
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
function geminiEffortOverlay(effort) {
|
|
274
|
+
const value = String(effort || '').trim().toLowerCase();
|
|
275
|
+
if (!value)
|
|
276
|
+
return null;
|
|
277
|
+
let level3;
|
|
278
|
+
let budget25;
|
|
279
|
+
if (value === 'low' || value === 'minimal') {
|
|
280
|
+
level3 = 'LOW';
|
|
281
|
+
budget25 = 512;
|
|
282
|
+
}
|
|
283
|
+
else if (value === 'medium') {
|
|
284
|
+
level3 = 'HIGH';
|
|
285
|
+
budget25 = 8192;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
level3 = 'HIGH';
|
|
289
|
+
budget25 = -1;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
'chat-base-3': {
|
|
293
|
+
modelConfig: { generateContentConfig: { thinkingConfig: { thinkingLevel: level3 } } },
|
|
294
|
+
},
|
|
295
|
+
'chat-base-2.5': {
|
|
296
|
+
modelConfig: { generateContentConfig: { thinkingConfig: { thinkingBudget: budget25 } } },
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function deepMergeAgents(base, overlay) {
|
|
301
|
+
const out = base && typeof base === 'object' && !Array.isArray(base) ? { ...base } : {};
|
|
302
|
+
for (const key of Object.keys(overlay)) {
|
|
303
|
+
out[key] = mergePlainObjects(out[key], overlay[key]);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function mergePlainObjects(a, b) {
|
|
308
|
+
if (b === undefined)
|
|
309
|
+
return a;
|
|
310
|
+
if (a === undefined || a === null || typeof a !== 'object' || Array.isArray(a))
|
|
311
|
+
return b;
|
|
312
|
+
if (typeof b !== 'object' || Array.isArray(b))
|
|
313
|
+
return b;
|
|
314
|
+
const out = { ...a };
|
|
315
|
+
for (const key of Object.keys(b))
|
|
316
|
+
out[key] = mergePlainObjects(a[key], b[key]);
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
function prepareGeminiHomeOverlay(opts) {
|
|
320
|
+
const effortOverrides = geminiEffortOverlay(opts.effort);
|
|
321
|
+
const needsFileFilterBypass = opts.hasAttachments;
|
|
322
|
+
if (!effortOverrides && !needsFileFilterBypass)
|
|
323
|
+
return null;
|
|
324
|
+
const home = getHome();
|
|
325
|
+
if (!home)
|
|
326
|
+
return null;
|
|
327
|
+
const userGeminiDir = path.join(home, '.gemini');
|
|
328
|
+
if (!fs.existsSync(userGeminiDir))
|
|
329
|
+
return null;
|
|
330
|
+
let overlayHome;
|
|
331
|
+
try {
|
|
332
|
+
overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pikiloop-gemini-'));
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const overlayGeminiDir = path.join(overlayHome, '.gemini');
|
|
338
|
+
try {
|
|
339
|
+
fs.mkdirSync(overlayGeminiDir, { recursive: true });
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
try {
|
|
343
|
+
fs.rmSync(overlayHome, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
catch { }
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
// Symlink every entry in ~/.gemini except settings.json so OAuth, projects,
|
|
349
|
+
// history, tmp/, etc. all stay shared with the user's real config.
|
|
350
|
+
try {
|
|
351
|
+
for (const entry of fs.readdirSync(userGeminiDir, { withFileTypes: true })) {
|
|
352
|
+
if (entry.name === 'settings.json')
|
|
353
|
+
continue;
|
|
354
|
+
try {
|
|
355
|
+
fs.symlinkSync(path.join(userGeminiDir, entry.name), path.join(overlayGeminiDir, entry.name));
|
|
356
|
+
}
|
|
357
|
+
catch { /* ignore individual symlink failures */ }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch { /* readdir failure → fall through with whatever we managed */ }
|
|
361
|
+
let userSettings = {};
|
|
362
|
+
const userSettingsPath = path.join(userGeminiDir, 'settings.json');
|
|
363
|
+
try {
|
|
364
|
+
if (fs.existsSync(userSettingsPath)) {
|
|
365
|
+
userSettings = JSON.parse(fs.readFileSync(userSettingsPath, 'utf-8'));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch { /* malformed user settings — start fresh */ }
|
|
369
|
+
const merged = { ...userSettings };
|
|
370
|
+
if (effortOverrides) {
|
|
371
|
+
merged.agents = deepMergeAgents(userSettings.agents, effortOverrides);
|
|
372
|
+
}
|
|
373
|
+
if (needsFileFilterBypass) {
|
|
374
|
+
const baseContext = userSettings.context && typeof userSettings.context === 'object' && !Array.isArray(userSettings.context)
|
|
375
|
+
? userSettings.context : {};
|
|
376
|
+
const baseFileFiltering = baseContext.fileFiltering && typeof baseContext.fileFiltering === 'object' && !Array.isArray(baseContext.fileFiltering)
|
|
377
|
+
? baseContext.fileFiltering : {};
|
|
378
|
+
merged.context = {
|
|
379
|
+
...baseContext,
|
|
380
|
+
fileFiltering: {
|
|
381
|
+
...baseFileFiltering,
|
|
382
|
+
respectGitIgnore: false,
|
|
383
|
+
respectGeminiIgnore: false,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
fs.writeFileSync(path.join(overlayGeminiDir, 'settings.json'), JSON.stringify(merged, null, 2));
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
try {
|
|
392
|
+
fs.rmSync(overlayHome, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
catch { }
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
homeDir: overlayHome,
|
|
399
|
+
cleanup: () => { try {
|
|
400
|
+
fs.rmSync(overlayHome, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
catch { } },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Stream
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
export async function doGeminiStream(opts) {
|
|
409
|
+
// Prompt is passed as -p argument; send empty stdin so run() doesn't duplicate it
|
|
410
|
+
const overlay = prepareGeminiHomeOverlay({
|
|
411
|
+
effort: opts.thinkingEffort,
|
|
412
|
+
hasAttachments: (opts.attachments?.length ?? 0) > 0,
|
|
413
|
+
});
|
|
414
|
+
const extraEnv = overlay
|
|
415
|
+
? { ...(opts.extraEnv || {}), GEMINI_CLI_HOME: overlay.homeDir }
|
|
416
|
+
: opts.extraEnv;
|
|
417
|
+
const streamOpts = { ...opts, _stdinOverride: '', extraEnv };
|
|
418
|
+
try {
|
|
419
|
+
return await run(geminiCmd(opts), streamOpts, geminiParse, geminiParseStderrLine);
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
overlay?.cleanup();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Sessions / Tail
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
/** Resolve Gemini project name for a workdir from ~/.gemini/projects.json */
|
|
429
|
+
function geminiProjectName(workdir) {
|
|
430
|
+
const home = getHome();
|
|
431
|
+
if (!home)
|
|
432
|
+
return null;
|
|
433
|
+
const projectsPath = path.join(home, '.gemini', 'projects.json');
|
|
434
|
+
try {
|
|
435
|
+
const data = JSON.parse(fs.readFileSync(projectsPath, 'utf8'));
|
|
436
|
+
const projects = data?.projects;
|
|
437
|
+
if (!projects || typeof projects !== 'object')
|
|
438
|
+
return null;
|
|
439
|
+
const resolved = path.resolve(workdir);
|
|
440
|
+
// Exact match first, then check entries
|
|
441
|
+
if (projects[resolved])
|
|
442
|
+
return projects[resolved];
|
|
443
|
+
for (const [dir, name] of Object.entries(projects)) {
|
|
444
|
+
if (path.resolve(dir) === resolved)
|
|
445
|
+
return name;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch { /* skip */ }
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
function geminiChatsDir(workdir) {
|
|
452
|
+
const home = getHome();
|
|
453
|
+
if (!home)
|
|
454
|
+
return null;
|
|
455
|
+
const projectName = geminiProjectName(workdir);
|
|
456
|
+
if (!projectName)
|
|
457
|
+
return null;
|
|
458
|
+
return path.join(home, '.gemini', 'tmp', projectName, 'chats');
|
|
459
|
+
}
|
|
460
|
+
function extractGeminiText(content) {
|
|
461
|
+
if (typeof content === 'string')
|
|
462
|
+
return content.trim();
|
|
463
|
+
if (!Array.isArray(content))
|
|
464
|
+
return '';
|
|
465
|
+
const parts = [];
|
|
466
|
+
for (const block of content) {
|
|
467
|
+
if (typeof block === 'string') {
|
|
468
|
+
if (block.trim())
|
|
469
|
+
parts.push(block.trim());
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const text = typeof block?.text === 'string' ? block.text.trim() : '';
|
|
473
|
+
if (text)
|
|
474
|
+
parts.push(text);
|
|
475
|
+
}
|
|
476
|
+
return parts.join('\n').trim();
|
|
477
|
+
}
|
|
478
|
+
// Gemini's -p mode is text-only, so pikiloop concatenates its system-prompt
|
|
479
|
+
// blocks ([Browser Automation], [Artifact Return], …) onto the user's prompt
|
|
480
|
+
// before invoking the CLI. That means the JSONL "user" message we read back
|
|
481
|
+
// later contains those orchestrator-injected blocks AND the gemini-CLI-emitted
|
|
482
|
+
// `--- Content from referenced files ---` markers it appends when expanding
|
|
483
|
+
// `@<path>` references. Both are noise for the dashboard / IM render path —
|
|
484
|
+
// the helpers below strip them so the displayed user bubble matches what the
|
|
485
|
+
// human actually typed, and surface staged image attachments as image blocks
|
|
486
|
+
// instead of raw `@<path>` text.
|
|
487
|
+
const GEMINI_SYSTEM_BLOCK_SENTINELS = [
|
|
488
|
+
'[Artifact Return]',
|
|
489
|
+
'[Asking the user]',
|
|
490
|
+
'[Browser Automation]',
|
|
491
|
+
'[Session Workspace]',
|
|
492
|
+
];
|
|
493
|
+
const GEMINI_REFERENCED_FILES_BLOCK_RE = /\n*--- Content from referenced files ---[\s\S]*?--- End of content ---\n*/g;
|
|
494
|
+
const GEMINI_FILE_REF_RE = /(^|\s)@(?:"([^"]+)"|([^\s"@]+))/g;
|
|
495
|
+
function stripGeminiSystemPreamble(text) {
|
|
496
|
+
let cur = text.replace(/^\s+/, '');
|
|
497
|
+
while (true) {
|
|
498
|
+
const sentinel = GEMINI_SYSTEM_BLOCK_SENTINELS.find(s => cur.startsWith(s));
|
|
499
|
+
if (!sentinel)
|
|
500
|
+
break;
|
|
501
|
+
const blockEnd = cur.indexOf('\n\n');
|
|
502
|
+
if (blockEnd < 0)
|
|
503
|
+
return '';
|
|
504
|
+
cur = cur.slice(blockEnd + 2).replace(/^\s+/, '');
|
|
505
|
+
}
|
|
506
|
+
return cur;
|
|
507
|
+
}
|
|
508
|
+
function cleanGeminiUserText(rawText) {
|
|
509
|
+
if (!rawText)
|
|
510
|
+
return '';
|
|
511
|
+
let text = stripInjectedPrompts(rawText);
|
|
512
|
+
text = stripGeminiSystemPreamble(text);
|
|
513
|
+
text = text.replace(GEMINI_REFERENCED_FILES_BLOCK_RE, '\n');
|
|
514
|
+
return text.trim();
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Build a (text, image blocks) pair for a rendered user bubble. `@<path>`
|
|
518
|
+
* references that resolve to readable image files are lifted into image
|
|
519
|
+
* blocks; refs that don't resolve are left in the text so the user can still
|
|
520
|
+
* see what they wrote.
|
|
521
|
+
*/
|
|
522
|
+
function buildGeminiUserMessageContent(rawText, workdir) {
|
|
523
|
+
const cleaned = cleanGeminiUserText(rawText);
|
|
524
|
+
if (!cleaned)
|
|
525
|
+
return { text: '', blocks: [] };
|
|
526
|
+
const blocks = [];
|
|
527
|
+
const textOnly = cleaned.replace(GEMINI_FILE_REF_RE, (match, lead, quoted, bare) => {
|
|
528
|
+
const ref = String(quoted || bare || '').trim();
|
|
529
|
+
if (!ref)
|
|
530
|
+
return match;
|
|
531
|
+
const abs = path.isAbsolute(ref) ? ref : path.resolve(workdir, ref);
|
|
532
|
+
const block = attachAgentImage({ imagePath: abs });
|
|
533
|
+
if (block) {
|
|
534
|
+
blocks.push(block);
|
|
535
|
+
return lead || '';
|
|
536
|
+
}
|
|
537
|
+
return match;
|
|
538
|
+
});
|
|
539
|
+
return { text: textOnly.replace(/\n{3,}/g, '\n\n').trim(), blocks };
|
|
540
|
+
}
|
|
541
|
+
/** Drop the attachment `@<path>` refs entirely so they don't surface as raw
|
|
542
|
+
* paths in plain-text contexts (tail snippets, sidebar previews). Newlines
|
|
543
|
+
* in the surrounding prose are preserved. */
|
|
544
|
+
function dropGeminiFileRefs(text) {
|
|
545
|
+
return text.replace(GEMINI_FILE_REF_RE, '$1');
|
|
546
|
+
}
|
|
547
|
+
/** Single-line variant for session list titles where the bubble shape is a
|
|
548
|
+
* one-liner — collapses every whitespace run to a single space. */
|
|
549
|
+
function flattenGeminiUserText(rawText) {
|
|
550
|
+
return dropGeminiFileRefs(cleanGeminiUserText(rawText)).replace(/\s+/g, ' ').trim();
|
|
551
|
+
}
|
|
552
|
+
function normalizeGeminiSessionTitle(value) {
|
|
553
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
554
|
+
if (!text)
|
|
555
|
+
return null;
|
|
556
|
+
return text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
|
|
557
|
+
}
|
|
558
|
+
function findGeminiSessionFile(workdir, sessionId) {
|
|
559
|
+
const chatsDir = geminiChatsDir(workdir);
|
|
560
|
+
if (!chatsDir || !fs.existsSync(chatsDir))
|
|
561
|
+
return null;
|
|
562
|
+
let entries;
|
|
563
|
+
try {
|
|
564
|
+
entries = fs.readdirSync(chatsDir, { withFileTypes: true });
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
for (const entry of entries) {
|
|
570
|
+
if (!entry.isFile() || !entry.name.startsWith('session-'))
|
|
571
|
+
continue;
|
|
572
|
+
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
|
|
573
|
+
continue;
|
|
574
|
+
const filePath = path.join(chatsDir, entry.name);
|
|
575
|
+
try {
|
|
576
|
+
const data = loadGeminiSessionData(filePath);
|
|
577
|
+
if (data?.sessionId === sessionId)
|
|
578
|
+
return filePath;
|
|
579
|
+
}
|
|
580
|
+
catch { /* skip */ }
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
function loadGeminiSessionData(filePath) {
|
|
585
|
+
try {
|
|
586
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
587
|
+
if (filePath.endsWith('.json'))
|
|
588
|
+
return JSON.parse(content);
|
|
589
|
+
// JSONL format: first line is metadata, subsequent lines are messages or $set updates
|
|
590
|
+
const lines = content.split('\n');
|
|
591
|
+
let data = {};
|
|
592
|
+
const messages = [];
|
|
593
|
+
for (const line of lines) {
|
|
594
|
+
if (!line.trim() || line[0] !== '{')
|
|
595
|
+
continue;
|
|
596
|
+
try {
|
|
597
|
+
const obj = JSON.parse(line);
|
|
598
|
+
if (obj.sessionId && !data.sessionId) {
|
|
599
|
+
data = { ...obj };
|
|
600
|
+
}
|
|
601
|
+
else if (obj.$set) {
|
|
602
|
+
if (obj.$set.lastUpdated)
|
|
603
|
+
data.lastUpdated = obj.$set.lastUpdated;
|
|
604
|
+
}
|
|
605
|
+
else if (obj.type === 'user' || obj.type === 'gemini' || obj.type === 'model' || obj.type === 'assistant') {
|
|
606
|
+
messages.push(obj);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch { /* skip */ }
|
|
610
|
+
}
|
|
611
|
+
data.messages = messages;
|
|
612
|
+
return data;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Per-file cache of the derived fields. getNativeGeminiSessionsFromFiles read +
|
|
619
|
+
// JSON-parsed every chat file's full contents on every list request AND per
|
|
620
|
+
// workspace×agent in the overview fan-out. Keyed by (mtime,size) so unchanged
|
|
621
|
+
// chats are never re-read; `running` depends on Date.now() so it's recomputed
|
|
622
|
+
// per call. Stores only the small derived fields, never the full messages array.
|
|
623
|
+
const nativeGeminiContentCache = new Map();
|
|
624
|
+
function readNativeGeminiContent(filePath) {
|
|
625
|
+
const data = loadGeminiSessionData(filePath);
|
|
626
|
+
if (!data?.sessionId)
|
|
627
|
+
return null;
|
|
628
|
+
// Gemini CLI writes stub session files for internal bookkeeping — e.g.
|
|
629
|
+
// `sessionId: "a2a-server"` for its built-in a2a server, plus abandoned
|
|
630
|
+
// UUID-named sessions that never received a turn. Both share the same shape:
|
|
631
|
+
// metadata only, no `messages` array. Nothing to render, so skip them.
|
|
632
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
633
|
+
if (messages.length === 0)
|
|
634
|
+
return null;
|
|
635
|
+
// Extract title from first user message + last Q&A from tail.
|
|
636
|
+
let title = null;
|
|
637
|
+
let lastQuestion = null;
|
|
638
|
+
let lastAnswer = null;
|
|
639
|
+
let lastMessageText = null;
|
|
640
|
+
for (const msg of messages) {
|
|
641
|
+
if (msg.type === 'user') {
|
|
642
|
+
const text = sanitizeSessionUserPreviewText(flattenGeminiUserText(extractGeminiText(msg.content)));
|
|
643
|
+
if (!title)
|
|
644
|
+
title = normalizeGeminiSessionTitle(text);
|
|
645
|
+
if (text) {
|
|
646
|
+
lastQuestion = shortValue(text, 500);
|
|
647
|
+
lastMessageText = shortValue(text, 500);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
else if (msg.type === 'model' || msg.type === 'assistant' || msg.type === 'gemini') {
|
|
651
|
+
const text = extractGeminiText(msg.content);
|
|
652
|
+
if (text) {
|
|
653
|
+
lastAnswer = shortValue(text, 500);
|
|
654
|
+
lastMessageText = shortValue(text, 500);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const numTurns = messages.filter((m) => m.type === 'user' && flattenGeminiUserText(extractGeminiText(m.content))).length;
|
|
659
|
+
return {
|
|
660
|
+
sessionId: String(data.sessionId),
|
|
661
|
+
title,
|
|
662
|
+
createdAt: data.startTime || data.createdAt || null,
|
|
663
|
+
updatedAt: data.lastUpdated || data.startTime || data.createdAt || null,
|
|
664
|
+
lastUpdated: data.lastUpdated || null,
|
|
665
|
+
lastQuestion,
|
|
666
|
+
lastAnswer,
|
|
667
|
+
lastMessageText,
|
|
668
|
+
numTurns: numTurns || null,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** Read native Gemini CLI sessions from ~/.gemini/tmp/{projectName}/chats/ */
|
|
672
|
+
function getNativeGeminiSessionsFromFiles(workdir) {
|
|
673
|
+
const chatsDir = geminiChatsDir(workdir);
|
|
674
|
+
if (!chatsDir || !fs.existsSync(chatsDir))
|
|
675
|
+
return [];
|
|
676
|
+
let entries;
|
|
677
|
+
try {
|
|
678
|
+
entries = fs.readdirSync(chatsDir, { withFileTypes: true });
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return [];
|
|
682
|
+
}
|
|
683
|
+
const sessionsById = new Map();
|
|
684
|
+
for (const entry of entries) {
|
|
685
|
+
if (!entry.isFile() || !entry.name.startsWith('session-'))
|
|
686
|
+
continue;
|
|
687
|
+
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
|
|
688
|
+
continue;
|
|
689
|
+
const filePath = path.join(chatsDir, entry.name);
|
|
690
|
+
let stat;
|
|
691
|
+
try {
|
|
692
|
+
stat = fs.statSync(filePath);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
let cached = nativeGeminiContentCache.get(filePath);
|
|
698
|
+
if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
|
|
699
|
+
cached = { mtimeMs: stat.mtimeMs, size: stat.size, content: readNativeGeminiContent(filePath) };
|
|
700
|
+
nativeGeminiContentCache.set(filePath, cached);
|
|
701
|
+
}
|
|
702
|
+
const content = cached.content;
|
|
703
|
+
if (!content)
|
|
704
|
+
continue;
|
|
705
|
+
// If we already saw this sessionId, only replace it if this file is newer.
|
|
706
|
+
const existing = sessionsById.get(content.sessionId);
|
|
707
|
+
if (existing && content.updatedAt && existing.runUpdatedAt && Date.parse(content.updatedAt) <= Date.parse(existing.runUpdatedAt)) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const running = content.lastUpdated ? Date.now() - Date.parse(content.lastUpdated) < SESSION_RUNNING_THRESHOLD_MS : false;
|
|
711
|
+
sessionsById.set(content.sessionId, {
|
|
712
|
+
sessionId: content.sessionId,
|
|
713
|
+
agent: 'gemini',
|
|
714
|
+
workdir,
|
|
715
|
+
workspacePath: null,
|
|
716
|
+
model: null,
|
|
717
|
+
createdAt: content.createdAt,
|
|
718
|
+
title: content.title,
|
|
719
|
+
running,
|
|
720
|
+
runState: running ? 'running' : 'completed',
|
|
721
|
+
runDetail: null,
|
|
722
|
+
runUpdatedAt: content.updatedAt,
|
|
723
|
+
classification: null,
|
|
724
|
+
userStatus: null,
|
|
725
|
+
userNote: null,
|
|
726
|
+
lastQuestion: content.lastQuestion,
|
|
727
|
+
lastAnswer: content.lastAnswer,
|
|
728
|
+
lastMessageText: content.lastMessageText,
|
|
729
|
+
migratedFrom: null,
|
|
730
|
+
migratedTo: null,
|
|
731
|
+
linkedSessions: [],
|
|
732
|
+
numTurns: content.numTurns,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return [...sessionsById.values()];
|
|
736
|
+
}
|
|
737
|
+
function getNativeGeminiSessions(workdir) {
|
|
738
|
+
return getNativeGeminiSessionsFromFiles(workdir);
|
|
739
|
+
}
|
|
740
|
+
function getGeminiSessions(workdir, limit) {
|
|
741
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
742
|
+
// Merge pikiloop-tracked sessions with native Gemini sessions
|
|
743
|
+
const pikiloopSessions = listPikiloopSessions(resolvedWorkdir, 'gemini').map(record => ({
|
|
744
|
+
sessionId: record.sessionId,
|
|
745
|
+
agent: 'gemini',
|
|
746
|
+
workdir: record.workdir,
|
|
747
|
+
workspacePath: record.workspacePath,
|
|
748
|
+
threadId: record.threadId,
|
|
749
|
+
model: record.model,
|
|
750
|
+
createdAt: record.createdAt,
|
|
751
|
+
title: record.title,
|
|
752
|
+
running: record.runState === 'running',
|
|
753
|
+
runState: record.runState,
|
|
754
|
+
runDetail: record.runDetail,
|
|
755
|
+
runUpdatedAt: record.runUpdatedAt,
|
|
756
|
+
runPid: record.runPid,
|
|
757
|
+
classification: record.classification,
|
|
758
|
+
userStatus: record.userStatus,
|
|
759
|
+
userNote: record.userNote,
|
|
760
|
+
lastQuestion: record.lastQuestion,
|
|
761
|
+
lastAnswer: record.lastAnswer,
|
|
762
|
+
lastMessageText: record.lastMessageText,
|
|
763
|
+
migratedFrom: record.migratedFrom,
|
|
764
|
+
migratedTo: record.migratedTo,
|
|
765
|
+
linkedSessions: record.linkedSessions,
|
|
766
|
+
numTurns: record.numTurns ?? null,
|
|
767
|
+
}));
|
|
768
|
+
const nativeSessions = getNativeGeminiSessions(resolvedWorkdir);
|
|
769
|
+
const merged = mergeManagedAndNativeSessions(pikiloopSessions, nativeSessions);
|
|
770
|
+
const sessions = typeof limit === 'number' ? merged.slice(0, limit) : merged;
|
|
771
|
+
const projectName = geminiProjectName(resolvedWorkdir);
|
|
772
|
+
const chatsDir = projectName ? geminiChatsDir(resolvedWorkdir) || '' : '';
|
|
773
|
+
agentLog(`[sessions:gemini] workdir=${resolvedWorkdir} projectName=${projectName || '(none)'} chatsDir=${chatsDir || '(none)'} ` +
|
|
774
|
+
`chatsDirExists=${chatsDir ? fs.existsSync(chatsDir) : false} pikiloop=${pikiloopSessions.length} native=${nativeSessions.length} merged=${sessions.length}`);
|
|
775
|
+
return { ok: true, sessions, error: null };
|
|
776
|
+
}
|
|
777
|
+
function getGeminiSessionTail(opts) {
|
|
778
|
+
const limit = opts.limit ?? 4;
|
|
779
|
+
const filePath = findGeminiSessionFile(opts.workdir, opts.sessionId);
|
|
780
|
+
if (!filePath)
|
|
781
|
+
return { ok: false, messages: [], error: 'Session file not found' };
|
|
782
|
+
try {
|
|
783
|
+
const data = loadGeminiSessionData(filePath);
|
|
784
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
785
|
+
const allMsgs = [];
|
|
786
|
+
for (const msg of messages) {
|
|
787
|
+
const type = typeof msg?.type === 'string' ? msg.type.trim().toLowerCase() : '';
|
|
788
|
+
const role = type === 'user' ? 'user' : (type === 'gemini' || type === 'model' || type === 'assistant') ? 'assistant' : null;
|
|
789
|
+
if (!role)
|
|
790
|
+
continue;
|
|
791
|
+
const rawText = extractGeminiText(msg?.content);
|
|
792
|
+
const text = role === 'user' ? dropGeminiFileRefs(cleanGeminiUserText(rawText)) : rawText;
|
|
793
|
+
if (text)
|
|
794
|
+
allMsgs.push({ role, text });
|
|
795
|
+
}
|
|
796
|
+
return { ok: true, messages: allMsgs.slice(-limit), error: null };
|
|
797
|
+
}
|
|
798
|
+
catch (e) {
|
|
799
|
+
return { ok: false, messages: [], error: e.message };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
// Session messages (full content)
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
function getGeminiSessionMessages(opts) {
|
|
806
|
+
const filePath = findGeminiSessionFile(opts.workdir, opts.sessionId);
|
|
807
|
+
if (!filePath)
|
|
808
|
+
return { ok: false, messages: [], totalTurns: 0, error: 'Session file not found' };
|
|
809
|
+
try {
|
|
810
|
+
const data = loadGeminiSessionData(filePath);
|
|
811
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
812
|
+
const allMsgs = [];
|
|
813
|
+
const richMsgs = [];
|
|
814
|
+
for (const msg of messages) {
|
|
815
|
+
const type = typeof msg?.type === 'string' ? msg.type.trim().toLowerCase() : '';
|
|
816
|
+
const role = type === 'user' ? 'user' : (type === 'gemini' || type === 'model' || type === 'assistant') ? 'assistant' : null;
|
|
817
|
+
if (!role)
|
|
818
|
+
continue;
|
|
819
|
+
const rawText = extractGeminiText(msg?.content);
|
|
820
|
+
if (role === 'user') {
|
|
821
|
+
const { text, blocks: imageBlocks } = buildGeminiUserMessageContent(rawText, opts.workdir);
|
|
822
|
+
if (!text && !imageBlocks.length)
|
|
823
|
+
continue;
|
|
824
|
+
allMsgs.push({ role, text });
|
|
825
|
+
const blocks = [];
|
|
826
|
+
if (text)
|
|
827
|
+
blocks.push({ type: 'text', content: text });
|
|
828
|
+
blocks.push(...imageBlocks);
|
|
829
|
+
richMsgs.push({ role, text, blocks });
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
if (!rawText)
|
|
833
|
+
continue;
|
|
834
|
+
allMsgs.push({ role, text: rawText });
|
|
835
|
+
richMsgs.push({ role, text: rawText, blocks: [{ type: 'text', content: rawText }] });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return applyTurnWindow(allMsgs, opts, opts.rich ? richMsgs : undefined);
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
return { ok: false, messages: [], totalTurns: 0, error: e.message };
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Models — static list for now, can be extended with `gemini models list`
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
// Model IDs from gemini-cli-core (no CLI command to list them dynamically)
|
|
848
|
+
const GEMINI_MODELS = [
|
|
849
|
+
{ id: 'auto-gemini-3', alias: 'auto-3' },
|
|
850
|
+
{ id: 'auto-gemini-2.5', alias: 'auto' },
|
|
851
|
+
{ id: 'gemini-3.1-pro-preview', alias: '3.1-pro' },
|
|
852
|
+
{ id: 'gemini-3-pro-preview', alias: '3-pro' },
|
|
853
|
+
{ id: 'gemini-3-flash-preview', alias: '3-flash' },
|
|
854
|
+
{ id: 'gemini-2.5-pro', alias: 'pro' },
|
|
855
|
+
{ id: 'gemini-2.5-flash', alias: 'flash' },
|
|
856
|
+
{ id: 'gemini-2.5-flash-lite', alias: 'flash-lite' },
|
|
857
|
+
];
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// Usage
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
const GEMINI_USAGE_TIMEOUT_MS = GEMINI_USAGE_TIMEOUTS.request;
|
|
862
|
+
const GEMINI_USAGE_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
|
863
|
+
let lastGeminiUsage = null;
|
|
864
|
+
function cachedGeminiUsage(error) {
|
|
865
|
+
return lastGeminiUsage?.ok ? lastGeminiUsage : emptyUsage('gemini', error);
|
|
866
|
+
}
|
|
867
|
+
function getGeminiOAuthToken() {
|
|
868
|
+
const home = getHome();
|
|
869
|
+
if (!home)
|
|
870
|
+
return null;
|
|
871
|
+
const credsPath = path.join(home, '.gemini', 'oauth_creds.json');
|
|
872
|
+
try {
|
|
873
|
+
const raw = fs.readFileSync(credsPath, 'utf-8').trim();
|
|
874
|
+
if (!raw || raw[0] !== '{')
|
|
875
|
+
return null;
|
|
876
|
+
const parsed = JSON.parse(raw);
|
|
877
|
+
const token = typeof parsed?.access_token === 'string' ? parsed.access_token.trim() : '';
|
|
878
|
+
return token || null;
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function geminiUsageLabel(modelId) {
|
|
885
|
+
const raw = typeof modelId === 'string' ? modelId.trim() : '';
|
|
886
|
+
const lower = raw.toLowerCase();
|
|
887
|
+
if (!lower)
|
|
888
|
+
return 'Gemini';
|
|
889
|
+
if (lower.includes('flash-lite'))
|
|
890
|
+
return 'Flash Lite';
|
|
891
|
+
if (lower.includes('flash'))
|
|
892
|
+
return 'Flash';
|
|
893
|
+
if (lower.includes('pro'))
|
|
894
|
+
return 'Pro';
|
|
895
|
+
return raw
|
|
896
|
+
.replace(/^gemini-/i, '')
|
|
897
|
+
.replace(/[-_]+/g, ' ')
|
|
898
|
+
.trim() || 'Gemini';
|
|
899
|
+
}
|
|
900
|
+
function geminiUsageStatus(usedPercent) {
|
|
901
|
+
if (usedPercent == null)
|
|
902
|
+
return null;
|
|
903
|
+
if (usedPercent >= 100)
|
|
904
|
+
return 'limit_reached';
|
|
905
|
+
if (usedPercent >= 80)
|
|
906
|
+
return 'warning';
|
|
907
|
+
return 'allowed';
|
|
908
|
+
}
|
|
909
|
+
function geminiResetAt(value) {
|
|
910
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
911
|
+
if (!raw)
|
|
912
|
+
return null;
|
|
913
|
+
const ms = Date.parse(raw);
|
|
914
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
915
|
+
}
|
|
916
|
+
function geminiResetAtMs(value) {
|
|
917
|
+
if (!value)
|
|
918
|
+
return Number.POSITIVE_INFINITY;
|
|
919
|
+
const ms = Date.parse(value);
|
|
920
|
+
return Number.isFinite(ms) ? ms : Number.POSITIVE_INFINITY;
|
|
921
|
+
}
|
|
922
|
+
function geminiUsageWindowSort(label) {
|
|
923
|
+
switch (label) {
|
|
924
|
+
case 'Pro': return 0;
|
|
925
|
+
case 'Flash': return 1;
|
|
926
|
+
case 'Flash Lite': return 2;
|
|
927
|
+
default: return 10;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function parseGeminiUsageResponse(data, capturedAt) {
|
|
931
|
+
const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
|
|
932
|
+
const grouped = new Map();
|
|
933
|
+
for (const bucket of buckets) {
|
|
934
|
+
const remainingFraction = Number(bucket?.remainingFraction);
|
|
935
|
+
if (!Number.isFinite(remainingFraction))
|
|
936
|
+
continue;
|
|
937
|
+
const label = geminiUsageLabel(bucket?.modelId);
|
|
938
|
+
const resetAt = geminiResetAt(bucket?.resetTime);
|
|
939
|
+
const prev = grouped.get(label);
|
|
940
|
+
if (!prev
|
|
941
|
+
|| remainingFraction < prev.remainingFraction
|
|
942
|
+
|| (remainingFraction === prev.remainingFraction && geminiResetAtMs(resetAt) < geminiResetAtMs(prev.resetAt))) {
|
|
943
|
+
grouped.set(label, { label, remainingFraction, resetAt });
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const windows = [...grouped.values()]
|
|
947
|
+
.map(entry => {
|
|
948
|
+
const usedPercent = roundPercent((1 - entry.remainingFraction) * 100);
|
|
949
|
+
const remainingPercent = roundPercent(entry.remainingFraction * 100);
|
|
950
|
+
let resetAfterSeconds = null;
|
|
951
|
+
if (entry.resetAt) {
|
|
952
|
+
const resetAtMs = Date.parse(entry.resetAt);
|
|
953
|
+
if (Number.isFinite(resetAtMs))
|
|
954
|
+
resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
label: entry.label,
|
|
958
|
+
usedPercent,
|
|
959
|
+
remainingPercent,
|
|
960
|
+
resetAt: entry.resetAt,
|
|
961
|
+
resetAfterSeconds,
|
|
962
|
+
status: geminiUsageStatus(usedPercent),
|
|
963
|
+
};
|
|
964
|
+
})
|
|
965
|
+
.sort((a, b) => {
|
|
966
|
+
const byLabel = geminiUsageWindowSort(a.label) - geminiUsageWindowSort(b.label);
|
|
967
|
+
return byLabel || a.label.localeCompare(b.label);
|
|
968
|
+
});
|
|
969
|
+
if (!windows.length)
|
|
970
|
+
return null;
|
|
971
|
+
const status = windows.some(window => window.status === 'limit_reached') ? 'limit_reached'
|
|
972
|
+
: windows.some(window => window.status === 'warning') ? 'warning'
|
|
973
|
+
: 'allowed';
|
|
974
|
+
return { ok: true, agent: 'gemini', source: 'quota-api', capturedAt, status, windows, error: null };
|
|
975
|
+
}
|
|
976
|
+
function geminiUsageError(status, bodyText) {
|
|
977
|
+
let detail = '';
|
|
978
|
+
const trimmed = String(bodyText || '').trim();
|
|
979
|
+
if (trimmed && trimmed[0] === '{') {
|
|
980
|
+
try {
|
|
981
|
+
const parsed = JSON.parse(trimmed);
|
|
982
|
+
detail = normalizeErrorMessage(parsed?.error?.message)
|
|
983
|
+
|| normalizeErrorMessage(parsed?.error)
|
|
984
|
+
|| normalizeErrorMessage(parsed?.message)
|
|
985
|
+
|| '';
|
|
986
|
+
}
|
|
987
|
+
catch { }
|
|
988
|
+
}
|
|
989
|
+
return cachedGeminiUsage(`HTTP ${status}${detail ? `: ${detail}` : ''}`);
|
|
990
|
+
}
|
|
991
|
+
async function getGeminiUsageLive() {
|
|
992
|
+
const token = getGeminiOAuthToken();
|
|
993
|
+
if (!token)
|
|
994
|
+
return cachedGeminiUsage('Gemini OAuth token not found.');
|
|
995
|
+
try {
|
|
996
|
+
const raw = execSync(`curl -sS --max-time ${Math.ceil(GEMINI_USAGE_TIMEOUT_MS / 1000)} -w '\\n%{http_code}' -H ${Q(`Authorization: Bearer ${token}`)} -H 'Content-Type: application/json' -d '{}' ${Q(GEMINI_USAGE_URL)}`, { encoding: 'utf-8', timeout: GEMINI_USAGE_TIMEOUT_MS + GEMINI_USAGE_TIMEOUTS.execSyncBuffer });
|
|
997
|
+
const trimmed = raw.trimEnd();
|
|
998
|
+
const sep = trimmed.lastIndexOf('\n');
|
|
999
|
+
const bodyText = sep >= 0 ? trimmed.slice(0, sep) : '';
|
|
1000
|
+
const status = Number(sep >= 0 ? trimmed.slice(sep + 1).trim() : '');
|
|
1001
|
+
if (!Number.isFinite(status))
|
|
1002
|
+
return cachedGeminiUsage('Gemini quota query returned an invalid HTTP status.');
|
|
1003
|
+
if (status < 200 || status >= 300)
|
|
1004
|
+
return geminiUsageError(status, bodyText);
|
|
1005
|
+
if (!bodyText.trim() || bodyText.trim()[0] !== '{')
|
|
1006
|
+
return cachedGeminiUsage('Gemini quota query returned an invalid response.');
|
|
1007
|
+
const usage = parseGeminiUsageResponse(JSON.parse(bodyText), new Date().toISOString())
|
|
1008
|
+
|| cachedGeminiUsage('No Gemini quota buckets returned.');
|
|
1009
|
+
if (usage.ok)
|
|
1010
|
+
lastGeminiUsage = usage;
|
|
1011
|
+
return usage;
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
const detail = normalizeErrorMessage(err?.message || err) || 'Gemini usage query failed.';
|
|
1015
|
+
return cachedGeminiUsage(detail);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// ---------------------------------------------------------------------------
|
|
1019
|
+
// Driver
|
|
1020
|
+
// ---------------------------------------------------------------------------
|
|
1021
|
+
class GeminiDriver {
|
|
1022
|
+
id = 'gemini';
|
|
1023
|
+
cmd = 'gemini';
|
|
1024
|
+
thinkLabel = 'Thinking';
|
|
1025
|
+
acceptedProviderKinds = ['google'];
|
|
1026
|
+
async doStream(opts) { return doGeminiStream(opts); }
|
|
1027
|
+
async getSessions(workdir, limit) {
|
|
1028
|
+
return getGeminiSessions(workdir, limit);
|
|
1029
|
+
}
|
|
1030
|
+
async getSessionTail(opts) {
|
|
1031
|
+
return getGeminiSessionTail(opts);
|
|
1032
|
+
}
|
|
1033
|
+
async getSessionMessages(opts) {
|
|
1034
|
+
return getGeminiSessionMessages(opts);
|
|
1035
|
+
}
|
|
1036
|
+
async listModels(_opts) {
|
|
1037
|
+
return { agent: 'gemini', models: [...GEMINI_MODELS], sources: [], note: null };
|
|
1038
|
+
}
|
|
1039
|
+
getUsage(_opts) {
|
|
1040
|
+
return cachedGeminiUsage('No recent Gemini usage data found.');
|
|
1041
|
+
}
|
|
1042
|
+
async getUsageLive(_opts) {
|
|
1043
|
+
return getGeminiUsageLive();
|
|
1044
|
+
}
|
|
1045
|
+
async deleteNativeSession(workdir, sessionId) {
|
|
1046
|
+
const file = findGeminiSessionFile(workdir, sessionId);
|
|
1047
|
+
if (!file)
|
|
1048
|
+
return [];
|
|
1049
|
+
try {
|
|
1050
|
+
fs.rmSync(file, { force: true });
|
|
1051
|
+
return [file];
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return [];
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
shutdown() { }
|
|
1058
|
+
}
|
|
1059
|
+
registerDriver(new GeminiDriver());
|