amicus 1.5.0 → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.6.0] - 2026-06-30
9
+
10
+ Install resilience and council-failure correctness — the first two blocks of the post-1.5 backlog program.
11
+
12
+ ### Added
13
+ - **Per-subcommand help.** `amicus <command> --help` now prints only that command's options instead of
14
+ the full global usage; bare `amicus --help` is unchanged.
15
+ - **Zero-credit OpenRouter key warning at setup.** Setup now does a non-blocking `GET /api/v1/key`
16
+ check and warns when a key is free-tier or has no remaining credit, so a credit-less key is flagged
17
+ up front instead of 402-ing on the first paid model call.
18
+ - **`amicus doctor` engine-recovery guidance.** The opencode-engine check now explains the
19
+ transient install-rollback failure mode and gives copy-paste recovery steps.
20
+ - **Postinstall verifies the Electron binary.** When the optional Electron download/extract fails (or
21
+ AV quarantines the binary), the install now prints a clear non-fatal notice that headless runs and
22
+ the council still work — instead of silently leaving a broken GUI to discover later.
23
+ - **CI tarball guard.** A new `check:tarball` step asserts every lifecycle-referenced script actually
24
+ ships in the published package, so a future packaging change can't silently drop it.
25
+ - **README "Requirements & Dependencies" section** consolidating Node, git, OpenRouter credits, API-key
26
+ env vars, the optional Electron GUI, the bundled opencode engine, and OS support.
27
+
28
+ ### Fixed
29
+ - **Council / headless runs no longer report success when every model call fails.** On the shared-server
30
+ MCP path, a run whose calls all errored (e.g. an OpenRouter 402) was finalized as `complete` with a
31
+ 0-byte summary, so `amicus_status` showed success and the error was lost. Non-2xx/402 responses are
32
+ now detected at the OpenCode client boundary even when no assistant message is emitted, the
33
+ shared-server finalize routes through the same terminal-state classifier as the CLI, and a failed run
34
+ can never silently default to `complete`; `amicus_read` surfaces the failure reason.
35
+ - **`amicus_status` elapsed time** is now bounded by the run's completed/aborted/crashed timestamp
36
+ instead of wall-clock-since-start, so a finished run reports its real duration.
37
+ - **`amicus_setup` (MCP)** no longer claims an Electron window appeared when Electron is unavailable —
38
+ it pre-flights and returns an honest error directing you to the headless terminal wizard.
39
+ - **Clearer "session not found"** — the message now names the resolved project so you know to pass the
40
+ original `project`.
41
+ - **Non-fatal postinstall.** An internal skill-copy / MCP-registration failure no longer exits non-zero
42
+ and rolls back the entire global install; it warns and continues.
43
+ - **`github:` install on Windows now runs identically to the registry install.** Removed the
44
+ consumer-facing `prepare` lifecycle that triggered npm's clone→prepare→nested-install→cached-pack
45
+ path (the rollback source); git hooks are still configured for contributors via `postinstall`.
46
+
47
+ ## [1.5.1] - 2026-06-29
48
+
49
+ A headless-reliability fix for reasoning-heavy models.
50
+
51
+ ### Fixed
52
+ - **Gemini (and other reasoning-only models) no longer hang headless with "No Output."** On the
53
+ direct-Google provider path, Gemini 3.x returns its answer as a `reasoning` part with no separate
54
+ `text` part. The conversation mirror only accumulated `text` parts, so it captured zero output, the
55
+ headless completion gates (which key on `output.length > 0`) never fired, and the run burned the full
56
+ timeout — while still billing input/thinking tokens. The mirror now accumulates reasoning into a
57
+ dedicated buffer and promotes it to the output **only when a finished assistant message produced no
58
+ visible text**, so models that emit both a reasoning part and a text part are unaffected (their
59
+ thinking never pollutes the answer). Fixes `--model gemini` / `gemini-pro` and any direct `google/*`
60
+ alias in headless `start`, `fanout`, and council runs.
61
+
8
62
  ## [1.5.0] - 2026-06-29
9
63
 
10
64
  A visual refresh plus a council-reliability fix and a config-dir consolidation.
package/README.md CHANGED
@@ -23,6 +23,7 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
23
23
 
24
24
  - [What is Amicus](#what-is-amicus)
25
25
  - [Quick start](#quick-start)
26
+ - [Requirements & Dependencies](#requirements--dependencies)
26
27
  - [The Council](#the-council)
27
28
  - [The parallel window](#the-parallel-window)
28
29
  - [Commands](#commands)
@@ -67,16 +68,6 @@ The postinstall step auto-configures everything — no manual registration:
67
68
  - Registers the **MCP server** in Claude Code and in Claude Desktop / Cowork, so the Amicus tools appear natively.
68
69
  - Installs **both skills** into `~/.claude/skills/` — `second-opinion` (the council) and `sidecar` (the chat skill).
69
70
 
70
- ## Prerequisites & what it costs you
71
-
72
- Before your first run:
73
-
74
- - **Node.js ≥ 18** — `node --version` to check.
75
- - **An active Claude Code or Cowork session** — Amicus is orchestrated by Claude; it is not a standalone chatbot.
76
- - **At least one paid model API key** — OpenRouter (covers the most models) or a direct Google / OpenAI / Anthropic / DeepSeek key. Add one with `amicus setup` or `amicus key <provider> <key>`.
77
-
78
- **What a run costs.** A sidecar is a single model call. A full council is typically **~5–8 paid model calls** (e.g. 3 reviewers across 2 fan-out waves + 1 chair). Amicus shows an estimate before each council and enforces a built-in budget gate that refuses ultra-expensive models (o3-pro class) unless you opt in with `--no-cost-gate`. You pay your providers directly for the tokens; Amicus itself is free and open-source.
79
-
80
71
  **Configure:**
81
72
 
82
73
  ```bash
@@ -110,18 +101,55 @@ A window opens alongside your editor with Gemini ready, pre-loaded with your con
110
101
 
111
102
  ### Install from GitHub
112
103
 
113
- The npm package is the primary path. To install straight from the repo instead (the postinstall runs identically same MCP registration, same two skills):
104
+ The npm package is the primary path. To install straight from the repo instead the postinstall runs **identically** (same MCP registration, same two skills) — you just need `git` on your `PATH`:
114
105
 
115
106
  ```bash
116
107
  npm install -g github:BourbonDog/amicus
117
108
  ```
118
109
 
110
+ See [Requirements & Dependencies](#requirements--dependencies) for the full prerequisite list.
111
+
119
112
  ### Contributor setup
120
113
 
121
114
  Cloning to develop Amicus? See **[CONTRIBUTING.md](./CONTRIBUTING.md)** for the dev setup, git-hook wiring, and test commands.
122
115
 
123
116
  ---
124
117
 
118
+ ## Requirements & Dependencies
119
+
120
+ Everything you need before your first run, and what's optional.
121
+
122
+ **Runtime**
123
+
124
+ - **Node.js ≥ 18** — `node --version` to check. This is the only hard runtime prerequisite.
125
+ - **An active Claude Code or Cowork session** — Amicus is orchestrated by Claude; it is not a standalone chatbot.
126
+
127
+ **Install path & the git toolchain**
128
+
129
+ - **From the npm registry (recommended):** `npm install -g amicus`. No build toolchain required — the package ships prebuilt.
130
+ - **From GitHub:** `npm install -g github:BourbonDog/amicus` runs **identically** — same MCP registration, same two skills, same postinstall. The one extra requirement is a working **git** on your `PATH`, since npm clones the repo to install it (`git --version` to check).
131
+
132
+ **Model API keys** — at least one is required
133
+
134
+ - **OpenRouter** covers the most models with one key, or use a **direct Google / OpenAI / Anthropic / DeepSeek** key. Add one with `amicus setup` or `amicus key <provider> <key>`; keys live in `~/.config/amicus/.env`. The supported env vars are `OPENROUTER_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY` (the legacy `GEMINI_API_KEY` is still accepted), `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `DEEPSEEK_API_KEY`.
135
+ - ⚠️ **OpenRouter keys need purchased credits.** A brand-new, zero-credit key *passes* setup's live validation but then fails at runtime with a **402** on the first real call. Buy a small amount of credit before you run a council.
136
+
137
+ **Electron — optional (GUI only)**
138
+
139
+ - Electron is an **optional** dependency. It powers the graphical setup wizard and the parallel sidecar window. **Headless runs and the full council work without it** — if Electron is absent or can't open a window, `amicus setup` falls back to a readline wizard and sidecars run headless. `amicus doctor` reports Electron's presence accurately and never treats its absence as fatal.
140
+
141
+ **The OpenCode engine**
142
+
143
+ - The bundled **`opencode-ai`** engine (the conversation runtime) installs automatically as a normal dependency — you don't install it separately. Its own postinstall lays down ~11 per-platform binaries; a **transient** failure there (a spawn `ENOENT`, or an antivirus file-lock) can roll back the atomic install. If install fails partway or `amicus doctor` reports the OpenCode binary "not found", just **re-run** `npm install -g amicus` (clear the cache first if it persists: `npm cache clean --force`).
144
+
145
+ **OS support**
146
+
147
+ - **Windows 11, macOS, and Linux** are all supported; Amicus is first-class on **Windows** (developed and tested there, no WSL required). See the [Windows](#windows) section for platform specifics.
148
+
149
+ **What a run costs.** A sidecar is a single model call. A full council is typically **~5–8 paid model calls** (e.g. 3 reviewers across 2 fan-out waves + 1 chair). Amicus shows an estimate before each council and enforces a built-in budget gate that refuses ultra-expensive models (o3-pro class) unless you opt in with `--no-cost-gate`. You pay your providers directly for the tokens; Amicus itself is free and open-source.
150
+
151
+ ---
152
+
125
153
  ## The Council
126
154
 
127
155
  **Why multi-model.** Any single model — including the one running your session — has consistent blind spots. Route the *same* material through models from *different* families and the disagreements surface: missed issues, overstated confidence, claims one model alone would have waved through. The council is the structured version of that idea.
@@ -442,6 +470,7 @@ Most Claude-adjacent tooling assumes macOS/Linux; Amicus doesn't.
442
470
  |---------|--------------|-----|
443
471
  | "council review this" does nothing | The `second-opinion` skill isn't installed | Check `~/.claude/skills/second-opinion/SKILL.md` exists; re-run `npm install -g amicus` (postinstall installs both skills) |
444
472
  | `npm install -g amicus` fails with `EEXIST: … claude-sidecar` | The old upstream `claude-sidecar` package is still installed globally; npm won't overwrite another package's bin shims | `npm uninstall -g claude-sidecar`, then `npm install -g amicus`. Your config and sessions carry over (legacy paths are still read). |
473
+ | Install fails partway, or `amicus doctor` reports the OpenCode binary "not found" | A **transient** error during the OpenCode engine's own postinstall (a spawn `ENOENT`, or an antivirus file-lock while it lays down its 11 per-platform binaries) can roll back the whole atomic install — retrying usually succeeds | Just re-run `npm install -g amicus`. If it still fails, clear the cache first: `npm cache clean --force && npm install -g amicus`. |
445
474
  | `401` / auth error | API key missing, or the model prefix doesn't match the key you have | Run `amicus setup`; make sure the prefix (`openrouter/…` vs `google/…` vs `openai/…` vs `anthropic/…`) matches the credentials you configured. |
446
475
  | Session not found | No session matches the given ID | Run `amicus list`, or omit `--session-id` to use the most recent. |
447
476
  | No conversation history found | Project-path encoding | Check `~/.claude/projects/`; `/` and `_` in the project path are encoded as `-` in the directory name. |
package/bin/amicus.js CHANGED
@@ -75,9 +75,10 @@ async function main() {
75
75
  process.exit(0);
76
76
  }
77
77
 
78
- // Handle --help or no command
78
+ // Handle --help or no command. 'amicus <cmd> --help' scopes the usage to that
79
+ // subcommand; bare 'amicus --help' (or no command) prints the full usage.
79
80
  if (args.help || args._.length === 0) {
80
- console.log(getUsage());
81
+ console.log(getUsage(args.help ? command : undefined));
81
82
  process.exit(0);
82
83
  }
83
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "keywords": [
6
6
  "claude",
@@ -63,9 +63,10 @@
63
63
  "generate-docs": "node scripts/generate-docs.js",
64
64
  "generate-docs:check": "node scripts/generate-docs.js --check",
65
65
  "validate-docs": "node scripts/validate-docs.js --full",
66
- "prepare": "node scripts/setup-hooks.js",
66
+ "setup-hooks": "node scripts/setup-hooks.js",
67
67
  "check:secrets": "node scripts/check-secrets.js --all",
68
- "check:sizes": "node scripts/check-file-sizes.js --all"
68
+ "check:sizes": "node scripts/check-file-sizes.js --all",
69
+ "check:tarball": "node scripts/check-tarball-lifecycle.js"
69
70
  },
70
71
  "dependencies": {
71
72
  "@modelcontextprotocol/sdk": "^1.27.0",
@@ -13,6 +13,8 @@ const path = require('path');
13
13
  const os = require('os');
14
14
  const { execFileSync } = require('child_process');
15
15
 
16
+ const SETUP_HOOKS_SCRIPT = path.join(__dirname, 'setup-hooks.js');
17
+
16
18
  const SKILL_SOURCE = path.join(__dirname, '..', 'skills', 'sidecar', 'SKILL.md');
17
19
  const COUNCIL_SOURCE_DIR = path.join(__dirname, '..', 'skills', 'second-opinion');
18
20
 
@@ -172,6 +174,79 @@ function registerClaudeDesktop() {
172
174
  addMcpToConfigFile(configPath, 'sidecar', MCP_CONFIG);
173
175
  }
174
176
 
177
+ /**
178
+ * Resolve the Electron binary path the same way src/sidecar/interactive.js
179
+ * getElectronPath() does: require('electron') returns the absolute path to the
180
+ * binary (or throws if the optionalDependency never installed/extracted).
181
+ * @returns {string|null} Path to the Electron binary, or null if unresolvable.
182
+ */
183
+ function resolveElectron() {
184
+ try {
185
+ return require('electron');
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * NON-FATAL verification that the OPTIONAL electron binary actually extracted.
193
+ * Electron is an optionalDependency: npm exits 0 even if its download/extract
194
+ * fails (or AV quarantines electron.exe), so without this the user only finds
195
+ * out the GUI is broken much later. Warn clearly that headless runs + the
196
+ * council still work, and point at `amicus doctor` / reinstall to get the GUI.
197
+ *
198
+ * This MUST never throw out of postinstall — the whole body is guarded so a
199
+ * resolver failure or a missing fs can never turn into a non-zero exit.
200
+ *
201
+ * @param {object} deps - { resolveElectron } override for testing.
202
+ */
203
+ function verifyElectron(deps = {}) {
204
+ try {
205
+ const _resolve = deps.resolveElectron || resolveElectron;
206
+ const binPath = _resolve();
207
+ if (binPath && fs.existsSync(binPath)) { return; }
208
+ console.warn('[amicus] Warning: the Electron binary did not install — the interactive GUI / setup-wizard is unavailable.');
209
+ console.warn('[amicus] Headless runs and the council still work. Run `amicus doctor` to check, or `npm install -g amicus` to reinstall and add the GUI.');
210
+ } catch {
211
+ // Never let the electron check throw out of postinstall.
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Configure git hooks for DEVELOPERS, folded into postinstall (#35).
217
+ *
218
+ * Previously this ran via npm's "prepare" lifecycle. But "prepare" also fires
219
+ * on the github: install path (`npm install -g github:BourbonDog/amicus`),
220
+ * where npm clones the repo, runs prepare, does a NESTED devDependency install,
221
+ * and re-packs — a fragile pipeline the registry install SKIPS entirely. That
222
+ * divergence (plus a reusable corrupt cached artifact) is what made github:
223
+ * installs roll back, especially on Windows. Removing the consumer-facing
224
+ * prepare lifecycle makes the github: path behave identically to the registry
225
+ * path.
226
+ *
227
+ * Hook setup still needs to happen for devs, so we run setup-hooks.js here.
228
+ * It is a no-op for consumers: setup-hooks.js exits 0 outside a git checkout
229
+ * (the published tarball / github: export has no .git), so this changes
230
+ * nothing for end users while keeping `npm install` wiring hooks for devs.
231
+ *
232
+ * Best-effort and self-contained: never throws (a hook-setup failure must
233
+ * never roll back the install), so it is safe to call before the optional
234
+ * skill/MCP setup.
235
+ *
236
+ * @param {object} deps - { env } override for testing.
237
+ */
238
+ function setupHooks(deps = {}) {
239
+ try {
240
+ execFileSync('node', [SETUP_HOOKS_SCRIPT], {
241
+ stdio: 'inherit',
242
+ env: deps.env || process.env,
243
+ });
244
+ } catch (err) {
245
+ // Hook setup is for devs only and must never fail the install.
246
+ console.warn(`[amicus] Warning: could not configure git hooks: ${err && err.message}`);
247
+ }
248
+ }
249
+
175
250
  function main(deps = {}) {
176
251
  if (process.env.AMICUS_SKIP_POSTINSTALL === '1') {
177
252
  console.log('[amicus] AMICUS_SKIP_POSTINSTALL set — skipping global setup (plugin channel handles registration).');
@@ -181,22 +256,47 @@ function main(deps = {}) {
181
256
  const _installCouncilSkill = deps.installCouncilSkill || installCouncilSkill;
182
257
  const _registerClaudeCode = deps.registerClaudeCode || registerClaudeCode;
183
258
  const _registerClaudeDesktop = deps.registerClaudeDesktop || registerClaudeDesktop;
259
+ const _setupHooks = deps.setupHooks || setupHooks;
184
260
 
185
261
  console.log('[amicus] Installing...');
262
+ // Dev-only: configure git hooks (no-op for consumers). Folded in from the
263
+ // removed "prepare" lifecycle (#35) so github: installs run identically.
264
+ _setupHooks();
186
265
  _installSkill();
187
266
  _installCouncilSkill();
188
267
  _registerClaudeCode();
189
268
  _registerClaudeDesktop();
190
269
 
270
+ // Non-fatal: warn (only) if the optional Electron binary failed to extract.
271
+ verifyElectron(deps);
272
+
191
273
  console.log('');
192
274
  console.log('[amicus] Setup:');
193
275
  console.log(' - Configure API: Run `amicus setup` or set API keys directly');
194
276
  console.log(' - API keys: OPENROUTER_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, OPENAI_API_KEY, etc.');
195
277
  }
196
278
 
197
- // Only run main when executed directly (not when required for testing)
279
+ /**
280
+ * Top-level entry point. Wraps main() so a failure is never fatal: npm treats
281
+ * a non-zero postinstall as a reason to roll back / uninstall the ENTIRE global
282
+ * package, but skill-copy + MCP registration are optional — amicus itself still
283
+ * works without them. Warn clearly and exit 0 (mirrors scripts/setup-hooks.js).
284
+ */
285
+ function runCli(deps = {}) {
286
+ try {
287
+ main(deps);
288
+ } catch (err) {
289
+ console.warn(`[amicus] Warning: optional post-install setup failed: ${err && err.message}`);
290
+ console.warn('[amicus] Skill install + MCP registration are optional — amicus itself still works.');
291
+ console.warn('[amicus] Run `amicus doctor` to check setup, or re-register manually later.');
292
+ }
293
+ // Always exit 0 so a failure here never rolls back the global install.
294
+ process.exit(0);
295
+ }
296
+
297
+ // Only run when executed directly (not when required for testing)
198
298
  if (require.main === module) {
199
- main();
299
+ runCli();
200
300
  }
201
301
 
202
- module.exports = { main, addMcpToConfigFile, installSkill, installCouncilSkill, COUNCIL_FILES };
302
+ module.exports = { main, runCli, addMcpToConfigFile, installSkill, installCouncilSkill, setupHooks, verifyElectron, resolveElectron, COUNCIL_FILES };
@@ -12,10 +12,14 @@
12
12
  * core.hooksPath at it makes hooks fire in the main clone and in every
13
13
  * worktree with no per-worktree setup.
14
14
  *
15
- * Runs automatically via npm's "prepare" lifecycle. If you install with
16
- * --ignore-scripts (recommended for this repo), run it once by hand:
15
+ * Runs automatically via the postinstall flow (scripts/postinstall.js) so a
16
+ * fresh dev `npm install` still wires hooks. It is NOT a "prepare" script:
17
+ * prepare also fires on the github: install path, where it triggers a nested
18
+ * devDependency install + re-pack that the registry path skips (#35) — the
19
+ * source of the Windows github: rollback. If you install with --ignore-scripts
20
+ * (recommended for this repo), run it once by hand:
17
21
  *
18
- * node scripts/setup-hooks.js
22
+ * npm run setup-hooks # or: node scripts/setup-hooks.js
19
23
  *
20
24
  * Safe to run anywhere: exits 0 outside a git checkout (npm tarball
21
25
  * installs, exported archives) and never fails the install.
@@ -105,7 +105,7 @@ function runDoctorChecks(depsOverride = {}) {
105
105
  checks.push(guard('opencode-bin', 'OpenCode binary', () => (
106
106
  d.hasOpencodeBinary()
107
107
  ? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
108
- : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: 'npm install -g amicus' }
108
+ : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: 'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus)' }
109
109
  )));
110
110
 
111
111
  checks.push(guard('electron', 'Electron (interactive GUI)', () => (
package/src/cli.js CHANGED
@@ -292,10 +292,15 @@ function isValidDurationFormat(duration) {
292
292
  }
293
293
 
294
294
  /**
295
- * Get usage text
295
+ * Usage text, split into composable parts so 'amicus <cmd> --help' can print
296
+ * only the relevant block while bare 'amicus --help' still composes the full
297
+ * text byte-identically.
298
+ *
299
+ * Each value is a section of the original monolithic template; concatenating
300
+ * USAGE_HEADER + every USAGE_COMMAND_BLOCKS value (in insertion order) +
301
+ * USAGE_TRAILER reproduces the original string exactly.
296
302
  */
297
- function getUsage() {
298
- return `
303
+ const USAGE_HEADER = `
299
304
  Usage: amicus <command> [options]
300
305
 
301
306
  Commands:
@@ -320,7 +325,12 @@ Commands:
320
325
  (no args) List all configured providers
321
326
  update Update to latest version
322
327
  mcp Start MCP server (stdio transport)
328
+ `;
323
329
 
330
+ // Per-command option blocks, keyed by the invoked subcommand. Insertion order
331
+ // must match the original template so the composed full usage is unchanged.
332
+ const USAGE_COMMAND_BLOCKS = {
333
+ start: `
324
334
  Options for 'start':
325
335
  --model <model> Optional (uses config default). Model to use:
326
336
  - Short aliases: gemini, opus, gpt (see 'amicus setup')
@@ -354,7 +364,8 @@ Options for 'start':
354
364
  --validate-model (Deprecated: validation is on by default)
355
365
  --no-validate-model Skip model-catalog validation before launch
356
366
  --position <pos> Window position: right (default), left, center
357
-
367
+ `,
368
+ fanout: `
358
369
  Options for 'fanout':
359
370
  --models <a,b,c> Required. Comma-separated aliases or provider/model IDs
360
371
  --council <name> Run a saved council instead of --models (e.g. free). Mutually exclusive with --models
@@ -369,27 +380,34 @@ Options for 'fanout':
369
380
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
370
381
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
371
382
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
372
-
383
+ `,
384
+ models: `
373
385
  Options for 'models':
374
386
  --search <q> Filter by substring over model id and name
375
387
  --refresh Force-refresh the catalog from provider APIs
376
388
  --check Audit aliases against the catalog (exit = stale count)
377
389
  --json Machine-readable output
378
-
390
+ `,
391
+ list: `
379
392
  Options for 'list':
380
393
  --status <filter> Filter by status (running, complete)
381
394
  --all Show all projects
382
395
  --json Output as JSON
383
-
396
+ `,
397
+ abort: `
384
398
  Options for 'abort':
385
399
  --all Abort all running sessions in this project
386
-
400
+ `,
401
+ read: `
387
402
  Options for 'read':
388
403
  --summary Show summary (default)
389
404
  --conversation Show full conversation
390
405
  --metadata Show session metadata
391
406
  --json Emit the run/wave result as stable JSON
407
+ `
408
+ };
392
409
 
410
+ const USAGE_TRAILER = `
393
411
  OpenCode Agent Types:
394
412
  Chat Reads auto, writes/bash ask permission (interactive default)
395
413
  Build Full tool access (headless default)
@@ -409,6 +427,25 @@ Examples:
409
427
  amicus resume abc123
410
428
  amicus read abc123 --conversation
411
429
  `;
430
+
431
+ /**
432
+ * Get usage text.
433
+ *
434
+ * @param {string} [command] When provided AND it has a dedicated options block,
435
+ * returns the top-level header plus only that command's block (scoped --help).
436
+ * When omitted, or when the command has no dedicated block, returns the full
437
+ * composed usage (byte-identical to the original monolithic string).
438
+ */
439
+ function getUsage(command) {
440
+ // Each part carries its own leading/trailing newlines exactly as they sat in
441
+ // the original single template literal, so plain concatenation reproduces the
442
+ // blank-line separators (the header's trailing "\n" + a block's leading "\n"
443
+ // form the blank line between them). Scoped help keeps the header + trailer
444
+ // and substitutes just the one command's block in place of all blocks.
445
+ const block = command && Object.prototype.hasOwnProperty.call(USAGE_COMMAND_BLOCKS, command)
446
+ ? USAGE_COMMAND_BLOCKS[command]
447
+ : Object.values(USAGE_COMMAND_BLOCKS).join('');
448
+ return USAGE_HEADER + block + USAGE_TRAILER;
412
449
  }
413
450
 
414
451
  module.exports = {
package/src/headless.js CHANGED
@@ -289,7 +289,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
289
289
  agent: promptOptions.agent,
290
290
  userMessageLength: userMessage.length
291
291
  });
292
- await sendPromptAsync(client, sessionId, promptOptions);
292
+ const promptResult = await sendPromptAsync(client, sessionId, promptOptions);
293
293
  writeProgress(sessionDir, 'prompt_sent');
294
294
  logger.info('Prompt sent successfully, entering polling loop', {
295
295
  sessionId,
@@ -302,6 +302,16 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
302
302
  let aborted = false;
303
303
  let sessionError = null; // Captures model/SDK errors from assistant messages
304
304
 
305
+ // Hard provider failure detected at the client boundary (#37): a non-2xx /
306
+ // 402 from promptAsync surfaces here even when the server never emits an
307
+ // assistant message carrying info.error. Seed sessionError so the loop's
308
+ // "error with no output" gate ends the run promptly with a usable reason.
309
+ const boundaryProviderError = !!(promptResult && promptResult.providerError);
310
+ if (boundaryProviderError) {
311
+ sessionError = promptResult.providerError;
312
+ logger.error('Provider error at client boundary', { taskId, sessionId, reason: sessionError });
313
+ }
314
+
305
315
  // Poll for completion by checking messages
306
316
  const startTime = Date.now();
307
317
  const deadline = startTime + timeoutMs;
@@ -383,6 +393,17 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
383
393
  break;
384
394
  }
385
395
 
396
+ // Hard provider failure at the client boundary (#37): the request was
397
+ // rejected (e.g., 402) so no assistant message will ever arrive. Exit as
398
+ // soon as we confirm nothing streamed — don't wait for assistantFinished
399
+ // (which never flips here) or the full timeout.
400
+ if (boundaryProviderError && !mirror.output) {
401
+ logger.error('Provider error at boundary with no output, exiting', {
402
+ sessionError, pollCount
403
+ });
404
+ break;
405
+ }
406
+
386
407
  // If the model returned an error with no output, exit immediately
387
408
  // (don't wait for timeout — the model won't produce anything)
388
409
  if (sessionError && !mirror.output && assistantFinished) {
package/src/mcp-server.js CHANGED
@@ -10,6 +10,19 @@ const { safeSessionDir } = require('./utils/validators');
10
10
  const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
11
11
  const { readProgress, isStalled } = require('./sidecar/progress');
12
12
  const { SharedServerManager } = require('./utils/shared-server');
13
+ const { durationBetween } = require('./utils/result-schema');
14
+
15
+ /**
16
+ * Elapsed run duration: time between createdAt and the run's end, bounding the
17
+ * end by completedAt/abortedAt/crashedAt so a delayed poll of a finished run
18
+ * reports the run duration, not time-since-start. Falls back to now() while
19
+ * still running. Returns 0 if createdAt is missing/malformed.
20
+ */
21
+ function elapsedMs(metadata) {
22
+ const end = metadata.completedAt || metadata.abortedAt || metadata.crashedAt
23
+ || new Date().toISOString();
24
+ return durationBetween(metadata.createdAt, end) ?? 0;
25
+ }
13
26
 
14
27
  const sharedServer = new SharedServerManager({ logger });
15
28
 
@@ -124,7 +137,7 @@ const handlers = {
124
137
  const { buildContext } = require('./sidecar/context-builder');
125
138
  const { buildPrompts } = require('./prompt-builder');
126
139
  const { runHeadless } = require('./headless');
127
- const { finalizeSession } = require('./sidecar/session-utils');
140
+ const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
128
141
  // resolvedModel is already available from validateStartInputs() above
129
142
 
130
143
  sessionId = await createSession(client);
@@ -185,10 +198,12 @@ const handlers = {
185
198
  mcp: undefined, // shared server already has MCP config
186
199
  }
187
200
  ).then((result) => {
188
- // Session complete - finalize and remove from tracking
201
+ // Session done route through resolveTerminalState (same single source
202
+ // of truth as the CLI start.js path) so an errored/timed-out/aborted run
203
+ // can never silently default to 'complete' with a 0-byte summary (#36).
189
204
  try {
190
205
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
191
- finalizeSession(sessionDir, result.summary || '', cwd, meta);
206
+ finalizeHeadlessResult(sessionDir, result, cwd, meta);
192
207
  } catch (finErr) {
193
208
  logger.warn('Failed to finalize session', { error: finErr.message });
194
209
  }
@@ -259,7 +274,10 @@ const handlers = {
259
274
  const cwd = project || getProjectDir(input.project);
260
275
  const sessionDir = safeSessionDir(cwd, input.taskId);
261
276
  const metadata = readMetadata(input.taskId, cwd);
262
- if (!metadata) { return textResult(`Session ${input.taskId} not found.`, true); }
277
+ if (!metadata) {
278
+ return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
279
+ 'If you ran it in a different project, pass the original "project".', true);
280
+ }
263
281
 
264
282
  if (metadata.type === 'wave') {
265
283
  const legs = (metadata.legs || []).map((legId) => {
@@ -306,7 +324,7 @@ const handlers = {
306
324
  }
307
325
  }
308
326
 
309
- const ms = Date.now() - new Date(metadata.createdAt).getTime();
327
+ const ms = elapsedMs(metadata);
310
328
  const response = {
311
329
  taskId: metadata.taskId, type: 'wave', status: metadata.status,
312
330
  legsComplete: done, legsTotal: legs.length, legs,
@@ -333,7 +351,7 @@ const handlers = {
333
351
  }
334
352
  }
335
353
 
336
- const ms = Date.now() - new Date(metadata.createdAt).getTime();
354
+ const ms = elapsedMs(metadata);
337
355
  const response = {
338
356
  taskId: metadata.taskId, status: metadata.status,
339
357
  elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
@@ -372,7 +390,8 @@ const handlers = {
372
390
  const cwd = project || getProjectDir(input.project);
373
391
  const sessionDir = safeSessionDir(cwd, input.taskId);
374
392
  if (!fs.existsSync(sessionDir)) {
375
- return textResult(`Session ${input.taskId} not found.`, true);
393
+ return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
394
+ 'If you ran it in a different project, pass the original "project".', true);
376
395
  }
377
396
 
378
397
  const readMeta = (() => {
@@ -413,6 +432,13 @@ const handlers = {
413
432
  })();
414
433
  const summaryText = fs.readFileSync(summaryPath, 'utf-8');
415
434
  const header = metaForRead.model ? `**Model:** ${metaForRead.model}\n\n` : '';
435
+ // A failed shared-server run writes an EXISTING 0-byte summary.md (so we hit
436
+ // this file-exists branch, not "No summary available"). Surface the failure
437
+ // reason instead of returning an empty/uninformative body (#36).
438
+ if ((metaForRead.status === 'error' || metaForRead.status === 'crashed') && !summaryText.trim()) {
439
+ const reason = metaForRead.reason || 'Unknown error';
440
+ return textResult(`${header}**Status:** ${metaForRead.status}\n**Reason:** ${reason}\n\n(No summary — the session ended with an error.)`);
441
+ }
416
442
  return textResult(header + summaryText);
417
443
  },
418
444
 
@@ -636,6 +662,15 @@ const handlers = {
636
662
  },
637
663
 
638
664
  async amicus_setup() {
665
+ const { checkElectronAvailable } = require('./sidecar/interactive');
666
+ if (!checkElectronAvailable()) {
667
+ return textResult(
668
+ 'The setup GUI cannot open because Electron is not installed, so no '
669
+ + 'window appeared. Run `amicus setup` in your terminal instead — it '
670
+ + 'falls back to a headless (readline) wizard for API key configuration.',
671
+ true
672
+ );
673
+ }
639
674
  try { spawnSidecarProcess(['setup']); } catch (err) {
640
675
  return textResult(`Failed to launch setup: ${err.message}`, true);
641
676
  }
@@ -27,6 +27,40 @@ async function getCreateOpencodeServer() {
27
27
  return sdk.createOpencodeServer;
28
28
  }
29
29
 
30
+ /**
31
+ * Shared reason string for an HTTP 402 from the provider.
32
+ * Exported so #36 and a later pre-flight credit check can reuse the exact phrasing
33
+ * instead of re-deriving it.
34
+ */
35
+ const INSUFFICIENT_CREDITS_REASON = 'Insufficient credits';
36
+
37
+ /**
38
+ * Map an SDK request result to a propagated provider-failure reason.
39
+ *
40
+ * Keys on the actual HTTP status (result.response.status, falling back to
41
+ * result.error.status) so benign informational `error` payloads that ride on a
42
+ * 2xx response stay "continue to poll" — only a real non-2xx status becomes a
43
+ * session error.
44
+ *
45
+ * 402 -> 'Insufficient credits'
46
+ * other non-2xx -> 'Provider error: <status>'
47
+ * 2xx / unknown -> null
48
+ *
49
+ * @param {{response?: {status?: number}, error?: {status?: number}}} [result]
50
+ * @returns {string|null} Reason string, or null when there is no hard failure.
51
+ */
52
+ function providerErrorReason(result) {
53
+ if (!result) { return null; }
54
+ const status = (result.response && result.response.status)
55
+ || (result.error && result.error.status);
56
+ // No usable status — cannot distinguish a benign warning from a failure, so
57
+ // do not regress the "continue to poll" behavior.
58
+ if (typeof status !== 'number') { return null; }
59
+ if (status >= 200 && status < 300) { return null; }
60
+ if (status === 402) { return INSUFFICIENT_CREDITS_REASON; }
61
+ return `Provider error: ${status}`;
62
+ }
63
+
30
64
  /**
31
65
  * Parse a model string into SDK format
32
66
  *
@@ -159,11 +193,25 @@ async function sendPrompt(client, sessionId, options) {
159
193
  }
160
194
  }
161
195
 
162
- // Log but don't throw on promptAsync errors.
163
- // promptAsync is fire-and-forget: the server queues the prompt for async
164
- // processing. Errors here may be informational (e.g., model config warnings)
165
- // rather than fatal. The polling loop will detect real failures via timeout.
166
- if (result.error) {
196
+ // Detect a hard provider failure at the client boundary (#37). A non-2xx /
197
+ // 402 here must surface as a session error EVEN WHEN the server emits no
198
+ // assistant message carrying info.error otherwise the run looks idle/empty.
199
+ // Keyed on HTTP status so benign informational errors (model config warnings
200
+ // on a 2xx) keep the fire-and-forget "continue to poll" behavior below.
201
+ const reason = providerErrorReason(result);
202
+ if (reason) {
203
+ result.providerError = reason;
204
+ const { logger } = require('./utils/logger');
205
+ logger.error('promptAsync returned a hard provider error', {
206
+ reason,
207
+ status: (result.response && result.response.status) || (result.error && result.error.status),
208
+ sessionId
209
+ });
210
+ } else if (result.error) {
211
+ // Log but don't throw on benign promptAsync errors.
212
+ // promptAsync is fire-and-forget: the server queues the prompt for async
213
+ // processing. These errors are informational (e.g., model config warnings)
214
+ // rather than fatal. The polling loop will detect real failures via timeout.
167
215
  const { logger } = require('./utils/logger');
168
216
  logger.error('promptAsync returned error (continuing to poll)', {
169
217
  error: result.error.message || JSON.stringify(result.error),
@@ -595,6 +643,8 @@ function parseMcpSpec(spec) {
595
643
  }
596
644
 
597
645
  module.exports = {
646
+ INSUFFICIENT_CREDITS_REASON,
647
+ providerErrorReason,
598
648
  parseModelString,
599
649
  createClient,
600
650
  createSession,
@@ -200,8 +200,9 @@ async function continueSidecar(options) {
200
200
  const metaPath = SessionPaths.metadataFile(sessionDir);
201
201
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
202
202
 
203
- // Finalize session
204
- finalizeSession(sessionDir, summary, project, meta);
203
+ // Finalize session. Interactive mode legitimately returns an empty summary,
204
+ // so pass status explicitly to stay out of the #36 empty-summary guard.
205
+ finalizeSession(sessionDir, summary, project, meta, { status: 'complete' });
205
206
  }
206
207
 
207
208
  module.exports = {
@@ -17,6 +17,8 @@ function createMirrorState() {
17
17
  seenToolResultIds: new Set(),
18
18
  receivingReported: false,
19
19
  output: '', // accumulated assistant text
20
+ seenReasoningParts: new Map(), // partId -> last captured reasoning length
21
+ reasoningOutput: '', // accumulated reasoning text (promoted to output only if no text part arrives)
20
22
  usageByMsg: new Map(), // msgId -> {tokens, cost}
21
23
  };
22
24
  }
@@ -101,6 +103,22 @@ function mirrorMessages(messages, state, opts = {}) {
101
103
  timestamp: now(),
102
104
  });
103
105
  }
106
+ } else if (part.type === 'reasoning' && part.text) {
107
+ // Some providers (e.g. Gemini 3.x on the direct Google path) return the
108
+ // answer as a reasoning part with no separate text part. Accumulate it in a
109
+ // dedicated buffer; it is promoted to `output` at finalization ONLY when no
110
+ // visible text part ever arrives (see below), so models that emit BOTH a
111
+ // reasoning part and a text part are unaffected (their thinking never
112
+ // pollutes the answer).
113
+ const prevLen = state.seenReasoningParts.get(partId) || 0;
114
+ if (part.text.length > prevLen) {
115
+ state.reasoningOutput += part.text.slice(prevLen);
116
+ state.seenReasoningParts.set(partId, part.text.length);
117
+ if (!state.receivingReported) {
118
+ state.receivingReported = true;
119
+ progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
120
+ }
121
+ }
104
122
  }
105
123
  }
106
124
  }
@@ -110,6 +128,15 @@ function mirrorMessages(messages, state, opts = {}) {
110
128
  const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
111
129
  assistantFinished = !!(lastAssistant && lastAssistant.info.time && lastAssistant.info.time.completed);
112
130
 
131
+ // Reasoning-only fallback: if the assistant finished but emitted only reasoning
132
+ // parts (no visible text), promote the reasoning text to `output` so the headless
133
+ // completion gates fire and the answer isn't lost as "No Output". Runs once — once
134
+ // `output` is non-empty this is skipped on subsequent polls.
135
+ if (assistantFinished && !state.output && state.reasoningOutput) {
136
+ state.output = state.reasoningOutput;
137
+ appendLines.push({ role: 'assistant', content: state.reasoningOutput, timestamp: now() });
138
+ }
139
+
113
140
  return { appendLines, progressUpdates, state, currentAssistantMsgId, assistantFinished, sessionError, messageCount };
114
141
  }
115
142
 
@@ -202,8 +202,10 @@ async function resumeSidecar(options) {
202
202
  // Output summary
203
203
  outputSummary(summary);
204
204
 
205
- // Finalize session (use updatedMetadata which has resumedAt)
206
- finalizeSession(sessionDir, summary, project, updatedMetadata);
205
+ // Finalize session (use updatedMetadata which has resumedAt). Pass status
206
+ // explicitly to preserve the pre-#36 default ('complete') and stay out of
207
+ // the empty-summary guard — interactive resume legitimately has no summary.
208
+ finalizeSession(sessionDir, summary, project, updatedMetadata, { status: 'complete' });
207
209
  } finally {
208
210
  if (heartbeat) { heartbeat.stop(); }
209
211
  releaseLock(sessionDir);
@@ -23,4 +23,44 @@ function resolveTerminalState(result, signal) {
23
23
  return { status: 'error', exitCode: 1 };
24
24
  }
25
25
 
26
- module.exports = { resolveTerminalState };
26
+ /**
27
+ * Finalize a headless run by routing through resolveTerminalState — the single
28
+ * source of truth shared with the CLI start.js path. An errored run writes
29
+ * status='error' + reason (and an EXISTING 0-byte summary.md so amicus_read hits
30
+ * its file-exists branch); every other state persists the (possibly partial)
31
+ * summary with the correct terminal status. Never defaults a failed run to
32
+ * 'complete'. Used by the shared-server MCP .then handler (#36).
33
+ *
34
+ * @param {string} sessionDir
35
+ * @param {{completed?:boolean,timedOut?:boolean,aborted?:boolean,error?:any,summary?:string}|null} result
36
+ * @param {string} project
37
+ * @param {object} metadata - mutated + persisted to metadata.json
38
+ */
39
+ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
40
+ // Lazy require to avoid a circular dependency (session-utils requires nothing
41
+ // here, but keep symmetry with start.js which also lazy-requires).
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const { finalizeSession, SessionPaths } = require('./session-utils');
45
+
46
+ const terminal = resolveTerminalState(result);
47
+ if (terminal.status === 'error') {
48
+ // Write an existing (0-byte) summary so amicus_read hits the file-exists
49
+ // branch and surfaces metadata.reason rather than "No summary available".
50
+ fs.writeFileSync(SessionPaths.summaryFile(sessionDir), result && result.summary ? result.summary : '', { mode: 0o600 });
51
+ metadata.status = 'error';
52
+ metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
53
+ metadata.completedAt = new Date().toISOString();
54
+ fs.writeFileSync(
55
+ path.join(sessionDir, 'metadata.json'),
56
+ JSON.stringify(metadata, null, 2),
57
+ { mode: 0o600 }
58
+ );
59
+ return;
60
+ }
61
+ // complete / timed-out / aborted: persist the (possibly partial) summary with
62
+ // the resolved status. Explicit status means the #36 guard won't re-classify.
63
+ finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status });
64
+ }
65
+
66
+ module.exports = { resolveTerminalState, finalizeHeadlessResult };
@@ -90,8 +90,14 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
90
90
  // Save summary
91
91
  fs.writeFileSync(SessionPaths.summaryFile(sessionDir), summary, { mode: 0o600 });
92
92
 
93
- // Update metadata to the resolved terminal status (default complete).
94
- metadata.status = opts.status || 'complete';
93
+ // Update metadata to the resolved terminal status. Callers that know the
94
+ // terminal state (CLI start.js, shared-server finalizeHeadlessResult) pass it
95
+ // explicitly via opts.status — that always wins, so they are never
96
+ // re-classified. Defense-in-depth (#36): when NO status is supplied, an empty
97
+ // summary must never silently default to 'complete' — that hid errored/empty
98
+ // shared-server runs behind a 0-byte summary and a false success.
99
+ const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
100
+ metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
95
101
  metadata.completedAt = new Date().toISOString();
96
102
  fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
97
103
 
@@ -70,6 +70,31 @@ function detectApiKeys() {
70
70
  return readApiKeys();
71
71
  }
72
72
 
73
+ /**
74
+ * #38 — Non-blocking OpenRouter credit warning. Reads the OpenRouter key
75
+ * value, calls checkOpenRouterCredit, and prints a WARNING (never blocks) when
76
+ * the key is zero-credit or free tier. Any failure is swallowed silently — a
77
+ * credit probe must never stop setup from completing.
78
+ */
79
+ /* eslint-disable no-console -- CLI wizard requires direct console output */
80
+ async function warnOnLowOpenRouterCredit() {
81
+ try {
82
+ const { readApiKeyValues } = require('../utils/api-key-store');
83
+ const { checkOpenRouterCredit } = require('../utils/api-key-validation');
84
+ const values = readApiKeyValues();
85
+ const key = values && values.openrouter;
86
+ if (!key) { return; }
87
+ const { warning } = await checkOpenRouterCredit(key);
88
+ if (warning) {
89
+ console.log(`Warning: ${warning}`);
90
+ console.log('');
91
+ }
92
+ } catch (err) {
93
+ logger.debug('OpenRouter credit check skipped', { error: err.message });
94
+ }
95
+ }
96
+ /* eslint-enable no-console */
97
+
73
98
  /**
74
99
  * Prompt the user with a question via readline
75
100
  * @param {readline.Interface} rl - Readline interface
@@ -246,6 +271,12 @@ async function runReadlineSetup() {
246
271
  }
247
272
  console.log('');
248
273
 
274
+ // #38 — non-blocking zero-credit / free-tier OpenRouter warning. Never
275
+ // blocks: free-tier councils against free models are legitimate.
276
+ if (keys.openrouter) {
277
+ await warnOnLowOpenRouterCredit();
278
+ }
279
+
249
280
  const mode = await askQuestion(rl,
250
281
  'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
251
282
  if (mode === '2') {
@@ -410,4 +441,5 @@ module.exports = {
410
441
  runApiKeySetup,
411
442
  seedCatalog,
412
443
  seedFreeCouncil,
444
+ warnOnLowOpenRouterCredit,
413
445
  };
@@ -87,11 +87,83 @@ function validateApiKey(provider, key) {
87
87
  });
88
88
  }
89
89
 
90
+ /** Warning string for a zero-credit OpenRouter key (paid models will 402). */
91
+ const OPENROUTER_NO_CREDIT_WARNING =
92
+ 'OpenRouter key has no remaining credit — paid models will fail (402). ' +
93
+ 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).';
94
+
95
+ /** Warning string for a free-tier OpenRouter key. */
96
+ const OPENROUTER_FREE_TIER_WARNING =
97
+ 'OpenRouter key is free tier — only :free models will route; paid models will fail (402). ' +
98
+ 'Add credit at openrouter.ai/credits to use paid models.';
99
+
100
+ /**
101
+ * Non-blocking credit/limit check for an OpenRouter key.
102
+ *
103
+ * Hits GET https://openrouter.ai/api/v1/key (returns limit, usage,
104
+ * is_free_tier, limit_remaining) and produces a WARNING — never an error —
105
+ * when is_free_tier is true or limit_remaining <= 0. Any failure (non-200,
106
+ * network error, malformed body) resolves with warning:null so setup is
107
+ * never blocked. Free-tier councils against free models are legitimate.
108
+ *
109
+ * @param {string} key OpenRouter API key
110
+ * @returns {Promise<{warning: string|null, isFreeTier: boolean,
111
+ * limitRemaining: number|null, limit: number|null, usage: number|null}>}
112
+ */
113
+ function checkOpenRouterCredit(key) {
114
+ const none = {
115
+ warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null
116
+ };
117
+ if (!key || key.trim().length === 0) {
118
+ return Promise.resolve(none);
119
+ }
120
+
121
+ const headers = { 'Authorization': `Bearer ${key.trim()}` };
122
+
123
+ return new Promise((resolve) => {
124
+ const req = https.get('https://openrouter.ai/api/v1/key', { headers }, (res) => {
125
+ let body = '';
126
+ res.on('data', (chunk) => { body += chunk; });
127
+ res.on('end', () => {
128
+ if (res.statusCode !== 200) { resolve(none); return; }
129
+ let data;
130
+ try {
131
+ data = (JSON.parse(body) || {}).data || {};
132
+ } catch (_e) {
133
+ resolve(none);
134
+ return;
135
+ }
136
+ const isFreeTier = data.is_free_tier === true;
137
+ const limitRemaining = (typeof data.limit_remaining === 'number')
138
+ ? data.limit_remaining : null;
139
+ const limit = (typeof data.limit === 'number') ? data.limit : null;
140
+ const usage = (typeof data.usage === 'number') ? data.usage : null;
141
+
142
+ let warning = null;
143
+ if (limitRemaining !== null && limitRemaining <= 0) {
144
+ warning = OPENROUTER_NO_CREDIT_WARNING;
145
+ } else if (isFreeTier) {
146
+ warning = OPENROUTER_FREE_TIER_WARNING;
147
+ }
148
+ resolve({ warning, isFreeTier, limitRemaining, limit, usage });
149
+ });
150
+ });
151
+ req.setTimeout(10000, () => {
152
+ req.destroy();
153
+ resolve(none);
154
+ });
155
+ req.on('error', () => { resolve(none); });
156
+ });
157
+ }
158
+
90
159
  // Backwards compat alias
91
160
  const validateOpenRouterKey = validateApiKey;
92
161
 
93
162
  module.exports = {
94
163
  validateApiKey,
95
164
  validateOpenRouterKey,
165
+ checkOpenRouterCredit,
166
+ OPENROUTER_NO_CREDIT_WARNING,
167
+ OPENROUTER_FREE_TIER_WARNING,
96
168
  VALIDATION_ENDPOINTS
97
169
  };
@@ -267,6 +267,7 @@ function buildDoctorDoc({ version, timestamp, checks }) {
267
267
  module.exports = {
268
268
  SCHEMA_VERSION,
269
269
  TERMINAL_STATUSES,
270
+ durationBetween,
270
271
  statusFromResult,
271
272
  buildRunResult,
272
273
  buildWaveResult,