lazyclaw 6.3.1 → 6.5.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/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +41 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +27 -3
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +68 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +29 -2
- package/providers/tool_use/openai.mjs +35 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// providers/cli_login.mjs — connect/login helpers for the keyless CLI
|
|
2
|
+
// providers (codex-cli / gemini-cli) so the picker can offer an inline
|
|
3
|
+
// "log in / connect" action instead of dead-ending on the CLI's own
|
|
4
|
+
// "please log in" message.
|
|
5
|
+
//
|
|
6
|
+
// Two halves:
|
|
7
|
+
// - cliLoginStatus(): pure, dependency-injectable detection of whether a
|
|
8
|
+
// provider's CLI is installed and signed in (unit-tested).
|
|
9
|
+
// - runCliLoginInteractive(): spawns the real login / install subprocess
|
|
10
|
+
// with the terminal inherited. Driven from the chat post-loop guard
|
|
11
|
+
// after the Ink UI releases stdin (same mechanism /setup uses), so the
|
|
12
|
+
// browser-OAuth flow gets a real TTY.
|
|
13
|
+
//
|
|
14
|
+
// codex has a headless login (`codex login`, `codex login status`); gemini
|
|
15
|
+
// does NOT — its Google sign-in only happens by launching `gemini`
|
|
16
|
+
// interactively, or by supplying GEMINI_API_KEY. We model both.
|
|
17
|
+
|
|
18
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
19
|
+
import { existsSync as _existsSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export const CLI_LOGIN_PROVIDERS = {
|
|
24
|
+
'codex-cli': {
|
|
25
|
+
bin: 'codex',
|
|
26
|
+
pkg: '@openai/codex',
|
|
27
|
+
// `codex login` opens a browser for ChatGPT OAuth; `codex login status`
|
|
28
|
+
// exits 0 when signed in. `codex login --with-api-key` reads a key on stdin.
|
|
29
|
+
loginArgs: ['login'],
|
|
30
|
+
statusArgs: ['login', 'status'],
|
|
31
|
+
apiKeyStdinArgs: ['login', '--with-api-key'],
|
|
32
|
+
browserHint: 'codex login',
|
|
33
|
+
apiKeyHint: 'OpenAI key (sk-…) — stored by codex via `codex login --with-api-key`',
|
|
34
|
+
},
|
|
35
|
+
'gemini-cli': {
|
|
36
|
+
bin: 'gemini',
|
|
37
|
+
pkg: '@google/gemini-cli',
|
|
38
|
+
// No headless login: Google OAuth only runs when `gemini` is launched
|
|
39
|
+
// interactively. We detect sign-in by the presence of the OAuth creds
|
|
40
|
+
// file (or a GEMINI_API_KEY in the environment).
|
|
41
|
+
loginArgs: [],
|
|
42
|
+
credPath: join(homedir(), '.gemini', 'oauth_creds.json'),
|
|
43
|
+
apiKeyEnv: 'GEMINI_API_KEY',
|
|
44
|
+
browserHint: 'gemini (Google sign-in)',
|
|
45
|
+
apiKeyHint: 'Google AI Studio key — saved in lazyclaw and passed as GEMINI_API_KEY',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Locate a binary on PATH without throwing. Returns the path or ''.
|
|
50
|
+
function _whichSync(bin) {
|
|
51
|
+
try {
|
|
52
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
|
|
53
|
+
if (r.status === 0) return String(r.stdout || '').split('\n')[0].trim();
|
|
54
|
+
} catch (_) { /* ignore */ }
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Run `<bin> <args>` just for its exit code (codex login status). Resolves the
|
|
59
|
+
// numeric code (or 1 on spawn error) — never rejects.
|
|
60
|
+
function _runForCode(bin, args) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let proc;
|
|
63
|
+
try {
|
|
64
|
+
proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
65
|
+
} catch (_) { resolve(1); return; }
|
|
66
|
+
proc.once('error', () => resolve(1));
|
|
67
|
+
proc.once('close', (code) => resolve(typeof code === 'number' ? code : 1));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect whether a keyless CLI provider is installed and signed in.
|
|
73
|
+
* All side-effecting lookups are injectable so this is unit-testable.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} provName 'codex-cli' | 'gemini-cli'
|
|
76
|
+
* @param {{
|
|
77
|
+
* which?: (bin:string)=>string,
|
|
78
|
+
* runStatus?: (bin:string, args:string[])=>Promise<number>,
|
|
79
|
+
* existsSync?: (p:string)=>boolean,
|
|
80
|
+
* env?: Record<string,string|undefined>,
|
|
81
|
+
* hasStoredKey?: boolean, // a key already saved in lazyclaw for this provider
|
|
82
|
+
* }} [deps]
|
|
83
|
+
* @returns {Promise<{supported:boolean, binMissing?:boolean, loggedIn?:boolean, via?:string, pkg?:string}>}
|
|
84
|
+
*/
|
|
85
|
+
export async function cliLoginStatus(provName, deps = {}) {
|
|
86
|
+
const spec = CLI_LOGIN_PROVIDERS[provName];
|
|
87
|
+
if (!spec) return { supported: false };
|
|
88
|
+
const which = deps.which || _whichSync;
|
|
89
|
+
if (!which(spec.bin)) {
|
|
90
|
+
return { supported: true, binMissing: true, loggedIn: false, pkg: spec.pkg };
|
|
91
|
+
}
|
|
92
|
+
// An explicit lazyclaw-stored key means we can authenticate regardless of
|
|
93
|
+
// the CLI's own login state (codex via env, gemini via GEMINI_API_KEY).
|
|
94
|
+
if (deps.hasStoredKey) return { supported: true, binMissing: false, loggedIn: true, via: 'api-key' };
|
|
95
|
+
if (spec.statusArgs) {
|
|
96
|
+
const code = await (deps.runStatus || _runForCode)(spec.bin, spec.statusArgs);
|
|
97
|
+
return { supported: true, binMissing: false, loggedIn: code === 0, via: `${spec.bin} ${spec.statusArgs.join(' ')}` };
|
|
98
|
+
}
|
|
99
|
+
// gemini: no status command — infer from creds file / env key.
|
|
100
|
+
const existsSync = deps.existsSync || _existsSync;
|
|
101
|
+
const env = deps.env || process.env;
|
|
102
|
+
const ok = (!!spec.credPath && existsSync(spec.credPath)) || (!!spec.apiKeyEnv && !!env[spec.apiKeyEnv]);
|
|
103
|
+
return { supported: true, binMissing: false, loggedIn: ok, via: 'creds/env' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Spawn a command with the terminal inherited; resolve on close (never reject).
|
|
107
|
+
function _spawnInherit(bin, args, opts = {}) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
let proc;
|
|
110
|
+
try {
|
|
111
|
+
proc = spawn(bin, args, { stdio: 'inherit', ...opts });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
process.stderr.write(`\n failed to launch ${bin}: ${e?.message || e}\n`);
|
|
114
|
+
resolve(1);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
proc.once('error', (e) => { process.stderr.write(`\n ${bin} error: ${e?.message || e}\n`); resolve(1); });
|
|
118
|
+
proc.once('close', (code) => resolve(typeof code === 'number' ? code : 0));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pipe a secret to a command's stdin (codex login --with-api-key), inheriting
|
|
123
|
+
// stdout/stderr so the user sees the result. Resolves the exit code.
|
|
124
|
+
function _spawnKeyStdin(bin, args, key) {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let proc;
|
|
127
|
+
try {
|
|
128
|
+
proc = spawn(bin, args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
129
|
+
} catch (e) { process.stderr.write(`\n failed to launch ${bin}: ${e?.message || e}\n`); resolve(1); return; }
|
|
130
|
+
proc.once('error', (e) => { process.stderr.write(`\n ${bin} error: ${e?.message || e}\n`); resolve(1); });
|
|
131
|
+
proc.once('close', (code) => resolve(typeof code === 'number' ? code : 0));
|
|
132
|
+
try { proc.stdin.write(String(key || '')); proc.stdin.end(); } catch (_) { /* closed already */ }
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run the chosen connect action in the foreground (terminal inherited). Called
|
|
138
|
+
* from the chat post-loop guard once Ink has released stdin.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ provider:string, mode:'browser'|'install'|'apikey', apiKey?:string }} req
|
|
141
|
+
*/
|
|
142
|
+
export async function runCliLoginInteractive(req = {}) {
|
|
143
|
+
const { provider, mode, apiKey } = req;
|
|
144
|
+
const spec = CLI_LOGIN_PROVIDERS[provider];
|
|
145
|
+
if (!spec) { process.stderr.write(`\n unknown login provider: ${provider}\n`); return; }
|
|
146
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
147
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
148
|
+
|
|
149
|
+
if (mode === 'install') {
|
|
150
|
+
process.stdout.write(`\n Installing ${spec.pkg} … ${dim(`npm i -g ${spec.pkg}`)}\n\n`);
|
|
151
|
+
const code = await _spawnInherit('npm', ['i', '-g', spec.pkg]);
|
|
152
|
+
process.stdout.write(code === 0
|
|
153
|
+
? `\n ${ok('✓ installed')} ${spec.pkg}. Re-open ${dim(`/provider ${provider}`)} to sign in.\n\n`
|
|
154
|
+
: `\n install exited ${code}. You can run ${dim(`npm i -g ${spec.pkg}`)} yourself.\n\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (mode === 'apikey') {
|
|
159
|
+
if (provider === 'codex-cli') {
|
|
160
|
+
process.stdout.write(`\n Storing your OpenAI key via ${dim('codex login --with-api-key')} …\n\n`);
|
|
161
|
+
const code = await _spawnKeyStdin(spec.bin, spec.apiKeyStdinArgs, apiKey);
|
|
162
|
+
process.stdout.write(code === 0 ? `\n ${ok('✓ codex signed in with an API key.')}\n\n` : `\n codex login exited ${code}.\n\n`);
|
|
163
|
+
}
|
|
164
|
+
// gemini's key is persisted in lazyclaw config by the caller and injected
|
|
165
|
+
// as GEMINI_API_KEY at spawn time — nothing to run here.
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Browser OAuth.
|
|
170
|
+
if (provider === 'codex-cli') {
|
|
171
|
+
process.stdout.write(`\n Opening ${dim('codex login')} — a sign-in URL will appear. Complete it in your browser, then return here.\n\n`);
|
|
172
|
+
const code = await _spawnInherit(spec.bin, spec.loginArgs);
|
|
173
|
+
process.stdout.write(code === 0 ? `\n ${ok('✓ codex signed in.')}\n\n` : `\n codex login exited ${code}.\n\n`);
|
|
174
|
+
} else if (provider === 'gemini-cli') {
|
|
175
|
+
process.stdout.write(`\n Launching ${dim('gemini')} for Google sign-in. Authenticate in the browser, then ${dim('/quit')} (or Ctrl-C) inside gemini to return.\n\n`);
|
|
176
|
+
await _spawnInherit(spec.bin, spec.loginArgs);
|
|
177
|
+
process.stdout.write(`\n Back in lazyclaw.\n\n`);
|
|
178
|
+
}
|
|
179
|
+
}
|
package/providers/codex_cli.mjs
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// shell input.
|
|
31
31
|
|
|
32
32
|
import { spawnSandboxed } from '../sandbox.mjs';
|
|
33
|
+
import { classifyCliExit } from './cli_error.mjs';
|
|
33
34
|
|
|
34
35
|
class AbortError extends Error {
|
|
35
36
|
constructor(message = 'aborted') {
|
|
@@ -51,19 +52,23 @@ class CliExitError extends Error {
|
|
|
51
52
|
constructor(code, signal, stderr) {
|
|
52
53
|
super(`codex CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
53
54
|
this.name = 'CodexCliExitError';
|
|
54
|
-
|
|
55
|
+
// Transient upstream throttle → retriable RATE_LIMIT; genuine cap → CLI_EXIT.
|
|
56
|
+
const cls = classifyCliExit(stderr);
|
|
57
|
+
this.code = cls.code;
|
|
58
|
+
if (cls.retryAfterMs !== undefined) this.retryAfterMs = cls.retryAfterMs;
|
|
55
59
|
this.exitCode = code;
|
|
56
60
|
this.signal = signal;
|
|
57
61
|
this.stderr = stderr;
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// No alias map. The previous aliases mapped "codex"/"gpt-codex" to
|
|
66
|
+
// "gpt-5-codex", but that model is rejected by a ChatGPT-account codex
|
|
67
|
+
// login ("not supported when using Codex with a ChatGPT account"), so the
|
|
68
|
+
// alias actively produced a broken `-m`. An empty/unknown model now means
|
|
69
|
+
// "no -m" → codex falls back to the account default in ~/.codex/config.toml,
|
|
70
|
+
// which is the only model set guaranteed to be allowed for that login.
|
|
71
|
+
const _ALIASES = {};
|
|
67
72
|
|
|
68
73
|
// Drop cross-vendor model ids (claude-*, gemini-*) silently so the
|
|
69
74
|
// CLI falls back to its own default. The orchestrator workflow forwards
|
|
@@ -107,13 +112,48 @@ function extractEventText(obj) {
|
|
|
107
112
|
return '';
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
// Pull a human-readable error out of a codex failure event. Codex reports
|
|
116
|
+
// API/turn failures on STDOUT (not stderr) as either:
|
|
117
|
+
// {"type":"error","message":"<json-string>"}
|
|
118
|
+
// {"type":"turn.failed","error":{"message":"<json-string>"}}
|
|
119
|
+
// where the message is usually itself a JSON document
|
|
120
|
+
// {"type":"error","status":400,"error":{"message":"<the real reason>"}}.
|
|
121
|
+
// We unwrap one level of nesting so callers surface "The 'x' model is not
|
|
122
|
+
// supported …" instead of the misleading "Reading additional input from
|
|
123
|
+
// stdin…" the CLI happens to print on stderr. Returns '' for non-error events.
|
|
124
|
+
function extractEventError(obj) {
|
|
125
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
126
|
+
if (obj.type !== 'error' && obj.type !== 'turn.failed') return '';
|
|
127
|
+
let raw = obj.type === 'turn.failed' ? (obj.error?.message ?? obj.message) : obj.message;
|
|
128
|
+
if (typeof raw === 'string') {
|
|
129
|
+
try {
|
|
130
|
+
const inner = JSON.parse(raw);
|
|
131
|
+
raw = inner?.error?.message || inner?.message || raw;
|
|
132
|
+
} catch (_) { /* not nested JSON — use the string as-is */ }
|
|
133
|
+
}
|
|
134
|
+
return typeof raw === 'string' ? raw : JSON.stringify(raw ?? obj);
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
function extractUsage(obj) {
|
|
111
138
|
if (!obj || typeof obj !== 'object' || obj.type !== 'turn.completed') return null;
|
|
112
139
|
const u = obj.usage || {};
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
// OpenAI Responses-API convention (codex follows it): input_tokens already
|
|
141
|
+
// INCLUDES cached_input_tokens, and output_tokens already INCLUDES
|
|
142
|
+
// reasoning_output_tokens — they are subset breakdowns, not additive. Summing
|
|
143
|
+
// them double-counts and trips the cost cap early. Report NET (non-cached)
|
|
144
|
+
// input — total minus the cached subset — to match Anthropic's convention so
|
|
145
|
+
// rates.mjs's single cost formula doesn't bill the cached tokens at BOTH the
|
|
146
|
+
// input rate and the cache-read rate. Surface the cached subset separately.
|
|
147
|
+
const cacheRead = u.cached_input_tokens ?? 0;
|
|
148
|
+
const input = (u.input_tokens ?? 0) - cacheRead;
|
|
149
|
+
const output = u.output_tokens ?? 0;
|
|
150
|
+
if (!input && !output && !cacheRead) return null;
|
|
151
|
+
return {
|
|
152
|
+
inputTokens: input,
|
|
153
|
+
outputTokens: output,
|
|
154
|
+
cacheReadInputTokens: cacheRead,
|
|
155
|
+
totalCostUsd: 0,
|
|
156
|
+
};
|
|
117
157
|
}
|
|
118
158
|
|
|
119
159
|
export const codexCliProvider = {
|
|
@@ -160,6 +200,7 @@ export const codexCliProvider = {
|
|
|
160
200
|
|
|
161
201
|
proc.stdout.setEncoding('utf8');
|
|
162
202
|
let buffer = '';
|
|
203
|
+
let apiError = null;
|
|
163
204
|
let exitInfo = null;
|
|
164
205
|
const exitPromise = new Promise((resolve) => {
|
|
165
206
|
proc.on('close', (code, signal) => {
|
|
@@ -181,6 +222,8 @@ export const codexCliProvider = {
|
|
|
181
222
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
182
223
|
const text = extractEventText(obj);
|
|
183
224
|
if (text) yield text;
|
|
225
|
+
const errMsg = extractEventError(obj);
|
|
226
|
+
if (errMsg) apiError = errMsg;
|
|
184
227
|
const usage = extractUsage(obj);
|
|
185
228
|
if (usage && typeof opts.onUsage === 'function') {
|
|
186
229
|
try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
|
|
@@ -192,6 +235,14 @@ export const codexCliProvider = {
|
|
|
192
235
|
const obj = JSON.parse(buffer.trim());
|
|
193
236
|
const text = extractEventText(obj);
|
|
194
237
|
if (text) yield text;
|
|
238
|
+
// Drain usage + error too, so a final turn.completed/turn.failed that
|
|
239
|
+
// isn't newline-terminated still reports tokens and surfaces the error.
|
|
240
|
+
const errMsg = extractEventError(obj);
|
|
241
|
+
if (errMsg) apiError = errMsg;
|
|
242
|
+
const usage = extractUsage(obj);
|
|
243
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
244
|
+
try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
|
|
245
|
+
}
|
|
195
246
|
} catch (_) { /* incomplete tail — drop */ }
|
|
196
247
|
}
|
|
197
248
|
// Wait for either a clean exit or an async spawn error. On ENOENT the
|
|
@@ -201,6 +252,13 @@ export const codexCliProvider = {
|
|
|
201
252
|
if (spawnError) {
|
|
202
253
|
throw spawnError.code === 'ENOENT' ? new CliMissingError() : spawnError;
|
|
203
254
|
}
|
|
255
|
+
// Prefer the real API/turn error (carried on stdout) over the CLI's
|
|
256
|
+
// unhelpful stderr ("Reading additional input from stdin…"). codex
|
|
257
|
+
// exits non-zero on turn.failed, so this runs before the generic
|
|
258
|
+
// exit-code branch and gives the actionable message.
|
|
259
|
+
if (apiError && !opts.signal?.aborted) {
|
|
260
|
+
throw new CliExitError(exitInfo?.code ?? 1, exitInfo?.signal ?? null, apiError);
|
|
261
|
+
}
|
|
204
262
|
if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
|
|
205
263
|
throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
|
|
206
264
|
}
|
|
@@ -213,4 +271,4 @@ export const codexCliProvider = {
|
|
|
213
271
|
},
|
|
214
272
|
};
|
|
215
273
|
|
|
216
|
-
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractUsage };
|
|
274
|
+
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractEventError, extractUsage };
|
package/providers/gemini.mjs
CHANGED
|
@@ -50,6 +50,60 @@ class ApiError extends Error {
|
|
|
50
50
|
this.body = body;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
class TimeoutError extends Error {
|
|
54
|
+
constructor(idleMs) {
|
|
55
|
+
super(`gemini: idle timeout — no stream activity for ${idleMs}ms`);
|
|
56
|
+
this.name = 'TimeoutError';
|
|
57
|
+
this.code = 'TIMEOUT';
|
|
58
|
+
this.idleMs = idleMs;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 120000;
|
|
63
|
+
|
|
64
|
+
// Resolve the IDLE (inter-chunk) timeout once per request: opts override
|
|
65
|
+
// wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
|
|
66
|
+
function resolveIdleTimeoutMs(opts) {
|
|
67
|
+
if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
|
|
68
|
+
const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
|
|
69
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
70
|
+
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
|
|
74
|
+
// arrived for idleMs (the timer resets on every received chunk, and also
|
|
75
|
+
// guards the connect/first-byte phase). This is NOT a total-duration cap,
|
|
76
|
+
// so a long but healthy generation that streams steadily is never aborted.
|
|
77
|
+
// `controller` is aborted on idle expiry so the real connection tears down.
|
|
78
|
+
async function* iterateWithIdleTimeout(body, idleMs, controller) {
|
|
79
|
+
const iterator = iterateBody(body)[Symbol.asyncIterator]();
|
|
80
|
+
try {
|
|
81
|
+
while (true) {
|
|
82
|
+
let timer;
|
|
83
|
+
const idle = new Promise((_, reject) => {
|
|
84
|
+
timer = setTimeout(() => {
|
|
85
|
+
controller.abort();
|
|
86
|
+
reject(new TimeoutError(idleMs));
|
|
87
|
+
}, idleMs);
|
|
88
|
+
});
|
|
89
|
+
let step;
|
|
90
|
+
try {
|
|
91
|
+
step = await Promise.race([iterator.next(), idle]);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
if (step.done) return;
|
|
96
|
+
yield step.value;
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
// Best-effort, NON-awaited cleanup: on idle abort the underlying reader
|
|
100
|
+
// may be suspended on a read that never settles, so awaiting return()
|
|
101
|
+
// would re-hang us. The controller abort already tears down the socket.
|
|
102
|
+
if (typeof iterator.return === 'function') {
|
|
103
|
+
try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
53
107
|
|
|
54
108
|
function parseRetryAfterMs(headers) {
|
|
55
109
|
let raw = null;
|
|
@@ -117,6 +171,7 @@ function toGeminiBody(messages, opts) {
|
|
|
117
171
|
}
|
|
118
172
|
const body = { contents };
|
|
119
173
|
if (systemText) body.systemInstruction = { parts: [{ text: systemText }] };
|
|
174
|
+
if (Number.isFinite(opts.maxTokens)) body.generationConfig = { maxOutputTokens: opts.maxTokens };
|
|
120
175
|
return body;
|
|
121
176
|
}
|
|
122
177
|
|
|
@@ -131,16 +186,31 @@ export const geminiProvider = {
|
|
|
131
186
|
const fetchFn = opts.fetch || globalThis.fetch;
|
|
132
187
|
if (!fetchFn) throw new Error('gemini: no fetch implementation available');
|
|
133
188
|
const baseUrl = (opts.baseUrl || DEFAULT_BASE).replace(/\/$/, '');
|
|
134
|
-
const model = opts.model || 'gemini-
|
|
189
|
+
const model = opts.model || 'gemini-2.5-pro';
|
|
135
190
|
|
|
136
191
|
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
137
192
|
|
|
193
|
+
// Compose the caller's signal with an internal controller that the idle
|
|
194
|
+
// timeout aborts. The composed signal goes to fetch so a stalled
|
|
195
|
+
// connection (or a user cancel) tears down the real socket.
|
|
196
|
+
const idleMs = resolveIdleTimeoutMs(opts);
|
|
197
|
+
const idleController = new AbortController();
|
|
198
|
+
const fetchSignal = opts.signal
|
|
199
|
+
? (typeof AbortSignal.any === 'function'
|
|
200
|
+
? AbortSignal.any([opts.signal, idleController.signal])
|
|
201
|
+
: idleController.signal)
|
|
202
|
+
: idleController.signal;
|
|
203
|
+
const forwardCallerAbort = () => idleController.abort();
|
|
204
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
205
|
+
opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
138
208
|
const url = `${baseUrl}/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(opts.apiKey)}`;
|
|
139
209
|
const res = await fetchFn(url, {
|
|
140
210
|
method: 'POST',
|
|
141
211
|
headers: { 'content-type': 'application/json' },
|
|
142
212
|
body: JSON.stringify(toGeminiBody(messages, opts)),
|
|
143
|
-
signal:
|
|
213
|
+
signal: fetchSignal,
|
|
144
214
|
});
|
|
145
215
|
|
|
146
216
|
if (!res.ok) {
|
|
@@ -152,7 +222,12 @@ export const geminiProvider = {
|
|
|
152
222
|
|
|
153
223
|
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
154
224
|
let buffer = '';
|
|
155
|
-
|
|
225
|
+
let lastUsage = null; // Gemini sends a cumulative usageMetadata; keep the last
|
|
226
|
+
let lastFinish = null;
|
|
227
|
+
try {
|
|
228
|
+
for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
|
|
229
|
+
// A user cancel surfaces as an idle-controller abort too; map it back
|
|
230
|
+
// to ABORT so it stays distinguishable from a TIMEOUT.
|
|
156
231
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
157
232
|
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
158
233
|
let consumed = 0;
|
|
@@ -169,6 +244,9 @@ export const geminiProvider = {
|
|
|
169
244
|
if (typeof p?.text === 'string' && p.text) yield p.text;
|
|
170
245
|
}
|
|
171
246
|
}
|
|
247
|
+
if (obj?.usageMetadata) lastUsage = obj.usageMetadata;
|
|
248
|
+
const fr = obj?.candidates?.[0]?.finishReason;
|
|
249
|
+
if (fr) lastFinish = fr;
|
|
172
250
|
// Some error responses surface mid-stream as {error: {...}}.
|
|
173
251
|
if (obj?.error) {
|
|
174
252
|
const message = obj.error.message || JSON.stringify(obj.error);
|
|
@@ -181,6 +259,29 @@ export const geminiProvider = {
|
|
|
181
259
|
}
|
|
182
260
|
if (consumed > 0) buffer = buffer.slice(consumed);
|
|
183
261
|
}
|
|
262
|
+
} finally {
|
|
263
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
264
|
+
opts.signal.removeEventListener('abort', forwardCallerAbort);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Stream completed cleanly — surface the final usage + any truncation.
|
|
268
|
+
if (lastUsage && typeof opts.onUsage === 'function') {
|
|
269
|
+
try {
|
|
270
|
+
opts.onUsage({
|
|
271
|
+
// promptTokenCount is cache-INCLUSIVE; report NET (minus cached) to
|
|
272
|
+
// match Anthropic's convention so rates.mjs doesn't bill the cached
|
|
273
|
+
// tokens at BOTH the input rate and the cache-read rate.
|
|
274
|
+
inputTokens: (lastUsage.promptTokenCount ?? 0) - (lastUsage.cachedContentTokenCount ?? 0),
|
|
275
|
+
outputTokens: lastUsage.candidatesTokenCount ?? null,
|
|
276
|
+
cacheReadInputTokens: lastUsage.cachedContentTokenCount ?? 0,
|
|
277
|
+
totalCostUsd: 0,
|
|
278
|
+
});
|
|
279
|
+
} catch { /* never fail the stream on a usage callback */ }
|
|
280
|
+
}
|
|
281
|
+
if (typeof opts.onTruncated === 'function'
|
|
282
|
+
&& (lastFinish === 'MAX_TOKENS' || lastFinish === 'SAFETY' || lastFinish === 'RECITATION')) {
|
|
283
|
+
try { opts.onTruncated(lastFinish); } catch { /* ditto */ }
|
|
284
|
+
}
|
|
184
285
|
},
|
|
185
286
|
};
|
|
186
287
|
|
package/providers/gemini_cli.mjs
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
// REPL both treat AsyncIterable<string> as their only contract, so
|
|
20
20
|
// non-streaming is transparent to callers.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// The trusted-folder bypass (`--skip-trust` + GEMINI_CLI_TRUST_WORKSPACE) is
|
|
23
|
+
// enabled by default because lazyclaw orchestrator / workflow subprocesses
|
|
24
|
+
// commonly run from /tmp or scratch dirs that gemini's trusted-folder policy
|
|
25
|
+
// rejects in headless -p mode. It is a genuine trust bypass, now consolidated
|
|
26
|
+
// behind one switch (opts.trustWorkspace, default true) — see geminiArgs/geminiEnv.
|
|
26
27
|
|
|
27
28
|
import { spawnSandboxed } from '../sandbox.mjs';
|
|
29
|
+
import { classifyCliExit } from './cli_error.mjs';
|
|
28
30
|
|
|
29
31
|
class AbortError extends Error {
|
|
30
32
|
constructor(message = 'aborted') {
|
|
@@ -46,7 +48,10 @@ class CliExitError extends Error {
|
|
|
46
48
|
constructor(code, signal, stderr) {
|
|
47
49
|
super(`gemini CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
48
50
|
this.name = 'GeminiCliExitError';
|
|
49
|
-
|
|
51
|
+
// Transient upstream throttle → retriable RATE_LIMIT; genuine cap → CLI_EXIT.
|
|
52
|
+
const cls = classifyCliExit(stderr);
|
|
53
|
+
this.code = cls.code;
|
|
54
|
+
if (cls.retryAfterMs !== undefined) this.retryAfterMs = cls.retryAfterMs;
|
|
50
55
|
this.exitCode = code;
|
|
51
56
|
this.signal = signal;
|
|
52
57
|
this.stderr = stderr;
|
|
@@ -95,14 +100,49 @@ function buildPrompt(messages, system) {
|
|
|
95
100
|
function extractUsage(stats) {
|
|
96
101
|
if (!stats || typeof stats !== 'object') return null;
|
|
97
102
|
const models = stats.models || {};
|
|
98
|
-
let input = 0, output = 0;
|
|
103
|
+
let input = 0, output = 0, cached = 0;
|
|
99
104
|
for (const m of Object.values(models)) {
|
|
100
105
|
const t = m?.tokens || {};
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
// cached is a subset of prompt → report NET (prompt minus cached) so it
|
|
107
|
+
// matches Anthropic's convention and rates.mjs doesn't bill the cached
|
|
108
|
+
// tokens at BOTH the input rate and the cache-read rate. Surface cached
|
|
109
|
+
// separately so it can still bill at the cache-read rate.
|
|
110
|
+
input += (t.prompt ?? t.input ?? 0) - (t.cached ?? 0);
|
|
111
|
+
// thoughts = reasoning tokens, billed as output and reported separately from
|
|
112
|
+
// candidates (additive).
|
|
113
|
+
output += (t.candidates ?? t.output ?? 0) + (t.thoughts ?? 0);
|
|
114
|
+
cached += (t.cached ?? 0);
|
|
103
115
|
}
|
|
104
|
-
if (!input && !output) return null;
|
|
105
|
-
return { inputTokens: input, outputTokens: output, totalCostUsd: 0 };
|
|
116
|
+
if (!input && !output && !cached) return null;
|
|
117
|
+
return { inputTokens: input, outputTokens: output, cacheReadInputTokens: cached, totalCostUsd: 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The gemini trusted-folder bypass — needed because lazyclaw runs gemini from
|
|
121
|
+
// scratch dirs (/tmp, worker cwds) that gemini's trusted-folder policy would
|
|
122
|
+
// otherwise reject in headless -p mode. It is a GENUINE trust bypass, so it
|
|
123
|
+
// lives behind ONE switch: opts.trustWorkspace (default true) gates BOTH the
|
|
124
|
+
// --skip-trust flag AND the GEMINI_CLI_TRUST_WORKSPACE env that previously
|
|
125
|
+
// duplicated it (so dropping one no longer silently left the other). Pass
|
|
126
|
+
// trustWorkspace:false to enforce the policy — safest when the subprocess also
|
|
127
|
+
// runs under confinement.
|
|
128
|
+
export function geminiArgs(prompt, opts = {}) {
|
|
129
|
+
const args = [];
|
|
130
|
+
if (opts.trustWorkspace !== false) args.push('--skip-trust');
|
|
131
|
+
args.push('-p', prompt, '-o', 'json');
|
|
132
|
+
const model = resolveModel(opts.model);
|
|
133
|
+
if (model) args.push('-m', model);
|
|
134
|
+
return args;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function geminiEnv(opts = {}) {
|
|
138
|
+
return {
|
|
139
|
+
...process.env,
|
|
140
|
+
...(opts.trustWorkspace !== false ? { GEMINI_CLI_TRUST_WORKSPACE: 'true' } : {}),
|
|
141
|
+
// Forward a lazyclaw-stored key as GEMINI_API_KEY so the "paste an API key"
|
|
142
|
+
// connect path authenticates the subprocess (the gemini CLI reads this env
|
|
143
|
+
// var). Omitted when blank so a Google-OAuth login is unaffected.
|
|
144
|
+
...(opts.apiKey ? { GEMINI_API_KEY: String(opts.apiKey) } : {}),
|
|
145
|
+
};
|
|
106
146
|
}
|
|
107
147
|
|
|
108
148
|
export const geminiCliProvider = {
|
|
@@ -112,9 +152,7 @@ export const geminiCliProvider = {
|
|
|
112
152
|
const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
|
|
113
153
|
if (!prompt) return;
|
|
114
154
|
|
|
115
|
-
const args =
|
|
116
|
-
const model = resolveModel(opts.model);
|
|
117
|
-
if (model) args.push('-m', model);
|
|
155
|
+
const args = geminiArgs(prompt, opts);
|
|
118
156
|
|
|
119
157
|
if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
|
|
120
158
|
|
|
@@ -123,7 +161,7 @@ export const geminiCliProvider = {
|
|
|
123
161
|
proc = spawnSandboxed(opts.sandbox || null, bin, args, {
|
|
124
162
|
cwd: opts.cwd || process.cwd(),
|
|
125
163
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
|
-
env:
|
|
164
|
+
env: geminiEnv(opts),
|
|
127
165
|
});
|
|
128
166
|
} catch (err) {
|
|
129
167
|
if (err && err.code === 'ENOENT') throw new CliMissingError();
|
|
@@ -177,7 +215,26 @@ export const geminiCliProvider = {
|
|
|
177
215
|
} catch (err) {
|
|
178
216
|
throw new Error(`gemini CLI returned non-JSON stdout: ${err.message} :: ${payload.slice(0, 200)}`);
|
|
179
217
|
}
|
|
218
|
+
// The JSON payload carries an optional `error` object on failure
|
|
219
|
+
// (gemini-cli --output-format json: "response, stats, and errors").
|
|
220
|
+
// A failed turn can still exit 0 with an empty response + an error,
|
|
221
|
+
// so surface it instead of silently yielding nothing.
|
|
180
222
|
const text = typeof parsed.response === 'string' ? parsed.response : '';
|
|
223
|
+
if (!text && parsed.error) {
|
|
224
|
+
// A failed turn is still METERED: gemini-cli puts the partial token
|
|
225
|
+
// usage in the SAME JSON's `stats` even when the turn errored out. Bill
|
|
226
|
+
// it BEFORE throwing so a failed-but-metered turn still reaches the cost
|
|
227
|
+
// cap (the error path used to throw before extractUsage ever ran, so a
|
|
228
|
+
// failed turn leaked its tokens past accounting). Guarded so a usage
|
|
229
|
+
// callback can never mask the real error.
|
|
230
|
+
const failedUsage = extractUsage(parsed.stats);
|
|
231
|
+
if (failedUsage && typeof opts.onUsage === 'function') {
|
|
232
|
+
try { opts.onUsage(failedUsage); } catch (_) { /* never let usage hide the error */ }
|
|
233
|
+
}
|
|
234
|
+
const em = parsed.error?.message
|
|
235
|
+
|| (typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error));
|
|
236
|
+
throw new CliExitError(exitInfo.code, exitInfo.signal, em);
|
|
237
|
+
}
|
|
181
238
|
if (text) yield text;
|
|
182
239
|
const usage = extractUsage(parsed.stats);
|
|
183
240
|
if (usage && typeof opts.onUsage === 'function') {
|
|
@@ -185,11 +242,15 @@ export const geminiCliProvider = {
|
|
|
185
242
|
}
|
|
186
243
|
} finally {
|
|
187
244
|
if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
245
|
+
// Unlike the streaming claude-cli/codex-cli providers (which yield chunks
|
|
246
|
+
// while the child still runs and so need a mid-stream-bail kill), gemini
|
|
247
|
+
// buffers the whole turn and `await`s the close/spawn-error race BEFORE the
|
|
248
|
+
// single yield above. By the time this finally runs the child has already
|
|
249
|
+
// exited (or we threw on spawnError before reaching here), so there is no
|
|
250
|
+
// live subprocess to reap — the old `exitInfo === null` guard was dead.
|
|
191
251
|
}
|
|
192
252
|
},
|
|
193
253
|
};
|
|
194
254
|
|
|
195
255
|
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractUsage };
|
|
256
|
+
// geminiArgs/geminiEnv are also exported at their definitions (trust-switch seam).
|