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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +54 -0
- package/README.md +40 -11
- package/bin/amicus.js +3 -2
- package/package.json +4 -3
- package/scripts/postinstall.js +103 -3
- package/scripts/setup-hooks.js +7 -3
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +45 -8
- package/src/headless.js +22 -1
- package/src/mcp-server.js +42 -7
- package/src/opencode-client.js +55 -5
- package/src/sidecar/continue.js +3 -2
- package/src/sidecar/conversation-mirror.js +27 -0
- package/src/sidecar/resume.js +4 -2
- package/src/sidecar/session-finalize.js +41 -1
- package/src/sidecar/session-utils.js +8 -2
- package/src/sidecar/setup.js +32 -0
- package/src/utils/api-key-validation.js +72 -0
- package/src/utils/result-schema.js +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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",
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/scripts/setup-hooks.js
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
}
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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,
|
package/src/sidecar/continue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/sidecar/resume.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
package/src/sidecar/setup.js
CHANGED
|
@@ -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
|
};
|