amicus 1.7.6 → 1.8.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,19 +1,40 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.6",
3
+ "version": "1.8.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
- "author": { "name": "Christian Wagner" },
5
+ "author": {
6
+ "name": "Christian Wagner"
7
+ },
6
8
  "homepage": "https://bourbondog.github.io/amicus/",
7
9
  "repository": "https://github.com/BourbonDog/amicus",
8
10
  "bugs": "https://github.com/BourbonDog/amicus/issues",
9
11
  "license": "MIT",
10
- "keywords": ["claude-code", "multi-model", "llm", "council", "second-opinion", "sidecar", "gemini", "gpt", "deepseek"],
11
- "skills": ["./skills/sidecar", "./skills/second-opinion"],
12
+ "keywords": [
13
+ "claude-code",
14
+ "multi-model",
15
+ "llm",
16
+ "council",
17
+ "second-opinion",
18
+ "sidecar",
19
+ "gemini",
20
+ "gpt",
21
+ "deepseek"
22
+ ],
23
+ "skills": [
24
+ "./skills/sidecar",
25
+ "./skills/second-opinion"
26
+ ],
12
27
  "mcpServers": {
13
28
  "amicus": {
14
29
  "command": "npx",
15
- "args": ["-y", "amicus@latest", "mcp"],
16
- "env": { "AMICUS_SKIP_POSTINSTALL": "1" }
30
+ "args": [
31
+ "-y",
32
+ "amicus@latest",
33
+ "mcp"
34
+ ],
35
+ "env": {
36
+ "AMICUS_SKIP_POSTINSTALL": "1"
37
+ }
17
38
  }
18
39
  }
19
40
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.8.0] - 2026-07-02
9
+
10
+ ### Added
11
+ - **`amicus_wait` MCP tool: blocking wait for a session or fan-out wave.** Blocks inside one tool call until the
12
+ target reaches a terminal state or the wait window closes, replacing the sleep+`amicus_status` polling loop with
13
+ a single call. Returns the same JSON shape as `amicus_status` plus `waitedMs` and `{timedOut: true}` (with a
14
+ `hint`) on expiry — re-call it while it keeps returning `timedOut: true`. Works for sessions or waves started by
15
+ other processes, not just the caller. Torn-read tolerant: a transient read of `metadata.json` mid-write is
16
+ treated as a missed poll tick, not a hard failure. Legacy alias `sidecar_wait` is available under
17
+ `AMICUS_LEGACY_ALIASES=1`.
18
+ - **Agent-visible progress.** A new `amicus status <task_id>` (or `--wave <id>`) one-shot CLI command delegates
19
+ directly to the MCP status handler — same crash detection and wave-leg rollup, zero duplicated logic.
20
+ `amicus_status` and `amicus_list` are enriched with agent-facing `mode`, `phase`, `messageCount`,
21
+ `lastActivityAt`, and `latestPreview` (the pinned raw `stage` field is unchanged for back-compat; wave legs
22
+ additionally surface the raw `stage` alongside the coarse `phase`). Interactive (Electron GUI) runs now write
23
+ the same lifecycle progress stages headless runs always have (`initializing`, `server_ready`, `session_created`,
24
+ `prompt_sent`), and long-thinking turns emit periodic thinking-delta progress ticks instead of at most one ever
25
+ — so a live GUI run no longer reads "Starting up... | 0 messages" forever.
26
+ - **`amicus doctor` duplicate-registration check.** A new `mcp-legacy` check flags plugin-channel installs
27
+ (`AMICUS_SKIP_POSTINSTALL=1`) that never ran the postinstall migration and still carry a duplicate legacy
28
+ `sidecar` MCP registration; `doctor --fix` cleans it up.
29
+
30
+ ### Fixed
31
+ - **`amicus abort` now actually stops interactive sessions and wave legs.** Marker-first, honest output — reports
32
+ what really happened including the unkillable-pid case — and no-ops cleanly with a clear message when the
33
+ target isn't running.
34
+ - **Legacy-MCP remediation's `claude mcp add-json` (CLI) path no longer drops a user's custom `env`** on
35
+ re-registration — it now merges the previous registration's `env` the same way the file-fallback path already did.
36
+
37
+ ### Changed
38
+ - **Legacy `sidecar_*` MCP tool aliases are now opt-in** via `AMICUS_LEGACY_ALIASES=1` (breaking-adjacent —
39
+ carrying release must be a MINOR, v1.8.0). The default client-visible surface is the `amicus_*` toolset (14
40
+ tools as of this release); saved allowlists that still reference `mcp__amicus__sidecar_*` stop resolving unless
41
+ you opt back in.
42
+ - **Postinstall no longer registers a separate `sidecar` MCP server** and auto-removes a verified-identical
43
+ duplicate left over from pre-1.8 installs. A customized `sidecar` entry or a sole `sidecar` registration (no
44
+ `amicus` twin) is never touched.
45
+
46
+ ## [1.7.7] - 2026-07-01
47
+
48
+ Correctness patch from the 2026-07-01 full product review (multi-agent review, every finding adversarially
49
+ verified against source), executed subagent-driven with per-task adversarial review plus a final whole-branch review.
50
+
51
+ ### Fixed
52
+ - **Terminal errors now show their actionable hint.** Human-mode errors printed only the message while `--json`
53
+ carried a `hint` field; the hint now prints on a second ` → …` line. Budget-gate refusals finally tell you the
54
+ offending model, the threshold, and the `--max-cost` / `--no-cost-gate` overrides.
55
+ - **Spawned sidecars no longer inherit Amicus's own MCP server.** The recursive-spawn guard only excluded a server
56
+ literally named `sidecar`, but the product registers as `amicus` — so every child model inherited the full
57
+ Amicus toolset and could spawn recursively. Children now exclude any inherited entry that *is* Amicus, matched
58
+ by name **or** by what the command actually runs (`amicus mcp`, `npx … amicus … mcp`, a `bin/amicus.js … mcp`
59
+ path). Note: this strip has no opt-out — a deliberately configured nested Amicus MCP entry is also removed from
60
+ spawned children.
61
+ - **Shared-server crash detection actually works.** The crash/restart machinery listened on an event emitter the
62
+ real server handle never exposed, so it was dead code — a dead engine silently degraded every later session.
63
+ A pid liveness poll now drives detection and restart, and shutting down during the restart backoff cancels the
64
+ pending restart instead of spawning a server nobody asked for.
65
+
66
+ ### Changed
67
+ - **`amicus continue` and `amicus resume` now report failures truthfully** (behavior change): error exits 1,
68
+ timeout exits 2, abort exits 130/143/2 — previously both always exited 0 and recorded the session as
69
+ `complete` even when the model errored or timed out. The session record now finalizes `error`/`timed-out`
70
+ accordingly (interactive sessions that legitimately end with an empty summary still finalize `complete`).
71
+ Scripts that gated on exit code 0 for these verbs will now see real failures.
72
+
8
73
  ## [1.7.6] - 2026-07-01
9
74
 
10
75
  A second independent review (GLM 5.2), adversarially verified against source, then fixed across 11 lanes.
package/LICENSE CHANGED
@@ -1,27 +1,7 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 John Renaldi Claude Sidecar
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
22
-
23
-
24
- Copyright (c) 2026 Christian Wagner Amicus
3
+ Copyright (c) 2026 Christian Wagner
4
+ Copyright (c) 2025 John Renaldi
25
5
 
26
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
27
7
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -57,13 +57,37 @@ Claude is the orchestrator. The council and chat skills run *on top of* the engi
57
57
 
58
58
  ## Quick start
59
59
 
60
- **Install:**
60
+ **Install** — pick whichever fits; all deliver the same CLI, MCP server, and both skills:
61
+
62
+ **As a Claude Code plugin** — the most native path if you use Claude Code:
63
+
64
+ ```text
65
+ /plugin marketplace add BourbonDog/amicus
66
+ /plugin install amicus@bourbondog-amicus
67
+ /reload-plugins
68
+ ```
69
+
70
+ Claude Code registers the MCP server and both skills for you — nothing to configure. (The standalone Electron window is npm-only, and the first council/sidecar call downloads the OpenCode engine.)
71
+
72
+ **With the install script** — macOS, Linux, or Windows (needs [Node.js](https://nodejs.org) ≥ 18):
73
+
74
+ ```bash
75
+ # macOS / Linux
76
+ curl -fsSL https://raw.githubusercontent.com/BourbonDog/amicus/main/install.sh | sh
77
+ ```
78
+
79
+ ```powershell
80
+ # Windows (PowerShell)
81
+ irm https://raw.githubusercontent.com/BourbonDog/amicus/main/install.ps1 | iex
82
+ ```
83
+
84
+ **With npm** — the canonical path (needs [Node.js](https://nodejs.org) ≥ 18):
61
85
 
62
86
  ```bash
63
87
  npm install -g amicus
64
88
  ```
65
89
 
66
- The postinstall step auto-configures everything — no manual registration:
90
+ For the **npm** and **install-script** paths, a postinstall auto-configures everything — no manual registration:
67
91
 
68
92
  - Registers the **MCP server** in Claude Code and in Claude Desktop / Cowork, so the Amicus tools appear natively.
69
93
  - Installs **both skills** into `~/.claude/skills/` — `second-opinion` (the council) and `sidecar` (the chat skill).
@@ -235,6 +259,7 @@ amicus update
235
259
  | `amicus resume` | Reopen a previous session with full history. |
236
260
  | `amicus continue` | Start a new session building on a previous one. |
237
261
  | `amicus read` | Output a session's summary / conversation / metadata. |
262
+ | `amicus status <id>` | One-shot status for a session or fan-out wave (human or `--json`; `--wave <id>` alternative spelling). |
238
263
  | `amicus models` | List, search, refresh the catalog, or audit aliases. |
239
264
  | `amicus abort` | Abort a running session (or `--all`). |
240
265
  | `amicus setup` | Configure default model, API keys, and aliases. |
@@ -305,6 +330,10 @@ amicus read <id> --conversation # full conversation
305
330
  amicus read <id> --metadata # session metadata
306
331
  amicus read <id> --json # stable JSON (run or wave document)
307
332
 
333
+ amicus status <id> # one-shot status for a session or wave
334
+ amicus status --wave <id> # alternative spelling for a wave ID
335
+ amicus status <id> --json # machine-readable output
336
+
308
337
  amicus resume <id> # reopen with full history
309
338
  amicus continue <id> --prompt "..." # new session, previous one as read-only context
310
339
 
@@ -347,12 +376,13 @@ amicus models --check # audit your aliases against the catalog
347
376
 
348
377
  ## MCP integration
349
378
 
350
- The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes ten tools:
379
+ The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes fourteen tools:
351
380
 
352
381
  | Tool | What it does |
353
382
  |------|--------------|
354
383
  | `amicus_start` | Spawn a session; returns a task ID immediately. |
355
384
  | `amicus_status` | Poll a task (or a fanout wave) for completion. |
385
+ | `amicus_wait` | Block inside one tool call until a session/wave finishes or the wait window closes. |
356
386
  | `amicus_read` | Read results: summary, conversation, metadata, or JSON. |
357
387
  | `amicus_list` | List past sessions. |
358
388
  | `amicus_resume` | Reopen a session. |
@@ -361,8 +391,11 @@ The MCP server is auto-registered on install (Claude Code and Claude Desktop / C
361
391
  | `amicus_setup` | Open the setup wizard. |
362
392
  | `amicus_guide` | Return usage guidance (model choice, briefings, polling). |
363
393
  | `amicus_fanout` | Launch a same-prompt wave; returns `{ waveId, taskIds[] }`. |
394
+ | `amicus_council_tally` | Aggregate a council wave's reviews into a scored tally. |
395
+ | `amicus_council_stats` | Reviewer-reliability stats from past council runs. |
396
+ | `amicus_verdict` | Build the final council verdict from a tally + decisions. |
364
397
 
365
- The async pattern is **start → status → read**: `amicus_start` (or `amicus_fanout`) returns immediately, you poll `amicus_status`, then `amicus_read` once it's done — so the calling agent never blocks.
398
+ The async pattern is **start → status → read**: `amicus_start` (or `amicus_fanout`) returns immediately, you poll `amicus_status`, then `amicus_read` once it's done — so the calling agent never blocks. Prefer `amicus_wait` over manual sleep+status polling for headless runs: it collapses the poll loop into a single blocking call that returns as soon as the run finishes (or the wait window closes).
366
399
 
367
400
  To register manually (user scope):
368
401
 
@@ -370,7 +403,7 @@ To register manually (user scope):
370
403
  claude mcp add-json amicus '{"command":"npx","args":["-y","amicus@latest","mcp"]}' --scope user
371
404
  ```
372
405
 
373
- > Legacy `sidecar_*` tool names are still registered as aliases of each `amicus_*` tool for backward compatibility.
406
+ > Legacy `sidecar_*` tool names are no longer registered by default (v1.8.0). To restore them, add `"env": {"AMICUS_LEGACY_ALIASES": "1"}` to the server entry. They will be removed entirely in the next major.
374
407
 
375
408
  ---
376
409
 
package/bin/amicus.js CHANGED
@@ -94,11 +94,16 @@ async function main() {
94
94
  case 'list':
95
95
  await handleList(args);
96
96
  break;
97
+ case 'status': {
98
+ const { handleStatus } = require('../src/cli-handlers-status');
99
+ exitCode = await handleStatus(args);
100
+ break;
101
+ }
97
102
  case 'resume':
98
- await handleResume(args);
103
+ exitCode = await handleResume(args);
99
104
  break;
100
105
  case 'continue':
101
- await handleContinue(args);
106
+ exitCode = await handleContinue(args);
102
107
  break;
103
108
  case 'read':
104
109
  await handleRead(args);
@@ -188,7 +193,7 @@ async function handleResume(args) {
188
193
 
189
194
  const { resumeSidecar } = require('../src/index');
190
195
 
191
- await resumeSidecar({
196
+ return await resumeSidecar({
192
197
  taskId,
193
198
  project: args.cwd,
194
199
  headless: args['no-ui'],
@@ -242,7 +247,7 @@ async function handleContinue(args) {
242
247
 
243
248
  const { continueSidecar } = require('../src/index');
244
249
 
245
- await continueSidecar({
250
+ return await continueSidecar({
246
251
  taskId,
247
252
  newTaskId: args['task-id'],
248
253
  briefing: args.prompt || args.briefing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.6",
3
+ "version": "1.8.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",
@@ -15,6 +15,7 @@ const { execFileSync } = require('child_process');
15
15
 
16
16
  const { repairElectron } = require('../src/sidecar/electron-install');
17
17
  const HINTS = require('../src/utils/remediation-hints');
18
+ const { isAmicusMcpConfig } = require('../src/utils/mcp-self-identity');
18
19
 
19
20
  const SETUP_HOOKS_SCRIPT = path.join(__dirname, 'setup-hooks.js');
20
21
 
@@ -41,7 +42,18 @@ const MCP_CONFIG = { command: 'npx', args: ['-y', 'amicus@latest', 'mcp'] };
41
42
 
42
43
  /**
43
44
  * Add or update an MCP server in a JSON config file.
44
- * Always overwrites the entry to ensure upgrades apply the latest config.
45
+ *
46
+ * Refreshes command/args on every install/upgrade. If the EXISTING entry at
47
+ * this key is already amicus-shaped (per isAmicusMcpConfig — Phase 1's single
48
+ * source of truth for "this is amicus"), we MERGE instead of overwrite: the
49
+ * user's `env` (and any other extra keys, e.g. a future `cwd`) survive the
50
+ * refresh. Without this, `npm i -g amicus` silently wiped
51
+ * "env": {"AMICUS_LEGACY_ALIASES":"1"} — the exact opt-in escape hatch Phase 4
52
+ * tells users to add — on every upgrade.
53
+ *
54
+ * A NON-amicus-shaped entry at this key is overwritten as before: 'amicus' is
55
+ * a reserved registration name, so a foreign entry there is reclaimed rather
56
+ * than merged with.
45
57
  *
46
58
  * @param {string} configPath - Path to the JSON config file
47
59
  * @param {string} name - MCP server name
@@ -59,9 +71,10 @@ function addMcpToConfigFile(configPath, name, config) {
59
71
  if (!existing.mcpServers) { existing.mcpServers = {}; }
60
72
 
61
73
  const prev = existing.mcpServers[name];
62
- const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(config) ? 'updated' : 'unchanged';
74
+ const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...config } : config;
75
+ const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
63
76
 
64
- existing.mcpServers[name] = config;
77
+ existing.mcpServers[name] = nextConfig;
65
78
  if (status !== 'unchanged') {
66
79
  const dir = path.dirname(configPath);
67
80
  if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }
@@ -111,28 +124,42 @@ function installCouncilSkill(sourceDir = COUNCIL_SOURCE_DIR) {
111
124
  }
112
125
  }
113
126
 
127
+ /**
128
+ * Read the previous 'amicus' entry from ~/.claude.json, if any — used by the
129
+ * CLI add-json path to merge env the same way the file-fallback path does.
130
+ * Never throws: a missing/unreadable file just means "no previous entry".
131
+ * @returns {object|undefined}
132
+ */
133
+ function readPrevClaudeCodeAmicusEntry() {
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf-8'));
136
+ return parsed && parsed.mcpServers ? parsed.mcpServers.amicus : undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
114
142
  /** Register MCP server in Claude Code config */
115
143
  function registerClaudeCode() {
116
144
  // Try the CLI first
117
145
  try {
118
- const mcpJson = JSON.stringify(MCP_CONFIG);
146
+ // Merge the PREVIOUS registration's env into the add-json payload — same
147
+ // merge semantics as addMcpToConfigFile's file-fallback path (`{ ...prev,
148
+ // ...config }`: prev env keys survive, canonical MCP_CONFIG keys win on
149
+ // collision). Without this, a user's custom env (API key, AMICUS_* tuning
150
+ // knobs) on the old registration was silently dropped whenever the
151
+ // `claude` CLI was present, because the CLI path built its JSON payload
152
+ // from the bare MCP_CONFIG and delegated overwrite semantics to the
153
+ // claude binary — which has no idea about the user's previous entry.
154
+ const prev = readPrevClaudeCodeAmicusEntry();
155
+ const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...MCP_CONFIG } : MCP_CONFIG;
156
+ const mcpJson = JSON.stringify(nextConfig);
119
157
  execFileSync('claude', ['mcp', 'add-json', 'amicus', mcpJson, '--scope', 'user'], {
120
158
  stdio: 'pipe',
121
159
  timeout: 10000,
122
160
  });
123
161
  console.log('[amicus] MCP registered in Claude Code (via CLI).');
124
162
 
125
- // DEPRECATED(amicus-shim): also register 'sidecar' so existing clients that
126
- // reference the old server name keep resolving. Remove in next major.
127
- try {
128
- execFileSync('claude', ['mcp', 'add-json', 'sidecar', mcpJson, '--scope', 'user'], {
129
- stdio: 'pipe',
130
- timeout: 10000,
131
- });
132
- } catch {
133
- // Best-effort; ignore failures for the shim registration
134
- }
135
-
136
163
  return;
137
164
  } catch {
138
165
  // CLI not available or failed — fall back to file edit
@@ -148,10 +175,6 @@ function registerClaudeCode() {
148
175
  } else {
149
176
  console.log('[amicus] MCP already registered in Claude Code.');
150
177
  }
151
-
152
- // DEPRECATED(amicus-shim): also register 'sidecar' entry so existing clients
153
- // that reference the old server name keep resolving. Remove in next major.
154
- addMcpToConfigFile(claudeConfigPath, 'sidecar', MCP_CONFIG);
155
178
  }
156
179
 
157
180
  /** Register MCP server in Claude Desktop / Cowork config */
@@ -174,10 +197,33 @@ function registerClaudeDesktop() {
174
197
  } else {
175
198
  console.log('[amicus] MCP already registered in Claude Desktop.');
176
199
  }
200
+ }
177
201
 
178
- // DEPRECATED(amicus-shim): also register 'sidecar' entry so existing clients
179
- // that reference the old server name keep resolving. Remove in next major.
180
- addMcpToConfigFile(configPath, 'sidecar', MCP_CONFIG);
202
+ /**
203
+ * One-shot migration: drop the duplicate legacy 'sidecar' MCP entry that
204
+ * pre-1.8 postinstalls registered alongside 'amicus' (same server twice —
205
+ * doubled the client-visible tool list). Only removes an entry whose command
206
+ * is an amicus MCP invocation; a customized 'sidecar' entry is left alone.
207
+ * Covers both files the three legacy registration paths wrote to:
208
+ * ~/.claude.json (CLI + file fallback) and claude_desktop_config.json.
209
+ * Never throws (postinstall must always exit 0).
210
+ */
211
+ function migrateLegacyMcp(deps = {}) {
212
+ try {
213
+ const impl = deps.migrateLegacySidecar
214
+ || require('../src/utils/legacy-mcp-migration').migrateLegacySidecar;
215
+ for (const r of impl()) {
216
+ if (r.result === 'removed') {
217
+ console.log(`[amicus] Removed duplicate legacy 'sidecar' MCP entry from ${r.target} (same server — kept as 'amicus').`);
218
+ } else if (r.result === 'customized') {
219
+ console.log(`[amicus] Kept custom 'sidecar' MCP entry in ${r.target} (does not point at amicus).`);
220
+ } else if (r.result === 'write-failed') {
221
+ console.warn(`[amicus] Warning: could not remove the legacy 'sidecar' MCP entry from ${r.target} — run: amicus doctor --fix`);
222
+ }
223
+ }
224
+ } catch (err) {
225
+ console.warn(`[amicus] Warning: legacy MCP cleanup skipped: ${err && err.message}`);
226
+ }
181
227
  }
182
228
 
183
229
  /**
@@ -291,6 +337,7 @@ async function main(deps = {}) {
291
337
  const _installCouncilSkill = deps.installCouncilSkill || installCouncilSkill;
292
338
  const _registerClaudeCode = deps.registerClaudeCode || registerClaudeCode;
293
339
  const _registerClaudeDesktop = deps.registerClaudeDesktop || registerClaudeDesktop;
340
+ const _migrateLegacyMcp = deps.migrateLegacyMcp || migrateLegacyMcp;
294
341
  const _setupHooks = deps.setupHooks || setupHooks;
295
342
  const _provisionElectron = deps.provisionElectron || provisionElectron;
296
343
 
@@ -302,6 +349,7 @@ async function main(deps = {}) {
302
349
  _installCouncilSkill();
303
350
  _registerClaudeCode();
304
351
  _registerClaudeDesktop();
352
+ _migrateLegacyMcp(deps);
305
353
 
306
354
  // Non-fatal, cache-only: heal the optional Electron binary from local cache
307
355
  // or emit a deferred notice (GUI provisions on first use). Never throws.
@@ -336,4 +384,5 @@ if (require.main === module) {
336
384
  runCli();
337
385
  }
338
386
 
339
- module.exports = { main, runCli, addMcpToConfigFile, installSkill, installCouncilSkill, setupHooks, provisionElectron, COUNCIL_FILES };
387
+ module.exports = { main, runCli, addMcpToConfigFile, installSkill, installCouncilSkill,
388
+ setupHooks, provisionElectron, registerClaudeCode, registerClaudeDesktop, migrateLegacyMcp, COUNCIL_FILES };
@@ -41,6 +41,8 @@ function realDeps() {
41
41
  fix: false,
42
42
  discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
43
43
  discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
44
+ inspectLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').inspectAllLegacySidecarEntries(),
45
+ migrateLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').migrateLegacySidecar(),
44
46
  skillInstalled: () => {
45
47
  const dir = path.join(os.homedir(), '.claude', 'skills');
46
48
  return fs.existsSync(path.join(dir, 'sidecar', 'SKILL.md'))
@@ -181,6 +183,47 @@ async function runDoctorChecks(depsOverride = {}) {
181
183
  return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
182
184
  }));
183
185
 
186
+ // Duplicate legacy 'sidecar' MCP registration (same server twice — doubles
187
+ // the client-visible tool list). Detection reads the raw config files via
188
+ // legacy-mcp-migration: mcp-discovery can't see it (it strips 'sidecar' as
189
+ // its own recursion guard). --fix removes only identical-in-effect twins.
190
+ checks.push(guard('mcp-legacy', 'Legacy sidecar MCP entry', () => {
191
+ const id = 'mcp-legacy'; const name = 'Legacy sidecar MCP entry';
192
+ const entries = d.inspectLegacyMcpEntries() || [];
193
+ const dupes = entries.filter(e => e.status === 'removable');
194
+ const custom = entries.filter(e => e.status === 'customized');
195
+ // An unreadable config is neither "no problem" nor a duplicate we can act
196
+ // on — reporting it as ok/'none' would hide a config doctor (and --fix)
197
+ // could not actually inspect. Always surface it, even alongside dupes.
198
+ const unreadable = entries.filter(e => e.status === 'unreadable');
199
+ const unreadableNote = unreadable.length
200
+ ? `${unreadable.map(e => e.target).join(', ')} config unreadable — skipped`
201
+ : null;
202
+ if (dupes.length === 0) {
203
+ if (unreadableNote) {
204
+ const suffix = custom.length ? `; custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone` : '';
205
+ return { id, name, status: 'warn', message: `${unreadableNote}${suffix}`, hint: null };
206
+ }
207
+ const message = custom.length
208
+ ? `custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone`
209
+ : 'none';
210
+ return { id, name, status: 'ok', message, hint: null };
211
+ }
212
+ if (d.fix) {
213
+ const removed = (d.migrateLegacyMcpEntries() || []).filter(r => r.result === 'removed');
214
+ if (removed.length >= dupes.length) {
215
+ const message = `removed legacy entry from: ${removed.map(r => r.target).join(', ')}`;
216
+ return unreadableNote
217
+ ? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar }
218
+ : { id, name, status: 'ok', message, hint: null };
219
+ }
220
+ const message = `removed ${removed.length}/${dupes.length} duplicate(s) — could not update every config`;
221
+ return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
222
+ }
223
+ const message = `duplicate 'sidecar' entry in ${dupes.map(e => e.target).join(', ')} — doubles the MCP tool list`;
224
+ return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
225
+ }));
226
+
184
227
  // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
185
228
  checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
186
229
  const values = d.readApiKeyValues() || {};
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `amicus status <task_id>` — one-shot human/JSON status for a session or wave.
3
+ * Reads the SAME sources as the MCP amicus_status handler by calling it
4
+ * directly (requiring mcp-server does NOT start the server; the MCP SDK is
5
+ * only loaded inside startMcpServer()). Zero duplicated status logic — this
6
+ * inherits crash detection, wave leg rollup, and P6-3 enrichment for free.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { validateTaskId } = require('./utils/validators');
12
+
13
+ /** Render a key-value block for a single-session status payload. */
14
+ function formatRunHuman(d) {
15
+ const lines = [
16
+ `Task: ${d.taskId}`,
17
+ `Status: ${d.status}${d.phase ? ` (${d.phase})` : ''}`,
18
+ `Elapsed: ${d.elapsed}`,
19
+ ];
20
+ if (d.model) { lines.push(`Model: ${d.model}`); }
21
+ if (d.mode) { lines.push(`Mode: ${d.mode}`); }
22
+ if (d.messageCount !== undefined) { lines.push(`Messages: ${d.messageCount}`); }
23
+ if (d.lastActivity) { lines.push(`Activity: ${d.lastActivity}`); }
24
+ if (d.latestPreview) { lines.push(`Latest: ${d.latestPreview}`); }
25
+ else if (d.latest) { lines.push(`Latest: ${d.latest}`); }
26
+ if (d.stalled) { lines.push(`STALLED: no activity for ${d.stalledForSeconds}s (see --json for recovery)`); }
27
+ if (d.reason) { lines.push(`Reason: ${d.reason}`); }
28
+ return lines.join('\n');
29
+ }
30
+
31
+ /** Render a wave payload: header + one line per leg. */
32
+ function formatWaveHumanStatus(d) {
33
+ const head = `Wave ${d.taskId}: ${d.status} — ${d.legsComplete}/${d.legsTotal} legs done (${d.elapsed})`;
34
+ const legLines = (d.legs || []).map((l) => {
35
+ const label = String(l.model || l.taskId || '').padEnd(28);
36
+ const st = String(l.status || 'unknown').padEnd(10);
37
+ const msgs = l.messages !== undefined ? `${l.messages} msg` : '';
38
+ const latest = l.latestPreview || l.latestActivity || '';
39
+ const flag = l.stalled ? ' ⏳stalled' : '';
40
+ return ` ${label} ${st} ${msgs} | ${latest}${flag}`;
41
+ });
42
+ return [head, ...legLines].join('\n');
43
+ }
44
+
45
+ /**
46
+ * Handle 'amicus status'. Exit code 0 = status retrieved (any run state, even
47
+ * a failed/crashed run — the QUERY succeeded); 1 = missing/invalid/unknown id.
48
+ * @param {object} args parsed CLI args
49
+ * @returns {Promise<number>}
50
+ */
51
+ async function handleStatus(args) {
52
+ const taskId = args.wave || args._[1];
53
+ if (!taskId || taskId === true) {
54
+ process.stderr.write('Error: task_id is required for status\n');
55
+ process.stderr.write('Usage: amicus status <task_id> [--json] (or: amicus status --wave <wave_id>)\n');
56
+ return 1;
57
+ }
58
+ const check = validateTaskId(String(taskId));
59
+ if (!check.valid) { process.stderr.write(`${check.error}\n`); return 1; }
60
+
61
+ const project = args.cwd || process.cwd();
62
+ const { handlers } = require('./mcp-server');
63
+ const result = await handlers.amicus_status({ taskId: String(taskId) }, project);
64
+ const text = result.content[0].text;
65
+ if (result.isError) { process.stderr.write(`${text}\n`); return 1; }
66
+
67
+ let data;
68
+ try { data = JSON.parse(text); } catch { process.stdout.write(`${text}\n`); return 0; }
69
+ delete data.next_poll; // MCP-agent polling guidance, not CLI output
70
+
71
+ if (args.json) { process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return 0; }
72
+ process.stdout.write(`${data.type === 'wave' ? formatWaveHumanStatus(data) : formatRunHuman(data)}\n`);
73
+ return 0;
74
+ }
75
+
76
+ module.exports = { handleStatus, formatRunHuman, formatWaveHumanStatus };
@@ -123,6 +123,16 @@ async function handleAbort(args) {
123
123
  console.error(`Session ${taskId} has malformed metadata`);
124
124
  process.exit(1);
125
125
  }
126
+ // Guard against a completed/terminal session: without this, metadata.pid
127
+ // still holds a value forever and `amicus abort <completed-task>` would
128
+ // wait the grace window then TerminateProcess whatever unrelated process
129
+ // now owns that (possibly recycled) pid. Mirrors MCP's amicus_abort guard
130
+ // (src/mcp-server.js) — same wording, no re-mark, no kill.
131
+ if (meta.status !== 'running') {
132
+ console.log(`Session ${taskId} is not running (status: ${meta.status}).`);
133
+ return;
134
+ }
135
+
126
136
  const { markAborted } = require('./utils/session-abort');
127
137
 
128
138
  // F4: aborting a wave aborts every still-running leg too.
@@ -147,6 +157,28 @@ async function handleAbort(args) {
147
157
 
148
158
  markAborted(sessionDir, 'manual abort');
149
159
  console.log(`Session ${taskId} marked as aborted.`);
160
+
161
+ // Phase 3: fallback direct-kill for a session that does not honor the
162
+ // marker. Headless loops poll the marker every ~2s and the interactive
163
+ // abort watch does too, so the normal outcome is a graceful exit during
164
+ // the grace window; only a wedged/legacy process gets SIGTERM. The wait is
165
+ // awaited on purpose — bin/amicus.js arms its force-exit watchdog only
166
+ // after this handler returns.
167
+ if (meta.pid) {
168
+ const { waitThenKill, abortGraceMs } = require('./utils/abort-coordinator');
169
+ const graceSec = Math.ceil(abortGraceMs() / 1000);
170
+ console.log(`Waiting up to ${graceSec}s for the session process (pid ${meta.pid}) to exit gracefully...`);
171
+ const { killed, exited } = await waitThenKill(meta.pid);
172
+ if (killed.length > 0) {
173
+ console.log(`Process ${meta.pid} did not exit in time — sent SIGTERM (a hard kill on Windows).`);
174
+ } else if (exited.length > 0) {
175
+ console.log('Process exited cleanly.');
176
+ } else {
177
+ // 3.1 contract: an EPERM-unkillable pid lands in NEITHER array —
178
+ // it is still alive and we could not signal it. Say so honestly.
179
+ console.log(`Process ${meta.pid} is still running — could not signal it (insufficient permission). It may require manual termination.`);
180
+ }
181
+ }
150
182
  }
151
183
 
152
184
  /**
package/src/cli.js CHANGED
@@ -330,6 +330,7 @@ Commands:
330
330
  start Launch a new amicus session
331
331
  fanout Run N models on the same prompt in parallel (headless)
332
332
  list Show previous sessions
333
+ status One-shot status for a session or wave (--json)
333
334
  resume Reopen a previous session
334
335
  continue New session building on previous
335
336
  read Output session summary/conversation
@@ -416,6 +417,13 @@ Options for 'list':
416
417
  --status <filter> Filter by status (running, complete)
417
418
  --all Show all projects
418
419
  --json Output as JSON
420
+ `,
421
+ status: `
422
+ Options for 'status':
423
+ <task_id> Required. Session or wave ID (positional)
424
+ --wave <wave_id> Alternative to the positional ID for waves
425
+ --json Machine-readable output
426
+ --cwd <path> Project directory (default: cwd)
419
427
  `,
420
428
  abort: `
421
429
  Options for 'abort':