lazyclaw 6.3.0 → 6.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.
Files changed (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. 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
+ }
@@ -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
- this.code = 'CLI_EXIT';
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
- // Map a few friendly aliases. Codex CLI accepts the literal model id
62
- // (e.g. gpt-5-codex) directly, so unknown inputs pass through.
63
- const _ALIASES = {
64
- codex: 'gpt-5-codex',
65
- 'gpt-codex': 'gpt-5-codex',
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,45 @@ 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
- const input = (u.input_tokens ?? 0) + (u.cached_input_tokens ?? 0);
114
- const output = (u.output_tokens ?? 0) + (u.reasoning_output_tokens ?? 0);
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 the totals and
144
+ // surface cached input separately so it can bill at the cache-read rate.
145
+ const input = u.input_tokens ?? 0;
146
+ const output = u.output_tokens ?? 0;
115
147
  if (!input && !output) return null;
116
- return { inputTokens: input, outputTokens: output, totalCostUsd: 0 };
148
+ return {
149
+ inputTokens: input,
150
+ outputTokens: output,
151
+ cacheReadInputTokens: u.cached_input_tokens ?? 0,
152
+ totalCostUsd: 0,
153
+ };
117
154
  }
118
155
 
119
156
  export const codexCliProvider = {
@@ -160,6 +197,7 @@ export const codexCliProvider = {
160
197
 
161
198
  proc.stdout.setEncoding('utf8');
162
199
  let buffer = '';
200
+ let apiError = null;
163
201
  let exitInfo = null;
164
202
  const exitPromise = new Promise((resolve) => {
165
203
  proc.on('close', (code, signal) => {
@@ -181,6 +219,8 @@ export const codexCliProvider = {
181
219
  try { obj = JSON.parse(line); } catch { continue; }
182
220
  const text = extractEventText(obj);
183
221
  if (text) yield text;
222
+ const errMsg = extractEventError(obj);
223
+ if (errMsg) apiError = errMsg;
184
224
  const usage = extractUsage(obj);
185
225
  if (usage && typeof opts.onUsage === 'function') {
186
226
  try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
@@ -192,6 +232,14 @@ export const codexCliProvider = {
192
232
  const obj = JSON.parse(buffer.trim());
193
233
  const text = extractEventText(obj);
194
234
  if (text) yield text;
235
+ // Drain usage + error too, so a final turn.completed/turn.failed that
236
+ // isn't newline-terminated still reports tokens and surfaces the error.
237
+ const errMsg = extractEventError(obj);
238
+ if (errMsg) apiError = errMsg;
239
+ const usage = extractUsage(obj);
240
+ if (usage && typeof opts.onUsage === 'function') {
241
+ try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
242
+ }
195
243
  } catch (_) { /* incomplete tail — drop */ }
196
244
  }
197
245
  // Wait for either a clean exit or an async spawn error. On ENOENT the
@@ -201,6 +249,13 @@ export const codexCliProvider = {
201
249
  if (spawnError) {
202
250
  throw spawnError.code === 'ENOENT' ? new CliMissingError() : spawnError;
203
251
  }
252
+ // Prefer the real API/turn error (carried on stdout) over the CLI's
253
+ // unhelpful stderr ("Reading additional input from stdin…"). codex
254
+ // exits non-zero on turn.failed, so this runs before the generic
255
+ // exit-code branch and gives the actionable message.
256
+ if (apiError && !opts.signal?.aborted) {
257
+ throw new CliExitError(exitInfo?.code ?? 1, exitInfo?.signal ?? null, apiError);
258
+ }
204
259
  if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
205
260
  throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
206
261
  }
@@ -213,4 +268,4 @@ export const codexCliProvider = {
213
268
  },
214
269
  };
215
270
 
216
- export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractUsage };
271
+ export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractEventError, extractUsage };
@@ -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-1.5-pro';
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: opts.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
- for await (const chunk of iterateBody(res.body)) {
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,26 @@ 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
+ inputTokens: lastUsage.promptTokenCount ?? null,
272
+ outputTokens: lastUsage.candidatesTokenCount ?? null,
273
+ cacheReadInputTokens: lastUsage.cachedContentTokenCount ?? 0,
274
+ totalCostUsd: 0,
275
+ });
276
+ } catch { /* never fail the stream on a usage callback */ }
277
+ }
278
+ if (typeof opts.onTruncated === 'function'
279
+ && (lastFinish === 'MAX_TOKENS' || lastFinish === 'SAFETY' || lastFinish === 'RECITATION')) {
280
+ try { opts.onTruncated(lastFinish); } catch { /* ditto */ }
281
+ }
184
282
  },
185
283
  };
186
284
 
@@ -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
- // Why `--skip-trust` is hard-coded: lazyclaw orchestrator / workflow
23
- // subprocesses commonly run from /tmp or scratch dirs which gemini's
24
- // trusted-folder policy rejects. We are spawning gemini ourselves with
25
- // our own prompt, so the policy adds friction without security value.
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
- this.code = 'CLI_EXIT';
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,45 @@ 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
106
  input += (t.prompt ?? t.input ?? 0);
102
- output += (t.candidates ?? t.output ?? 0);
107
+ // thoughts = reasoning tokens, billed as output and reported separately from
108
+ // candidates (additive). cached is a subset of prompt → surface, don't sum.
109
+ output += (t.candidates ?? t.output ?? 0) + (t.thoughts ?? 0);
110
+ cached += (t.cached ?? 0);
103
111
  }
104
112
  if (!input && !output) return null;
105
- return { inputTokens: input, outputTokens: output, totalCostUsd: 0 };
113
+ return { inputTokens: input, outputTokens: output, cacheReadInputTokens: cached, totalCostUsd: 0 };
114
+ }
115
+
116
+ // The gemini trusted-folder bypass — needed because lazyclaw runs gemini from
117
+ // scratch dirs (/tmp, worker cwds) that gemini's trusted-folder policy would
118
+ // otherwise reject in headless -p mode. It is a GENUINE trust bypass, so it
119
+ // lives behind ONE switch: opts.trustWorkspace (default true) gates BOTH the
120
+ // --skip-trust flag AND the GEMINI_CLI_TRUST_WORKSPACE env that previously
121
+ // duplicated it (so dropping one no longer silently left the other). Pass
122
+ // trustWorkspace:false to enforce the policy — safest when the subprocess also
123
+ // runs under confinement.
124
+ export function geminiArgs(prompt, opts = {}) {
125
+ const args = [];
126
+ if (opts.trustWorkspace !== false) args.push('--skip-trust');
127
+ args.push('-p', prompt, '-o', 'json');
128
+ const model = resolveModel(opts.model);
129
+ if (model) args.push('-m', model);
130
+ return args;
131
+ }
132
+
133
+ export function geminiEnv(opts = {}) {
134
+ return {
135
+ ...process.env,
136
+ ...(opts.trustWorkspace !== false ? { GEMINI_CLI_TRUST_WORKSPACE: 'true' } : {}),
137
+ // Forward a lazyclaw-stored key as GEMINI_API_KEY so the "paste an API key"
138
+ // connect path authenticates the subprocess (the gemini CLI reads this env
139
+ // var). Omitted when blank so a Google-OAuth login is unaffected.
140
+ ...(opts.apiKey ? { GEMINI_API_KEY: String(opts.apiKey) } : {}),
141
+ };
106
142
  }
107
143
 
108
144
  export const geminiCliProvider = {
@@ -112,9 +148,7 @@ export const geminiCliProvider = {
112
148
  const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
113
149
  if (!prompt) return;
114
150
 
115
- const args = ['--skip-trust', '-p', prompt, '-o', 'json'];
116
- const model = resolveModel(opts.model);
117
- if (model) args.push('-m', model);
151
+ const args = geminiArgs(prompt, opts);
118
152
 
119
153
  if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
120
154
 
@@ -123,7 +157,7 @@ export const geminiCliProvider = {
123
157
  proc = spawnSandboxed(opts.sandbox || null, bin, args, {
124
158
  cwd: opts.cwd || process.cwd(),
125
159
  stdio: ['ignore', 'pipe', 'pipe'],
126
- env: { ...process.env, GEMINI_CLI_TRUST_WORKSPACE: 'true' },
160
+ env: geminiEnv(opts),
127
161
  });
128
162
  } catch (err) {
129
163
  if (err && err.code === 'ENOENT') throw new CliMissingError();
@@ -177,7 +211,16 @@ export const geminiCliProvider = {
177
211
  } catch (err) {
178
212
  throw new Error(`gemini CLI returned non-JSON stdout: ${err.message} :: ${payload.slice(0, 200)}`);
179
213
  }
214
+ // The JSON payload carries an optional `error` object on failure
215
+ // (gemini-cli --output-format json: "response, stats, and errors").
216
+ // A failed turn can still exit 0 with an empty response + an error,
217
+ // so surface it instead of silently yielding nothing.
180
218
  const text = typeof parsed.response === 'string' ? parsed.response : '';
219
+ if (!text && parsed.error) {
220
+ const em = parsed.error?.message
221
+ || (typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error));
222
+ throw new CliExitError(exitInfo.code, exitInfo.signal, em);
223
+ }
181
224
  if (text) yield text;
182
225
  const usage = extractUsage(parsed.stats);
183
226
  if (usage && typeof opts.onUsage === 'function') {
@@ -185,11 +228,15 @@ export const geminiCliProvider = {
185
228
  }
186
229
  } finally {
187
230
  if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
188
- if (!proc.killed && exitInfo === null) {
189
- try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
190
- }
231
+ // Unlike the streaming claude-cli/codex-cli providers (which yield chunks
232
+ // while the child still runs and so need a mid-stream-bail kill), gemini
233
+ // buffers the whole turn and `await`s the close/spawn-error race BEFORE the
234
+ // single yield above. By the time this finally runs the child has already
235
+ // exited (or we threw on spawnError before reaching here), so there is no
236
+ // live subprocess to reap — the old `exitInfo === null` guard was dead.
191
237
  }
192
238
  },
193
239
  };
194
240
 
195
241
  export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractUsage };
242
+ // geminiArgs/geminiEnv are also exported at their definitions (trust-switch seam).
@@ -175,6 +175,33 @@ export function _codexStoredApiKey({ home, readFileSync } = {}) {
175
175
  return null;
176
176
  }
177
177
 
178
+ // A ChatGPT-plan `codex` login has no platform API key, so /v1/models can't be
179
+ // listed. The one model such a login can actually use is the one configured in
180
+ // ~/.codex/config.toml (`model = "gpt-5.5"`). Return it so the picker shows the
181
+ // account's real model instead of a "fetch failed" error. Returns [] when the
182
+ // file is missing or has no model line.
183
+ export function _codexConfigModels({ home, readFileSync } = {}) {
184
+ const h = home || os.homedir();
185
+ const read = readFileSync || fs.readFileSync;
186
+ try {
187
+ const txt = read(path.join(h, '.codex/config.toml'), 'utf8');
188
+ const m = /^\s*model\s*=\s*"([^"]+)"/m.exec(String(txt));
189
+ return m && m[1] ? [m[1]] : [];
190
+ } catch { return []; }
191
+ }
192
+
193
+ // Same idea for a `gemini` Google-account login: no listable platform catalogue,
194
+ // but ~/.gemini/settings.json may pin a model. Returns [] when absent.
195
+ export function _geminiConfigModels({ home, readFileSync } = {}) {
196
+ const h = home || os.homedir();
197
+ const read = readFileSync || fs.readFileSync;
198
+ try {
199
+ const obj = JSON.parse(read(path.join(h, '.gemini/settings.json'), 'utf8'));
200
+ const m = obj && (obj.model || obj.defaultModel || (obj.model && obj.model.name));
201
+ return typeof m === 'string' && m ? [m] : [];
202
+ } catch { return []; }
203
+ }
204
+
178
205
  export async function fetchModelsForProvider(deps) {
179
206
  const providerId = deps && deps.providerId;
180
207
  const key = (id) => (typeof deps?.resolveAuthKey === 'function' ? deps.resolveAuthKey(id) : '') || '';
@@ -199,7 +226,9 @@ export async function fetchModelsForProvider(deps) {
199
226
  if (providerId === 'gemini-cli') {
200
227
  const apiKey = key('gemini-cli') || key('gemini') || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
201
228
  if (apiKey) return fetchGeminiModels({ apiKey, fetchImpl: deps?.fetchImpl });
202
- throw new Error('gemini-cli model listing needs a credential: set GEMINI_API_KEY or GOOGLE_API_KEY (the `gemini` CLI login token cannot list models)');
229
+ // Google-account login can't list a platform catalogue surface the model
230
+ // pinned in ~/.gemini/settings.json (if any) instead of throwing.
231
+ return deps?._geminiConfigModels ? deps._geminiConfigModels() : _geminiConfigModels();
203
232
  }
204
233
  if (providerId === 'codex-cli') {
205
234
  const apiKey = key('codex-cli') || key('openai') || process.env.OPENAI_API_KEY
@@ -208,7 +237,9 @@ export async function fetchModelsForProvider(deps) {
208
237
  const { fetchOpenAICompatModels } = await import('./openai_compat.mjs');
209
238
  return fetchOpenAICompatModels({ baseUrl: 'https://api.openai.com/v1', apiKey, fetch: deps?.fetchImpl });
210
239
  }
211
- throw new Error('codex-cli model listing needs a credential: set OPENAI_API_KEY (a ChatGPT-plan `codex` login has no platform API key)');
240
+ // ChatGPT-plan login has no platform API key — list the model the local
241
+ // codex config is set to use instead of erroring with "fetch failed".
242
+ return deps?._codexConfigModels ? deps._codexConfigModels() : _codexConfigModels();
212
243
  }
213
244
  const c = modelCatalogueFor(deps);
214
245
  if (!c) {