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.
Files changed (179) 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 +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. 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,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
- const input = (u.input_tokens ?? 0) + (u.cached_input_tokens ?? 0);
114
- const output = (u.output_tokens ?? 0) + (u.reasoning_output_tokens ?? 0);
115
- if (!input && !output) return null;
116
- return { inputTokens: input, outputTokens: output, totalCostUsd: 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 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 };
@@ -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,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
 
@@ -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,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
- input += (t.prompt ?? t.input ?? 0);
102
- output += (t.candidates ?? t.output ?? 0);
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 = ['--skip-trust', '-p', prompt, '-o', 'json'];
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: { ...process.env, GEMINI_CLI_TRUST_WORKSPACE: 'true' },
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
- if (!proc.killed && exitInfo === null) {
189
- try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
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).